Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions code_puppy/agents/agent_code_puppy.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ def get_available_tools(self) -> list[str]:
return [
"list_agents",
"invoke_agent",
"invoke_agents",
"list_files",
"read_file",
"grep",
Expand Down
28 changes: 26 additions & 2 deletions code_puppy/agents/run_stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,9 @@ def format_conversation_stats(

# Tool names whose response has already been rendered inline (streamed or
# via display_non_streamed_result) and should NOT be dumped again.
_ALREADY_RENDERED_TOOLS = frozenset({"invoke_agent", "invoke_agent_with_model"})
_ALREADY_RENDERED_TOOLS = frozenset(
{"invoke_agent", "invoke_agent_with_model", "invoke_agents"}
)

# Tools whose output is already rendered by the rich_renderer via MessageBus
# messages (FileContentMessage, DiffMessage, GrepResultMessage, etc.).
Expand Down Expand Up @@ -415,9 +417,31 @@ def _render_high_mode_agent_result(
result: Any,
dur_str: str,
) -> None:
"""Compact one-liner for invoke_agent / invoke_agent_with_model results."""
"""Compact one-liner for invoke_agent(s) results.

``invoke_agent`` / ``invoke_agent_with_model`` return a single
``AgentInvokeOutput``; ``invoke_agents`` returns a *list* of them. Handle
both so the batch fan-out gets a useful OK/FAIL summary instead of "?".
"""
from rich.markup import escape as _esc

# Batch result (invoke_agents): summarise per-agent success/failure.
if isinstance(result, (list, tuple)):
total = len(result)
ok = sum(1 for r in result if getattr(r, "error", None) is None)
failed = total - ok
status = (
f"[green]{ok} OK[/green]"
if failed == 0
else f"[green]{ok} OK[/green] / [red]{failed} FAIL[/red]"
)
names = ", ".join(getattr(r, "agent_name", None) or "?" for r in result)
console.print(
f"[dim] \u21a9 {_esc(tool_name)} returned ({dur_str} ms): "
f"{total} sub-agent(s) {status} \u2014 {_esc(names)}[/dim]"
)
return

agent_name = getattr(result, "agent_name", None) or "?"
error = getattr(result, "error", None)
response = getattr(result, "response", None)
Expand Down
19 changes: 19 additions & 0 deletions code_puppy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1198,6 +1198,25 @@ def get_yolo_mode():
return True


def get_allow_parallel_tool_calls():
"""
Checks puppy.cfg for 'allow_parallel_tool_calls' (case-insensitive value).
Defaults to False if not set.

When off (default), parallel tool calls are disabled outside yolo mode so
the user can review each call sequentially. Turn this on to let the model
fan out tool calls (including parallel sub-agent invocations) even when
yolo mode is off. Note: the ``invoke_agents`` batch tool fans out
internally via one reviewable tool call and does NOT require this flag.
Allowed values for ON: 1, '1', 'true', 'yes', 'on' (case-insensitive).
"""
true_vals = {"1", "true", "yes", "on"}
cfg_val = get_value("allow_parallel_tool_calls")
if cfg_val is not None:
return str(cfg_val).strip().lower() in true_vals
return False


def get_safety_permission_level():
"""
Checks puppy.cfg for 'safety_permission_level' (case-insensitive in value only).
Expand Down
2 changes: 2 additions & 0 deletions code_puppy/messaging/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
get_message_bus,
get_session_context,
reset_message_bus,
reset_session_context,
set_session_context,
)
from .bus import emit as bus_emit # Convenience functions (new API versions)
Expand Down Expand Up @@ -247,6 +248,7 @@
"reset_message_bus",
# Session context
"set_session_context",
"reset_session_context",
"get_session_context",
# New API convenience functions (prefixed to avoid collision)
"bus_emit",
Expand Down
63 changes: 50 additions & 13 deletions code_puppy/messaging/bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
import asyncio
import queue
import threading
from contextvars import ContextVar, Token
from typing import Any, Dict, List, Optional, Tuple
from uuid import uuid4

Expand All @@ -57,6 +58,16 @@
UserInputRequest,
)

# Session context lives in a ContextVar (NOT a plain shared attribute) so that
# concurrently-running sub-agents each get an isolated copy. ``asyncio`` copies
# the current context when a Task is created, so every fanned-out sub-agent
# invocation sees its own session id and can't clobber a sibling's. This mirrors
# the per-session browser isolation in ``browser_manager`` — same pattern, one
# source of truth for "who am I right now".
_session_context_var: ContextVar[Optional[str]] = ContextVar(
"code_puppy_session_id", default=None
)


class MessageBus:
"""Central coordinator for bidirectional Agent <-> UI communication.
Expand Down Expand Up @@ -89,8 +100,8 @@ def __init__(self, maxsize: int = 1000) -> None:
# Request/Response correlation: prompt_id → Future (for async usage)
self._pending_requests: Dict[str, asyncio.Future[Any]] = {}

# Session context for multi-agent tracking
self._current_session_id: Optional[str] = None
# Session context for multi-agent tracking lives in a module-level
# ContextVar (see top of file) so parallel sub-agents stay isolated.

# =========================================================================
# Outgoing Messages (Agent → UI)
Expand All @@ -106,10 +117,13 @@ def emit(self, message: AnyMessage) -> None:
Args:
message: The message to emit.
"""
# Auto-tag message with current session if not already set
# Auto-tag message with current session if not already set. The session
# id comes from the ContextVar so it's correct per-task even when many
# sub-agents run concurrently.
current_session_id = _session_context_var.get()
with self._lock:
if message.session_id is None and self._current_session_id is not None:
message.session_id = self._current_session_id
if message.session_id is None and current_session_id is not None:
message.session_id = current_session_id

if not self._has_active_renderer:
self._startup_buffer.append(message)
Expand Down Expand Up @@ -181,26 +195,40 @@ def emit_shell_line(self, line: str, stream: str = "stdout") -> None:
# Session Context (Multi-Agent Tracking)
# =========================================================================

def set_session_context(self, session_id: Optional[str]) -> None:
def set_session_context(self, session_id: Optional[str]) -> Token:
"""Set the current session context for auto-tagging messages.

When set, all messages emitted via emit() will be automatically tagged
with this session_id unless they already have one set.

Returns a ``Token`` that callers MUST hand back to
``reset_session_context`` (typically in a ``finally``) so the previous
value is restored without the racey "save the old value, restore it
later" dance that breaks under concurrency.

Args:
session_id: The session ID to tag messages with, or None to clear.

Returns:
A contextvars ``Token`` for restoring the prior context.
"""
with self._lock:
self._current_session_id = session_id
return _session_context_var.set(session_id)

def reset_session_context(self, token: Token) -> None:
"""Restore the session context to the value captured by ``token``.

Args:
token: The ``Token`` returned by a prior ``set_session_context``.
"""
_session_context_var.reset(token)

def get_session_context(self) -> Optional[str]:
"""Get the current session context.

Returns:
The current session_id, or None if not set.
"""
with self._lock:
return self._current_session_id
return _session_context_var.get()

# =========================================================================
# User Input Requests (Agent waits for UI response)
Expand Down Expand Up @@ -594,9 +622,17 @@ def emit_shell_line(line: str, stream: str = "stdout") -> None:
get_message_bus().emit_shell_line(line, stream)


def set_session_context(session_id: Optional[str]) -> None:
"""Set the session context on the global bus."""
get_message_bus().set_session_context(session_id)
def set_session_context(session_id: Optional[str]) -> Token:
"""Set the session context on the global bus.

Returns a ``Token`` to pass to ``reset_session_context`` when done.
"""
return get_message_bus().set_session_context(session_id)


def reset_session_context(token: Token) -> None:
"""Restore the session context using a token from ``set_session_context``."""
get_message_bus().reset_session_context(token)


def get_session_context() -> Optional[str]:
Expand Down Expand Up @@ -624,5 +660,6 @@ def get_session_context() -> Optional[str]:
"emit_shell_line",
# Session context
"set_session_context",
"reset_session_context",
"get_session_context",
]
14 changes: 11 additions & 3 deletions code_puppy/model_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,12 @@

from . import callbacks
from .claude_cache_client import ClaudeCacheAsyncClient, patch_anthropic_client_messages
from .config import EXTRA_MODELS_FILE, get_value, get_yolo_mode
from .config import (
EXTRA_MODELS_FILE,
get_allow_parallel_tool_calls,
get_value,
get_yolo_mode,
)
from .http_utils import create_async_client, get_cert_bundle_path, get_http2
from .provider_identity import (
make_anthropic_provider,
Expand Down Expand Up @@ -168,8 +173,11 @@ def make_model_settings(
effective_settings = get_effective_model_settings(model_name)
model_settings_dict.update(effective_settings)

# Disable parallel tool calls when yolo_mode is off (sequential so user can review each call)
if not get_yolo_mode():
# Disable parallel tool calls when yolo_mode is off so the user can review
# each call sequentially -- UNLESS they've explicitly opted into parallel
# fan-out via allow_parallel_tool_calls (needed for Claude-Code-style
# parallel sub-agent spawning outside yolo mode).
if not get_yolo_mode() and not get_allow_parallel_tool_calls():
model_settings_dict["parallel_tool_calls"] = False

# Default to clear_thinking=False for GLM-4.7 and GLM-5 models (preserved thinking)
Expand Down
2 changes: 2 additions & 0 deletions code_puppy/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from code_puppy.tools.subagent_invocation import (
register_invoke_agent,
register_invoke_agent_with_model,
register_invoke_agents,
)
from code_puppy.tools.ask_user_question import register_ask_user_question

Expand Down Expand Up @@ -94,6 +95,7 @@
"list_agents": register_list_agents,
"invoke_agent": register_invoke_agent,
"invoke_agent_with_model": register_invoke_agent_with_model,
"invoke_agents": register_invoke_agents,
"list_available_models": register_list_available_models,
# File Operations
"list_files": register_list_files,
Expand Down
2 changes: 2 additions & 0 deletions code_puppy/tools/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def list_agents(context: RunContext) -> ListAgentsOutput:
_active_subagent_tasks,
register_invoke_agent,
register_invoke_agent_with_model,
register_invoke_agents,
)

__all__ = [
Expand All @@ -290,6 +291,7 @@ def list_agents(context: RunContext) -> ListAgentsOutput:
"_validate_session_id",
"register_invoke_agent",
"register_invoke_agent_with_model",
"register_invoke_agents",
"register_list_agents",
"set_session_context",
]
Loading