From 7f8921e2e936df981ca19547174d58f9f17d200c Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 12:07:01 +0300 Subject: [PATCH 01/44] =?UTF-8?q?workflow:=20Agents=20as=20Tools=20?= =?UTF-8?q?=E2=80=94=20BASIC=20agents=20with=20child=5Fagents=20expose=20c?= =?UTF-8?q?hildren=20as=20tools=20with=20parallel=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add AgentsAsToolsAgent (ToolAgent subclass) that lists child agents as tools and runs tool calls in parallel - factory: BASIC with child_agents -> AgentsAsToolsAgent; otherwise keep McpAgent - validation: include BASIC.child_agents in dependency graph for proper creation order --- .../agents/workflow/agents_as_tools_agent.py | 212 ++++++++++++++++++ src/fast_agent/core/direct_decorators.py | 2 + src/fast_agent/core/direct_factory.py | 90 ++++++-- src/fast_agent/core/validation.py | 1 + 4 files changed, 281 insertions(+), 24 deletions(-) create mode 100644 src/fast_agent/agents/workflow/agents_as_tools_agent.py diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py new file mode 100644 index 000000000..a12b5ab1d --- /dev/null +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -0,0 +1,212 @@ +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional + +from mcp import ListToolsResult, Tool +from mcp.types import CallToolResult + +from fast_agent.agents.agent_types import AgentConfig +from fast_agent.agents.llm_agent import LlmAgent +from fast_agent.agents.tool_agent import ToolAgent +from fast_agent.core.logging.logger import get_logger +from fast_agent.core.prompt import Prompt +from fast_agent.mcp.helpers.content_helpers import text_content +from fast_agent.types import PromptMessageExtended, RequestParams + +logger = get_logger(__name__) + + +class AgentsAsToolsAgent(ToolAgent): + """ + An agent that makes each child agent available as an MCP Tool to the parent LLM. + + - list_tools() advertises one tool per child agent + - call_tool() routes execution to the corresponding child agent + - run_tools() is overridden to process multiple tool calls in parallel + """ + + def __init__( + self, + config: AgentConfig, + agents: List[LlmAgent], + context: Optional[Any] = None, + **kwargs: Any, + ) -> None: + # Initialize as a ToolAgent but without local FastMCP tools; we'll override list_tools + super().__init__(config=config, tools=[], context=context) + self._child_agents: Dict[str, LlmAgent] = {} + self._tool_names: List[str] = [] + + # Build tool name mapping for children + for child in agents: + tool_name = self._make_tool_name(child.name) + if tool_name in self._child_agents: + logger.warning( + f"Duplicate tool name '{tool_name}' for child agent '{child.name}', overwriting" + ) + self._child_agents[tool_name] = child + self._tool_names.append(tool_name) + + def _make_tool_name(self, child_name: str) -> str: + # Use a distinct prefix to avoid collisions with MCP tools + return f"agent__{child_name}" + + async def initialize(self) -> None: + await super().initialize() + # Initialize all child agents + for agent in self._child_agents.values(): + if not getattr(agent, "initialized", False): + await agent.initialize() + + async def shutdown(self) -> None: + await super().shutdown() + # Shutdown children, but do not fail the parent if any child errors + for agent in self._child_agents.values(): + try: + await agent.shutdown() + except Exception as e: + logger.warning(f"Error shutting down child agent {agent.name}: {e}") + + async def list_tools(self) -> ListToolsResult: + # Dynamically advertise one tool per child agent + tools: List[Tool] = [] + for tool_name, agent in self._child_agents.items(): + # Minimal permissive schema: accept either plain text or arbitrary JSON + input_schema: Dict[str, Any] = { + "type": "object", + "properties": { + "text": {"type": "string", "description": "Plain text input"}, + "json": {"type": "object", "description": "Arbitrary JSON payload"}, + }, + "additionalProperties": True, + } + tools.append( + Tool( + name=tool_name, + description=agent.instruction, + inputSchema=input_schema, + ) + ) + return ListToolsResult(tools=tools) + + async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> CallToolResult: + # Route the call to the corresponding child agent + child = self._child_agents.get(name) + if child is None: + # Fallback: try to resolve without prefix in case the LLM omitted it + alt = self._child_agents.get(self._make_tool_name(name)) + if alt is not None: + child = alt + if child is None: + return CallToolResult(content=[text_content(f"Unknown agent-tool: {name}")], isError=True) + + args = arguments or {} + # Prefer explicit text; otherwise serialize json; otherwise serialize entire dict + input_text: str + if isinstance(args.get("text"), str): + input_text = args["text"] + else: + import json + + if "json" in args: + try: + input_text = json.dumps(args["json"], ensure_ascii=False) + except Exception: + input_text = str(args["json"]) + else: + try: + input_text = json.dumps(args, ensure_ascii=False) + except Exception: + input_text = str(args) + + # Build a single-user message to the child and execute + child_request = Prompt.user(input_text) + try: + # We do not override child's request_params; pass None to use child's defaults + response: PromptMessageExtended = await child.generate([child_request], None) + return CallToolResult( + content=[text_content(response.all_text() or "")], + isError=False, + ) + except Exception as e: + logger.error(f"Child agent {child.name} failed: {e}") + return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) + + async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtended: + """ + Override ToolAgent.run_tools to execute multiple tool calls in parallel. + """ + if not request.tool_calls: + logger.warning("No tool calls found in request", data=request) + return PromptMessageExtended(role="user", tool_results={}) + + tool_results: Dict[str, CallToolResult] = {} + tool_loop_error: str | None = None + + # Snapshot available tools for validation and UI + try: + listed = await self.list_tools() + available_tools = [t.name for t in listed.tools] + except Exception as exc: + logger.warning(f"Failed to list tools before execution: {exc}") + available_tools = list(self._child_agents.keys()) + + # Build tasks for parallel execution + tasks: List[asyncio.Task] = [] + id_list: List[str] = [] + for correlation_id, tool_request in request.tool_calls.items(): + tool_name = tool_request.params.name + tool_args = tool_request.params.arguments or {} + + if tool_name not in available_tools and self._make_tool_name(tool_name) not in available_tools: + # Mark error in results but continue other tools + error_message = f"Tool '{tool_name}' is not available" + tool_results[correlation_id] = CallToolResult( + content=[text_content(error_message)], isError=True + ) + tool_loop_error = tool_loop_error or error_message + continue + + # UI: show planned tool call + try: + highlight_index = available_tools.index(tool_name) + except ValueError: + highlight_index = None + self.display.show_tool_call( + name=self.name, + tool_args=tool_args, + bottom_items=available_tools, + tool_name=tool_name, + highlight_index=highlight_index, + max_item_length=12, + ) + + # Schedule execution + id_list.append(correlation_id) + tasks.append(asyncio.create_task(self.call_tool(tool_name, tool_args))) + + # Execute concurrently + if tasks: + results = await asyncio.gather(*tasks, return_exceptions=True) + for i, result in enumerate(results): + correlation_id = id_list[i] + if isinstance(result, Exception): + msg = f"Tool execution failed: {result}" + tool_results[correlation_id] = CallToolResult( + content=[text_content(msg)], isError=True + ) + tool_loop_error = tool_loop_error or msg + else: + tool_results[correlation_id] = result + + # UI: show results + for cid, res in tool_results.items(): + # Try to infer the name shown in UI + try: + tool_name = request.tool_calls[cid].params.name + except Exception: + tool_name = None + self.display.show_tool_result(name=self.name, result=res, tool_name=tool_name) + + return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) diff --git a/src/fast_agent/core/direct_decorators.py b/src/fast_agent/core/direct_decorators.py index 585633fb0..7c6036950 100644 --- a/src/fast_agent/core/direct_decorators.py +++ b/src/fast_agent/core/direct_decorators.py @@ -257,6 +257,7 @@ def agent( instruction_or_kwarg: Optional[str | Path | AnyUrl] = None, *, instruction: str | Path | AnyUrl = "You are a helpful agent.", + agents: List[str] = [], servers: List[str] = [], tools: Optional[Dict[str, List[str]]] = None, resources: Optional[Dict[str, List[str]]] = None, @@ -302,6 +303,7 @@ def agent( AgentType.BASIC, name=name, instruction=final_instruction, + child_agents=agents, servers=servers, model=model, use_history=use_history, diff --git a/src/fast_agent/core/direct_factory.py b/src/fast_agent/core/direct_factory.py index 68df79240..bb35b05be 100644 --- a/src/fast_agent/core/direct_factory.py +++ b/src/fast_agent/core/direct_factory.py @@ -161,33 +161,75 @@ async def create_agents_by_type( # Type-specific initialization based on the Enum type # Note: Above we compared string values from config, here we compare Enum objects directly if agent_type == AgentType.BASIC: - # Create agent with UI support if needed - agent = _create_agent_with_ui_if_needed( - McpAgent, - config, - app_instance.context, - ) + # If BASIC agent declares child_agents, build an Agents-as-Tools wrapper + child_names = agent_data.get("child_agents", []) or [] + if child_names: + # Ensure child agents are already created + child_agents: list[AgentProtocol] = [] + for agent_name in child_names: + if agent_name not in active_agents: + raise AgentConfigError(f"Agent {agent_name} not found") + child_agents.append(active_agents[agent_name]) + + # Import here to avoid circulars at module import time + from fast_agent.agents.workflow.agents_as_tools_agent import ( + AgentsAsToolsAgent, + ) - await agent.initialize() + agent = AgentsAsToolsAgent( + config=config, + context=app_instance.context, + agents=child_agents, # expose children as tools + ) - # Attach LLM to the agent - llm_factory = model_factory_func(model=config.model) - await agent.attach_llm( - llm_factory, - request_params=config.default_request_params, - api_key=config.api_key, - ) - result_agents[name] = agent + await agent.initialize() - # Log successful agent creation - logger.info( - f"Loaded {name}", - data={ - "progress_action": ProgressAction.LOADED, - "agent_name": name, - "target": name, - }, - ) + # Attach LLM to the agent + llm_factory = model_factory_func(model=config.model) + await agent.attach_llm( + llm_factory, + request_params=config.default_request_params, + api_key=config.api_key, + ) + result_agents[name] = agent + + # Log successful agent creation + logger.info( + f"Loaded {name}", + data={ + "progress_action": ProgressAction.LOADED, + "agent_name": name, + "target": name, + }, + ) + else: + # Create agent with UI support if needed + agent = _create_agent_with_ui_if_needed( + McpAgent, + config, + app_instance.context, + ) + + await agent.initialize() + + # Attach LLM to the agent + llm_factory = model_factory_func(model=config.model) + await agent.attach_llm( + llm_factory, + request_params=config.default_request_params, + api_key=config.api_key, + ) + result_agents[name] = agent + + # Log successful agent creation + logger.info( + f"Loaded {name}", + data={ + "progress_action": ProgressAction.LOADED, + "agent_name": name, + "target": name, + }, + ) elif agent_type == AgentType.CUSTOM: # Get the class to instantiate (support legacy 'agent_class' and new 'cls') diff --git a/src/fast_agent/core/validation.py b/src/fast_agent/core/validation.py index 66cb5c25f..2ffe780b7 100644 --- a/src/fast_agent/core/validation.py +++ b/src/fast_agent/core/validation.py @@ -207,6 +207,7 @@ def get_agent_dependencies(agent_data: dict[str, Any]) -> set[str]: AgentType.EVALUATOR_OPTIMIZER: ("evaluator", "generator", "eval_optimizer_agents"), AgentType.ITERATIVE_PLANNER: ("child_agents",), AgentType.ORCHESTRATOR: ("child_agents",), + AgentType.BASIC: ("child_agents",), AgentType.PARALLEL: ("fan_out", "fan_in", "parallel_agents"), AgentType.ROUTER: ("router_agents",), } From aedfdda37bcf58a06a2d5fd9c36f4e155875e9ca Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 13:34:29 +0300 Subject: [PATCH 02/44] workflow: suppress child agent display + simplify aggregated view for Agents-as-Tools - pass RequestParams(show_chat=False, show_tools=False) to child agents when invoked as tools - always use aggregated display regardless of single/parallel tool count - single agent: 'Calling agent: X' with full content blocks in result - multiple agents: summary list with previews - removes duplicate stacked tool call/result blocks --- .../agents/workflow/agents_as_tools_agent.py | 176 +++++++++++++++--- 1 file changed, 146 insertions(+), 30 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index a12b5ab1d..ac064a1b7 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -11,7 +11,8 @@ from fast_agent.agents.tool_agent import ToolAgent from fast_agent.core.logging.logger import get_logger from fast_agent.core.prompt import Prompt -from fast_agent.mcp.helpers.content_helpers import text_content +from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content, text_content +from fast_agent.ui.message_primitives import MessageType from fast_agent.types import PromptMessageExtended, RequestParams logger = get_logger(__name__) @@ -123,16 +124,122 @@ async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> # Build a single-user message to the child and execute child_request = Prompt.user(input_text) try: - # We do not override child's request_params; pass None to use child's defaults - response: PromptMessageExtended = await child.generate([child_request], None) + # Suppress child agent display when invoked as a tool + child_params = RequestParams(show_chat=False, show_tools=False) + response: PromptMessageExtended = await child.generate([child_request], child_params) + # Prefer preserving original content blocks for better UI fidelity + content_blocks = list(response.content or []) + + # Mark error if error channel contains entries, and surface them + from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL + + error_blocks = None + if response.channels and FAST_AGENT_ERROR_CHANNEL in response.channels: + error_blocks = response.channels.get(FAST_AGENT_ERROR_CHANNEL) or [] + # Append error blocks so they are visible in the tool result panel + if error_blocks: + content_blocks.extend(error_blocks) + return CallToolResult( - content=[text_content(response.all_text() or "")], - isError=False, + content=content_blocks, + isError=bool(error_blocks), ) except Exception as e: logger.error(f"Child agent {child.name} failed: {e}") return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) + def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: + if not descriptors: + return + + status_labels = { + "pending": "running", + "error": "error", + "missing": "missing", + } + + bottom_items: List[str] = [] + for desc in descriptors: + tool_label = desc.get("tool", "(unknown)") + status = desc.get("status", "pending") + status_label = status_labels.get(status, status) + bottom_items.append(f"{tool_label} · {status_label}") + + if len(descriptors) == 1: + content = f"Calling agent: {descriptors[0].get('tool', '(unknown)')}" + else: + lines = [f"Calling {len(descriptors)} agents:"] + for desc in descriptors: + tool_label = desc.get("tool", "(unknown)") + status = desc.get("status", "pending") + status_label = status_labels.get(status, status) + lines.append(f" • {tool_label}: {status_label}") + content = "\n".join(lines) + + self.display.display_message( + content=content, + message_type=MessageType.TOOL_CALL, + name=self.name, + bottom_metadata=bottom_items, + max_item_length=28, + ) + + def _summarize_result_text(self, result: CallToolResult) -> str: + for block in result.content or []: + if is_text_content(block): + text = (get_text(block) or "").strip() + if text: + text = text.replace("\n", " ") + return text[:180] + "…" if len(text) > 180 else text + return "" + + def _show_parallel_tool_results( + self, records: List[Dict[str, Any]] + ) -> None: + if not records: + return + + bottom_items: List[str] = [] + any_error = False + + for record in records: + descriptor = record.get("descriptor", {}) + result: CallToolResult = record.get("result") + tool_label = descriptor.get("tool", "(unknown)") + status = "error" if result and result.isError else "done" + if result and result.isError: + any_error = True + bottom_items.append(f"{tool_label} · {status}") + + if len(records) == 1: + # Single result: show content directly + record = records[0] + result = record.get("result") + content = result.content if result else [] + else: + # Multiple results: show summary list + lines = [f"Completed {len(records)} agents:"] + for record in records: + descriptor = record.get("descriptor", {}) + result = record.get("result") + tool_label = descriptor.get("tool", "(unknown)") + status = "error" if result and result.isError else "done" + preview = self._summarize_result_text(result) if result else "" + if preview: + lines.append(f" • {tool_label}: {status} — {preview}") + else: + lines.append(f" • {tool_label}: {status}") + content = "\n".join(lines) + + self.display.display_message( + content=content, + message_type=MessageType.TOOL_RESULT, + name=self.name, + bottom_metadata=bottom_items, + max_item_length=28, + is_error=any_error, + ) + async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtended: """ Override ToolAgent.run_tools to execute multiple tool calls in parallel. @@ -152,40 +259,40 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend logger.warning(f"Failed to list tools before execution: {exc}") available_tools = list(self._child_agents.keys()) - # Build tasks for parallel execution + # Build aggregated view of all tool calls + call_descriptors: List[Dict[str, Any]] = [] + descriptor_by_id: Dict[str, Dict[str, Any]] = {} tasks: List[asyncio.Task] = [] id_list: List[str] = [] + for correlation_id, tool_request in request.tool_calls.items(): tool_name = tool_request.params.name tool_args = tool_request.params.arguments or {} + descriptor = { + "id": correlation_id, + "tool": tool_name, + "args": tool_args, + } + call_descriptors.append(descriptor) + descriptor_by_id[correlation_id] = descriptor + if tool_name not in available_tools and self._make_tool_name(tool_name) not in available_tools: - # Mark error in results but continue other tools error_message = f"Tool '{tool_name}' is not available" tool_results[correlation_id] = CallToolResult( content=[text_content(error_message)], isError=True ) tool_loop_error = tool_loop_error or error_message + descriptor["status"] = "error" continue - # UI: show planned tool call - try: - highlight_index = available_tools.index(tool_name) - except ValueError: - highlight_index = None - self.display.show_tool_call( - name=self.name, - tool_args=tool_args, - bottom_items=available_tools, - tool_name=tool_name, - highlight_index=highlight_index, - max_item_length=12, - ) - - # Schedule execution + descriptor["status"] = "pending" id_list.append(correlation_id) tasks.append(asyncio.create_task(self.call_tool(tool_name, tool_args))) + # Show aggregated tool call(s) + self._show_parallel_tool_calls(call_descriptors) + # Execute concurrently if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) @@ -197,16 +304,25 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend content=[text_content(msg)], isError=True ) tool_loop_error = tool_loop_error or msg + if descriptor_by_id.get(correlation_id): + descriptor_by_id[correlation_id]["status"] = "error" + descriptor_by_id[correlation_id]["error_message"] = msg else: tool_results[correlation_id] = result + if descriptor_by_id.get(correlation_id): + descriptor_by_id[correlation_id]["status"] = ( + "error" if result.isError else "done" + ) - # UI: show results - for cid, res in tool_results.items(): - # Try to infer the name shown in UI - try: - tool_name = request.tool_calls[cid].params.name - except Exception: - tool_name = None - self.display.show_tool_result(name=self.name, result=res, tool_name=tool_name) + # Show aggregated result(s) + ordered_records: List[Dict[str, Any]] = [] + for cid in request.tool_calls.keys(): + result = tool_results.get(cid) + if result is None: + continue + descriptor = descriptor_by_id.get(cid, {}) + ordered_records.append({"descriptor": descriptor, "result": result}) + + self._show_parallel_tool_results(ordered_records) return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 82df4ba5a438f773aa28faa14958fad24704b1ac Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 13:51:55 +0300 Subject: [PATCH 03/44] fix: suppress child display via config modification, not RequestParams - RequestParams doesn't support show_chat/show_tools (those are Settings.logger fields) - temporarily modify child.display.config before calling generate() - restore original config in finally block - fixes 'AsyncCompletions.create() got unexpected keyword argument' error --- .../agents/workflow/agents_as_tools_agent.py | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index ac064a1b7..80ded0d7b 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -125,8 +125,21 @@ async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> child_request = Prompt.user(input_text) try: # Suppress child agent display when invoked as a tool - child_params = RequestParams(show_chat=False, show_tools=False) - response: PromptMessageExtended = await child.generate([child_request], child_params) + # Save original config, temporarily disable display + original_config = None + if hasattr(child, 'display') and child.display and child.display.config: + original_config = child.display.config + # Create a modified config with display disabled + from copy import copy + temp_config = copy(original_config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = False + temp_config.logger = temp_logger + child.display.config = temp_config + + response: PromptMessageExtended = await child.generate([child_request], None) # Prefer preserving original content blocks for better UI fidelity content_blocks = list(response.content or []) @@ -147,6 +160,10 @@ async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> except Exception as e: logger.error(f"Child agent {child.name} failed: {e}") return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) + finally: + # Restore original config + if original_config and hasattr(child, 'display') and child.display: + child.display.config = original_config def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: if not descriptors: From 5e4f855f015e75d77f74ed36755c78be32faa828 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 13:58:05 +0300 Subject: [PATCH 04/44] display: show detailed I/O for each agent tool call/result - display individual tool call blocks with full arguments for each agent - display individual tool result blocks with full content for each agent - removes minimal aggregated view in favor of detailed per-agent display - fixes missing chat logs for agent arguments and responses --- .../agents/workflow/agents_as_tools_agent.py | 74 ++++++++----------- 1 file changed, 29 insertions(+), 45 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 80ded0d7b..27b4c30a8 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -182,24 +182,23 @@ def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: status_label = status_labels.get(status, status) bottom_items.append(f"{tool_label} · {status_label}") - if len(descriptors) == 1: - content = f"Calling agent: {descriptors[0].get('tool', '(unknown)')}" - else: - lines = [f"Calling {len(descriptors)} agents:"] - for desc in descriptors: - tool_label = desc.get("tool", "(unknown)") - status = desc.get("status", "pending") - status_label = status_labels.get(status, status) - lines.append(f" • {tool_label}: {status_label}") - content = "\n".join(lines) - - self.display.display_message( - content=content, - message_type=MessageType.TOOL_CALL, - name=self.name, - bottom_metadata=bottom_items, - max_item_length=28, - ) + # Show detailed call information for each agent + for desc in descriptors: + tool_name = desc.get("tool", "(unknown)") + args = desc.get("args", {}) + status = desc.get("status", "pending") + + if status == "error": + continue # Skip display for error tools, will show in results + + # Show individual tool call with arguments + self.display.show_tool_call( + name=self.name, + tool_name=tool_name, + tool_args=args, + bottom_items=bottom_items, + max_item_length=28, + ) def _summarize_result_text(self, result: CallToolResult) -> str: for block in result.content or []: @@ -228,34 +227,19 @@ def _show_parallel_tool_results( any_error = True bottom_items.append(f"{tool_label} · {status}") - if len(records) == 1: - # Single result: show content directly - record = records[0] + # Show detailed result for each agent + for record in records: + descriptor = record.get("descriptor", {}) result = record.get("result") - content = result.content if result else [] - else: - # Multiple results: show summary list - lines = [f"Completed {len(records)} agents:"] - for record in records: - descriptor = record.get("descriptor", {}) - result = record.get("result") - tool_label = descriptor.get("tool", "(unknown)") - status = "error" if result and result.isError else "done" - preview = self._summarize_result_text(result) if result else "" - if preview: - lines.append(f" • {tool_label}: {status} — {preview}") - else: - lines.append(f" • {tool_label}: {status}") - content = "\n".join(lines) - - self.display.display_message( - content=content, - message_type=MessageType.TOOL_RESULT, - name=self.name, - bottom_metadata=bottom_items, - max_item_length=28, - is_error=any_error, - ) + tool_name = descriptor.get("tool", "(unknown)") + + if result: + # Show individual tool result with full content + self.display.show_tool_result( + name=self.name, + tool_name=tool_name, + result=result, + ) async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtended: """ From 7937c737f786bd98504ac4ddd0b072a995b88492 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:01:58 +0300 Subject: [PATCH 05/44] feat: add instance count indicator for parallel agent execution - show 'instances N' in status when multiple agents called in parallel - metadata['instance_info'] passed to tool_call display - _instance_count attribute added to tool_result for display - parallel execution already working via asyncio.gather - displays in right_info: 'tool request - name | instances 2' --- .../agents/workflow/agents_as_tools_agent.py | 19 ++++++++++++++++++- src/fast_agent/ui/tool_display.py | 15 +++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 27b4c30a8..10c33000a 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -182,8 +182,11 @@ def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: status_label = status_labels.get(status, status) bottom_items.append(f"{tool_label} · {status_label}") + # Show instance count if multiple agents + instance_count = len([d for d in descriptors if d.get("status") != "error"]) + # Show detailed call information for each agent - for desc in descriptors: + for i, desc in enumerate(descriptors): tool_name = desc.get("tool", "(unknown)") args = desc.get("args", {}) status = desc.get("status", "pending") @@ -191,6 +194,11 @@ def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: if status == "error": continue # Skip display for error tools, will show in results + # Build metadata for display + metadata = {} + if instance_count > 1: + metadata["instance_info"] = f"instances {instance_count}" + # Show individual tool call with arguments self.display.show_tool_call( name=self.name, @@ -198,6 +206,7 @@ def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: tool_args=args, bottom_items=bottom_items, max_item_length=28, + metadata=metadata, ) def _summarize_result_text(self, result: CallToolResult) -> str: @@ -227,6 +236,9 @@ def _show_parallel_tool_results( any_error = True bottom_items.append(f"{tool_label} · {status}") + # Show instance count if multiple agents + instance_count = len(records) + # Show detailed result for each agent for record in records: descriptor = record.get("descriptor", {}) @@ -234,6 +246,11 @@ def _show_parallel_tool_results( tool_name = descriptor.get("tool", "(unknown)") if result: + # Add instance count to result if multiple + if instance_count > 1: + # Add metadata to track parallel execution + setattr(result, "_instance_count", instance_count) + # Show individual tool result with full content self.display.show_tool_result( name=self.name, diff --git a/src/fast_agent/ui/tool_display.py b/src/fast_agent/ui/tool_display.py index 31bf20daa..200dce210 100644 --- a/src/fast_agent/ui/tool_display.py +++ b/src/fast_agent/ui/tool_display.py @@ -98,7 +98,13 @@ def show_tool_result( bottom_metadata_items.append("Structured ■") bottom_metadata = bottom_metadata_items or None - right_info = f"[dim]tool result - {status}[/dim]" + + # Build right_info with instance count if present + right_parts = [f"tool result - {status}"] + instance_count = getattr(result, "_instance_count", None) + if instance_count and instance_count > 1: + right_parts.append(f"instances {instance_count}") + right_info = f"[dim]{' | '.join(right_parts)}[/dim]" if has_structured: config_map = MESSAGE_CONFIGS[MessageType.TOOL_RESULT] @@ -206,7 +212,12 @@ def show_tool_call( tool_args = tool_args or {} metadata = metadata or {} - right_info = f"[dim]tool request - {tool_name}[/dim]" + # Build right_info with instance count if present + right_parts = [f"tool request - {tool_name}"] + if metadata.get("instance_info"): + right_parts.append(metadata["instance_info"]) + right_info = f"[dim]{' | '.join(right_parts)}[/dim]" + content: Any = tool_args pre_content: Text | None = None truncate_content = True From 2f5f5f9934dba93bef3f550517b5d621d76bfa62 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:03:48 +0300 Subject: [PATCH 06/44] refactor: optimize AgentsAsToolsAgent code Optimizations: - Move json and copy imports to module level (avoid repeated imports) - Remove unused _tool_names variable - Simplify child agent lookup with chained or operator - Streamline input_text serialization logic (remove nested try/except) - Remove redundant iteration in _show_parallel_tool_results - Remove unnecessary descriptor_by_id.get() checks (key always exists) - Simplify inline conditionals for readability No behavior changes, purely code cleanup and performance improvement. --- .../agents/workflow/agents_as_tools_agent.py | 53 ++++--------------- 1 file changed, 9 insertions(+), 44 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 10c33000a..257752020 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -1,6 +1,8 @@ from __future__ import annotations import asyncio +import json +from copy import copy from typing import Any, Dict, List, Optional from mcp import ListToolsResult, Tool @@ -37,7 +39,6 @@ def __init__( # Initialize as a ToolAgent but without local FastMCP tools; we'll override list_tools super().__init__(config=config, tools=[], context=context) self._child_agents: Dict[str, LlmAgent] = {} - self._tool_names: List[str] = [] # Build tool name mapping for children for child in agents: @@ -47,7 +48,6 @@ def __init__( f"Duplicate tool name '{tool_name}' for child agent '{child.name}', overwriting" ) self._child_agents[tool_name] = child - self._tool_names.append(tool_name) def _make_tool_name(self, child_name: str) -> str: # Use a distinct prefix to avoid collisions with MCP tools @@ -93,44 +93,26 @@ async def list_tools(self) -> ListToolsResult: async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> CallToolResult: # Route the call to the corresponding child agent - child = self._child_agents.get(name) - if child is None: - # Fallback: try to resolve without prefix in case the LLM omitted it - alt = self._child_agents.get(self._make_tool_name(name)) - if alt is not None: - child = alt + child = self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name)) if child is None: return CallToolResult(content=[text_content(f"Unknown agent-tool: {name}")], isError=True) args = arguments or {} # Prefer explicit text; otherwise serialize json; otherwise serialize entire dict - input_text: str if isinstance(args.get("text"), str): input_text = args["text"] + elif "json" in args: + input_text = json.dumps(args["json"], ensure_ascii=False) if isinstance(args["json"], dict) else str(args["json"]) else: - import json - - if "json" in args: - try: - input_text = json.dumps(args["json"], ensure_ascii=False) - except Exception: - input_text = str(args["json"]) - else: - try: - input_text = json.dumps(args, ensure_ascii=False) - except Exception: - input_text = str(args) + input_text = json.dumps(args, ensure_ascii=False) if args else "" # Build a single-user message to the child and execute child_request = Prompt.user(input_text) try: # Suppress child agent display when invoked as a tool - # Save original config, temporarily disable display original_config = None if hasattr(child, 'display') and child.display and child.display.config: original_config = child.display.config - # Create a modified config with display disabled - from copy import copy temp_config = copy(original_config) if hasattr(temp_config, 'logger'): temp_logger = copy(temp_config.logger) @@ -224,19 +206,6 @@ def _show_parallel_tool_results( if not records: return - bottom_items: List[str] = [] - any_error = False - - for record in records: - descriptor = record.get("descriptor", {}) - result: CallToolResult = record.get("result") - tool_label = descriptor.get("tool", "(unknown)") - status = "error" if result and result.isError else "done" - if result and result.isError: - any_error = True - bottom_items.append(f"{tool_label} · {status}") - - # Show instance count if multiple agents instance_count = len(records) # Show detailed result for each agent @@ -322,15 +291,11 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend content=[text_content(msg)], isError=True ) tool_loop_error = tool_loop_error or msg - if descriptor_by_id.get(correlation_id): - descriptor_by_id[correlation_id]["status"] = "error" - descriptor_by_id[correlation_id]["error_message"] = msg + descriptor_by_id[correlation_id]["status"] = "error" + descriptor_by_id[correlation_id]["error_message"] = msg else: tool_results[correlation_id] = result - if descriptor_by_id.get(correlation_id): - descriptor_by_id[correlation_id]["status"] = ( - "error" if result.isError else "done" - ) + descriptor_by_id[correlation_id]["status"] = "error" if result.isError else "done" # Show aggregated result(s) ordered_records: List[Dict[str, Any]] = [] From 643ea394e4afaddec8d2b5a8626e6f7f8a8af372 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:22:14 +0300 Subject: [PATCH 07/44] feat: add instance IDs to progress + restore child tool logs Changes: - Add instance IDs (: 1, : 2, etc.) to child agent names when instances > 1 - Modified before task creation so progress events use numbered names - Restored after execution completes - Shows as 'PM-1-DayStatusSummarizer: 1' and 'PM-1-DayStatusSummarizer: 2' in progress panel - Restore child agent tool call logs (show_tools) - Only suppress show_chat (child's assistant messages) - Keep show_tools=True to see child's internal tool activity - Fixes 'lost logs from child agents' issue Result: Separate progress lines for parallel instances + full visibility into child tool calls --- .../agents/workflow/agents_as_tools_agent.py | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 257752020..ed4eb4512 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -109,7 +109,7 @@ async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> # Build a single-user message to the child and execute child_request = Prompt.user(input_text) try: - # Suppress child agent display when invoked as a tool + # Suppress only child agent chat messages (keep tool calls visible) original_config = None if hasattr(child, 'display') and child.display and child.display.config: original_config = child.display.config @@ -117,7 +117,7 @@ async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> if hasattr(temp_config, 'logger'): temp_logger = copy(temp_config.logger) temp_logger.show_chat = False - temp_logger.show_tools = False + # Keep show_tools = True to see child's internal tool activity temp_config.logger = temp_logger child.display.config = temp_config @@ -275,6 +275,22 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) + + # Add instance IDs to child agent names BEFORE creating tasks + pending_count = len(id_list) + original_names = {} + if pending_count > 1: + for i, cid in enumerate(id_list, 1): + tool_name = descriptor_by_id[cid]["tool"] + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child and hasattr(child, 'name'): + original_names[cid] = child.name + child.name = f"{child.name}: {i}" + + # Now create tasks with modified names + for cid in id_list: + tool_name = descriptor_by_id[cid]["tool"] + tool_args = descriptor_by_id[cid]["args"] tasks.append(asyncio.create_task(self.call_tool(tool_name, tool_args))) # Show aggregated tool call(s) @@ -308,4 +324,11 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend self._show_parallel_tool_results(ordered_records) + # Restore original agent names + for cid, original_name in original_names.items(): + tool_name = descriptor_by_id[cid]["tool"] + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child: + child.name = original_name + return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From cae115505372e32b026a50e236b83d5fc87bbb04 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:30:48 +0300 Subject: [PATCH 08/44] fix: use _name attribute instead of name property for instance IDs - name is a read-only @property that returns self._name - setting child.name had no effect - now properly modifies child._name to show instance numbers in progress panel - fixes missing :1 :2 labels in progress display --- src/fast_agent/agents/workflow/agents_as_tools_agent.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index ed4eb4512..c4d4507fc 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -283,9 +283,9 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child and hasattr(child, 'name'): - original_names[cid] = child.name - child.name = f"{child.name}: {i}" + if child and hasattr(child, '_name'): + original_names[cid] = child._name + child._name = f"{child._name}: {i}" # Now create tasks with modified names for cid in id_list: @@ -329,6 +329,6 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend tool_name = descriptor_by_id[cid]["tool"] child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child: - child.name = original_name + child._name = original_name return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 132c76e0e89362d562627616c09cb69309002b2a Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:34:34 +0300 Subject: [PATCH 09/44] style: align code style with library conventions - Use modern type hints: dict/list instead of Dict/List (PEP 585) - Use pipe union syntax: Any | None instead of Optional[Any] (PEP 604) - Add comprehensive docstrings to all public methods - Remove unnecessary imports (Dict, List, Optional) - Improve inline comments clarity - Match formatting style used in tool_agent.py and parallel_agent.py No functional changes, pure style alignment. --- .../agents/workflow/agents_as_tools_agent.py | 83 +++++++++++++------ 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index c4d4507fc..bce3940ec 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -3,7 +3,7 @@ import asyncio import json from copy import copy -from typing import Any, Dict, List, Optional +from typing import Any from mcp import ListToolsResult, Tool from mcp.types import CallToolResult @@ -32,13 +32,21 @@ class AgentsAsToolsAgent(ToolAgent): def __init__( self, config: AgentConfig, - agents: List[LlmAgent], - context: Optional[Any] = None, + agents: list[LlmAgent], + context: Any | None = None, **kwargs: Any, ) -> None: + """Initialize AgentsAsToolsAgent. + + Args: + config: Agent configuration + agents: List of child agents to expose as tools + context: Optional context for agent execution + **kwargs: Additional arguments passed to ToolAgent + """ # Initialize as a ToolAgent but without local FastMCP tools; we'll override list_tools super().__init__(config=config, tools=[], context=context) - self._child_agents: Dict[str, LlmAgent] = {} + self._child_agents: dict[str, LlmAgent] = {} # Build tool name mapping for children for child in agents: @@ -50,19 +58,26 @@ def __init__( self._child_agents[tool_name] = child def _make_tool_name(self, child_name: str) -> str: - # Use a distinct prefix to avoid collisions with MCP tools + """Generate a tool name for a child agent. + + Args: + child_name: Name of the child agent + + Returns: + Prefixed tool name to avoid collisions with MCP tools + """ return f"agent__{child_name}" async def initialize(self) -> None: + """Initialize this agent and all child agents.""" await super().initialize() - # Initialize all child agents for agent in self._child_agents.values(): if not getattr(agent, "initialized", False): await agent.initialize() async def shutdown(self) -> None: + """Shutdown this agent and all child agents.""" await super().shutdown() - # Shutdown children, but do not fail the parent if any child errors for agent in self._child_agents.values(): try: await agent.shutdown() @@ -70,11 +85,14 @@ async def shutdown(self) -> None: logger.warning(f"Error shutting down child agent {agent.name}: {e}") async def list_tools(self) -> ListToolsResult: - # Dynamically advertise one tool per child agent - tools: List[Tool] = [] + """List all available tools (one per child agent). + + Returns: + ListToolsResult containing tool schemas for all child agents + """ + tools: list[Tool] = [] for tool_name, agent in self._child_agents.items(): - # Minimal permissive schema: accept either plain text or arbitrary JSON - input_schema: Dict[str, Any] = { + input_schema: dict[str, Any] = { "type": "object", "properties": { "text": {"type": "string", "description": "Plain text input"}, @@ -91,14 +109,21 @@ async def list_tools(self) -> ListToolsResult: ) return ListToolsResult(tools=tools) - async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> CallToolResult: - # Route the call to the corresponding child agent + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult: + """Execute a child agent by name. + + Args: + name: Tool name (agent name with prefix) + arguments: Optional arguments to pass to the child agent + + Returns: + CallToolResult containing the child agent's response + """ child = self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name)) if child is None: return CallToolResult(content=[text_content(f"Unknown agent-tool: {name}")], isError=True) args = arguments or {} - # Prefer explicit text; otherwise serialize json; otherwise serialize entire dict if isinstance(args.get("text"), str): input_text = args["text"] elif "json" in args: @@ -106,7 +131,7 @@ async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> else: input_text = json.dumps(args, ensure_ascii=False) if args else "" - # Build a single-user message to the child and execute + # Serialize arguments to text input child_request = Prompt.user(input_text) try: # Suppress only child agent chat messages (keep tool calls visible) @@ -147,7 +172,12 @@ async def call_tool(self, name: str, arguments: Dict[str, Any] | None = None) -> if original_config and hasattr(child, 'display') and child.display: child.display.config = original_config - def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: + def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: + """Display tool call headers for parallel agent execution. + + Args: + descriptors: List of tool call descriptors with metadata + """ if not descriptors: return @@ -157,7 +187,7 @@ def _show_parallel_tool_calls(self, descriptors: List[Dict[str, Any]]) -> None: "missing": "missing", } - bottom_items: List[str] = [] + bottom_items: list[str] = [] for desc in descriptors: tool_label = desc.get("tool", "(unknown)") status = desc.get("status", "pending") @@ -201,8 +231,13 @@ def _summarize_result_text(self, result: CallToolResult) -> str: return "" def _show_parallel_tool_results( - self, records: List[Dict[str, Any]] + self, records: list[dict[str, Any]] ) -> None: + """Display tool result panels for parallel agent execution. + + Args: + records: List of result records with descriptor and result data + """ if not records: return @@ -235,7 +270,7 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend logger.warning("No tool calls found in request", data=request) return PromptMessageExtended(role="user", tool_results={}) - tool_results: Dict[str, CallToolResult] = {} + tool_results: dict[str, CallToolResult] = {} tool_loop_error: str | None = None # Snapshot available tools for validation and UI @@ -247,10 +282,10 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend available_tools = list(self._child_agents.keys()) # Build aggregated view of all tool calls - call_descriptors: List[Dict[str, Any]] = [] - descriptor_by_id: Dict[str, Dict[str, Any]] = {} - tasks: List[asyncio.Task] = [] - id_list: List[str] = [] + call_descriptors: list[dict[str, Any]] = [] + descriptor_by_id: dict[str, dict[str, Any]] = {} + tasks: list[asyncio.Task] = [] + id_list: list[str] = [] for correlation_id, tool_request in request.tool_calls.items(): tool_name = tool_request.params.name @@ -314,7 +349,7 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor_by_id[correlation_id]["status"] = "error" if result.isError else "done" # Show aggregated result(s) - ordered_records: List[Dict[str, Any]] = [] + ordered_records: list[dict[str, Any]] = [] for cid in request.tool_calls.keys(): result = tool_results.get(cid) if result is None: From 0a137a56970228b322adc98d652d3a80eb297b8f Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:37:51 +0300 Subject: [PATCH 10/44] style: change instance ID format from ': 1' to '#1' - Cleaner display format for parallel agent instances - Shows as 'PM-1-DayStatusSummarizer#1' and 'PM-1-DayStatusSummarizer#2' - Appears in both progress panel and chat headers --- src/fast_agent/agents/workflow/agents_as_tools_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index bce3940ec..83b802c23 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -320,7 +320,7 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child and hasattr(child, '_name'): original_names[cid] = child._name - child._name = f"{child._name}: {i}" + child._name = f"{child._name}#{i}" # Now create tasks with modified names for cid in id_list: From bd7c4c929418a8e258b6066ad05db434ca80c5e2 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:40:49 +0300 Subject: [PATCH 11/44] ui: show instance count in tool name instead of metadata Changes: - Agent names: 'PM-1-DayStatusSummarizer[1]' instead of 'PM-1-DayStatusSummarizer#1' - Tool headers: '[tool request - agent__PM-1-DayStatusSummarizer[2]]' instead of '[... | instances 2]' - Tool results: '[tool result - agent__PM-1-DayStatusSummarizer[2]]' - Removed metadata-based instance display from tool_display.py Cleaner display: instance count embedded directly in tool name for both requests and results. --- .../agents/workflow/agents_as_tools_agent.py | 19 +++++++++---------- src/fast_agent/ui/tool_display.py | 15 ++------------- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 83b802c23..fb57bd05e 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -206,19 +206,18 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: if status == "error": continue # Skip display for error tools, will show in results - # Build metadata for display - metadata = {} + # Add instance count to tool name if multiple + display_tool_name = tool_name if instance_count > 1: - metadata["instance_info"] = f"instances {instance_count}" + display_tool_name = f"{tool_name}[{instance_count}]" # Show individual tool call with arguments self.display.show_tool_call( name=self.name, - tool_name=tool_name, + tool_name=display_tool_name, tool_args=args, bottom_items=bottom_items, max_item_length=28, - metadata=metadata, ) def _summarize_result_text(self, result: CallToolResult) -> str: @@ -250,15 +249,15 @@ def _show_parallel_tool_results( tool_name = descriptor.get("tool", "(unknown)") if result: - # Add instance count to result if multiple + # Add instance count to tool name if multiple + display_tool_name = tool_name if instance_count > 1: - # Add metadata to track parallel execution - setattr(result, "_instance_count", instance_count) + display_tool_name = f"{tool_name}[{instance_count}]" # Show individual tool result with full content self.display.show_tool_result( name=self.name, - tool_name=tool_name, + tool_name=display_tool_name, result=result, ) @@ -320,7 +319,7 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child and hasattr(child, '_name'): original_names[cid] = child._name - child._name = f"{child._name}#{i}" + child._name = f"{child._name}[{i}]" # Now create tasks with modified names for cid in id_list: diff --git a/src/fast_agent/ui/tool_display.py b/src/fast_agent/ui/tool_display.py index 200dce210..31bf20daa 100644 --- a/src/fast_agent/ui/tool_display.py +++ b/src/fast_agent/ui/tool_display.py @@ -98,13 +98,7 @@ def show_tool_result( bottom_metadata_items.append("Structured ■") bottom_metadata = bottom_metadata_items or None - - # Build right_info with instance count if present - right_parts = [f"tool result - {status}"] - instance_count = getattr(result, "_instance_count", None) - if instance_count and instance_count > 1: - right_parts.append(f"instances {instance_count}") - right_info = f"[dim]{' | '.join(right_parts)}[/dim]" + right_info = f"[dim]tool result - {status}[/dim]" if has_structured: config_map = MESSAGE_CONFIGS[MessageType.TOOL_RESULT] @@ -212,12 +206,7 @@ def show_tool_call( tool_args = tool_args or {} metadata = metadata or {} - # Build right_info with instance count if present - right_parts = [f"tool request - {tool_name}"] - if metadata.get("instance_info"): - right_parts.append(metadata["instance_info"]) - right_info = f"[dim]{' | '.join(right_parts)}[/dim]" - + right_info = f"[dim]tool request - {tool_name}[/dim]" content: Any = tool_args pre_content: Text | None = None truncate_content = True From f7f19afa0c616aaa664b6778ca40f1f350ac9c3f Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:50:18 +0300 Subject: [PATCH 12/44] fix: show individual instance numbers [1], [2] in tool headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: 1. Tool headers now show individual instance numbers [1], [2] instead of total count [2] - Tool request: 'agent__PM-1-DayStatusSummarizer[1]' for first call - Tool request: 'agent__PM-1-DayStatusSummarizer[2]' for second call 2. Bottom items show unique labels: 'agent__PM-1[1] · running', 'agent__PM-1[2] · running' 3. Store original names before ANY modifications to prevent [1][2] bug 4. Wrapper coroutine sets agent name at execution time for progress tracking Note: Separate progress panel lines require architecture changes (same agent object issue). --- .../agents/workflow/agents_as_tools_agent.py | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index fb57bd05e..733fe1afc 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -187,18 +187,22 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: "missing": "missing", } + # Show instance count if multiple agents + instance_count = len([d for d in descriptors if d.get("status") != "error"]) + + # Build bottom items with unique instance numbers if multiple bottom_items: list[str] = [] - for desc in descriptors: + for i, desc in enumerate(descriptors, 1): tool_label = desc.get("tool", "(unknown)") status = desc.get("status", "pending") status_label = status_labels.get(status, status) - bottom_items.append(f"{tool_label} · {status_label}") - - # Show instance count if multiple agents - instance_count = len([d for d in descriptors if d.get("status") != "error"]) + if instance_count > 1: + bottom_items.append(f"{tool_label}[{i}] · {status_label}") + else: + bottom_items.append(f"{tool_label} · {status_label}") # Show detailed call information for each agent - for i, desc in enumerate(descriptors): + for i, desc in enumerate(descriptors, 1): tool_name = desc.get("tool", "(unknown)") args = desc.get("args", {}) status = desc.get("status", "pending") @@ -206,10 +210,10 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: if status == "error": continue # Skip display for error tools, will show in results - # Add instance count to tool name if multiple + # Add individual instance number if multiple display_tool_name = tool_name if instance_count > 1: - display_tool_name = f"{tool_name}[{instance_count}]" + display_tool_name = f"{tool_name}[{i}]" # Show individual tool call with arguments self.display.show_tool_call( @@ -243,16 +247,16 @@ def _show_parallel_tool_results( instance_count = len(records) # Show detailed result for each agent - for record in records: + for i, record in enumerate(records, 1): descriptor = record.get("descriptor", {}) result = record.get("result") tool_name = descriptor.get("tool", "(unknown)") if result: - # Add instance count to tool name if multiple + # Add individual instance number if multiple display_tool_name = tool_name if instance_count > 1: - display_tool_name = f"{tool_name}[{instance_count}]" + display_tool_name = f"{tool_name}[{i}]" # Show individual tool result with full content self.display.show_tool_result( @@ -310,22 +314,30 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) - # Add instance IDs to child agent names BEFORE creating tasks + # Collect original names pending_count = len(id_list) original_names = {} if pending_count > 1: - for i, cid in enumerate(id_list, 1): + for cid in id_list: tool_name = descriptor_by_id[cid]["tool"] + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child and hasattr(child, '_name') and tool_name not in original_names: + original_names[tool_name] = child._name + + # Create wrapper coroutine that sets name at execution time + async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], instance: int) -> CallToolResult: + if pending_count > 1: child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child and hasattr(child, '_name'): - original_names[cid] = child._name - child._name = f"{child._name}[{i}]" - - # Now create tasks with modified names - for cid in id_list: + original = original_names.get(tool_name, child._name) + child._name = f"{original}[{instance}]" + return await self.call_tool(tool_name, tool_args) + + # Create tasks with instance-specific wrappers + for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] tool_args = descriptor_by_id[cid]["args"] - tasks.append(asyncio.create_task(self.call_tool(tool_name, tool_args))) + tasks.append(asyncio.create_task(call_with_instance_name(tool_name, tool_args, i))) # Show aggregated tool call(s) self._show_parallel_tool_calls(call_descriptors) @@ -359,8 +371,7 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend self._show_parallel_tool_results(ordered_records) # Restore original agent names - for cid, original_name in original_names.items(): - tool_name = descriptor_by_id[cid]["tool"] + for tool_name, original_name in original_names.items(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child: child._name = original_name From c9aa9b90c873fa44e907daa7596fbad60923a7a8 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:55:08 +0300 Subject: [PATCH 13/44] feat: add separate progress panel lines for parallel instances Implements user's suggested UX: 1. Parent agent line shows 'Ready' status while instances run 2. New lines appear: PM-1-DayStatusSummarizer[1], PM-1-DayStatusSummarizer[2] 3. Each instance line shows real-time progress (Chatting, turn N, tool calls) 4. After completion, instance lines are hidden from progress panel 5. Parent agent name restored Flow: - Emit READY event for parent agent (sets to idle state) - Create unique agent_name for each instance - Emit CHATTING event to create separate progress line - Child agent emits normal progress events with instance name - After gather() completes, hide instance task lines Result: Clean visual separation of parallel executions in left status panel. --- .../agents/workflow/agents_as_tools_agent.py | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 733fe1afc..a27368417 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -324,15 +324,43 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend if child and hasattr(child, '_name') and tool_name not in original_names: original_names[tool_name] = child._name - # Create wrapper coroutine that sets name at execution time + # Create wrapper coroutine that sets name and emits progress for instance async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], instance: int) -> CallToolResult: + from fast_agent.event_progress import ProgressAction, ProgressEvent + from fast_agent.ui.progress_display import progress_display + + instance_name = None if pending_count > 1: child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child and hasattr(child, '_name'): original = original_names.get(tool_name, child._name) - child._name = f"{original}[{instance}]" + instance_name = f"{original}[{instance}]" + child._name = instance_name + + # Emit progress event to create separate line in progress panel + progress_display.update(ProgressEvent( + action=ProgressAction.CHATTING, + target=instance_name, + details="", + agent_name=instance_name + )) + return await self.call_tool(tool_name, tool_args) + # Set parent agent lines to Ready status while instances run + if pending_count > 1: + from fast_agent.event_progress import ProgressAction, ProgressEvent + from fast_agent.ui.progress_display import progress_display + + for tool_name in original_names.keys(): + original = original_names[tool_name] + progress_display.update(ProgressEvent( + action=ProgressAction.READY, + target=original, + details="", + agent_name=original + )) + # Create tasks with instance-specific wrappers for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] @@ -370,10 +398,29 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins self._show_parallel_tool_results(ordered_records) - # Restore original agent names - for tool_name, original_name in original_names.items(): - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - child._name = original_name + # Restore original agent names and hide instance lines from progress panel + if pending_count > 1: + from fast_agent.ui.progress_display import progress_display + + for tool_name, original_name in original_names.items(): + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child: + child._name = original_name + + # Hide instance lines from progress panel + for i in range(1, pending_count + 1): + instance_name = f"{original_name}[{i}]" + if instance_name in progress_display._taskmap: + task_id = progress_display._taskmap[instance_name] + for task in progress_display._progress.tasks: + if task.id == task_id: + task.visible = False + break + else: + # Single instance, just restore name + for tool_name, original_name in original_names.items(): + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child: + child._name = original_name return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From d1afe69f13c9d175831bf42f440267082454f499 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 14:59:56 +0300 Subject: [PATCH 14/44] fix: show tool call status in instance lines, not parent Problem: When child agents called tools, progress events (CALLING_TOOL) were emitted with parent agent name instead of instance name, causing tool status to appear in wrong line. Root cause: MCPAggregator caches agent_name in __init__, so changing child._name didn't update the aggregator's agent_name. When aggregator emits progress for tool calls, it used the old cached name. Solution: - Update child._aggregator.agent_name when setting instance name - Restore child._aggregator.agent_name when restoring original name - Now tool call progress (Calling tool, tg-ro, etc.) appears in correct instance line Result: Each instance line shows its own 'Calling tool' status independently. --- src/fast_agent/agents/workflow/agents_as_tools_agent.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index a27368417..2e1fc673d 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -337,6 +337,10 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins instance_name = f"{original}[{instance}]" child._name = instance_name + # Also update aggregator's agent_name so tool progress events use instance name + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = instance_name + # Emit progress event to create separate line in progress panel progress_display.update(ProgressEvent( action=ProgressAction.CHATTING, @@ -406,6 +410,9 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child: child._name = original_name + # Restore aggregator's agent_name too + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = original_name # Hide instance lines from progress panel for i in range(1, pending_count + 1): @@ -422,5 +429,7 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child: child._name = original_name + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = original_name return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 15cb923536d137b4628d3026db752e6405223bdd Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:03:30 +0300 Subject: [PATCH 15/44] fix: explicitly enable show_tools for child agents Ensures child agent tool calls remain visible in chat log by explicitly setting show_tools = True when creating temporary config. --- src/fast_agent/agents/workflow/agents_as_tools_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 2e1fc673d..7a32ffcc0 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -142,7 +142,7 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> if hasattr(temp_config, 'logger'): temp_logger = copy(temp_config.logger) temp_logger.show_chat = False - # Keep show_tools = True to see child's internal tool activity + temp_logger.show_tools = True # Explicitly keep tools visible temp_config.logger = temp_logger child.display.config = temp_config From 414301543aa5b6ab0ae7e568039161976f1a18cd Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:11:27 +0300 Subject: [PATCH 16/44] fix: hide parent line during parallel execution, only show instances Changes: - Parent agent line now hidden when child instances start (not 'Ready') - Only child instance lines visible during parallel execution - Each instance shows independent status - After completion: parent line restored, instance lines hidden Result: Clean progress panel with no 'stuck' parent status. Only active instance lines show during execution. --- .../agents/workflow/agents_as_tools_agent.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 7a32ffcc0..330080055 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -351,19 +351,19 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins return await self.call_tool(tool_name, tool_args) - # Set parent agent lines to Ready status while instances run + # Hide parent agent lines while instances run if pending_count > 1: - from fast_agent.event_progress import ProgressAction, ProgressEvent from fast_agent.ui.progress_display import progress_display for tool_name in original_names.keys(): original = original_names[tool_name] - progress_display.update(ProgressEvent( - action=ProgressAction.READY, - target=original, - details="", - agent_name=original - )) + # Hide parent line from progress panel + if original in progress_display._taskmap: + task_id = progress_display._taskmap[original] + for task in progress_display._progress.tasks: + if task.id == task_id: + task.visible = False + break # Create tasks with instance-specific wrappers for i, cid in enumerate(id_list, 1): @@ -414,6 +414,14 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins if hasattr(child, '_aggregator') and child._aggregator: child._aggregator.agent_name = original_name + # Show parent line again and hide instance lines + if original_name in progress_display._taskmap: + task_id = progress_display._taskmap[original_name] + for task in progress_display._progress.tasks: + if task.id == task_id: + task.visible = True # Restore parent line + break + # Hide instance lines from progress panel for i in range(1, pending_count + 1): instance_name = f"{original_name}[{i}]" From 7905edbcb5b397f7fe1aef42cf3663d5345b02ee Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:14:09 +0300 Subject: [PATCH 17/44] docs: add comprehensive README for agents-as-tools pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added module-level documentation covering: 1. Overview - Pattern inspired by OpenAI Agents SDK - Hierarchical composition without orchestrator complexity 2. Rationale - Benefits over traditional orchestrator/iterative_planner - Simpler codebase, better LLM utilization - Natural composition with parallel by default 3. Algorithm - 4-step process: init → discovery → execution → parallel - Detailed explanation of each phase 4. Progress Panel Behavior - Before/during/after parallel execution states - Parent line shows 'Ready' during child execution - Instance lines with [1], [2] numbering - Visibility management for clean UX 5. Implementation Notes - Name modification timing (runtime vs creation time) - Original name caching to prevent [1][2] bugs - Progress event routing via aggregator.agent_name - Display suppression strategy 6. Usage Example - Simple code snippet showing pattern in action 7. References - OpenAI Agents SDK link - GitHub issue placeholder --- .../agents/workflow/agents_as_tools_agent.py | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 330080055..f8ccf7a09 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -1,3 +1,150 @@ +""" +Agents as Tools Pattern Implementation +======================================= + +Overview +-------- +This module implements the "Agents as Tools" pattern, inspired by OpenAI's Agents SDK +(https://openai.github.io/openai-agents-python/tools). It allows child agents to be +exposed as callable tools to a parent agent, enabling hierarchical agent composition +without the complexity of traditional orchestrator patterns. + +Rationale +--------- +Traditional approaches to multi-agent systems often require: +1. Complex orchestration logic with explicit routing rules +2. Iterative planning mechanisms that add cognitive overhead +3. Tight coupling between parent and child agent implementations + +The "Agents as Tools" pattern simplifies this by: +- **Treating agents as first-class tools**: Each child agent becomes a tool that the + parent LLM can call naturally via function calling +- **Delegation, not orchestration**: The parent LLM decides which child agents to invoke + based on its instruction and context, without hardcoded routing logic +- **Parallel execution**: Multiple child agents can run concurrently when the LLM makes + parallel tool calls +- **Clean abstraction**: Child agents expose minimal schemas (text or JSON input), + making them universally composable + +Benefits over iterative_planner/orchestrator: +- Simpler codebase: No custom planning loops or routing tables +- Better LLM utilization: Modern LLMs excel at function calling +- Natural composition: Agents nest cleanly without special handling +- Parallel by default: Leverage asyncio.gather for concurrent execution + +Algorithm +--------- +1. **Initialization** + - Parent agent receives list of child agents + - Each child agent is mapped to a tool name: `agent__{child_name}` + - Tool schemas advertise text/json input capabilities + +2. **Tool Discovery (list_tools)** + - Parent LLM receives one tool per child agent + - Each tool schema includes child agent's instruction as description + - LLM decides which tools (child agents) to call based on user request + +3. **Tool Execution (call_tool)** + - Route tool name to corresponding child agent + - Convert tool arguments (text or JSON) to child agent input + - Suppress child agent's chat messages (show_chat=False) + - Keep child agent's tool calls visible (show_tools=True) + - Execute child agent and return response as CallToolResult + +4. **Parallel Execution (run_tools)** + - Collect all tool calls from parent LLM response + - Create asyncio tasks for each child agent call + - Modify child agent names with instance numbers: `AgentName[1]`, `AgentName[2]` + - Update aggregator agent_name for proper progress tracking + - Execute all tasks concurrently via asyncio.gather + - Aggregate results and return to parent LLM + +Progress Panel Behavior +----------------------- +To provide clear visibility into parallel executions, the progress panel (left status +table) undergoes dynamic updates: + +**Before parallel execution:** +``` +▎▶ Chatting ▎ PM-1-DayStatusSummarizer gpt-5 turn 1 +``` + +**During parallel execution (2+ instances):** +- Parent line switches to "Ready" status to indicate waiting for children +- New lines appear for each instance: +``` +▎ Ready ▎ PM-1-DayStatusSummarizer ← parent waiting +▎▶ Calling tool ▎ PM-1-DayStatusSummarizer[1] tg-ro (list_messages) +▎▶ Chatting ▎ PM-1-DayStatusSummarizer[2] gpt-5 turn 2 +``` + +**Key implementation details:** +- Each instance gets unique agent_name: `OriginalName[instance_number]` +- Both child._name and child._aggregator.agent_name are updated for correct progress routing +- Tool progress events (CALLING_TOOL) use instance name, not parent name +- Each instance shows independent status: Chatting, Calling tool, turn count + +**After parallel execution completes:** +- Instance lines are hidden (task.visible = False) +- Parent line returns to normal agent lifecycle +- Original agent names are restored + +**Chat log display:** +Tool headers show instance numbers for clarity: +``` +▎▶ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[1]] +▎◀ orchestrator [tool result - agent__PM-1-DayStatusSummarizer[1]] +▎▶ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[2]] +▎◀ orchestrator [tool result - agent__PM-1-DayStatusSummarizer[2]] +``` + +Bottom status bar shows all instances: +``` +| agent__PM-1-DayStatusSummarizer[1] · running | agent__PM-1-DayStatusSummarizer[2] · running | +``` + +Implementation Notes +-------------------- +- **Name modification timing**: Agent names are modified in a wrapper coroutine that + executes at task runtime, not task creation time, to avoid race conditions +- **Original name caching**: Store original names before ANY modifications to prevent + [1][2] bugs when the same agent is called multiple times +- **Progress event routing**: Must update both agent._name and agent._aggregator.agent_name + since MCPAggregator caches agent_name for progress events +- **Display suppression**: Child agents run with show_chat=False but show_tools=True to + show their internal tool activity without cluttering the log with intermediate responses + +Usage Example +------------- +```python +from fast_agent import FastAgent + +fast = FastAgent("parent") + +# Define child agents +@fast.agent(name="researcher", instruction="Research topics") +async def researcher(): pass + +@fast.agent(name="writer", instruction="Write content") +async def writer(): pass + +# Define parent with agents-as-tools +@fast.agent( + name="coordinator", + instruction="Coordinate research and writing", + child_agents=["researcher", "writer"] # Exposes children as tools +) +async def coordinator(): pass +``` + +The parent LLM can now naturally call researcher and writer as tools. + +References +---------- +- OpenAI Agents SDK: https://openai.github.io/openai-agents-python/tools +- GitHub Issue: https://github.com/evalstate/fast-agent/issues/XXX +""" + from __future__ import annotations import asyncio From 85610a4df2f8beccc17b94d02918bb48b27cd8a4 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:20:10 +0300 Subject: [PATCH 18/44] fix: hide instance lines immediately when each task completes Problem: Instance lines stayed visible showing 'stuck' status even after completing their work. Instance[1] would show 'Chatting' even though it finished and returned results. Root cause: Instance lines were only hidden after ALL tasks completed via asyncio.gather(). If one instance finished quickly and another took longer, the first instance's line remained visible with stale status. Solution: - Add finally block to task wrapper coroutine - Hide each instance line immediately when its task completes - Remove duplicate hiding logic from cleanup section - Now each instance disappears as soon as it's done Result: Clean, dynamic progress panel where instance lines appear when tasks start and disappear as each individual task finishes. --- .../agents/workflow/agents_as_tools_agent.py | 34 +++++++------------ 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index f8ccf7a09..fc5c9f83e 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -496,7 +496,17 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins agent_name=instance_name )) - return await self.call_tool(tool_name, tool_args) + try: + return await self.call_tool(tool_name, tool_args) + finally: + # Hide instance line immediately when this task completes + if instance_name and pending_count > 1: + if instance_name in progress_display._taskmap: + task_id = progress_display._taskmap[instance_name] + for task in progress_display._progress.tasks: + if task.id == task_id: + task.visible = False + break # Hide parent agent lines while instances run if pending_count > 1: @@ -549,10 +559,8 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins self._show_parallel_tool_results(ordered_records) - # Restore original agent names and hide instance lines from progress panel + # Restore original agent names (instance lines already hidden in task finally blocks) if pending_count > 1: - from fast_agent.ui.progress_display import progress_display - for tool_name, original_name in original_names.items(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child: @@ -560,24 +568,6 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins # Restore aggregator's agent_name too if hasattr(child, '_aggregator') and child._aggregator: child._aggregator.agent_name = original_name - - # Show parent line again and hide instance lines - if original_name in progress_display._taskmap: - task_id = progress_display._taskmap[original_name] - for task in progress_display._progress.tasks: - if task.id == task_id: - task.visible = True # Restore parent line - break - - # Hide instance lines from progress panel - for i in range(1, pending_count + 1): - instance_name = f"{original_name}[{i}]" - if instance_name in progress_display._taskmap: - task_id = progress_display._taskmap[instance_name] - for task in progress_display._progress.tasks: - if task.id == task_id: - task.visible = False - break else: # Single instance, just restore name for tool_name, original_name in original_names.items(): From e3783dbb72b95bebc0089df61e2525965542892a Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:25:02 +0300 Subject: [PATCH 19/44] fix: use consistent progress_display instance for visibility control Problem: Instance lines remained visible ('stuck') even after tasks completed. Root cause: progress_display was being re-imported in multiple scopes, potentially creating different singleton instances or scope issues. Solution: - Import progress_display once at outer scope as 'outer_progress_display' - Use same instance in wrapper coroutine's finally block - Use same instance for parent Ready status update - Added debug logging to track visibility changes Note: The 'duplicate records' in chat log are actually separate results from parallel instances [1] and [2], not true duplicates. Each instance gets its own tool request/result header for clarity. --- .../agents/workflow/agents_as_tools_agent.py | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index fc5c9f83e..b3880c8e3 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -471,11 +471,12 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend if child and hasattr(child, '_name') and tool_name not in original_names: original_names[tool_name] = child._name + # Import progress_display at outer scope to ensure same instance + from fast_agent.event_progress import ProgressAction, ProgressEvent + from fast_agent.ui.progress_display import progress_display as outer_progress_display + # Create wrapper coroutine that sets name and emits progress for instance async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], instance: int) -> CallToolResult: - from fast_agent.event_progress import ProgressAction, ProgressEvent - from fast_agent.ui.progress_display import progress_display - instance_name = None if pending_count > 1: child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) @@ -489,7 +490,7 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins child._aggregator.agent_name = instance_name # Emit progress event to create separate line in progress panel - progress_display.update(ProgressEvent( + outer_progress_display.update(ProgressEvent( action=ProgressAction.CHATTING, target=instance_name, details="", @@ -501,26 +502,26 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins finally: # Hide instance line immediately when this task completes if instance_name and pending_count > 1: - if instance_name in progress_display._taskmap: - task_id = progress_display._taskmap[instance_name] - for task in progress_display._progress.tasks: + logger.info(f"Hiding instance line: {instance_name}") + if instance_name in outer_progress_display._taskmap: + task_id = outer_progress_display._taskmap[instance_name] + for task in outer_progress_display._progress.tasks: if task.id == task_id: task.visible = False + logger.info(f"Set visible=False for {instance_name}") break - # Hide parent agent lines while instances run + # Set parent agent lines to Ready status while instances run if pending_count > 1: - from fast_agent.ui.progress_display import progress_display - for tool_name in original_names.keys(): original = original_names[tool_name] - # Hide parent line from progress panel - if original in progress_display._taskmap: - task_id = progress_display._taskmap[original] - for task in progress_display._progress.tasks: - if task.id == task_id: - task.visible = False - break + # Set parent to Ready status + outer_progress_display.update(ProgressEvent( + action=ProgressAction.READY, + target=original, + details="", + agent_name=original + )) # Create tasks with instance-specific wrappers for i, cid in enumerate(id_list, 1): From 2b922b53667b000786653b75511124d129f7278a Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:33:21 +0300 Subject: [PATCH 20/44] fix: prevent display config race conditions in parallel instances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Only seeing logs from instance #4 when multiple instances of the same child agent run in parallel. Root cause: Multiple parallel instances share the same child agent object. When instance 1 finishes, it restores display config (show_chat=True), which immediately affects instances 2, 3, 4 that are still running. The last instance (#4) ends up with restored config and shows all its chat logs. Race condition flow: 1. Instance 1 starts → sets show_chat=False on shared object 2. Instances 2,3,4 start → see show_chat=False 3. Instance 1 finishes → restores show_chat=True 4. Instances 2,3,4 still running → now have show_chat=True (see logs!) Solution: Reference counting - Track active instance count per child agent ID - Only modify display config when first instance starts - Only restore display config when last instance completes - Store original config per child_id for safe restoration Data structures: - _display_suppression_count[child_id] → count of active instances - _original_display_configs[child_id] → stored original config Now all instances respect show_chat=False until ALL complete. --- .../agents/workflow/agents_as_tools_agent.py | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index b3880c8e3..93e4b70dd 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -280,18 +280,32 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> # Serialize arguments to text input child_request = Prompt.user(input_text) + + # Track display config changes per child to handle parallel instances + child_id = id(child) + if not hasattr(self, '_display_suppression_count'): + self._display_suppression_count = {} + self._original_display_configs = {} + try: - # Suppress only child agent chat messages (keep tool calls visible) - original_config = None - if hasattr(child, 'display') and child.display and child.display.config: - original_config = child.display.config - temp_config = copy(original_config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = True # Explicitly keep tools visible - temp_config.logger = temp_logger - child.display.config = temp_config + # Suppress child agent chat messages (keep tool calls visible) + # Only modify config on first parallel instance + if child_id not in self._display_suppression_count: + self._display_suppression_count[child_id] = 0 + + if hasattr(child, 'display') and child.display and child.display.config: + # Store original config for restoration later + self._original_display_configs[child_id] = child.display.config + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = True # Explicitly keep tools visible + temp_config.logger = temp_logger + child.display.config = temp_config + + # Increment active instance count + self._display_suppression_count[child_id] += 1 response: PromptMessageExtended = await child.generate([child_request], None) # Prefer preserving original content blocks for better UI fidelity @@ -315,9 +329,22 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> logger.error(f"Child agent {child.name} failed: {e}") return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) finally: - # Restore original config - if original_config and hasattr(child, 'display') and child.display: - child.display.config = original_config + # Decrement active instance count + if child_id in self._display_suppression_count: + self._display_suppression_count[child_id] -= 1 + + # Restore original config only when last instance completes + if self._display_suppression_count[child_id] == 0: + del self._display_suppression_count[child_id] + + # Restore from stored original config + if child_id in self._original_display_configs: + original_config = self._original_display_configs[child_id] + del self._original_display_configs[child_id] + + if hasattr(child, 'display') and child.display: + child.display.config = original_config + logger.info(f"Restored display config for {child.name} (all instances completed)") def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: """Display tool call headers for parallel agent execution. From 5c503dbf9e6dbbc237efeafe6c2f3b2db0f87098 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:35:36 +0300 Subject: [PATCH 21/44] docs: update module documentation with latest implementation details Updated comprehensive documentation to reflect: Algorithm section: - Reference counting for display config suppression - Parallel execution improvements (name+aggregator updates, immediate hiding) Progress Panel Behavior: - As each instance completes (not after all complete) - No stuck status lines - After all complete (restoration of configs) Implementation Notes: - Display suppression with reference counting explanation - _display_suppression_count and _original_display_configs dictionaries - Race condition prevention details (only modify on first, restore on last) - Instance line visibility using consistent progress_display singleton - Chat log separation with instance numbers for traceability All documentation now accurately reflects the production implementation. --- .../agents/workflow/agents_as_tools_agent.py | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 93e4b70dd..d592cebcb 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -47,16 +47,20 @@ 3. **Tool Execution (call_tool)** - Route tool name to corresponding child agent - Convert tool arguments (text or JSON) to child agent input - - Suppress child agent's chat messages (show_chat=False) + - Suppress child agent's chat messages (show_chat=False) using reference counting - Keep child agent's tool calls visible (show_tools=True) + - Track active instances per child agent to prevent race conditions + - Only modify display config on first instance, restore on last instance - Execute child agent and return response as CallToolResult 4. **Parallel Execution (run_tools)** - Collect all tool calls from parent LLM response - Create asyncio tasks for each child agent call - Modify child agent names with instance numbers: `AgentName[1]`, `AgentName[2]` - - Update aggregator agent_name for proper progress tracking + - Update both child._name and child._aggregator.agent_name for progress routing + - Set parent agent to "Ready" status while instances run - Execute all tasks concurrently via asyncio.gather + - Hide instance lines immediately as each task completes (via finally block) - Aggregate results and return to parent LLM Progress Panel Behavior @@ -84,10 +88,15 @@ - Tool progress events (CALLING_TOOL) use instance name, not parent name - Each instance shows independent status: Chatting, Calling tool, turn count -**After parallel execution completes:** -- Instance lines are hidden (task.visible = False) -- Parent line returns to normal agent lifecycle -- Original agent names are restored +**As each instance completes:** +- Instance line disappears immediately (task.visible = False in finally block) +- Other instances continue showing their independent progress +- No "stuck" status lines after completion + +**After all parallel executions complete:** +- All instance lines hidden +- Parent line returns to normal agent lifecycle +- Original agent names and display configs restored **Chat log display:** Tool headers show instance numbers for clarity: @@ -111,8 +120,18 @@ [1][2] bugs when the same agent is called multiple times - **Progress event routing**: Must update both agent._name and agent._aggregator.agent_name since MCPAggregator caches agent_name for progress events -- **Display suppression**: Child agents run with show_chat=False but show_tools=True to - show their internal tool activity without cluttering the log with intermediate responses +- **Display suppression with reference counting**: Multiple parallel instances of the same + child agent share a single agent object. Use reference counting to track active instances: + - `_display_suppression_count[child_id]`: Count of active parallel instances + - `_original_display_configs[child_id]`: Stored original config + - Only modify display config when first instance starts (count 0→1) + - Only restore display config when last instance completes (count 1→0) + - Prevents race condition where early-finishing instances restore config while others run +- **Instance line visibility**: Each instance line is hidden immediately in the task's + finally block, not after all tasks complete. Uses consistent progress_display singleton + reference to ensure visibility changes work correctly +- **Chat log separation**: Each parallel instance gets its own tool request/result headers + with instance numbers [1], [2], etc. for traceability Usage Example ------------- From 963275e7df560eae3c1ccaa56305453057522722 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 15:50:47 +0300 Subject: [PATCH 22/44] fix: duplicate labels, final logs without instance index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed three issues: 1. Duplicate labels in bottom status bar - Before: Each tool call showed ALL instance labels - After: Each tool call shows only its OWN label - Changed from passing shared bottom_items array to passing single-item array per call 2. Final logs showing without instance index - Before: Display config restored in call_tool finally block, causing final logs to use original name (no [N]) - After: Display config restoration moved to run_tools, AFTER all tool results are displayed - Now all logs (including final) keep instance numbers: PM-1[1], PM-1[2], etc. 3. Display config restoration timing - Removed restoration from call_tool finally block - Added restoration in run_tools after _show_parallel_tool_results - Cleanup of _display_suppression_count and _original_display_configs dictionaries Result: - Bottom bar: | PM-1[1] · running | (no duplicates) - Final logs: ▎◀ PM-1-DayStatusSummarizer[4] [tool result] (keeps index) - Clean separation of instance logs throughout execution --- .../agents/workflow/agents_as_tools_agent.py | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index d592cebcb..a73cf929c 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -352,18 +352,8 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> if child_id in self._display_suppression_count: self._display_suppression_count[child_id] -= 1 - # Restore original config only when last instance completes - if self._display_suppression_count[child_id] == 0: - del self._display_suppression_count[child_id] - - # Restore from stored original config - if child_id in self._original_display_configs: - original_config = self._original_display_configs[child_id] - del self._original_display_configs[child_id] - - if hasattr(child, 'display') and child.display: - child.display.config = original_config - logger.info(f"Restored display config for {child.name} (all instances completed)") + # Don't restore config here - let run_tools restore after results are displayed + # This ensures final logs keep instance numbers [N] def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: """Display tool call headers for parallel agent execution. @@ -383,17 +373,6 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: # Show instance count if multiple agents instance_count = len([d for d in descriptors if d.get("status") != "error"]) - # Build bottom items with unique instance numbers if multiple - bottom_items: list[str] = [] - for i, desc in enumerate(descriptors, 1): - tool_label = desc.get("tool", "(unknown)") - status = desc.get("status", "pending") - status_label = status_labels.get(status, status) - if instance_count > 1: - bottom_items.append(f"{tool_label}[{i}] · {status_label}") - else: - bottom_items.append(f"{tool_label} · {status_label}") - # Show detailed call information for each agent for i, desc in enumerate(descriptors, 1): tool_name = desc.get("tool", "(unknown)") @@ -408,12 +387,16 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: if instance_count > 1: display_tool_name = f"{tool_name}[{i}]" + # Build bottom item for THIS instance only (not all instances) + status_label = status_labels.get(status, "pending") + bottom_item = f"{display_tool_name} · {status_label}" + # Show individual tool call with arguments self.display.show_tool_call( name=self.name, tool_name=display_tool_name, tool_args=args, - bottom_items=bottom_items, + bottom_items=[bottom_item], # Only this instance's label max_item_length=28, ) @@ -606,7 +589,7 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins self._show_parallel_tool_results(ordered_records) - # Restore original agent names (instance lines already hidden in task finally blocks) + # Restore original agent names and display configs (instance lines already hidden in task finally blocks) if pending_count > 1: for tool_name, original_name in original_names.items(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) @@ -615,6 +598,18 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins # Restore aggregator's agent_name too if hasattr(child, '_aggregator') and child._aggregator: child._aggregator.agent_name = original_name + + # Restore display config now that all results are shown + child_id = id(child) + if child_id in self._display_suppression_count: + del self._display_suppression_count[child_id] + + if child_id in self._original_display_configs: + original_config = self._original_display_configs[child_id] + del self._original_display_configs[child_id] + if hasattr(child, 'display') and child.display: + child.display.config = original_config + logger.info(f"Restored display config for {original_name} after all results displayed") else: # Single instance, just restore name for tool_name, original_name in original_names.items(): From eba9fabb132e1e8d53ce534116546d772534e7fd Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 16:05:34 +0300 Subject: [PATCH 23/44] fix: label truncation and display config restoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed three issues: 1. Label truncation in bottom status bar - Increased max_item_length from 28 to 50 characters - Prevents '...' truncation of long agent/tool names - Now shows: agent__PM-1-DayStatusSummarizer[1] (full name) 2. Display config reference counting improvements - Separate initialization of _display_suppression_count and _original_display_configs - Increment count BEFORE checking if first instance - Only modify config if count==1 AND not already stored - Added debug logging to track suppression lifecycle 3. Config restoration timing and cleanup - Added logging to track decrements in finally block - Check existence before accessing/deleting dictionary keys - Restore config for both multi-instance and single-instance cases - Clean up suppression count only when it reaches 0 The reference counting now ensures: - First instance (count 0→1): Suppress chat, store original config - Additional instances (count 1→2,3,4): Use existing suppressed config - Instances complete (count 4→3,2,1): Keep suppressed config - Last instance completes (count 1→0): Restore original config Debug logs added: - 'Suppressed chat for {name} (first instance)' - 'Decremented count for {name}: N instances remaining' - 'Restored display config for {name}' --- .../agents/workflow/agents_as_tools_agent.py | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index a73cf929c..90bbc09e8 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -304,6 +304,7 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> child_id = id(child) if not hasattr(self, '_display_suppression_count'): self._display_suppression_count = {} + if not hasattr(self, '_original_display_configs'): self._original_display_configs = {} try: @@ -311,7 +312,12 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> # Only modify config on first parallel instance if child_id not in self._display_suppression_count: self._display_suppression_count[child_id] = 0 - + + # Increment active instance count first + self._display_suppression_count[child_id] += 1 + + # Only modify config if this is the first instance and we haven't stored the original yet + if self._display_suppression_count[child_id] == 1 and child_id not in self._original_display_configs: if hasattr(child, 'display') and child.display and child.display.config: # Store original config for restoration later self._original_display_configs[child_id] = child.display.config @@ -322,9 +328,7 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> temp_logger.show_tools = True # Explicitly keep tools visible temp_config.logger = temp_logger child.display.config = temp_config - - # Increment active instance count - self._display_suppression_count[child_id] += 1 + logger.info(f"Suppressed chat for {child.name} (first instance)") response: PromptMessageExtended = await child.generate([child_request], None) # Prefer preserving original content blocks for better UI fidelity @@ -351,6 +355,7 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> # Decrement active instance count if child_id in self._display_suppression_count: self._display_suppression_count[child_id] -= 1 + logger.info(f"Decremented count for {child.name}: {self._display_suppression_count[child_id]} instances remaining") # Don't restore config here - let run_tools restore after results are displayed # This ensures final logs keep instance numbers [N] @@ -397,7 +402,7 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: tool_name=display_tool_name, tool_args=args, bottom_items=[bottom_item], # Only this instance's label - max_item_length=28, + max_item_length=50, # Increased from 28 to prevent truncation ) def _summarize_result_text(self, result: CallToolResult) -> str: @@ -601,22 +606,36 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins # Restore display config now that all results are shown child_id = id(child) - if child_id in self._display_suppression_count: - del self._display_suppression_count[child_id] - if child_id in self._original_display_configs: + # Check and clean up suppression count + if hasattr(self, '_display_suppression_count') and child_id in self._display_suppression_count: + if self._display_suppression_count[child_id] == 0: + del self._display_suppression_count[child_id] + logger.info(f"Cleaned up suppression count for {original_name}") + + # Restore original display config + if hasattr(self, '_original_display_configs') and child_id in self._original_display_configs: original_config = self._original_display_configs[child_id] del self._original_display_configs[child_id] if hasattr(child, 'display') and child.display: child.display.config = original_config logger.info(f"Restored display config for {original_name} after all results displayed") else: - # Single instance, just restore name + # Single instance, also restore name and config for tool_name, original_name in original_names.items(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child: child._name = original_name if hasattr(child, '_aggregator') and child._aggregator: child._aggregator.agent_name = original_name + + # Also restore display config for single instance + child_id = id(child) + if hasattr(self, '_original_display_configs') and child_id in self._original_display_configs: + original_config = self._original_display_configs[child_id] + del self._original_display_configs[child_id] + if hasattr(child, 'display') and child.display: + child.display.config = original_config + logger.info(f"Restored display config for {original_name} (single instance)") return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 10cfe75cb0c896e362c43ef262cedea71cbe202c Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 16:17:46 +0300 Subject: [PATCH 24/44] fix: move display suppression to run_tools before parallel execution Problem: Only instance #4 was showing chat logs. The issue was that call_tool was trying to suppress display config inside each parallel task, creating a race condition where configs would get overwritten. Solution: 1. Move display suppression to run_tools BEFORE parallel execution starts 2. Iterate through all child agents that will be called and suppress once 3. Store original configs in _original_display_configs dictionary 4. Remove all suppression logic from call_tool - it just executes now 5. After results displayed, restore all configs that were suppressed This ensures: - All instances use the same suppressed config (no race conditions) - Config is suppressed ONCE before parallel tasks start - All parallel instances respect show_chat=False - Config restored after all results are displayed The key insight: Don't try to suppress config inside parallel tasks - do it before they start so they all inherit the same suppressed state. --- .../agents/workflow/agents_as_tools_agent.py | 135 +++++++----------- 1 file changed, 52 insertions(+), 83 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 90bbc09e8..88c4289e5 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -300,36 +300,9 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> # Serialize arguments to text input child_request = Prompt.user(input_text) - # Track display config changes per child to handle parallel instances - child_id = id(child) - if not hasattr(self, '_display_suppression_count'): - self._display_suppression_count = {} - if not hasattr(self, '_original_display_configs'): - self._original_display_configs = {} - try: - # Suppress child agent chat messages (keep tool calls visible) - # Only modify config on first parallel instance - if child_id not in self._display_suppression_count: - self._display_suppression_count[child_id] = 0 - - # Increment active instance count first - self._display_suppression_count[child_id] += 1 - - # Only modify config if this is the first instance and we haven't stored the original yet - if self._display_suppression_count[child_id] == 1 and child_id not in self._original_display_configs: - if hasattr(child, 'display') and child.display and child.display.config: - # Store original config for restoration later - self._original_display_configs[child_id] = child.display.config - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = True # Explicitly keep tools visible - temp_config.logger = temp_logger - child.display.config = temp_config - logger.info(f"Suppressed chat for {child.name} (first instance)") - + # Note: Display suppression is now handled in run_tools before parallel execution + # This ensures all instances use the same suppressed config response: PromptMessageExtended = await child.generate([child_request], None) # Prefer preserving original content blocks for better UI fidelity content_blocks = list(response.content or []) @@ -351,14 +324,6 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> except Exception as e: logger.error(f"Child agent {child.name} failed: {e}") return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) - finally: - # Decrement active instance count - if child_id in self._display_suppression_count: - self._display_suppression_count[child_id] -= 1 - logger.info(f"Decremented count for {child.name}: {self._display_suppression_count[child_id]} instances remaining") - - # Don't restore config here - let run_tools restore after results are displayed - # This ensures final logs keep instance numbers [N] def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: """Display tool call headers for parallel agent execution. @@ -495,15 +460,45 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) - # Collect original names + # Collect original names and setup display suppression BEFORE parallel execution pending_count = len(id_list) original_names = {} + suppressed_children = set() # Track which children we suppressed + + # Initialize tracking dictionaries if needed + if not hasattr(self, '_display_suppression_count'): + self._display_suppression_count = {} + if not hasattr(self, '_original_display_configs'): + self._original_display_configs = {} + if pending_count > 1: for cid in id_list: tool_name = descriptor_by_id[cid]["tool"] child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child and hasattr(child, '_name') and tool_name not in original_names: - original_names[tool_name] = child._name + if child: + # Store original name + if hasattr(child, '_name') and tool_name not in original_names: + original_names[tool_name] = child._name + + # Suppress display for this child (only once per unique child) + child_id = id(child) + if child_id not in suppressed_children: + suppressed_children.add(child_id) + + # Only suppress if not already suppressed + if child_id not in self._original_display_configs: + if hasattr(child, 'display') and child.display and child.display.config: + # Store original config + self._original_display_configs[child_id] = child.display.config + # Create suppressed config + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = True + temp_config.logger = temp_logger + child.display.config = temp_config + logger.info(f"Pre-suppressed chat for {child._name} before parallel execution") # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent @@ -594,48 +589,22 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins self._show_parallel_tool_results(ordered_records) - # Restore original agent names and display configs (instance lines already hidden in task finally blocks) - if pending_count > 1: - for tool_name, original_name in original_names.items(): - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - child._name = original_name - # Restore aggregator's agent_name too - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = original_name - - # Restore display config now that all results are shown - child_id = id(child) - - # Check and clean up suppression count - if hasattr(self, '_display_suppression_count') and child_id in self._display_suppression_count: - if self._display_suppression_count[child_id] == 0: - del self._display_suppression_count[child_id] - logger.info(f"Cleaned up suppression count for {original_name}") - - # Restore original display config - if hasattr(self, '_original_display_configs') and child_id in self._original_display_configs: - original_config = self._original_display_configs[child_id] - del self._original_display_configs[child_id] - if hasattr(child, 'display') and child.display: - child.display.config = original_config - logger.info(f"Restored display config for {original_name} after all results displayed") - else: - # Single instance, also restore name and config - for tool_name, original_name in original_names.items(): - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - child._name = original_name - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = original_name - - # Also restore display config for single instance - child_id = id(child) - if hasattr(self, '_original_display_configs') and child_id in self._original_display_configs: - original_config = self._original_display_configs[child_id] - del self._original_display_configs[child_id] - if hasattr(child, 'display') and child.display: - child.display.config = original_config - logger.info(f"Restored display config for {original_name} (single instance)") + # Restore original agent names and display configs + for tool_name, original_name in original_names.items(): + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child: + child._name = original_name + # Restore aggregator's agent_name too + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = original_name + + # Restore display config if it was suppressed + child_id = id(child) + if hasattr(self, '_original_display_configs') and child_id in self._original_display_configs: + original_config = self._original_display_configs[child_id] + del self._original_display_configs[child_id] + if hasattr(child, 'display') and child.display: + child.display.config = original_config + logger.info(f"Restored display config for {original_name} after all results displayed") return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From e58b465773c5897373d8d444f7a662106c54064e Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 16:52:33 +0300 Subject: [PATCH 25/44] fix: create new display objects for suppression instead of just modifying config Problem: Even with pre-suppression, instances were still showing chat logs because they all share the same display object and config modifications weren't taking effect properly. Solution: 1. Create completely new ConsoleDisplay objects with suppressed config 2. Replace child.display with the new suppressed display object 3. Store both the original display object and config for restoration 4. After results shown, restore the original display object (not just config) This ensures complete isolation - each parallel execution uses a display object that has show_chat=False baked in from creation, eliminating any timing issues or race conditions with config modifications. The key insight: Don't just modify config on shared objects - create new objects with the desired behavior to ensure complete isolation. --- .../agents/workflow/agents_as_tools_agent.py | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 88c4289e5..4a2b7e206 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -487,18 +487,29 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend # Only suppress if not already suppressed if child_id not in self._original_display_configs: - if hasattr(child, 'display') and child.display and child.display.config: - # Store original config - self._original_display_configs[child_id] = child.display.config + if hasattr(child, 'display') and child.display: + # Store original display for complete restoration + self._original_display_configs[child_id] = { + 'config': child.display.config, + 'display': child.display + } + + # Create a display wrapper that blocks chat messages + from fast_agent.ui.console_display import ConsoleDisplay + # Create suppressed config - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = True - temp_config.logger = temp_logger - child.display.config = temp_config - logger.info(f"Pre-suppressed chat for {child._name} before parallel execution") + if child.display.config: + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = True + temp_config.logger = temp_logger + + # Create new display with suppressed config + suppressed_display = ConsoleDisplay(config=temp_config) + child.display = suppressed_display + logger.info(f"Pre-suppressed chat for {child._name} with new display object") # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent @@ -598,13 +609,14 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins if hasattr(child, '_aggregator') and child._aggregator: child._aggregator.agent_name = original_name - # Restore display config if it was suppressed + # Restore display if it was suppressed child_id = id(child) if hasattr(self, '_original_display_configs') and child_id in self._original_display_configs: - original_config = self._original_display_configs[child_id] + original_data = self._original_display_configs[child_id] del self._original_display_configs[child_id] - if hasattr(child, 'display') and child.display: - child.display.config = original_config - logger.info(f"Restored display config for {original_name} after all results displayed") + if hasattr(child, 'display'): + # Restore both display object and config + child.display = original_data['display'] + logger.info(f"Restored display object for {original_name} after all results displayed") return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 20afe3ba9b08788986d80dab218f409f9a74f54a Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 17:05:14 +0300 Subject: [PATCH 26/44] fix: eliminate name mutation race condition in parallel execution Problem: All 4 parallel tasks were modifying the same child agent's _name simultaneously, causing a race condition where the last task to set it (usually instance [4]) would dominate the logs. Events from instances [1], [2], [3] were showing up under the main instance name or instance [4]. Root Cause: - Tasks ran concurrently: asyncio.gather(*tasks) - Each task did: child._name = instance_name (MUTATING SHARED STATE\!) - Race condition: Last writer wins, all tasks use that name - Result: All logs showed instance [4] name Solution - Sequential Name Ownership: 1. Build instance_map BEFORE tasks start - Maps correlation_id -> (child, instance_name, instance_num) - No shared state mutation yet 2. Each task owns the name during its execution: - On entry: Save old_name, set instance_name - Execute: All logs use this instance's name - On exit (finally): Restore old_name immediately 3. This creates sequential ownership windows: - Task 1: Sets [1], executes, restores - Task 2: Sets [2], executes, restores - Each task's logs correctly show its instance number Additional Changes: - Removed display suppression to see all logs for debugging - Keep main instance visible in progress panel (don't hide/suppress) - Each task restores names in finally block (no global cleanup needed) - Pass correlation_id to wrapper so it can lookup pre-assigned instance info This ensures each instance's logs are correctly attributed to that instance, making event routing visible for debugging. --- .../agents/workflow/agents_as_tools_agent.py | 153 +++++++----------- 1 file changed, 58 insertions(+), 95 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 4a2b7e206..5caf6ddd5 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -460,114 +460,92 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) - # Collect original names and setup display suppression BEFORE parallel execution + # Collect original names and prepare for parallel execution pending_count = len(id_list) original_names = {} - suppressed_children = set() # Track which children we suppressed - - # Initialize tracking dictionaries if needed - if not hasattr(self, '_display_suppression_count'): - self._display_suppression_count = {} - if not hasattr(self, '_original_display_configs'): - self._original_display_configs = {} + instance_map = {} # Map correlation_id -> (child, instance_name, instance_number) + # Build instance map - assign instance numbers and names if pending_count > 1: - for cid in id_list: + instance_counter = {} # Track instance numbers per tool_name + for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child: - # Store original name - if hasattr(child, '_name') and tool_name not in original_names: + # Store original name once + if tool_name not in original_names and hasattr(child, '_name'): original_names[tool_name] = child._name - # Suppress display for this child (only once per unique child) - child_id = id(child) - if child_id not in suppressed_children: - suppressed_children.add(child_id) - - # Only suppress if not already suppressed - if child_id not in self._original_display_configs: - if hasattr(child, 'display') and child.display: - # Store original display for complete restoration - self._original_display_configs[child_id] = { - 'config': child.display.config, - 'display': child.display - } - - # Create a display wrapper that blocks chat messages - from fast_agent.ui.console_display import ConsoleDisplay - - # Create suppressed config - if child.display.config: - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = True - temp_config.logger = temp_logger - - # Create new display with suppressed config - suppressed_display = ConsoleDisplay(config=temp_config) - child.display = suppressed_display - logger.info(f"Pre-suppressed chat for {child._name} with new display object") + # Create instance name + original = original_names.get(tool_name, child._name if hasattr(child, '_name') else tool_name) + instance_name = f"{original}[{i}]" + instance_map[cid] = (child, instance_name, i) + + logger.info(f"Mapped {cid} -> {instance_name}") # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent from fast_agent.ui.progress_display import progress_display as outer_progress_display - # Create wrapper coroutine that sets name and emits progress for instance - async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], instance: int) -> CallToolResult: + # Create wrapper coroutine that uses pre-assigned instance info + async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args: dict[str, Any]) -> CallToolResult: + instance_info = instance_map.get(correlation_id) instance_name = None - if pending_count > 1: - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child and hasattr(child, '_name'): - original = original_names.get(tool_name, child._name) - instance_name = f"{original}[{instance}]" + + if instance_info: + child, instance_name, instance_num = instance_info + + # Emit progress event to create separate line in progress panel + outer_progress_display.update(ProgressEvent( + action=ProgressAction.CHATTING, + target=instance_name, + details="", + agent_name=instance_name + )) + + # Temporarily set instance name for this execution + # Store the current names to restore after + old_name = child._name if hasattr(child, '_name') else None + old_agg_name = child._aggregator.agent_name if hasattr(child, '_aggregator') and child._aggregator else None + + try: + # Set instance name for THIS execution only child._name = instance_name - - # Also update aggregator's agent_name so tool progress events use instance name if hasattr(child, '_aggregator') and child._aggregator: child._aggregator.agent_name = instance_name - # Emit progress event to create separate line in progress panel - outer_progress_display.update(ProgressEvent( - action=ProgressAction.CHATTING, - target=instance_name, - details="", - agent_name=instance_name - )) - - try: - return await self.call_tool(tool_name, tool_args) - finally: - # Hide instance line immediately when this task completes - if instance_name and pending_count > 1: + logger.info(f"[{instance_name}] Starting execution") + result = await self.call_tool(tool_name, tool_args) + logger.info(f"[{instance_name}] Completed execution") + return result + + finally: + # Restore original names immediately + if old_name is not None: + child._name = old_name + if old_agg_name is not None and hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = old_agg_name + + # Hide instance line logger.info(f"Hiding instance line: {instance_name}") if instance_name in outer_progress_display._taskmap: task_id = outer_progress_display._taskmap[instance_name] for task in outer_progress_display._progress.tasks: if task.id == task_id: task.visible = False - logger.info(f"Set visible=False for {instance_name}") break + else: + # Single instance or no mapping - just call normally + return await self.call_tool(tool_name, tool_args) - # Set parent agent lines to Ready status while instances run - if pending_count > 1: - for tool_name in original_names.keys(): - original = original_names[tool_name] - # Set parent to Ready status - outer_progress_display.update(ProgressEvent( - action=ProgressAction.READY, - target=original, - details="", - agent_name=original - )) + # DON'T hide or suppress main instance - keep it visible to see event routing + # Parent stays visible in progress panel during parallel execution # Create tasks with instance-specific wrappers - for i, cid in enumerate(id_list, 1): + for cid in id_list: tool_name = descriptor_by_id[cid]["tool"] tool_args = descriptor_by_id[cid]["args"] - tasks.append(asyncio.create_task(call_with_instance_name(tool_name, tool_args, i))) + tasks.append(asyncio.create_task(call_with_instance_name(cid, tool_name, tool_args))) # Show aggregated tool call(s) self._show_parallel_tool_calls(call_descriptors) @@ -600,23 +578,8 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins self._show_parallel_tool_results(ordered_records) - # Restore original agent names and display configs - for tool_name, original_name in original_names.items(): - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - child._name = original_name - # Restore aggregator's agent_name too - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = original_name - - # Restore display if it was suppressed - child_id = id(child) - if hasattr(self, '_original_display_configs') and child_id in self._original_display_configs: - original_data = self._original_display_configs[child_id] - del self._original_display_configs[child_id] - if hasattr(child, 'display'): - # Restore both display object and config - child.display = original_data['display'] - logger.info(f"Restored display object for {original_name} after all results displayed") + # Names are already restored in the finally blocks of each task + # No additional cleanup needed - each task restored its own changes + logger.info(f"Parallel execution complete for {len(id_list)} instances") return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 12459adcc22f199061a14793d9156478500101cb Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 17:22:57 +0300 Subject: [PATCH 27/44] fix: remove agent renaming to eliminate race condition Problem: Multiple concurrent tasks were mutating the same child agent's _name, causing: 1. Race condition - tool calls from different instances got mixed up 2. Duplicate progress panel rows - each rename triggered new events 3. Logs showing wrong instance numbers Root Cause: Even with try/finally, execution overlaps: - Task 1: Sets name to [1], starts executing - Task 2: Sets name to [2] (overwrites\!), Task 1 still running - Task 1's logs now show [2] instead of [1] Solution: Don't rename agents AT ALL - Instance numbers already shown in display headers via _show_parallel_tool_calls - Display code already does: display_tool_name = f'{tool_name}[{i}]' - No need to mutate shared agent state - Each task just calls the tool directly - Parallel execution works without interference Benefits: - True parallel execution (no locks/serialization) - No race conditions (no shared state mutation) - No duplicate panel rows (child emits events with original name) - Instance numbers still visible in tool call/result headers The instance_map is now only used for logging context, not for renaming. --- .../agents/workflow/agents_as_tools_agent.py | 57 +++---------------- 1 file changed, 9 insertions(+), 48 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 5caf6ddd5..e24dd9625 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -483,64 +483,25 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend logger.info(f"Mapped {cid} -> {instance_name}") - # Import progress_display at outer scope to ensure same instance + # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent from fast_agent.ui.progress_display import progress_display as outer_progress_display - # Create wrapper coroutine that uses pre-assigned instance info + # Simple wrapper - NO renaming, just call the tool + # Instance numbers already shown in display headers via _show_parallel_tool_calls async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args: dict[str, Any]) -> CallToolResult: instance_info = instance_map.get(correlation_id) - instance_name = None if instance_info: - child, instance_name, instance_num = instance_info - - # Emit progress event to create separate line in progress panel - outer_progress_display.update(ProgressEvent( - action=ProgressAction.CHATTING, - target=instance_name, - details="", - agent_name=instance_name - )) - - # Temporarily set instance name for this execution - # Store the current names to restore after - old_name = child._name if hasattr(child, '_name') else None - old_agg_name = child._aggregator.agent_name if hasattr(child, '_aggregator') and child._aggregator else None - - try: - # Set instance name for THIS execution only - child._name = instance_name - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = instance_name - - logger.info(f"[{instance_name}] Starting execution") - result = await self.call_tool(tool_name, tool_args) - logger.info(f"[{instance_name}] Completed execution") - return result - - finally: - # Restore original names immediately - if old_name is not None: - child._name = old_name - if old_agg_name is not None and hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = old_agg_name - - # Hide instance line - logger.info(f"Hiding instance line: {instance_name}") - if instance_name in outer_progress_display._taskmap: - task_id = outer_progress_display._taskmap[instance_name] - for task in outer_progress_display._progress.tasks: - if task.id == task_id: - task.visible = False - break + _, instance_name, _ = instance_info + logger.info(f"[{instance_name}] Starting parallel execution") + result = await self.call_tool(tool_name, tool_args) + logger.info(f"[{instance_name}] Completed parallel execution") + return result else: - # Single instance or no mapping - just call normally + # Single instance - just call normally return await self.call_tool(tool_name, tool_args) - # DON'T hide or suppress main instance - keep it visible to see event routing - # Parent stays visible in progress panel during parallel execution - # Create tasks with instance-specific wrappers for cid in id_list: tool_name = descriptor_by_id[cid]["tool"] From c937913e23140a905570c45e9bb7a37727b33f4f Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 17:44:05 +0300 Subject: [PATCH 28/44] fix: suppress child progress events to eliminate duplicate panel rows Problem: Duplicate progress panel rows showing 4+ entries for PM-1-DayStatusSummarizer Root Cause: Each child agent execution emits its own progress events, creating a new panel row each time. With 4 parallel instances, we got 4+ duplicate rows. Solution: Suppress child display output during parallel execution 1. BEFORE parallel tasks start: Suppress child.display.config - Set show_chat = False - Set show_tools = False - This prevents child from emitting ANY display events 2. Execute parallel tasks: Child runs silently, no panel rows created 3. AFTER results shown: Restore original child.display.config Benefits: - Only orchestrator's display headers show (with instance numbers [1], [2], etc.) - No duplicate progress panel rows - Clean consolidated view of parallel execution - Instance numbers still visible in tool call/result headers The key insight: Child agents should be 'silent' during parallel execution, letting the orchestrator handle all display output. --- .../agents/workflow/agents_as_tools_agent.py | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index e24dd9625..6d073b64f 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -464,10 +464,10 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend pending_count = len(id_list) original_names = {} instance_map = {} # Map correlation_id -> (child, instance_name, instance_number) + suppressed_configs = {} # Store original configs to restore later - # Build instance map - assign instance numbers and names + # Build instance map and suppress child progress events if pending_count > 1: - instance_counter = {} # Track instance numbers per tool_name for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) @@ -481,6 +481,25 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend instance_name = f"{original}[{i}]" instance_map[cid] = (child, instance_name, i) + # Suppress child's progress events to prevent duplicate panel rows + child_id = id(child) + if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: + if child.display.config: + # Store original config + suppressed_configs[child_id] = child.display.config + + # Create suppressed config (no chat, no progress events) + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = False # Hide child's internal tool calls too + temp_config.logger = temp_logger + + # Apply suppressed config + child.display.config = temp_config + logger.info(f"Suppressed progress events for {child._name}") + logger.info(f"Mapped {cid} -> {instance_name}") # Import progress_display at outer scope to ensure same instance @@ -539,8 +558,17 @@ async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args self._show_parallel_tool_results(ordered_records) - # Names are already restored in the finally blocks of each task - # No additional cleanup needed - each task restored its own changes + # Restore suppressed child display configs + for child_id, original_config in suppressed_configs.items(): + # Find the child agent by id + for tool_name in original_names.keys(): + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child and id(child) == child_id: + if hasattr(child, 'display') and child.display: + child.display.config = original_config + logger.info(f"Restored display config for {child._name}") + break + logger.info(f"Parallel execution complete for {len(id_list)} instances") return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 6df253c04c033471b180ce1ee060f354adbd70f5 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 17:51:16 +0300 Subject: [PATCH 29/44] fix: use NullDisplay to completely suppress child output during parallel execution Problem: Still seeing duplicate progress panel rows despite display config suppression Root Cause: Progress events are NOT controlled by display.config.logger settings. They come from a separate progress system that gets called regardless of config. Solution: Replace child.display with NullDisplay during parallel execution NullDisplay class: - Has config = None - Returns no-op lambda for ANY method call via __getattr__ - Completely suppresses ALL output: chat, tools, progress events, everything Flow: 1. BEFORE parallel: child.display = NullDisplay() 2. DURING parallel: All child output suppressed (no panel rows) 3. AFTER parallel: child.display = original_display (restored) Benefits: - Zero duplicate panel rows (child can't emit ANY events) - Zero race conditions (no shared state mutations) - Clean orchestrator-only display with instance numbers [1], [2], [3], [4] - True parallel execution maintained --- .../agents/workflow/agents_as_tools_agent.py | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 6d073b64f..40e294632 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -481,24 +481,23 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend instance_name = f"{original}[{i}]" instance_map[cid] = (child, instance_name, i) - # Suppress child's progress events to prevent duplicate panel rows + # Suppress ALL child output/events to prevent duplicate panel rows child_id = id(child) - if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: - if child.display.config: - # Store original config - suppressed_configs[child_id] = child.display.config - - # Create suppressed config (no chat, no progress events) - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = False # Hide child's internal tool calls too - temp_config.logger = temp_logger - - # Apply suppressed config - child.display.config = temp_config - logger.info(f"Suppressed progress events for {child._name}") + if child_id not in suppressed_configs and hasattr(child, 'display'): + # Store original display object + suppressed_configs[child_id] = child.display + + # Replace with a null display that does nothing + class NullDisplay: + """A display that suppresses ALL output and events""" + def __init__(self): + self.config = None + def __getattr__(self, name): + # Return a no-op function for any method call + return lambda *args, **kwargs: None + + child.display = NullDisplay() + logger.info(f"Replaced display with NullDisplay for {child._name}") logger.info(f"Mapped {cid} -> {instance_name}") @@ -558,15 +557,14 @@ async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args self._show_parallel_tool_results(ordered_records) - # Restore suppressed child display configs - for child_id, original_config in suppressed_configs.items(): + # Restore original display objects + for child_id, original_display in suppressed_configs.items(): # Find the child agent by id for tool_name in original_names.keys(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child and id(child) == child_id: - if hasattr(child, 'display') and child.display: - child.display.config = original_config - logger.info(f"Restored display config for {child._name}") + child.display = original_display + logger.info(f"Restored original display for {child._name}") break logger.info(f"Parallel execution complete for {len(id_list)} instances") From 9cef0a6fd825c65e7ad9fafd0a6c7850b8fda7d3 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 17:56:16 +0300 Subject: [PATCH 30/44] fix: also suppress child logger to prevent progress events Progress events are emitted by logger.info() calls, not just display. Need to suppress BOTH display AND logger to eliminate duplicate panel rows. Added NullLogger class that suppresses all logging calls. Store and restore both display and logger during parallel execution. --- .../agents/workflow/agents_as_tools_agent.py | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 40e294632..6a886c766 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -483,21 +483,32 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend # Suppress ALL child output/events to prevent duplicate panel rows child_id = id(child) - if child_id not in suppressed_configs and hasattr(child, 'display'): - # Store original display object - suppressed_configs[child_id] = child.display + if child_id not in suppressed_configs: + # Store original display and logger + suppressed_configs[child_id] = { + 'display': child.display if hasattr(child, 'display') else None, + 'logger': child.logger if hasattr(child, 'logger') else None + } - # Replace with a null display that does nothing + # Replace with null objects that do nothing class NullDisplay: """A display that suppresses ALL output and events""" def __init__(self): self.config = None def __getattr__(self, name): - # Return a no-op function for any method call return lambda *args, **kwargs: None - child.display = NullDisplay() - logger.info(f"Replaced display with NullDisplay for {child._name}") + class NullLogger: + """A logger that suppresses ALL logging""" + def __getattr__(self, name): + return lambda *args, **kwargs: None + + if hasattr(child, 'display'): + child.display = NullDisplay() + if hasattr(child, 'logger'): + child.logger = NullLogger() + + logger.info(f"Replaced display & logger with null objects for {child._name}") logger.info(f"Mapped {cid} -> {instance_name}") @@ -557,14 +568,17 @@ async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args self._show_parallel_tool_results(ordered_records) - # Restore original display objects - for child_id, original_display in suppressed_configs.items(): + # Restore original display and logger objects + for child_id, originals in suppressed_configs.items(): # Find the child agent by id for tool_name in original_names.keys(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child and id(child) == child_id: - child.display = original_display - logger.info(f"Restored original display for {child._name}") + if originals.get('display') and hasattr(child, 'display'): + child.display = originals['display'] + if originals.get('logger') and hasattr(child, 'logger'): + child.logger = originals['logger'] + logger.info(f"Restored original display & logger for {child._name}") break logger.info(f"Parallel execution complete for {len(id_list)} instances") From 63ae11eb908c8e0bd72d09caa7ece9eb30ae380f Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 18:00:07 +0300 Subject: [PATCH 31/44] fix: also suppress aggregator logger to block MCP tool progress events MCP tools emit progress events via aggregator.logger, not child.logger. Need to suppress aggregator's logger too. Now suppressing: - child.display - child.logger - child._aggregator.logger (NEW - this was the missing piece\!) This should finally eliminate all duplicate progress panel rows. --- .../agents/workflow/agents_as_tools_agent.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 6a886c766..c136929d1 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -484,12 +484,17 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend # Suppress ALL child output/events to prevent duplicate panel rows child_id = id(child) if child_id not in suppressed_configs: - # Store original display and logger + # Store original display, logger, and aggregator logger suppressed_configs[child_id] = { 'display': child.display if hasattr(child, 'display') else None, - 'logger': child.logger if hasattr(child, 'logger') else None + 'logger': child.logger if hasattr(child, 'logger') else None, + 'agg_logger': None } + # Also store aggregator logger if it exists + if hasattr(child, '_aggregator') and child._aggregator and hasattr(child._aggregator, 'logger'): + suppressed_configs[child_id]['agg_logger'] = child._aggregator.logger + # Replace with null objects that do nothing class NullDisplay: """A display that suppresses ALL output and events""" @@ -503,12 +508,17 @@ class NullLogger: def __getattr__(self, name): return lambda *args, **kwargs: None + # Replace child's display and logger if hasattr(child, 'display'): child.display = NullDisplay() if hasattr(child, 'logger'): child.logger = NullLogger() - logger.info(f"Replaced display & logger with null objects for {child._name}") + # CRITICAL: Also replace aggregator's logger (MCP tools emit progress here) + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.logger = NullLogger() + + logger.info(f"Replaced display, logger & aggregator logger with null objects for {child._name}") logger.info(f"Mapped {cid} -> {instance_name}") @@ -568,7 +578,7 @@ async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args self._show_parallel_tool_results(ordered_records) - # Restore original display and logger objects + # Restore original display, logger, and aggregator logger for child_id, originals in suppressed_configs.items(): # Find the child agent by id for tool_name in original_names.keys(): @@ -578,7 +588,9 @@ async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args child.display = originals['display'] if originals.get('logger') and hasattr(child, 'logger'): child.logger = originals['logger'] - logger.info(f"Restored original display & logger for {child._name}") + if originals.get('agg_logger') and hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.logger = originals['agg_logger'] + logger.info(f"Restored original display, logger & aggregator logger for {child._name}") break logger.info(f"Parallel execution complete for {len(id_list)} instances") From 73d3d048f2354f06d2e14cfe2b996019d629a26f Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 20:39:07 +0300 Subject: [PATCH 32/44] refactor: simplify child suppression to config-only approach Reverted from NullDisplay/NullLogger approach back to simpler config modification. Suppression approach: - Store original child.display.config - Create temp config with show_chat=False, show_tools=False - Apply temp config during parallel execution - Restore original config after results shown Benefits: - Simpler implementation (no complex null object classes) - Less intrusive (just config changes, not object replacement) - Easier to debug and maintain - Still prevents duplicate progress panel rows This approach relies on display.config.logger settings to control output, which should be sufficient for most cases. --- .../agents/workflow/agents_as_tools_agent.py | 68 ++++++------------- 1 file changed, 22 insertions(+), 46 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index c136929d1..6d073b64f 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -481,44 +481,24 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend instance_name = f"{original}[{i}]" instance_map[cid] = (child, instance_name, i) - # Suppress ALL child output/events to prevent duplicate panel rows + # Suppress child's progress events to prevent duplicate panel rows child_id = id(child) - if child_id not in suppressed_configs: - # Store original display, logger, and aggregator logger - suppressed_configs[child_id] = { - 'display': child.display if hasattr(child, 'display') else None, - 'logger': child.logger if hasattr(child, 'logger') else None, - 'agg_logger': None - } - - # Also store aggregator logger if it exists - if hasattr(child, '_aggregator') and child._aggregator and hasattr(child._aggregator, 'logger'): - suppressed_configs[child_id]['agg_logger'] = child._aggregator.logger - - # Replace with null objects that do nothing - class NullDisplay: - """A display that suppresses ALL output and events""" - def __init__(self): - self.config = None - def __getattr__(self, name): - return lambda *args, **kwargs: None - - class NullLogger: - """A logger that suppresses ALL logging""" - def __getattr__(self, name): - return lambda *args, **kwargs: None - - # Replace child's display and logger - if hasattr(child, 'display'): - child.display = NullDisplay() - if hasattr(child, 'logger'): - child.logger = NullLogger() - - # CRITICAL: Also replace aggregator's logger (MCP tools emit progress here) - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.logger = NullLogger() - - logger.info(f"Replaced display, logger & aggregator logger with null objects for {child._name}") + if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: + if child.display.config: + # Store original config + suppressed_configs[child_id] = child.display.config + + # Create suppressed config (no chat, no progress events) + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = False # Hide child's internal tool calls too + temp_config.logger = temp_logger + + # Apply suppressed config + child.display.config = temp_config + logger.info(f"Suppressed progress events for {child._name}") logger.info(f"Mapped {cid} -> {instance_name}") @@ -578,19 +558,15 @@ async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args self._show_parallel_tool_results(ordered_records) - # Restore original display, logger, and aggregator logger - for child_id, originals in suppressed_configs.items(): + # Restore suppressed child display configs + for child_id, original_config in suppressed_configs.items(): # Find the child agent by id for tool_name in original_names.keys(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) if child and id(child) == child_id: - if originals.get('display') and hasattr(child, 'display'): - child.display = originals['display'] - if originals.get('logger') and hasattr(child, 'logger'): - child.logger = originals['logger'] - if originals.get('agg_logger') and hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.logger = originals['agg_logger'] - logger.info(f"Restored original display, logger & aggregator logger for {child._name}") + if hasattr(child, 'display') and child.display: + child.display.config = original_config + logger.info(f"Restored display config for {child._name}") break logger.info(f"Parallel execution complete for {len(id_list)} instances") From 8a48d3067e7534965c86b01d1560ff879dd858c3 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 20:40:53 +0300 Subject: [PATCH 33/44] docs: add comprehensive documentation for parallel execution approach Added detailed inline documentation explaining: 1. PARALLEL EXECUTION SETUP section: - Instance numbering strategy (displayed in headers only) - Display suppression approach (config modification) - Why we avoid agent renaming (prevents race conditions) 2. _show_parallel_tool_calls docstring: - Example output showing instance numbers [1], [2], [3], [4] - Explains orchestrator displays tool call headers 3. _show_parallel_tool_results docstring: - Example output showing matching instance numbers in results - Shows how instance numbers correspond to calls Key design principles documented: - NO agent renaming during execution (true parallelism) - Instance numbers ONLY in display headers (no shared state) - Display suppression via config (prevents duplicate panel rows) - Orchestrator-only display (child agents silent during parallel execution) This documentation makes the parallel execution strategy clear for future maintenance and debugging. --- .../agents/workflow/agents_as_tools_agent.py | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 6d073b64f..95ed4a5b2 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -325,8 +325,19 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> logger.error(f"Child agent {child.name} failed: {e}") return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) - def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: - """Display tool call headers for parallel agent execution. + def _show_parallel_tool_calls( + self, descriptors: list[dict[str, Any]] + ) -> None: + """Display aggregated view of parallel agent tool calls. + + Shows individual tool call headers with instance numbers when multiple + instances of the same agent execute in parallel. + + Example output: + ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[1]] + ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[2]] + ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[3]] + ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[4]] Args: descriptors: List of tool call descriptors with metadata @@ -384,6 +395,15 @@ def _show_parallel_tool_results( ) -> None: """Display tool result panels for parallel agent execution. + Shows individual tool result headers with instance numbers matching + the tool call headers shown earlier. + + Example output: + ▎▶ orchestrator [tool result - Text Only 78 chars] PM-1-DayStatusSummarizer[1] + ▎▶ orchestrator [tool result - Text Only 78 chars] PM-1-DayStatusSummarizer[2] + ▎▶ orchestrator [tool result - ERROR] PM-1-DayStatusSummarizer[3] + ▎▶ orchestrator [tool result - Text Only 33 chars] PM-1-DayStatusSummarizer[4] + Args: records: List of result records with descriptor and result data """ @@ -460,7 +480,27 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) - # Collect original names and prepare for parallel execution + # ═══════════════════════════════════════════════════════════════════════════════ + # PARALLEL EXECUTION SETUP + # ═══════════════════════════════════════════════════════════════════════════════ + # When multiple tool calls invoke the same child agent, we execute them in parallel. + # + # INSTANCE NUMBERING: + # - Tool headers show: PM-1-DayStatusSummarizer[1], [2], [3], [4] + # - Progress panel shows: PM-1-DayStatusSummarizer (single entry, no duplicates) + # + # DISPLAY SUPPRESSION: + # - Child agents get display.config modified: show_chat=False, show_tools=False + # - This prevents duplicate progress panel rows during parallel execution + # - Orchestrator displays all tool calls/results with instance numbers + # - Original configs restored after parallel execution completes + # + # NO AGENT RENAMING: + # - We do NOT rename child._name during execution (causes race conditions) + # - Instance numbers only appear in display headers (via _show_parallel_tool_calls) + # - This ensures true parallel execution without shared state mutations + # ═══════════════════════════════════════════════════════════════════════════════ + pending_count = len(id_list) original_names = {} instance_map = {} # Map correlation_id -> (child, instance_name, instance_number) From 37bfacf893d8b83497c34f3cad2a8c8e2d487617 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 20:44:29 +0300 Subject: [PATCH 34/44] refactor: first instance runs normally, only instances 2+ get indexed Architectural improvement suggested by user: - First instance executes without index or suppression (natural behavior) - Only when 2nd+ instances appear, they get indexed [2], [3], [4] and suppressed Benefits: 1. Simpler logic - first instance untouched, runs as designed 2. Less config manipulation - only suppress when truly needed 3. More intuitive - single execution looks normal, parallel adds indexes 4. Cleaner code - fewer edge cases and state changes New numbering: - Instance 1: PM-1-DayStatusSummarizer (no index, full display) - Instance 2: PM-1-DayStatusSummarizer[2] (indexed, suppressed) - Instance 3: PM-1-DayStatusSummarizer[3] (indexed, suppressed) - Instance 4: PM-1-DayStatusSummarizer[4] (indexed, suppressed) Progress panel shows single entry from first instance. Instances 2+ are silent (suppressed) to avoid duplicates. Updated documentation and examples to reflect new approach. --- .../agents/workflow/agents_as_tools_agent.py | 84 +++++++++++-------- 1 file changed, 51 insertions(+), 33 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 95ed4a5b2..ab6d6e018 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -333,8 +333,10 @@ def _show_parallel_tool_calls( Shows individual tool call headers with instance numbers when multiple instances of the same agent execute in parallel. - Example output: - ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[1]] + NOTE: First instance has no index (runs normally), instances 2+ are indexed. + + Example output (4 parallel instances): + ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer] ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[2]] ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[3]] ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[4]] @@ -398,8 +400,10 @@ def _show_parallel_tool_results( Shows individual tool result headers with instance numbers matching the tool call headers shown earlier. - Example output: - ▎▶ orchestrator [tool result - Text Only 78 chars] PM-1-DayStatusSummarizer[1] + NOTE: First instance has no index, instances 2+ are indexed. + + Example output (4 parallel instances): + ▎▶ orchestrator [tool result - Text Only 78 chars] PM-1-DayStatusSummarizer ▎▶ orchestrator [tool result - Text Only 78 chars] PM-1-DayStatusSummarizer[2] ▎▶ orchestrator [tool result - ERROR] PM-1-DayStatusSummarizer[3] ▎▶ orchestrator [tool result - Text Only 33 chars] PM-1-DayStatusSummarizer[4] @@ -485,13 +489,17 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend # ═══════════════════════════════════════════════════════════════════════════════ # When multiple tool calls invoke the same child agent, we execute them in parallel. # - # INSTANCE NUMBERING: - # - Tool headers show: PM-1-DayStatusSummarizer[1], [2], [3], [4] - # - Progress panel shows: PM-1-DayStatusSummarizer (single entry, no duplicates) + # INSTANCE NUMBERING (NEW APPROACH): + # - First instance: PM-1-DayStatusSummarizer (no index, runs normally) + # - Second instance: PM-1-DayStatusSummarizer[2] (indexed, suppressed) + # - Third instance: PM-1-DayStatusSummarizer[3] (indexed, suppressed) + # - Fourth instance: PM-1-DayStatusSummarizer[4] (indexed, suppressed) + # - Progress panel shows: PM-1-DayStatusSummarizer (single entry from first instance) # # DISPLAY SUPPRESSION: - # - Child agents get display.config modified: show_chat=False, show_tools=False - # - This prevents duplicate progress panel rows during parallel execution + # - First instance: NOT suppressed - runs with full display (progress panel visible) + # - Instances 2+: display.config modified (show_chat=False, show_tools=False) + # - This prevents duplicate progress panel rows for parallel instances # - Orchestrator displays all tool calls/results with instance numbers # - Original configs restored after parallel execution completes # @@ -507,6 +515,8 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend suppressed_configs = {} # Store original configs to restore later # Build instance map and suppress child progress events + # NOTE: First instance runs normally (no index, no suppression) + # Only instances 2+ get indexed and suppressed if pending_count > 1: for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] @@ -516,31 +526,39 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend if tool_name not in original_names and hasattr(child, '_name'): original_names[tool_name] = child._name - # Create instance name + # First instance: no index, runs normally + # Instances 2+: indexed and suppressed original = original_names.get(tool_name, child._name if hasattr(child, '_name') else tool_name) - instance_name = f"{original}[{i}]" - instance_map[cid] = (child, instance_name, i) - - # Suppress child's progress events to prevent duplicate panel rows - child_id = id(child) - if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: - if child.display.config: - # Store original config - suppressed_configs[child_id] = child.display.config - - # Create suppressed config (no chat, no progress events) - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = False # Hide child's internal tool calls too - temp_config.logger = temp_logger - - # Apply suppressed config - child.display.config = temp_config - logger.info(f"Suppressed progress events for {child._name}") - - logger.info(f"Mapped {cid} -> {instance_name}") + if i == 1: + # First instance - no index, no suppression + instance_name = original + instance_map[cid] = (child, instance_name, i) + logger.info(f"Mapped {cid} -> {instance_name} (first instance, not suppressed)") + else: + # Instances 2+ - add index and suppress + instance_name = f"{original}[{i}]" + instance_map[cid] = (child, instance_name, i) + + # Suppress child's progress events to prevent duplicate panel rows + child_id = id(child) + if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: + if child.display.config: + # Store original config + suppressed_configs[child_id] = child.display.config + + # Create suppressed config (no chat, no progress events) + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = False # Hide child's internal tool calls too + temp_config.logger = temp_logger + + # Apply suppressed config + child.display.config = temp_config + logger.info(f"Suppressed progress events for {child._name}") + + logger.info(f"Mapped {cid} -> {instance_name} (suppressed)") # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent From 37d3198978adaf36f01d7fcecea3d3eb9d286415 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Sat, 8 Nov 2025 20:49:59 +0300 Subject: [PATCH 35/44] feat: all instances visible in panel, only streaming suppressed for 2+ Major architectural improvements based on user feedback: 1. PANEL VISIBILITY: - First instance: PM-1-DayStatusSummarizer (full display + streaming) - Instances 2+: PM-1-DayStatusSummarizer[2], [3], [4] (visible in panel) - ALL instances shown in progress panel (no hiding) 2. STREAMING SUPPRESSION: - First instance: streaming_display=True (typing effect visible) - Instances 2+: streaming_display=False (no typing clutter) - Instances 2+: show_chat=True, show_tools=True (panel entries visible) - Only the typing effect is suppressed, not the entire display 3. THREAD SAFETY: - Added self._instance_lock (asyncio.Lock) in __init__ - Protected instance creation with async with self._instance_lock - Prevents race conditions on concurrent run_tools calls - Sequential modification of instance_map and suppressed_configs Benefits: - User sees all parallel instances progressing in panel - No visual clutter from multiple streaming outputs - First instance behaves naturally (untouched) - Thread-safe instance creation for concurrent calls This approach provides full visibility into parallel execution while avoiding the distraction of multiple simultaneous typing effects. --- .../agents/workflow/agents_as_tools_agent.py | 124 ++++++++++-------- 1 file changed, 72 insertions(+), 52 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index ab6d6e018..c581cc666 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -213,6 +213,10 @@ def __init__( # Initialize as a ToolAgent but without local FastMCP tools; we'll override list_tools super().__init__(config=config, tools=[], context=context) self._child_agents: dict[str, LlmAgent] = {} + + # Lock for protecting instance creation and config modification + # Prevents race conditions when multiple run_tools calls happen concurrently + self._instance_lock = asyncio.Lock() # Build tool name mapping for children for child in agents: @@ -489,20 +493,31 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend # ═══════════════════════════════════════════════════════════════════════════════ # When multiple tool calls invoke the same child agent, we execute them in parallel. # - # INSTANCE NUMBERING (NEW APPROACH): + # INSTANCE NUMBERING: # - First instance: PM-1-DayStatusSummarizer (no index, runs normally) - # - Second instance: PM-1-DayStatusSummarizer[2] (indexed, suppressed) - # - Third instance: PM-1-DayStatusSummarizer[3] (indexed, suppressed) - # - Fourth instance: PM-1-DayStatusSummarizer[4] (indexed, suppressed) - # - Progress panel shows: PM-1-DayStatusSummarizer (single entry from first instance) + # - Second instance: PM-1-DayStatusSummarizer[2] (indexed, streaming suppressed) + # - Third instance: PM-1-DayStatusSummarizer[3] (indexed, streaming suppressed) + # - Fourth instance: PM-1-DayStatusSummarizer[4] (indexed, streaming suppressed) + # + # PROGRESS PANEL: + # - ALL instances visible in panel (no hiding) + # - PM-1-DayStatusSummarizer - first instance, full display + # - PM-1-DayStatusSummarizer[2] - second instance, no streaming + # - PM-1-DayStatusSummarizer[3] - third instance, no streaming + # - PM-1-DayStatusSummarizer[4] - fourth instance, no streaming # - # DISPLAY SUPPRESSION: - # - First instance: NOT suppressed - runs with full display (progress panel visible) - # - Instances 2+: display.config modified (show_chat=False, show_tools=False) - # - This prevents duplicate progress panel rows for parallel instances - # - Orchestrator displays all tool calls/results with instance numbers + # STREAMING SUPPRESSION: + # - First instance: Full display with streaming (typing effect visible) + # - Instances 2+: streaming_display=False (no typing effect) + # - Instances 2+: show_chat=True, show_tools=True (panel visible) + # - This prevents visual clutter from multiple streaming outputs # - Original configs restored after parallel execution completes # + # THREAD SAFETY: + # - Instance creation protected by self._instance_lock (asyncio.Lock) + # - Prevents race conditions on concurrent run_tools calls + # - Ensures sequential modification of instance_map and suppressed_configs + # # NO AGENT RENAMING: # - We do NOT rename child._name during execution (causes race conditions) # - Instance numbers only appear in display headers (via _show_parallel_tool_calls) @@ -514,51 +529,56 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend instance_map = {} # Map correlation_id -> (child, instance_name, instance_number) suppressed_configs = {} # Store original configs to restore later - # Build instance map and suppress child progress events + # Build instance map and suppress streaming for instances 2+ # NOTE: First instance runs normally (no index, no suppression) - # Only instances 2+ get indexed and suppressed + # Instances 2+ get indexed and streaming suppressed (but shown in panel) + # PROTECTED: Use lock to prevent race conditions on concurrent run_tools calls if pending_count > 1: - for i, cid in enumerate(id_list, 1): - tool_name = descriptor_by_id[cid]["tool"] - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - # Store original name once - if tool_name not in original_names and hasattr(child, '_name'): - original_names[tool_name] = child._name - - # First instance: no index, runs normally - # Instances 2+: indexed and suppressed - original = original_names.get(tool_name, child._name if hasattr(child, '_name') else tool_name) - if i == 1: - # First instance - no index, no suppression - instance_name = original - instance_map[cid] = (child, instance_name, i) - logger.info(f"Mapped {cid} -> {instance_name} (first instance, not suppressed)") - else: - # Instances 2+ - add index and suppress - instance_name = f"{original}[{i}]" - instance_map[cid] = (child, instance_name, i) - - # Suppress child's progress events to prevent duplicate panel rows - child_id = id(child) - if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: - if child.display.config: - # Store original config - suppressed_configs[child_id] = child.display.config - - # Create suppressed config (no chat, no progress events) - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = False # Hide child's internal tool calls too - temp_config.logger = temp_logger - - # Apply suppressed config - child.display.config = temp_config - logger.info(f"Suppressed progress events for {child._name}") + async with self._instance_lock: + for i, cid in enumerate(id_list, 1): + tool_name = descriptor_by_id[cid]["tool"] + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child: + # Store original name once + if tool_name not in original_names and hasattr(child, '_name'): + original_names[tool_name] = child._name - logger.info(f"Mapped {cid} -> {instance_name} (suppressed)") + # First instance: no index, runs normally + # Instances 2+: indexed, streaming suppressed, panel visible + original = original_names.get(tool_name, child._name if hasattr(child, '_name') else tool_name) + if i == 1: + # First instance - no index, no suppression, full display + instance_name = original + instance_map[cid] = (child, instance_name, i) + logger.info(f"Mapped {cid} -> {instance_name} (first instance, full display)") + else: + # Instances 2+ - add index and suppress streaming only + instance_name = f"{original}[{i}]" + instance_map[cid] = (child, instance_name, i) + + # Suppress only streaming output (typing effect), keep progress panel + child_id = id(child) + if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: + if child.display.config: + # Store original config + suppressed_configs[child_id] = child.display.config + + # Create config that suppresses streaming but shows progress + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + # Suppress streaming/typing output + temp_logger.streaming_display = False + # Keep chat and tools visible in panel + temp_logger.show_chat = True + temp_logger.show_tools = True + temp_config.logger = temp_logger + + # Apply config with streaming suppressed + child.display.config = temp_config + logger.info(f"Suppressed streaming for {child._name}, panel visible") + + logger.info(f"Mapped {cid} -> {instance_name} (streaming suppressed, panel visible)") # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent From f1783c5350fde0b513f1cd9e4da2625dbb919acc Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 01:35:51 +0300 Subject: [PATCH 36/44] feat: detach agents-as-tools instances and harden MCP task groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detached per-call cloning in LlmDecorator so child agents can be spawned via spawn_detached_instance and later merged with merge_usage_from. - Rework AgentsAsToolsAgent.run_tools to execute child agents in parallel using detached clones, with clearer per-instance progress lines and tool-call/result panels. - Track ownership of MCPConnectionManager in MCPAggregator and only shut it down from the owning aggregator, fixing “Task group is not active” errors when short‑lived clones exit. - Improve MCPAggregator tool refresh to rebuild namespaced tool maps per server and log UPDATED progress events with tool counts. - Extend log→ProgressEvent conversion to treat THINKING like STREAMING for token counts and to use the typed ProgressAction field. - Add RichProgressDisplay.hide_task API for future UI behaviors and wire small fastagent/listener changes around the updated progress pipeline. --- .../agents/workflow/agents_as_tools_agent.py | 264 ++++++++---------- 1 file changed, 117 insertions(+), 147 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index c581cc666..a73cf929c 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -213,10 +213,6 @@ def __init__( # Initialize as a ToolAgent but without local FastMCP tools; we'll override list_tools super().__init__(config=config, tools=[], context=context) self._child_agents: dict[str, LlmAgent] = {} - - # Lock for protecting instance creation and config modification - # Prevents race conditions when multiple run_tools calls happen concurrently - self._instance_lock = asyncio.Lock() # Build tool name mapping for children for child in agents: @@ -304,9 +300,32 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> # Serialize arguments to text input child_request = Prompt.user(input_text) + # Track display config changes per child to handle parallel instances + child_id = id(child) + if not hasattr(self, '_display_suppression_count'): + self._display_suppression_count = {} + self._original_display_configs = {} + try: - # Note: Display suppression is now handled in run_tools before parallel execution - # This ensures all instances use the same suppressed config + # Suppress child agent chat messages (keep tool calls visible) + # Only modify config on first parallel instance + if child_id not in self._display_suppression_count: + self._display_suppression_count[child_id] = 0 + + if hasattr(child, 'display') and child.display and child.display.config: + # Store original config for restoration later + self._original_display_configs[child_id] = child.display.config + temp_config = copy(child.display.config) + if hasattr(temp_config, 'logger'): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = True # Explicitly keep tools visible + temp_config.logger = temp_logger + child.display.config = temp_config + + # Increment active instance count + self._display_suppression_count[child_id] += 1 + response: PromptMessageExtended = await child.generate([child_request], None) # Prefer preserving original content blocks for better UI fidelity content_blocks = list(response.content or []) @@ -328,22 +347,16 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> except Exception as e: logger.error(f"Child agent {child.name} failed: {e}") return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) + finally: + # Decrement active instance count + if child_id in self._display_suppression_count: + self._display_suppression_count[child_id] -= 1 + + # Don't restore config here - let run_tools restore after results are displayed + # This ensures final logs keep instance numbers [N] - def _show_parallel_tool_calls( - self, descriptors: list[dict[str, Any]] - ) -> None: - """Display aggregated view of parallel agent tool calls. - - Shows individual tool call headers with instance numbers when multiple - instances of the same agent execute in parallel. - - NOTE: First instance has no index (runs normally), instances 2+ are indexed. - - Example output (4 parallel instances): - ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer] - ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[2]] - ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[3]] - ▎◀ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[4]] + def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: + """Display tool call headers for parallel agent execution. Args: descriptors: List of tool call descriptors with metadata @@ -384,7 +397,7 @@ def _show_parallel_tool_calls( tool_name=display_tool_name, tool_args=args, bottom_items=[bottom_item], # Only this instance's label - max_item_length=50, # Increased from 28 to prevent truncation + max_item_length=28, ) def _summarize_result_text(self, result: CallToolResult) -> str: @@ -401,17 +414,6 @@ def _show_parallel_tool_results( ) -> None: """Display tool result panels for parallel agent execution. - Shows individual tool result headers with instance numbers matching - the tool call headers shown earlier. - - NOTE: First instance has no index, instances 2+ are indexed. - - Example output (4 parallel instances): - ▎▶ orchestrator [tool result - Text Only 78 chars] PM-1-DayStatusSummarizer - ▎▶ orchestrator [tool result - Text Only 78 chars] PM-1-DayStatusSummarizer[2] - ▎▶ orchestrator [tool result - ERROR] PM-1-DayStatusSummarizer[3] - ▎▶ orchestrator [tool result - Text Only 33 chars] PM-1-DayStatusSummarizer[4] - Args: records: List of result records with descriptor and result data """ @@ -488,122 +490,73 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) - # ═══════════════════════════════════════════════════════════════════════════════ - # PARALLEL EXECUTION SETUP - # ═══════════════════════════════════════════════════════════════════════════════ - # When multiple tool calls invoke the same child agent, we execute them in parallel. - # - # INSTANCE NUMBERING: - # - First instance: PM-1-DayStatusSummarizer (no index, runs normally) - # - Second instance: PM-1-DayStatusSummarizer[2] (indexed, streaming suppressed) - # - Third instance: PM-1-DayStatusSummarizer[3] (indexed, streaming suppressed) - # - Fourth instance: PM-1-DayStatusSummarizer[4] (indexed, streaming suppressed) - # - # PROGRESS PANEL: - # - ALL instances visible in panel (no hiding) - # - PM-1-DayStatusSummarizer - first instance, full display - # - PM-1-DayStatusSummarizer[2] - second instance, no streaming - # - PM-1-DayStatusSummarizer[3] - third instance, no streaming - # - PM-1-DayStatusSummarizer[4] - fourth instance, no streaming - # - # STREAMING SUPPRESSION: - # - First instance: Full display with streaming (typing effect visible) - # - Instances 2+: streaming_display=False (no typing effect) - # - Instances 2+: show_chat=True, show_tools=True (panel visible) - # - This prevents visual clutter from multiple streaming outputs - # - Original configs restored after parallel execution completes - # - # THREAD SAFETY: - # - Instance creation protected by self._instance_lock (asyncio.Lock) - # - Prevents race conditions on concurrent run_tools calls - # - Ensures sequential modification of instance_map and suppressed_configs - # - # NO AGENT RENAMING: - # - We do NOT rename child._name during execution (causes race conditions) - # - Instance numbers only appear in display headers (via _show_parallel_tool_calls) - # - This ensures true parallel execution without shared state mutations - # ═══════════════════════════════════════════════════════════════════════════════ - + # Collect original names pending_count = len(id_list) original_names = {} - instance_map = {} # Map correlation_id -> (child, instance_name, instance_number) - suppressed_configs = {} # Store original configs to restore later - - # Build instance map and suppress streaming for instances 2+ - # NOTE: First instance runs normally (no index, no suppression) - # Instances 2+ get indexed and streaming suppressed (but shown in panel) - # PROTECTED: Use lock to prevent race conditions on concurrent run_tools calls if pending_count > 1: - async with self._instance_lock: - for i, cid in enumerate(id_list, 1): - tool_name = descriptor_by_id[cid]["tool"] - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - # Store original name once - if tool_name not in original_names and hasattr(child, '_name'): - original_names[tool_name] = child._name - - # First instance: no index, runs normally - # Instances 2+: indexed, streaming suppressed, panel visible - original = original_names.get(tool_name, child._name if hasattr(child, '_name') else tool_name) - if i == 1: - # First instance - no index, no suppression, full display - instance_name = original - instance_map[cid] = (child, instance_name, i) - logger.info(f"Mapped {cid} -> {instance_name} (first instance, full display)") - else: - # Instances 2+ - add index and suppress streaming only - instance_name = f"{original}[{i}]" - instance_map[cid] = (child, instance_name, i) - - # Suppress only streaming output (typing effect), keep progress panel - child_id = id(child) - if child_id not in suppressed_configs and hasattr(child, 'display') and child.display: - if child.display.config: - # Store original config - suppressed_configs[child_id] = child.display.config - - # Create config that suppresses streaming but shows progress - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - # Suppress streaming/typing output - temp_logger.streaming_display = False - # Keep chat and tools visible in panel - temp_logger.show_chat = True - temp_logger.show_tools = True - temp_config.logger = temp_logger - - # Apply config with streaming suppressed - child.display.config = temp_config - logger.info(f"Suppressed streaming for {child._name}, panel visible") - - logger.info(f"Mapped {cid} -> {instance_name} (streaming suppressed, panel visible)") + for cid in id_list: + tool_name = descriptor_by_id[cid]["tool"] + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child and hasattr(child, '_name') and tool_name not in original_names: + original_names[tool_name] = child._name - # Import progress_display at outer scope to ensure same instance + # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent from fast_agent.ui.progress_display import progress_display as outer_progress_display - # Simple wrapper - NO renaming, just call the tool - # Instance numbers already shown in display headers via _show_parallel_tool_calls - async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args: dict[str, Any]) -> CallToolResult: - instance_info = instance_map.get(correlation_id) + # Create wrapper coroutine that sets name and emits progress for instance + async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], instance: int) -> CallToolResult: + instance_name = None + if pending_count > 1: + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child and hasattr(child, '_name'): + original = original_names.get(tool_name, child._name) + instance_name = f"{original}[{instance}]" + child._name = instance_name + + # Also update aggregator's agent_name so tool progress events use instance name + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = instance_name + + # Emit progress event to create separate line in progress panel + outer_progress_display.update(ProgressEvent( + action=ProgressAction.CHATTING, + target=instance_name, + details="", + agent_name=instance_name + )) - if instance_info: - _, instance_name, _ = instance_info - logger.info(f"[{instance_name}] Starting parallel execution") - result = await self.call_tool(tool_name, tool_args) - logger.info(f"[{instance_name}] Completed parallel execution") - return result - else: - # Single instance - just call normally + try: return await self.call_tool(tool_name, tool_args) + finally: + # Hide instance line immediately when this task completes + if instance_name and pending_count > 1: + logger.info(f"Hiding instance line: {instance_name}") + if instance_name in outer_progress_display._taskmap: + task_id = outer_progress_display._taskmap[instance_name] + for task in outer_progress_display._progress.tasks: + if task.id == task_id: + task.visible = False + logger.info(f"Set visible=False for {instance_name}") + break + + # Set parent agent lines to Ready status while instances run + if pending_count > 1: + for tool_name in original_names.keys(): + original = original_names[tool_name] + # Set parent to Ready status + outer_progress_display.update(ProgressEvent( + action=ProgressAction.READY, + target=original, + details="", + agent_name=original + )) # Create tasks with instance-specific wrappers - for cid in id_list: + for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] tool_args = descriptor_by_id[cid]["args"] - tasks.append(asyncio.create_task(call_with_instance_name(cid, tool_name, tool_args))) + tasks.append(asyncio.create_task(call_with_instance_name(tool_name, tool_args, i))) # Show aggregated tool call(s) self._show_parallel_tool_calls(call_descriptors) @@ -636,17 +589,34 @@ async def call_with_instance_name(correlation_id: str, tool_name: str, tool_args self._show_parallel_tool_results(ordered_records) - # Restore suppressed child display configs - for child_id, original_config in suppressed_configs.items(): - # Find the child agent by id - for tool_name in original_names.keys(): + # Restore original agent names and display configs (instance lines already hidden in task finally blocks) + if pending_count > 1: + for tool_name, original_name in original_names.items(): child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child and id(child) == child_id: - if hasattr(child, 'display') and child.display: - child.display.config = original_config - logger.info(f"Restored display config for {child._name}") - break - - logger.info(f"Parallel execution complete for {len(id_list)} instances") + if child: + child._name = original_name + # Restore aggregator's agent_name too + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = original_name + + # Restore display config now that all results are shown + child_id = id(child) + if child_id in self._display_suppression_count: + del self._display_suppression_count[child_id] + + if child_id in self._original_display_configs: + original_config = self._original_display_configs[child_id] + del self._original_display_configs[child_id] + if hasattr(child, 'display') and child.display: + child.display.config = original_config + logger.info(f"Restored display config for {original_name} after all results displayed") + else: + # Single instance, just restore name + for tool_name, original_name in original_names.items(): + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child: + child._name = original_name + if hasattr(child, '_aggregator') and child._aggregator: + child._aggregator.agent_name = original_name return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) From 1520a5b427e6b3b8cefe2113d727b0f65313407e Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 01:36:06 +0300 Subject: [PATCH 37/44] feat: detach agents-as-tools instances and harden MCP task groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add detached per-call cloning in LlmDecorator so child agents can be spawned via spawn_detached_instance and later merged with merge_usage_from. - Rework AgentsAsToolsAgent.run_tools to execute child agents in parallel using detached clones, with clearer per-instance progress lines and tool-call/result panels. - Track ownership of MCPConnectionManager in MCPAggregator and only shut it down from the owning aggregator, fixing “Task group is not active” errors when short‑lived clones exit. - Improve MCPAggregator tool refresh to rebuild namespaced tool maps per server and log UPDATED progress events with tool counts. - Extend log→ProgressEvent conversion to treat THINKING like STREAMING for token counts and to use the typed ProgressAction field. - Add RichProgressDisplay.hide_task API for future UI behaviors and wire small fastagent/listener changes around the updated progress pipeline. --- agetns_as_tools_plan_fix.md | 373 ++++++++++++++ agetns_as_tools_plan_scratch.md | 473 ++++++++++++++++++ src/fast_agent/agents/llm_decorator.py | 67 +++ .../agents/workflow/agents_as_tools_agent.py | 380 +++++++------- src/fast_agent/core/fastagent.py | 12 + src/fast_agent/core/logging/listeners.py | 71 ++- src/fast_agent/mcp/mcp_aggregator.py | 49 +- src/fast_agent/ui/rich_progress.py | 10 + 8 files changed, 1236 insertions(+), 199 deletions(-) create mode 100644 agetns_as_tools_plan_fix.md create mode 100644 agetns_as_tools_plan_scratch.md diff --git a/agetns_as_tools_plan_fix.md b/agetns_as_tools_plan_fix.md new file mode 100644 index 000000000..9209aac6d --- /dev/null +++ b/agetns_as_tools_plan_fix.md @@ -0,0 +1,373 @@ +# Agents-as-Tools — Fix Plan for Current Implementation + +## 1. Scope + +This document describes how to evolve and harden the current `AgentsAsToolsAgent` implementation in this repo: + +- File: `src/fast_agent/agents/workflow/agents_as_tools_agent.py` +- Wiring: + - `direct_decorators.agent(..., agents=[...])` + - `direct_factory.create_agents_by_type` (BASIC agents with `child_agents`) +- Supporting components: + - `ToolAgent`, `LlmAgent` + - `McpAgent`, `MCPAggregator` + - UI: `RichProgressDisplay`, `ConsoleDisplay`, `history_display`, `usage_display` + - Stats: `UsageAccumulator` + +Goal: keep this implementation **experimental but coherent**, good enough for real workflows and for an upstream-quality PR later. + +--- + +## 2. Recovered Intended Design + +From the module docstring and issue #458: + +- **Concept** + - Parent is a normal tool-calling LLM. + - Each child agent is exposed as a tool: `agent__{child_name}`. + - Parent delegates; it doesn't orchestrate explicitly. + +- **Tool interface** + - `list_tools()` → one tool per child, permissive schema: + - `{ text?: string, json?: object, ... }` + - `call_tool()`: + - Routes tool name → child agent. + - Normalizes arguments to a single `Prompt.user(text)`. + - Executes `child.generate([...])` and returns `CallToolResult`. + +- **Parallelism** + - Parent LLM may emit multiple tool calls in one turn. + - `run_tools()` should: + - Validate tools against `list_tools()`. + - Run all valid calls via `asyncio.gather`. + - Associate each physical tool call with a **virtual instance** index: `[1]`, `[2]`. + +- **Progress panel semantics** (Rich progress, left side) + - Before fan-out: one line per *agent* (parent and children). + - During fan-out: + - Parent line shows `Ready` (waiting on children). + - Each child instance shows its own line, with instance-index-suffixed name: `OriginalName[1]`, `OriginalName[2]`. + - Lines disappear as soon as each instance finishes. + - After fan-in: + - Only base agent lines remain; original names restored. + +- **Chat/log semantics** + - Parent chat should show **tool request/result panels** for each instance. + - Child chat should **not** stream to the panel when invoked as a tool. + - Child **tool usage** (MCP tools, shell, etc.) should still be visible. + +- **MCP initialization semantics** + - Children are real agents (`McpAgent` or similar) with MCP clients & aggregators. + - Multiple instances of the same child **share** one MCP aggregator. + - Parent itself does **not** talk to MCP directly; it only calls children. + +- **Stats semantics** + - Token/tool stats are tracked per *agent* via `UsageAccumulator`. + - Instances are **transient**; they may be visible in progress/chat but stats roll up per agent. + +--- + +## 3. Current Implementation Review + +### 3.1. What's already good + +- **Tool naming & discovery** + - `_make_tool_name(child_name)` → `agent__{child_name}`. + - `list_tools()` returns Tool schemas with the minimal `{ text, json }` interface. + +- **Routing & argument handling** + - `call_tool()` resolves both `agent__Child` and bare `Child`. + - Arguments → `text` precedence, then `json`, then `full args` JSON. + - Child is called via `Prompt.user(...)` + `child.generate([...])`. + +- **Error surfacing** + - If child writes to the `FAST_AGENT_ERROR_CHANNEL`, those blocks are appended to the tool result contents and `CallToolResult.isError` is set. + +- **Parallel fan-out** + - `run_tools()` builds `call_descriptors` and `descriptor_by_id`. + - Uses `asyncio.gather(..., return_exceptions=True)` to execute all calls concurrently. + +- **Instance naming for UI** + - For `pending_count > 1`, collects `original_names[tool_name] = child._name`. + - In `call_with_instance_name()`: + - Computes `instance_name = f"{original}[{instance}]"`. + - Mutates `child._name` and `child._aggregator.agent_name`. + - Emits a synthetic `ProgressEvent(CHATTING, target=instance_name, agent_name=instance_name)` to create a line in the progress panel. + - On completion, hides that line by flipping `task.visible = False` in `RichProgressDisplay`. + +- **Child display suppression** + - `call_tool()` lazily creates: + - `_display_suppression_count: { id(child) -> int }`. + - `_original_display_configs: { id(child) -> ConsoleDisplayConfig }`. + - On first use of a given child, makes a copy of `child.display.config`, sets: + - `logger.show_chat = False` + - `logger.show_tools = True` + - Ensures **children don't spam chat**, but still show their own MCP tool usage. + +- **Top/bottom panels** + - `_show_parallel_tool_calls()` and `_show_parallel_tool_results()` correctly label tools as `tool_name[instance]` in chat panels and bottom status items. + +Overall, the core mechanics of Agents-as-Tools are present and coherent. + +### 3.2. Gaps and fragilities + +1. **Display config restoration logic is incomplete** + + - In `call_tool()` we: + - Always increment `_display_suppression_count[child_id]`. + - In `finally` we **only decrement** the counter, do **not** restore config. + - In `run_tools()` we restore config **only if `pending_count > 1`**: + - For each `child` in `original_names`: + - Delete `_display_suppression_count[child_id]`. + - Restore `display.config` from `_original_display_configs`. + - Problems: + - For a **single tool call** (the most common case!), `pending_count == 1`, so `original_names` is empty and **display configs are never restored**. + - Even for `pending_count > 1`, restoration is decoupled from `_display_suppression_count[child_id]` (no 0→1 / 1→0 semantics). + + **Effect:** once a child is ever used as a tool, its chat may remain permanently suppressed for all subsequent uses, including direct runs, which is surprising. + +2. **Instance naming races on shared child objects** + + - Multiple tool calls to the **same child agent** share a single `child` object and a single `child._aggregator`. + - `call_with_instance_name()` mutates `child._name` and `child._aggregator.agent_name` in each task. + - Under concurrency, whichever task last mutates these fields wins; log lines from the child and from its aggregator may be attributed to the last instance, not this instance. + + **Effect:** progress rows are mostly correct (because we also emit explicit `ProgressEvent`s), but logs and transport stats that come from `MCPAggregator` may mix instance names. + +3. **Direct reliance on private internals of `RichProgressDisplay`** + + - `call_with_instance_name()` accesses: + - `outer_progress_display._taskmap` + - `outer_progress_display._progress.tasks` + - and flips `task.visible = False`. + + **Risk:** this is brittle against internal refactors of the progress UI and difficult to test in isolation. + +4. **`MessageType` import is unused** + + - `from fast_agent.ui.message_primitives import MessageType` is imported but not used. + - Indicates some UI scenarios were planned (e.g. structured tool headers) and not implemented. + +5. **Stats are per-agent only, not per-instance** + + - `UsageAccumulator` is owned by the LLM (via `LlmDecorator.usage_accumulator`). + - Usage is aggregated per **agent** (e.g. `PM-1-DayStatusSummarizer`), not per `[i]` instance. + - This matches the general fast-agent philosophy but does **not** match the stronger requirement separate rows in the stats panel per instance. + + **Current behavior is acceptable**, but the instance-per-row requirement should be documented as **out of scope** for the first implementation. + +6. **Tool availability check and naming** + + - `run_tools()` validates tool names against `list_tools()` of `AgentsAsToolsAgent` (agent-tools only). + - There is no support to **merge MCP tools and agent-tools** in `list_tools()`. + + **Status:** this matches a conservative interpretation of issue #458, but the design doc leaves the door open to unifying MCP tools and agent-tools; that needs an explicit decision. + +--- + +## 4. Design Decisions to Lock In (for this branch) + +Before making changes, clarify the intended semantics for this repo: + +1. **Child chat visibility** + - When a child agent is used as a tool via `AgentsAsToolsAgent`, its chat is **never** shown. + - When a child is run directly (by the user), its chat **is** shown. + +2. **Instance stats vs agent stats** + - For this implementation, stats remain **per agent**, not per instance. + - Instance-level visibility is provided by: + - Progress panel (per-instance lines). + - Chat log (tool headers `tool_name[i]`). + +3. **MCP reuse model** + - Child MCP aggregators are **shared** between all instances and all parents. + - No per-instance MCP clients. + +4. **Tool namespace composition** + - For now, `AgentsAsToolsAgent.list_tools()` returns **only agent-tools**. + - MCP tools, if any, must be accessed via separate agents (not through this orchestrator). + +These decisions simplify the fix plan and keep surface area small. + +--- + +## 5. Step-by-Step Fix Plan + +### 5.1. Fix display suppression and restoration + +**Goal:** implement correct reference counting per-child and always restore display config after the last instance completes, regardless of `pending_count`. + +**Steps:** + +1. [x] **Introduce explicit helpers on `AgentsAsToolsAgent`** + + - Private methods: + - `_ensure_display_maps_initialized()` + - `_suppress_child_display(child)` + - `_release_child_display(child)` + + - Semantics: + - `_suppress_child_display(child)`: + - If `child_id` not in `_display_suppression_count`: + - Snapshot `child.display.config` into `_original_display_configs[child_id]`. + - Install a modified config with `show_chat=False, show_tools=True`. + - Initialize counter to `0`. + - Increment counter. + - `_release_child_display(child)`: + - Decrement counter. + - If counter reaches `0`: + - Restore original config from `_original_display_configs`. + - Delete both entries for this `child_id`. + +2. [x] **Apply helpers in `call_tool()`** + + - Replace direct manipulation with: + - `_suppress_child_display(child)` before `await child.generate(...)`. + - `_release_child_display(child)` in `finally`. + +3. [x] **Remove display restoration from `run_tools()`** + + - The `_display_suppression_count` & `_original_display_configs` clean-up should be **entirely local** to `call_tool()`; `run_tools()` should not know about it. + - This also makes `call_tool()` correct if it's ever used outside of `run_tools()`. + +**Outcome:** display configs are always restored after the last parallel/sequential instance finishes, independent of how many tools or which code path called them. + +--- + +### 5.2. Stabilize instance naming and progress UI + +**Goal:** keep existing UX (progress lines + names `[i]`) but reduce reliance on private internals. + +1. **Add a small public API to `RichProgressDisplay`** + + - In `rich_progress.py`: + - Add methods: + - `def hide_task(self, task_name: str) -> None:` + - Look up `task_id` via `_taskmap.get(task_name)`. + - If found, set `task.visible = False`. + - Optionally `def ensure_task(self, event: ProgressEvent) -> TaskID:` to encapsulate `add_task` + update logic. + + - Refactor `update()` to use `ensure_task()` internally. + +2. [x] **Use the public API in `AgentsAsToolsAgent`** + + - Replace direct access to `_taskmap` and `_progress.tasks` with: + - `outer_progress_display.hide_task(instance_name)`. + +3. **Document expected lifetime** + + - Comment in `AgentsAsToolsAgent`: + - Instance lines are **ephemeral**; they are hidden immediately when each task completes but progress data continues to exist for the duration of the run. + +**Outcome:** same UI behavior, less fragile coupling to UI internals. + +--- + +### 5.3. Reduce naming races (best-effort for experimental phase) + +Completely eliminating races around `child._name` and `child._aggregator.agent_name` would require: + +- Either a per-instance `MCPAggregator`, or +- Making `MCPAggregator` fully stateless in terms of `agent_name`, or +- Augmenting all tool/progress logs with an explicit correlation/instance id. + +That is a larger refactor than we want for the current experimental implementation. Instead, we can apply a **minimal mitigation**: + +1. [x] **Minimize mutation window** + + - In `call_with_instance_name()`: + - Set `child._name` and `child._aggregator.agent_name` **immediately** before `await self.call_tool(...)`. + - Right after the `await`, restore them to the base `original_names[tool_name]` (inside the same task's `try/finally`). + - `run_tools()` should **no longer perform name restoration** for children; it only needs to restore parent-level names (if we ever mutate them) and handle display. + +2. **Clarify known limitation** + + - In the module docstring, add a short Limitations section explaining: + - Under heavy concurrency, some low-level logs from MCP may still show mixed instance names; the progress panel and chat tool headers are the authoritative view. + +**Outcome:** race window is strictly bounded to the duration of a single tool call in a single task; we no longer keep children renamed after the call completes. + +--- + +### 5.4. Explicitly document stats behavior + +**Goal:** align user expectations with current implementation. + +1. **Update README / docs** (or a dedicated experimental note): + + - Describe that: + - Token and tool usage stats are aggregated **per agent**. + - Agents-as-Tools does **not** create per-instance stats rows; instead: + - Per-instance work is visible in the progress panel. + - Tool calls are visible in the history summary as `tool→` / `result→` rows. + +2. **Optionally tag tool results with instance index in content** + + - For debug clarity, `AgentsAsToolsAgent` could prepend a short header block to each `CallToolResult` content: + - e.g. `"[instance 1]"`. + - This would make the instance index visible in `history_display` even outside the UI tool headers. + + This is optional and can be added behind a config flag if needed. + +--- + +### 5.5. Tests and diagnostics + +1. **Unit tests for `AgentsAsToolsAgent`** + + - Scenarios: + - Single tool call to one child. + - Two sequential tool calls in separate turns. + - Two parallel tool calls to **different** children. + - Two parallel tool calls to the **same** child. + - Tool-not-found error path. + - Assertions: + - `list_tools()` returns expected tool names. + - `call_tool()` forwards `text` and `json` correctly. + - Display suppression: + - `child.display.config.logger.show_chat` toggles to False during calls. + - Restored to original after calls (check for all scenarios). + +2. **Integration-style test with a fake `RichProgressDisplay`** + + - Inject a fake progress display with a deterministic in-memory representation. + - Assert that for parallel calls: + - Parent gets a `READY` event. + - Each instance gets a `CHATTING` event with `target=OriginalName[i]`. + - `hide_task()` is called exactly once per instance. + +3. **Manual diagnostic recipe** + + - Document a small `fastagent.config.yaml` example that: + - Defines N children representing mocked projects. + - Defines a parent with `agents: [...]` using Agents-as-Tools. + - Steps to reproduce and visually verify: + - Instance lines in progress panel. + - Tool rows in history summary. + - Stats table showing aggregate per agent. + +--- + +## 6. Future Enhancements (Beyond Fix Plan) + +These are candidates for the from-scratch design rather than this incremental fix: + +- **Per-instance stats** + - Attach a lightweight `InstanceUsage` struct per tool call and aggregate it at run end. + +- **Correlation IDs and structured logging** + - Emit a unique correlation ID for each tool call and propagate it through: + - Parent request → tool_call. + - Child logs and progress events. + - MCPAggregator transport tracking. + +- **Cleaner abstraction boundary** + - Extract an `AgentsAsToolsRuntime` helper that contains **no UI or LLM logic**, only: + - Tool mapping. + - Parallel execution. + - Result collation. + - A separate `AgentsAsToolsDisplayAdapter` layer would handle: + - Progress events. + - Display config changes. + +These ideas are elaborated further in `agetns_as_tools_plan_scratcj.md`. diff --git a/agetns_as_tools_plan_scratch.md b/agetns_as_tools_plan_scratch.md new file mode 100644 index 000000000..10738788a --- /dev/null +++ b/agetns_as_tools_plan_scratch.md @@ -0,0 +1,473 @@ +# Agents-as-Tools — From-Scratch Design Plan (Upstream-Oriented) + +## 1. Objectives + +Design a clean, upstream-friendly implementation of the **Agents-as-Tools** pattern for `fast-agent`, starting from the upstream repository semantics: + +- **Model**: a parent LLM agent exposes other agents as callable tools. +- **Behavior**: parent can invoke children in arbitrary order and in parallel, using normal tool-calling. +- **DX**: minimal new concepts; works naturally with existing decorators and config. +- **UX**: integrates with current progress panel, history, and usage stats without introducing ad hoc hacks. + +This plan does **not** assume any existing WIP code; it re-derives the feature from first principles using the current architecture (decorators, factory, MCP, UI, stats). + +--- + +## 2. Conceptual Model + +### 2.1. Roles & responsibilities + +- **Parent agent (Agents-as-Tools orchestrator)** + - A normal LLM agent with tool-calling capability. + - Exposes *child agents* as tools (`agent__ChildName`). + - Delegates the actual work to children; no custom planning. + +- **Child agent(s)** + - Existing agents (typically `McpAgent`-based) with their own MCP servers, skills, tools, etc. + - Own their own `UsageAccumulator`, history, and MCP aggregator. + - Are reused as-is; we do not clone them per instance. + +- **Virtual child instances** + - Logical construct: per tool call, we treat it as an `Instance` of a child with an index `[i]`. + - Instances are used purely for **UI and logging**, not for real objects. + +### 2.2. Key invariants + +- **Single source of truth for child agents** + - One `LlmAgent` object per defined agent name. + - All parents and instances refer to the same child objects. + +- **LLM tool-loop compatibility** + - The parents `generate()` uses the standard `ToolAgent` loop: + - LLM → `stop_reason=TOOL_USE` → `run_tools()` → new USER message. + +- **MCP reuse** + - Each child has exactly one `MCPAggregator` that persists according to its config. + - Instances never create or destroy MCP connections directly. + +- **Stats aggregation per agent** + - Usage summary is per *agent name* (parent + each child), not per instance. + - Instances show up only in progress/historical views. + +### 2.3. Alternative execution models (future options) + +While the core plan intentionally reuses a single child object per agent, there are cases where **"honest" per-call isolation** is preferred. Two strategies can be layered onto this design later: + +1. **Dedicated child agent per call** + - Before dispatching a tool call, clone the target child (including MCP aggregator, LLM, memory) to form a short-lived agent. + - Guarantees zero shared state: logs, history, MCP connections stay scoped to that instance. + - Downsides: high startup cost (MCP discovery, model warm-up) for every call; extra resource usage if multiple calls run in parallel. + +2. **Pre-warmed agent pool** + - Keep `N` fully initialized child agents per name (each with its own MCP aggregator/LLM). + - A call acquires a free agent from the pool; after completion it returns the instance for reuse. + - Pros: isolates state without per-call bootstrap; allows true parallelism as long as pool capacity is available. + - Cons: more memory + open MCP connections proportional to pool size; scheduling logic needed when pool is exhausted. + +Both approaches can be integrated into the factory/runtime layer without rewriting the Agents-as-Tools surface: the parent would simply target a different acquisition strategy when resolving `self._children`. Documenting these options here keeps the plan aligned with future requirements around strict isolation. + +### 2.4. Current implementation snapshot — Detached per-call clones (Nov 2025) + +While §2.3 framed cloning/pooling as optional futures, the active codebase now runs with the **Dedicated child agent per call** strategy so we can guarantee honest per-instance state: + +1. **Clone creation** + - `AgentsAsToolsAgent.run_tools()` calls `child.spawn_detached_instance(name=f"{child}[i]")` before every tool dispatch. + - `spawn_detached_instance` (added to `LlmDecorator`) deep-copies the agent config, re-attaches the same LLM factory/request params, and replays initialization hooks. + +2. **MCP aggregator ownership** + - Each detached clone constructs its own `MCPAggregator`, which in turn acquires a shared `MCPConnectionManager` from context. + - To avoid tearing down the shared TaskGroup, `MCPAggregator` now tracks `_owns_connection_manager`; only the original agent that created the manager performs shutdown on `close()`. + +3. **Lifecycle + cleanup** + - After the tool call completes we `await clone.shutdown()` and merge its `UsageAccumulator` back into the parent child via `child.merge_usage_from(clone)`. + - Progress entries remain visible by emitting `ProgressAction.FINISHED` events instead of hiding tasks, ensuring traceability per instance. + +4. **Implications** + - Logs, MCP events, and usage rows now display fully indexed names (`PM-1-DayStatusSummarizer[2]`). + - Resource cost is higher than the single-object model, but correctness (agent naming, MCP routing, per-instance usage summaries) takes priority for the current StratoSpace workflows. + +This snapshot should stay in sync with the actual code to document why the detached-instance path is the default today, even though the plan keeps the door open for lighter reuse models. + +--- + +## 3. High-Level Architecture + +### 3.1. New class: `AgentsAsToolsAgent` + +Location: `src/fast_agent/agents/workflow/agents_as_tools_agent.py`. + +Base class: **`ToolAgent`** (not `McpAgent`). + +Responsibilities: + +- Adapter between **LLM tool schema** and **child agents**. +- `list_tools()` → synthetic tools for children. +- `call_tool()` → executes the appropriate child. +- `run_tools()` → parallel fan-out + fan-in. +- UI integration via a **small display adapter**, not raw access to progress internals. + +Constructor: + +```python +class AgentsAsToolsAgent(ToolAgent): + def __init__( + self, + config: AgentConfig, + agents: list[LlmAgent], + context: Context | None = None, + ) -> None: + super().__init__(config=config, tools=[], context=context) + self._children: dict[str, LlmAgent] = {} + # Maps tool name -> child agent (keys are agent__ChildName) +``` + +### 3.2. Integration points + +1. **Decorators (`direct_decorators.agent`)** + - Add parameter `agents: List[str]` (already present upstream). + - Store `child_agents=agents` in the agent metadata. + +2. **Factory (`direct_factory.create_agents_by_type`)** + - For `AgentType.BASIC`: + - If `child_agents` is non-empty: + - Resolve child names to **already-created** agents. + - Construct `AgentsAsToolsAgent(config, context, agents=child_agents)`. + - Attach LLM. + - Else: create a normal `McpAgent` (as today). + +3. **UI / CLI** + - No CLI flags change. + - New behavior is activated simply by specifying `agents:` in the decorator/config. + +--- + +## 4. Detailed Design by Concern + +### 4.1. Tool exposure (`list_tools`) + +**Goal:** make each child agent a callable tool with a permissive schema. + +- Tool naming: + - `tool_name = f"agent__{child.name}"`. + - We store the mapping internally, not relying on `child.name` string matching later. + +- Input schema: + - Keep it minimal and robust: + + ```json + { + "type": "object", + "properties": { + "text": { "type": "string", "description": "Plain text input" }, + "json": { "type": "object", "description": "Arbitrary JSON payload" } + }, + "additionalProperties": true + } + ``` + +- Implementation sketch: + - For each child in `self._children`: + - Build an `mcp.Tool`: + - `name = tool_name` + - `description = child.instruction` + - `inputSchema = schema_above`. + +**Open design choice:** whether to **merge** these tools with MCP tools if the parent is also an MCP-enabled agent. For from-scratch, keep them **separate**: Agents-as-Tools is the *only* tool surface of this agent. + +### 4.2. Argument mapping (`call_tool`) + +**Goal:** map tool arguments to a single child **user message**. + +Rules: + +- If `arguments["text"]` is a string → use as-is. +- Else if `"json" in arguments`: + - If it is a dict → `json.dumps` (UTF-8, no ASCII-escaping). + - Else → `str(...)`. +- Else: + - If there are other arguments → `json.dumps(arguments)`. + - Else → empty string. + +Then: + +- Build `PromptMessageExtended.user(input_text)` (or `Prompt.user` helper) and call: + - `child.generate([user_message], request_params=None)`. + +Error handling: + +- Unknown tool name → `CallToolResult(isError=True, content=["Unknown agent-tool: {name}"])`. +- Unhandled exception in child → `CallToolResult(isError=True, content=["Error: {e}"])`. + +Wire error-channel content: + +- If childs response has `channels[FAST_AGENT_ERROR_CHANNEL]`, append those blocks to `CallToolResult.content` and set `isError=True`. + +### 4.3. Display behavior for children + +**Requirement:** when a child is used as a tool: + +- Do **not** show its normal assistant chat blocks. +- Do show its **tool usage** (MCP tools, shell, etc.). + +Design: + +- Define a tiny utility in `AgentsAsToolsAgent`: + + - `self._display_suppression: dict[int, DisplayState]` where `DisplayState` holds: + - `original_config: ConsoleDisplayConfig`. + - `ref_count: int`. + +- Methods: + + - `_suppress_child_display(child: LlmAgent)` + - On first entry for this child: + - Copy `child.display.config` → `original_config`. + - Clone config and set `logger.show_chat = False`, `logger.show_tools = True`. + - Assign cloned config to `child.display.config`. + - Increment `ref_count`. + + - `_release_child_display(child: LlmAgent)` + - Decrement `ref_count`. + - If it reaches 0: + - Restore `child.display.config = original_config`. + - Remove entry from `_display_suppression`. + +- Use these methods in `call_tool()` via `try/finally`. + +Rationale: children can still be run standalone (outside Agents-as-Tools) with full chat visible; we only alter display while they are acting as tools. + +### 4.4. Parallel `run_tools` semantics + +**Goal:** replace `ToolAgent.run_tools` with a parallel implementation that preserves its contract but allows: + +- multiple tool calls per LLM turn; +- concurrent execution via `asyncio.gather`; +- clear UI for each virtual instance. + +#### 4.4.1. Data structures + +- `call_descriptors: list[dict]`: + - `{"id", "tool", "args", "status", "error_message"?}`. + +- `descriptor_by_id: dict[correlation_id -> descriptor]`. +- `tasks: list[Task[CallToolResult]]`. +- `ids_in_order: list[str]` for stable correlation. + +#### 4.4.2. Algorithm + +1. **Validate tool calls** + - Snapshot `available_tools` from `list_tools()`. + - For each `request.tool_calls[correlation_id]`: + - If name not in available_tools → create `CallToolResult(isError=True, ...)`, mark descriptor as `status="error"`, skip task. + - Else → `status="pending"`, add to `ids_in_order`. + +2. **Prepare virtual instance names** + + - `pending_count = len(ids_in_order)`. + - If `pending_count <= 1`: + - No instance suffixing; just run sequentially or as a trivial gather. + - Else: + - For each `tool_name` used: + - Capture `original_name = child.name` in a dict for later restoration. + +3. **Instance execution wrapper** + + Define: + + ```python + async def _run_instance(tool_name, args, instance_index) -> CallToolResult: + child = self._children[tool_name] + instance_name = f"{child.name}[{instance_index}]" if pending_count > 1 else child.name + # UI: start instance line + self._display_adapter.start_instance(parent=self, child=child, instance_name=instance_name) + try: + return await self.call_tool(tool_name, args) + finally: + self._display_adapter.finish_instance(instance_name) + ``` + +4. **Display adapter abstraction** + +To avoid touching `RichProgressDisplay` internals from this class, introduce a tiny adapter: + +- `AgentsAsToolsDisplayAdapter` (internal helper, same module or `ui/agents_as_tools_display.py`): + + - Depends only on: + - `progress_display: RichProgressDisplay` + - `ConsoleDisplay` of the parent agent. + + - Responsibilities: + - `start_parent_waiting(original_parent_name)` → emit `ProgressAction.READY`. + - `start_instance(parent, child, instance_name)` → emit `ProgressAction.CHATTING` or `CALLING_TOOL` with `agent_name=instance_name`. + - `finish_instance(instance_name)` → ask `progress_display` to hide instance task (via a **public** `hide_task` API). + - `_show_parallel_tool_calls(call_descriptors)` → call `parent.display.show_tool_call` with `[i]` suffixes. + - `_show_parallel_tool_results(ordered_records)` → call `parent.display.show_tool_result` with `[i]` suffixes. + +The `AgentsAsToolsAgent` itself: + +- Holds a `self._display_adapter` instance. +- Delegates all UI updates to it. + +5. **Parallel execution** + +- For each `correlation_id` with a valid tool call, create a task: + + ```python + tasks.append(asyncio.create_task( + _run_instance(tool_name, tool_args, instance_index=i) + )) + ``` + +- Show aggregated calls via display adapter. +- `results = await asyncio.gather(*tasks, return_exceptions=True)`. +- Map each result back to `correlation_id`. + +6. **Finalize** + +- Build ordered `records = [{"descriptor": ..., "result": ...}, ...]` in input order. +- Ask display adapter to show results. +- Return `self._finalize_tool_results(tool_results, tool_loop_error)` for consistency with `ToolAgent`. + +### 4.5. Stats and history integration + +- Leave `UsageAccumulator` unchanged. +- Parent and each child agent track their own usage normally. +- History: + - `PromptMessageExtended.tool_results` remains a flat mapping by correlation id. + - `history_display` will show: + - `tool→` and `result→` sections per tool call. + - We can optionally prepend `tool_name[i]` into either: + - the preview text, or + - a dedicated text block in the tool result content. + +No new data model types are needed for stats. + +--- + +## 5. Engineering Model & Separation of Concerns + +To make the design understandable and maintainable, structure it into three layers: + +1. **Core runtime (no UI)** + + - Handles: + - Tool name mapping (`agent__Child`). + - `list_tools`, `call_tool`, `run_tools` logic. + - Argument normalization. + - Result collation. + - Exposes hooks: + - `on_tool_call_start(tool_name, instance_index, correlation_id)` + - `on_tool_call_end(tool_name, instance_index, correlation_id, result)` + - No knowledge of Rich, ConsoleDisplay, or MCP. + +2. **UI adapter layer** + + - Subscribes to core runtime hooks. + - Responsible for: + - Creating/updating progress tasks. + - Formatting tool call & result panels. + - Talks to: + - `RichProgressDisplay` + - Parent agents `ConsoleDisplay`. + +3. **Integration/glue layer (factory + decorators)** + + - Binds user-level config/decorators to concrete runtime instances. + - Ensures that: + - Children are created before parents. + - The same context (settings, logs, executor) is reused. + +This layered model allows future refactors such as a **web UI** or a **non-Rich CLI** to adopt the core Agents-as-Tools runtime without touching orchestration logic. + +--- + +## 6. Implementation Phasing + +### Phase 0 — Skeleton + +- Add `AgentsAsToolsAgent` class with: + - Constructor storing children. + - Basic `list_tools()` and `call_tool()` (no parallelism, no UI tweaks). +- Wire into `direct_factory` for BASIC agents with `child_agents`. +- Provide a minimal example in `examples/` using synchronous tool calls. + +### Phase 1 — Parallel execution + +- Implement `run_tools()` with `asyncio.gather` but **no special UI**: + - Just run calls concurrently and aggregate results. + - Keep the behavior as close as possible to `ToolAgent.run_tools`. + +- Add tests: + - Unit tests for argument mapping and error handling. + - Concurrency tests with fake children that sleep. + +### Phase 2 — UI integration (progress + instance naming) + +- Introduce `AgentsAsToolsDisplayAdapter` and new APIs on `RichProgressDisplay` (e.g. `hide_task`). +- Implement instance naming and ephemeral progress lines. +- Suppress child chat via ref-counted display config changes. + +- Manual QA: + - Validate panel behavior with 1, 2, N parallel tasks. + - Validate that parent name & child names are restored. + +### Phase 3 — Documentation & ergonomics + +- Add docs page / section: + - Concept explanation. + - Example usage with YAML + decorators. + - Comparison with Orchestrator / IterativePlanner / Parallel workflows. + +- Add clear notes about: + - Stats aggregation semantics. + - Reuse of MCP connections. + - Limitations (e.g. no per-instance stats rows). + +--- + +## 7. Potential Future Extensions + +The above design keeps the surface area small. After it is stable, consider these additions: + +1. **Per-instance stats & traces** + +- Extend core runtime to emit per-instance events with: + - `instance_id` (UUID or (tool_name, index)). + - `start_time`, `end_time`, `duration_ms`. +- Expose hooks so UI can show: + - Per-instance durations. + - Aggregate bars per instance in a detail view. + +2. **Recursive Agents-as-Tools** + +- Allow children themselves to be `AgentsAsToolsAgent`. +- This already works logically, but we can: + - Make it explicit in docs. + - Ensure UI still renders nested tool calls clearly. + +3. **Merged MCP + agent-tools view** + +- Add an optional mode where `list_tools()` returns: + - All MCP tools from connected servers. + - All agent-tools. +- Provide filters via `AgentConfig.tools` to control which surface is visible per parent. + +4. **Correlation-friendly logging** + +- Standardize structured log fields for tools: + - `agent_name`, `instance_name`, `correlation_id`, `tool_name`. +- Make `history_display` able to group tool rows per correlation id + instance. + +--- + +## 8. Summary + +This from-scratch plan defines Agents-as-Tools as a **lightweight adapter agent** that: + +- Exposes existing agents as tools. +- Delegates execution to them, preserving their MCP connections and stats. +- Adds a small, well-encapsulated UI layer for: + - Parallel instance progress lines. + - Clear tool call/result labeling (`agent__Child[i]`). + +By keeping a strict separation between core runtime, UI adapter, and factories, the feature remains understandable and testable, and it aligns with fast-agents existing engineering patterns and philosophy. diff --git a/src/fast_agent/agents/llm_decorator.py b/src/fast_agent/agents/llm_decorator.py index 4ab26ac37..b421bb017 100644 --- a/src/fast_agent/agents/llm_decorator.py +++ b/src/fast_agent/agents/llm_decorator.py @@ -4,9 +4,11 @@ import json from collections import Counter, defaultdict +from copy import deepcopy from dataclasses import dataclass from typing import ( TYPE_CHECKING, + Any, Dict, List, Mapping, @@ -111,6 +113,8 @@ def __init__( # Initialize the LLM to None (will be set by attach_llm) self._llm: Optional[FastAgentLLMProtocol] = None self._initialized = False + self._llm_factory_ref: LLMFactoryProtocol | None = None + self._llm_attach_kwargs: dict[str, Any] | None = None @property def context(self) -> Context | None: @@ -181,8 +185,71 @@ async def attach_llm( agent=self, request_params=effective_params, context=self._context, **additional_kwargs ) + # Store attachment details for future cloning + self._llm_factory_ref = llm_factory + attach_kwargs: dict[str, Any] = dict(additional_kwargs) + attach_kwargs["request_params"] = deepcopy(effective_params) + self._llm_attach_kwargs = attach_kwargs + return self._llm + def _clone_constructor_kwargs(self) -> dict[str, Any]: + """Hook for subclasses/mixins to supply constructor kwargs when cloning.""" + return {} + + async def spawn_detached_instance(self, *, name: str | None = None) -> "LlmAgent": + """Create a fresh agent instance with its own MCP/LLM stack.""" + + new_config = deepcopy(self.config) + if name: + new_config.name = name + + constructor_kwargs = self._clone_constructor_kwargs() + clone = type(self)(config=new_config, context=self.context, **constructor_kwargs) + await clone.initialize() + + if self._llm_factory_ref is not None: + if self._llm_attach_kwargs is None: + raise RuntimeError( + "LLM attachment parameters missing despite factory being available" + ) + + attach_kwargs = dict(self._llm_attach_kwargs) + request_params = attach_kwargs.pop("request_params", None) + if request_params is not None: + request_params = deepcopy(request_params) + + await clone.attach_llm( + self._llm_factory_ref, + request_params=request_params, + **attach_kwargs, + ) + + return clone + + def merge_usage_from(self, other: "LlmAgent") -> None: + """Merge LLM usage metrics from another agent instance into this one.""" + + if not hasattr(self, "_llm") or not hasattr(other, "_llm"): + return + + source_llm = getattr(other, "_llm", None) + target_llm = getattr(self, "_llm", None) + if not source_llm or not target_llm: + return + + source_usage = getattr(source_llm, "usage_accumulator", None) + target_usage = getattr(target_llm, "usage_accumulator", None) + if not source_usage or not target_usage: + return + + for turn in source_usage.turns: + try: + target_usage.add_turn(turn.model_copy(deep=True)) + except AttributeError: + # Fallback if turn doesn't provide model_copy + target_usage.add_turn(turn) + async def __call__( self, message: Union[ diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index a73cf929c..6f2b61fc7 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -7,7 +7,10 @@ This module implements the "Agents as Tools" pattern, inspired by OpenAI's Agents SDK (https://openai.github.io/openai-agents-python/tools). It allows child agents to be exposed as callable tools to a parent agent, enabling hierarchical agent composition -without the complexity of traditional orchestrator patterns. +without the complexity of traditional orchestrator patterns. The current implementation +goes a step further by spawning **detached per-call clones** of every child so that each +parallel execution has its own LLM + MCP stack, eliminating name overrides and shared +state hacks. Rationale --------- @@ -45,23 +48,18 @@ - LLM decides which tools (child agents) to call based on user request 3. **Tool Execution (call_tool)** - - Route tool name to corresponding child agent - - Convert tool arguments (text or JSON) to child agent input - - Suppress child agent's chat messages (show_chat=False) using reference counting - - Keep child agent's tool calls visible (show_tools=True) - - Track active instances per child agent to prevent race conditions - - Only modify display config on first instance, restore on last instance - - Execute child agent and return response as CallToolResult + - Route tool name to corresponding child template + - Convert tool arguments (text or JSON) to child input + - Execution itself is performed by detached clones created inside `run_tools` + - Responses are converted to `CallToolResult` objects (errors propagate as `isError=True`) 4. **Parallel Execution (run_tools)** - Collect all tool calls from parent LLM response - - Create asyncio tasks for each child agent call - - Modify child agent names with instance numbers: `AgentName[1]`, `AgentName[2]` - - Update both child._name and child._aggregator.agent_name for progress routing - - Set parent agent to "Ready" status while instances run - - Execute all tasks concurrently via asyncio.gather - - Hide instance lines immediately as each task completes (via finally block) - - Aggregate results and return to parent LLM + - For each call, spawn a detached clone with its own LLM + MCP aggregator and suffixed name + - Emit `ProgressAction.CHATTING` for each instance and keep parent status untouched + - Execute tasks concurrently via `asyncio.gather` + - On completion, mark instance lines `FINISHED` (no hiding) and merge usage back into the template + - Aggregate results and return them to the parent LLM Progress Panel Behavior ----------------------- @@ -74,29 +72,40 @@ ``` **During parallel execution (2+ instances):** -- Parent line switches to "Ready" status to indicate waiting for children -- New lines appear for each instance: +- Parent line stays in whatever lifecycle state it already had; no forced "Ready" flips. +- New lines appear for each detached instance with suffixed names: ``` -▎ Ready ▎ PM-1-DayStatusSummarizer ← parent waiting -▎▶ Calling tool ▎ PM-1-DayStatusSummarizer[1] tg-ro (list_messages) -▎▶ Chatting ▎ PM-1-DayStatusSummarizer[2] gpt-5 turn 2 +▎▶ Chatting ▎ PM-1-DayStatusSummarizer[1] gpt-5 turn 2 +▎▶ Calling tool ▎ PM-1-DayStatusSummarizer[2] tg-ro (list_messages) ``` **Key implementation details:** -- Each instance gets unique agent_name: `OriginalName[instance_number]` -- Both child._name and child._aggregator.agent_name are updated for correct progress routing -- Tool progress events (CALLING_TOOL) use instance name, not parent name -- Each instance shows independent status: Chatting, Calling tool, turn count +- Each clone advertises its own `agent_name` (e.g., `OriginalName[instance_number]`). +- MCP progress events originate from the clone's aggregator, so tool activity always shows under the suffixed name. +- Parent status lines remain visible for context while children run. **As each instance completes:** -- Instance line disappears immediately (task.visible = False in finally block) -- Other instances continue showing their independent progress -- No "stuck" status lines after completion +- We emit `ProgressAction.FINISHED` with elapsed time, keeping the line in the panel for auditability. +- Other instances continue showing their independent progress until they also finish. **After all parallel executions complete:** -- All instance lines hidden -- Parent line returns to normal agent lifecycle -- Original agent names and display configs restored +- Finished instance lines remain until the parent agent moves on, giving a full record of what ran. +- Parent and child template names stay untouched because clones carry the suffixed identity. + +- **Instance line visibility**: We now leave finished instance lines visible (marked `FINISHED`) + instead of hiding them immediately, preserving a full audit trail of parallel runs. +- **Chat log separation**: Each parallel instance gets its own tool request/result headers + with instance numbers [1], [2], etc. for traceability. + +Stats and Usage Semantics +------------------------- +- Each detached clone accrues usage on its own `UsageAccumulator`; after shutdown we + call `child.merge_usage_from(clone)` so template agents retain consolidated totals. +- Runtime events (logs, MCP progress, chat headers) use the suffixed clone names, + ensuring per-instance traceability even though usage rolls up to the template. +- The CLI *Usage Summary* table still reports one row per template agent + (for example, `PM-1-DayStatusSummarizer`), not per `[i]` instance; clones are + runtime-only and do not appear as separate agents in that table. **Chat log display:** Tool headers show instance numbers for clarity: @@ -114,12 +123,12 @@ Implementation Notes -------------------- -- **Name modification timing**: Agent names are modified in a wrapper coroutine that - executes at task runtime, not task creation time, to avoid race conditions -- **Original name caching**: Store original names before ANY modifications to prevent - [1][2] bugs when the same agent is called multiple times -- **Progress event routing**: Must update both agent._name and agent._aggregator.agent_name - since MCPAggregator caches agent_name for progress events +- **Instance naming**: `run_tools` computes `instance_name = f"{child.name}[i]"` inside the + per-call wrapper and passes it into `spawn_detached_instance`, so the template child object + keeps its original name while each detached clone owns the suffixed identity. +- **Progress event routing**: Because each clone's `MCPAggregator` is constructed with the + suffixed `agent_name`, all MCP/tool progress events naturally use + `PM-1-DayStatusSummarizer[i]` without mutating base agent fields or using `ContextVar` hacks. - **Display suppression with reference counting**: Multiple parallel instances of the same child agent share a single agent object. Use reference counting to track active instances: - `_display_suppression_count[child_id]`: Count of active parallel instances @@ -127,9 +136,14 @@ - Only modify display config when first instance starts (count 0→1) - Only restore display config when last instance completes (count 1→0) - Prevents race condition where early-finishing instances restore config while others run -- **Instance line visibility**: Each instance line is hidden immediately in the task's - finally block, not after all tasks complete. Uses consistent progress_display singleton - reference to ensure visibility changes work correctly +- **Child agent(s)** + - Existing agents (typically `McpAgent`-based) with their own MCP servers, skills, tools, etc. + - Serve as **templates**; `run_tools` now clones them before every tool call via + `spawn_detached_instance`, so runtime work happens inside short-lived replicas. + +- **Detached instances** + - Each tool call gets an actual cloned agent with suffixed name `Child[i]`. + - Clones own their MCP aggregator/LLM stacks and merge usage back into the template after shutdown. - **Chat log separation**: Each parallel instance gets its own tool request/result headers with instance numbers [1], [2], etc. for traceability @@ -275,62 +289,76 @@ async def list_tools(self) -> ListToolsResult: ) return ListToolsResult(tools=tools) - async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult: - """Execute a child agent by name. - - Args: - name: Tool name (agent name with prefix) - arguments: Optional arguments to pass to the child agent - - Returns: - CallToolResult containing the child agent's response - """ - child = self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name)) - if child is None: - return CallToolResult(content=[text_content(f"Unknown agent-tool: {name}")], isError=True) + def _ensure_display_maps_initialized(self) -> None: + """Lazily initialize display suppression tracking maps.""" + if not hasattr(self, "_display_suppression_count"): + self._display_suppression_count = {} + self._original_display_configs = {} + + def _suppress_child_display(self, child: LlmAgent) -> None: + """Suppress child chat output while preserving tool logs.""" + self._ensure_display_maps_initialized() + child_id = id(child) + count = self._display_suppression_count.get(child_id, 0) + if 0 == count: + if hasattr(child, "display") and child.display and getattr(child.display, "config", None): + # Store original config for restoration later + self._original_display_configs[child_id] = child.display.config + temp_config = copy(child.display.config) + if hasattr(temp_config, "logger"): + temp_logger = copy(temp_config.logger) + temp_logger.show_chat = False + temp_logger.show_tools = True # Explicitly keep tools visible + temp_config.logger = temp_logger + child.display.config = temp_config + self._display_suppression_count[child_id] = count + 1 + + def _release_child_display(self, child: LlmAgent) -> None: + """Restore child display configuration when the last tool instance completes.""" + if not hasattr(self, "_display_suppression_count"): + return + child_id = id(child) + if child_id not in self._display_suppression_count: + return + self._display_suppression_count[child_id] -= 1 + if self._display_suppression_count[child_id] <= 0: + del self._display_suppression_count[child_id] + original_config = self._original_display_configs.pop(child_id, None) + if original_config is not None and hasattr(child, "display") and child.display: + child.display.config = original_config + + async def _invoke_child_agent( + self, + child: LlmAgent, + arguments: dict[str, Any] | None = None, + *, + suppress_display: bool = True, + ) -> CallToolResult: + """Shared helper to execute a child agent with standard serialization and display rules.""" args = arguments or {} + # Serialize arguments to text input if isinstance(args.get("text"), str): input_text = args["text"] elif "json" in args: - input_text = json.dumps(args["json"], ensure_ascii=False) if isinstance(args["json"], dict) else str(args["json"]) + input_text = ( + json.dumps(args["json"], ensure_ascii=False) + if isinstance(args["json"], dict) + else str(args["json"]) + ) else: input_text = json.dumps(args, ensure_ascii=False) if args else "" - # Serialize arguments to text input child_request = Prompt.user(input_text) - - # Track display config changes per child to handle parallel instances - child_id = id(child) - if not hasattr(self, '_display_suppression_count'): - self._display_suppression_count = {} - self._original_display_configs = {} - + try: # Suppress child agent chat messages (keep tool calls visible) - # Only modify config on first parallel instance - if child_id not in self._display_suppression_count: - self._display_suppression_count[child_id] = 0 - - if hasattr(child, 'display') and child.display and child.display.config: - # Store original config for restoration later - self._original_display_configs[child_id] = child.display.config - temp_config = copy(child.display.config) - if hasattr(temp_config, 'logger'): - temp_logger = copy(temp_config.logger) - temp_logger.show_chat = False - temp_logger.show_tools = True # Explicitly keep tools visible - temp_config.logger = temp_logger - child.display.config = temp_config - - # Increment active instance count - self._display_suppression_count[child_id] += 1 - + self._suppress_child_display(child) + response: PromptMessageExtended = await child.generate([child_request], None) # Prefer preserving original content blocks for better UI fidelity content_blocks = list(response.content or []) - # Mark error if error channel contains entries, and surface them from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL error_blocks = None @@ -348,12 +376,23 @@ async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> logger.error(f"Child agent {child.name} failed: {e}") return CallToolResult(content=[text_content(f"Error: {e}")], isError=True) finally: - # Decrement active instance count - if child_id in self._display_suppression_count: - self._display_suppression_count[child_id] -= 1 - - # Don't restore config here - let run_tools restore after results are displayed - # This ensures final logs keep instance numbers [N] + if suppress_display: + self._release_child_display(child) + + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult: + """Execute a child agent by name. + + Args: + name: Tool name (agent name with prefix) + arguments: Optional arguments to pass to the child agent + + Returns: + CallToolResult containing the child agent's response + """ + child = self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name)) + if child is None: + return CallToolResult(content=[text_content(f"Unknown agent-tool: {name}")], isError=True) + return await self._invoke_child_agent(child, arguments) def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: """Display tool call headers for parallel agent execution. @@ -370,9 +409,6 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: "missing": "missing", } - # Show instance count if multiple agents - instance_count = len([d for d in descriptors if d.get("status") != "error"]) - # Show detailed call information for each agent for i, desc in enumerate(descriptors, 1): tool_name = desc.get("tool", "(unknown)") @@ -382,10 +418,8 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: if status == "error": continue # Skip display for error tools, will show in results - # Add individual instance number if multiple - display_tool_name = tool_name - if instance_count > 1: - display_tool_name = f"{tool_name}[{i}]" + # Always add individual instance number for clarity + display_tool_name = f"{tool_name}[{i}]" # Build bottom item for THIS instance only (not all instances) status_label = status_labels.get(status, "pending") @@ -420,8 +454,6 @@ def _show_parallel_tool_results( if not records: return - instance_count = len(records) - # Show detailed result for each agent for i, record in enumerate(records, 1): descriptor = record.get("descriptor", {}) @@ -429,10 +461,8 @@ def _show_parallel_tool_results( tool_name = descriptor.get("tool", "(unknown)") if result: - # Add individual instance number if multiple - display_tool_name = tool_name - if instance_count > 1: - display_tool_name = f"{tool_name}[{i}]" + # Always add individual instance number for clarity + display_tool_name = f"{tool_name}[{i}]" # Show individual tool result with full content self.display.show_tool_result( @@ -490,67 +520,85 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) - # Collect original names pending_count = len(id_list) - original_names = {} - if pending_count > 1: - for cid in id_list: - tool_name = descriptor_by_id[cid]["tool"] - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child and hasattr(child, '_name') and tool_name not in original_names: - original_names[tool_name] = child._name + parent_base_names: set[str] = set() + for cid in id_list: + tool_name = descriptor_by_id[cid]["tool"] + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if child: + parent_base_names.add(child.name) # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent from fast_agent.ui.progress_display import progress_display as outer_progress_display - - # Create wrapper coroutine that sets name and emits progress for instance - async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], instance: int) -> CallToolResult: - instance_name = None - if pending_count > 1: - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child and hasattr(child, '_name'): - original = original_names.get(tool_name, child._name) - instance_name = f"{original}[{instance}]" - child._name = instance_name - - # Also update aggregator's agent_name so tool progress events use instance name - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = instance_name - - # Emit progress event to create separate line in progress panel - outer_progress_display.update(ProgressEvent( + + # Create wrapper coroutine that sets names and emits progress for instance + async def call_with_instance_name( + tool_name: str, tool_args: dict[str, Any], instance: int + ) -> CallToolResult: + child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + if not child: + error_msg = f"Unknown agent-tool: {tool_name}" + return CallToolResult(content=[text_content(error_msg)], isError=True) + + base_name = getattr(child, "_name", child.name) + instance_name = f"{base_name}[{instance}]" + + try: + clone = await child.spawn_detached_instance(name=instance_name) + except Exception as exc: + logger.error( + "Failed to spawn dedicated child instance", + data={ + "tool_name": tool_name, + "agent_name": base_name, + "error": str(exc), + }, + ) + return CallToolResult(content=[text_content(f"Spawn failed: {exc}")], isError=True) + + progress_started = False + try: + outer_progress_display.update( + ProgressEvent( action=ProgressAction.CHATTING, target=instance_name, details="", - agent_name=instance_name - )) - - try: - return await self.call_tool(tool_name, tool_args) + agent_name=instance_name, + ) + ) + progress_started = True + return await self._invoke_child_agent(clone, tool_args) finally: - # Hide instance line immediately when this task completes - if instance_name and pending_count > 1: - logger.info(f"Hiding instance line: {instance_name}") - if instance_name in outer_progress_display._taskmap: - task_id = outer_progress_display._taskmap[instance_name] - for task in outer_progress_display._progress.tasks: - if task.id == task_id: - task.visible = False - logger.info(f"Set visible=False for {instance_name}") - break - - # Set parent agent lines to Ready status while instances run - if pending_count > 1: - for tool_name in original_names.keys(): - original = original_names[tool_name] - # Set parent to Ready status - outer_progress_display.update(ProgressEvent( - action=ProgressAction.READY, - target=original, - details="", - agent_name=original - )) + try: + await clone.shutdown() + except Exception as shutdown_exc: + logger.warning( + "Error shutting down dedicated child instance", + data={ + "instance_name": instance_name, + "error": str(shutdown_exc), + }, + ) + try: + child.merge_usage_from(clone) + except Exception as merge_exc: + logger.warning( + "Failed to merge usage from child instance", + data={ + "instance_name": instance_name, + "error": str(merge_exc), + }, + ) + if progress_started and instance_name: + outer_progress_display.update( + ProgressEvent( + action=ProgressAction.FINISHED, + target=instance_name, + details="Completed", + agent_name=instance_name, + ) + ) # Create tasks with instance-specific wrappers for i, cid in enumerate(id_list, 1): @@ -589,34 +637,4 @@ async def call_with_instance_name(tool_name: str, tool_args: dict[str, Any], ins self._show_parallel_tool_results(ordered_records) - # Restore original agent names and display configs (instance lines already hidden in task finally blocks) - if pending_count > 1: - for tool_name, original_name in original_names.items(): - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - child._name = original_name - # Restore aggregator's agent_name too - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = original_name - - # Restore display config now that all results are shown - child_id = id(child) - if child_id in self._display_suppression_count: - del self._display_suppression_count[child_id] - - if child_id in self._original_display_configs: - original_config = self._original_display_configs[child_id] - del self._original_display_configs[child_id] - if hasattr(child, 'display') and child.display: - child.display.config = original_config - logger.info(f"Restored display config for {original_name} after all results displayed") - else: - # Single instance, just restore name - for tool_name, original_name in original_names.items(): - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - child._name = original_name - if hasattr(child, '_aggregator') and child._aggregator: - child._aggregator.agent_name = original_name - return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) diff --git a/src/fast_agent/core/fastagent.py b/src/fast_agent/core/fastagent.py index 3024a05da..ddbaed17e 100644 --- a/src/fast_agent/core/fastagent.py +++ b/src/fast_agent/core/fastagent.py @@ -92,6 +92,7 @@ F = TypeVar("F", bound=Callable[..., Any]) # For decorated functions logger = get_logger(__name__) +_FASTAGENT_DEBUG_PRINTED = False class FastAgent: @@ -123,6 +124,17 @@ def __init__( (like FastAPI/Uvicorn) that handles its own arguments. quiet: If True, disable progress display, tool and message logging for cleaner output """ + global _FASTAGENT_DEBUG_PRINTED + if not _FASTAGENT_DEBUG_PRINTED: + try: + from pathlib import Path + + source_path = Path(__file__).resolve() + print(f"[FAST_AGENT DEBUG] FastAgent __init__ from {source_path}") + except Exception: + print("[FAST_AGENT DEBUG] FastAgent __init__ (path resolution failed)") + _FASTAGENT_DEBUG_PRINTED = True + self.args = argparse.Namespace() # Initialize args always self._programmatic_quiet = quiet # Store the programmatic quiet setting self._skills_directory_override = ( diff --git a/src/fast_agent/core/logging/listeners.py b/src/fast_agent/core/logging/listeners.py index b486abc01..4985b789a 100644 --- a/src/fast_agent/core/logging/listeners.py +++ b/src/fast_agent/core/logging/listeners.py @@ -28,17 +28,72 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": if not isinstance(event_data, dict): return None - progress_action = event_data.get("progress_action") - if not progress_action: + raw_action = event_data.get("progress_action") + if not raw_action: + return None + + # Coerce raw_action (enum or string) into a ProgressAction instance + try: + action = ( + raw_action + if isinstance(raw_action, ProgressAction) + else ProgressAction(str(raw_action)) + ) + except Exception: + # If we cannot coerce, drop this event from progress handling return None # Build target string based on the event type. # Progress display is currently [time] [event] --- [target] [details] namespace = event.namespace agent_name = event_data.get("agent_name") + + # General progress debug logging (including action value and type) + try: + from pathlib import Path + + debug_path = Path.home() / "logs" / "progress_actions_debug.log" + debug_line = ( + "[DEBUG PROGRESS] " + f"namespace={namespace} " + f"action={action.value} " + f"raw_type={type(raw_action).__name__} " + f"agent_name={agent_name} " + f"tool_name={event_data.get('tool_name')} " + f"server_name={event_data.get('server_name')} " + f"model={event_data.get('model')} " + f"tool_event={event_data.get('tool_event')}\n" + ) + debug_path.parent.mkdir(parents=True, exist_ok=True) + with debug_path.open("a", encoding="utf-8") as f: + f.write(debug_line) + except Exception: + pass + + # Temporary diagnostic logging for CALLING_TOOL routing issues + if action == ProgressAction.CALLING_TOOL: + try: + from pathlib import Path + + ct_path = Path.home() / "logs" / "calling_tool_debug.log" + ct_line = ( + "[DEBUG CALLING_TOOL] " + f"namespace={namespace} " + f"agent_name={agent_name} " + f"tool_name={event_data.get('tool_name')} " + f"server_name={event_data.get('server_name')} " + f"model={event_data.get('model')} " + f"tool_event={event_data.get('tool_event')}\n" + ) + ct_path.parent.mkdir(parents=True, exist_ok=True) + with ct_path.open("a", encoding="utf-8") as f: + f.write(ct_line) + except Exception: + pass + target = agent_name details = "" - if progress_action == ProgressAction.FATAL_ERROR: + if action == ProgressAction.FATAL_ERROR: details = event_data.get("error_message", "An error occurred") elif "mcp_aggregator" in namespace: server_name = event_data.get("server_name", "") @@ -50,7 +105,7 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": details = f"{server_name}" # For TOOL_PROGRESS, use progress message if available, otherwise keep default - if progress_action == ProgressAction.TOOL_PROGRESS: + if action == ProgressAction.TOOL_PROGRESS: progress_message = event_data.get("details", "") if progress_message: # Only override if message is non-empty details = progress_message @@ -76,20 +131,20 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": if not target: target = event_data.get("target", "unknown") - # Extract streaming token count for STREAMING actions + # Extract streaming token count for STREAMING/THINKING actions streaming_tokens = None - if progress_action == ProgressAction.STREAMING or progress_action == ProgressAction.THINKING: + if action == ProgressAction.STREAMING or action == ProgressAction.THINKING: streaming_tokens = event_data.get("details", "") # Extract progress data for TOOL_PROGRESS actions progress = None total = None - if progress_action == ProgressAction.TOOL_PROGRESS: + if action == ProgressAction.TOOL_PROGRESS: progress = event_data.get("progress") total = event_data.get("total") return ProgressEvent( - action=ProgressAction(progress_action), + action=action, target=target or "unknown", details=details, agent_name=event_data.get("agent_name"), diff --git a/src/fast_agent/mcp/mcp_aggregator.py b/src/fast_agent/mcp/mcp_aggregator.py index 8a4fd72ba..c9db85a02 100644 --- a/src/fast_agent/mcp/mcp_aggregator.py +++ b/src/fast_agent/mcp/mcp_aggregator.py @@ -140,9 +140,12 @@ async def __aenter__(self): manager = MCPConnectionManager(server_registry, context=context) await manager.__aenter__() context._connection_manager = manager + self._owns_connection_manager = True self._persistent_connection_manager = cast( "MCPConnectionManager", context._connection_manager ) + else: + self._persistent_connection_manager = None # Import the display component here to avoid circular imports from fast_agent.ui.console_display import ConsoleDisplay @@ -181,6 +184,8 @@ def __init__( self.connection_persistence = connection_persistence self.agent_name = name self.config = config # Store the config for access in session factory + self._persistent_connection_manager: MCPConnectionManager | None = None + self._owns_connection_manager = False # Set up logger with agent name in namespace if available global logger @@ -236,7 +241,7 @@ async def close(self) -> None: if self.connection_persistence and self._persistent_connection_manager: try: # Only attempt cleanup if we own the connection manager - if ( + if self._owns_connection_manager and ( hasattr(self.context, "_connection_manager") and self.context._connection_manager == self._persistent_connection_manager ): @@ -1525,17 +1530,41 @@ async def list_prompts( operation_type="prompts/list", operation_name="", method_name="list_prompts", - error_factory=lambda _: None, + method_args={}, ) + new_tools = result.tools or [] + + # Update tool maps + async with self._tool_map_lock: + # Remove old tools for this server + old_tools = self._server_to_tool_map.get(server_name, []) + for old_tool in old_tools: + if old_tool.namespaced_tool_name in self._namespaced_tool_map: + del self._namespaced_tool_map[old_tool.namespaced_tool_name] + + # Add new tools + self._server_to_tool_map[server_name] = [] + for tool in new_tools: + namespaced_tool_name = create_namespaced_name(server_name, tool.name) + namespaced_tool = NamespacedTool( + tool=tool, + server_name=server_name, + namespaced_tool_name=namespaced_tool_name, + ) - # Get prompts from result - prompts = getattr(result, "prompts", []) - - # Update cache - async with self._prompt_cache_lock: - self._prompt_cache[server_name] = prompts + self._namespaced_tool_map[namespaced_tool_name] = namespaced_tool + self._server_to_tool_map[server_name].append(namespaced_tool) - results[server_name] = prompts + logger.info( + f"Successfully refreshed tools for server '{server_name}'", + data={ + "progress_action": ProgressAction.UPDATED, + "server_name": server_name, + "agent_name": self.agent_name, + "tool_count": len(new_tools), + }, + ) + results[server_name] = new_tools return results # No specific server - check if we can use the cache for all servers @@ -1564,7 +1593,7 @@ async def list_prompts( operation_type="prompts/list", operation_name="", method_name="list_prompts", - error_factory=lambda _: None, + method_args={}, ) prompts = getattr(result, "prompts", []) diff --git a/src/fast_agent/ui/rich_progress.py b/src/fast_agent/ui/rich_progress.py index 660d81084..4af524df7 100644 --- a/src/fast_agent/ui/rich_progress.py +++ b/src/fast_agent/ui/rich_progress.py @@ -61,6 +61,16 @@ def resume(self) -> None: self._paused = False self._progress.start() + def hide_task(self, task_name: str) -> None: + """Hide an existing task from the progress display by name.""" + task_id = self._taskmap.get(task_name) + if task_id is None: + return + for task in self._progress.tasks: + if task.id == task_id: + task.visible = False + break + @contextmanager def paused(self): """Context manager for temporarily pausing the display.""" From 81bb7a9d7463ac8667d6202f3e31dd460e721bec Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 02:06:27 +0300 Subject: [PATCH 38/44] agents-as-tools: clean debug hooks and finalize progress UI - Remove temporary FAST_AGENT_DEBUG flag and prints from FastAgent.__init__ - Drop file-based progress debug logging from core.logging.listeners.convert_log_event - Remove RichProgressDisplay.hide_task and update design docs to FINISHED-based instance lines - Fix _invoke_child_agent indentation and guard display suppression with suppress_display flag --- agetns_as_tools_plan_fix.md | 2 +- agetns_as_tools_plan_scratch.md | 12 ++--- .../agents/workflow/agents_as_tools_agent.py | 1 + src/fast_agent/core/fastagent.py | 13 ------ src/fast_agent/core/logging/listeners.py | 45 ------------------- src/fast_agent/ui/rich_progress.py | 10 ----- 6 files changed, 9 insertions(+), 74 deletions(-) diff --git a/agetns_as_tools_plan_fix.md b/agetns_as_tools_plan_fix.md index 9209aac6d..caa7f784e 100644 --- a/agetns_as_tools_plan_fix.md +++ b/agetns_as_tools_plan_fix.md @@ -334,7 +334,7 @@ That is a larger refactor than we want for the current experimental implementati - Assert that for parallel calls: - Parent gets a `READY` event. - Each instance gets a `CHATTING` event with `target=OriginalName[i]`. - - `hide_task()` is called exactly once per instance. + - Each instance eventually receives a `FINISHED` event and remains visible for inspection. 3. **Manual diagnostic recipe** diff --git a/agetns_as_tools_plan_scratch.md b/agetns_as_tools_plan_scratch.md index 10738788a..07aa8970e 100644 --- a/agetns_as_tools_plan_scratch.md +++ b/agetns_as_tools_plan_scratch.md @@ -83,8 +83,9 @@ While §2.3 framed cloning/pooling as optional futures, the active codebase now - Progress entries remain visible by emitting `ProgressAction.FINISHED` events instead of hiding tasks, ensuring traceability per instance. 4. **Implications** - - Logs, MCP events, and usage rows now display fully indexed names (`PM-1-DayStatusSummarizer[2]`). - - Resource cost is higher than the single-object model, but correctness (agent naming, MCP routing, per-instance usage summaries) takes priority for the current StratoSpace workflows. + - Logs, MCP events, and progress panel lines now display fully indexed names (for example, `PM-1-DayStatusSummarizer[2]`). + - The CLI *Usage Summary* table still reports a single aggregated row per template agent (for example, `PM-1-DayStatusSummarizer`), not per `[i]` instance. + - Resource cost is higher than the single-object model, but correctness (agent naming, MCP routing, and per-instance traceability in logs/UI) takes priority for the current StratoSpace workflows. This snapshot should stay in sync with the actual code to document why the detached-instance path is the default today, even though the plan keeps the door open for lighter reuse models. @@ -299,7 +300,7 @@ To avoid touching `RichProgressDisplay` internals from this class, introduce a t - Responsibilities: - `start_parent_waiting(original_parent_name)` → emit `ProgressAction.READY`. - `start_instance(parent, child, instance_name)` → emit `ProgressAction.CHATTING` or `CALLING_TOOL` with `agent_name=instance_name`. - - `finish_instance(instance_name)` → ask `progress_display` to hide instance task (via a **public** `hide_task` API). + - `finish_instance(instance_name)` → emit `ProgressAction.FINISHED` for the instance and rely on the standard progress UI for visibility. - `_show_parallel_tool_calls(call_descriptors)` → call `parent.display.show_tool_call` with `[i]` suffixes. - `_show_parallel_tool_results(ordered_records)` → call `parent.display.show_tool_result` with `[i]` suffixes. @@ -332,6 +333,7 @@ The `AgentsAsToolsAgent` itself: - Leave `UsageAccumulator` unchanged. - Parent and each child agent track their own usage normally. + - In the detached-clone implementation, each clone accrues usage on its own accumulator and then merges it back into the template child. - History: - `PromptMessageExtended.tool_results` remains a flat mapping by correlation id. - `history_display` will show: @@ -403,8 +405,8 @@ This layered model allows future refactors such as a **web UI** or a **non-Rich ### Phase 2 — UI integration (progress + instance naming) -- Introduce `AgentsAsToolsDisplayAdapter` and new APIs on `RichProgressDisplay` (e.g. `hide_task`). -- Implement instance naming and ephemeral progress lines. +- Introduce `AgentsAsToolsDisplayAdapter` to centralize Agents-as-Tools-specific progress behavior. +- Implement instance naming and FINISHED-based progress lines so instances remain visible after completion. - Suppress child chat via ref-counted display config changes. - Manual QA: diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 6f2b61fc7..666157294 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -353,6 +353,7 @@ async def _invoke_child_agent( try: # Suppress child agent chat messages (keep tool calls visible) + if suppress_display: self._suppress_child_display(child) response: PromptMessageExtended = await child.generate([child_request], None) diff --git a/src/fast_agent/core/fastagent.py b/src/fast_agent/core/fastagent.py index ddbaed17e..d70adc9f7 100644 --- a/src/fast_agent/core/fastagent.py +++ b/src/fast_agent/core/fastagent.py @@ -92,8 +92,6 @@ F = TypeVar("F", bound=Callable[..., Any]) # For decorated functions logger = get_logger(__name__) -_FASTAGENT_DEBUG_PRINTED = False - class FastAgent: """ @@ -124,17 +122,6 @@ def __init__( (like FastAPI/Uvicorn) that handles its own arguments. quiet: If True, disable progress display, tool and message logging for cleaner output """ - global _FASTAGENT_DEBUG_PRINTED - if not _FASTAGENT_DEBUG_PRINTED: - try: - from pathlib import Path - - source_path = Path(__file__).resolve() - print(f"[FAST_AGENT DEBUG] FastAgent __init__ from {source_path}") - except Exception: - print("[FAST_AGENT DEBUG] FastAgent __init__ (path resolution failed)") - _FASTAGENT_DEBUG_PRINTED = True - self.args = argparse.Namespace() # Initialize args always self._programmatic_quiet = quiet # Store the programmatic quiet setting self._skills_directory_override = ( diff --git a/src/fast_agent/core/logging/listeners.py b/src/fast_agent/core/logging/listeners.py index 4985b789a..8ab9be803 100644 --- a/src/fast_agent/core/logging/listeners.py +++ b/src/fast_agent/core/logging/listeners.py @@ -32,7 +32,6 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": if not raw_action: return None - # Coerce raw_action (enum or string) into a ProgressAction instance try: action = ( raw_action @@ -40,7 +39,6 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": else ProgressAction(str(raw_action)) ) except Exception: - # If we cannot coerce, drop this event from progress handling return None # Build target string based on the event type. @@ -48,49 +46,6 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": namespace = event.namespace agent_name = event_data.get("agent_name") - # General progress debug logging (including action value and type) - try: - from pathlib import Path - - debug_path = Path.home() / "logs" / "progress_actions_debug.log" - debug_line = ( - "[DEBUG PROGRESS] " - f"namespace={namespace} " - f"action={action.value} " - f"raw_type={type(raw_action).__name__} " - f"agent_name={agent_name} " - f"tool_name={event_data.get('tool_name')} " - f"server_name={event_data.get('server_name')} " - f"model={event_data.get('model')} " - f"tool_event={event_data.get('tool_event')}\n" - ) - debug_path.parent.mkdir(parents=True, exist_ok=True) - with debug_path.open("a", encoding="utf-8") as f: - f.write(debug_line) - except Exception: - pass - - # Temporary diagnostic logging for CALLING_TOOL routing issues - if action == ProgressAction.CALLING_TOOL: - try: - from pathlib import Path - - ct_path = Path.home() / "logs" / "calling_tool_debug.log" - ct_line = ( - "[DEBUG CALLING_TOOL] " - f"namespace={namespace} " - f"agent_name={agent_name} " - f"tool_name={event_data.get('tool_name')} " - f"server_name={event_data.get('server_name')} " - f"model={event_data.get('model')} " - f"tool_event={event_data.get('tool_event')}\n" - ) - ct_path.parent.mkdir(parents=True, exist_ok=True) - with ct_path.open("a", encoding="utf-8") as f: - f.write(ct_line) - except Exception: - pass - target = agent_name details = "" if action == ProgressAction.FATAL_ERROR: diff --git a/src/fast_agent/ui/rich_progress.py b/src/fast_agent/ui/rich_progress.py index 4af524df7..660d81084 100644 --- a/src/fast_agent/ui/rich_progress.py +++ b/src/fast_agent/ui/rich_progress.py @@ -61,16 +61,6 @@ def resume(self) -> None: self._paused = False self._progress.start() - def hide_task(self, task_name: str) -> None: - """Hide an existing task from the progress display by name.""" - task_id = self._taskmap.get(task_name) - if task_id is None: - return - for task in self._progress.tasks: - if task.id == task_id: - task.visible = False - break - @contextmanager def paused(self): """Context manager for temporarily pausing the display.""" From de07f01b8fcaf968f583be7b57406e22c1c61a13 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 03:33:30 +0300 Subject: [PATCH 39/44] agents-as-tools: clean progress wiring and restore upstream listeners - Restore convert_log_event in core/logging/listeners.py to upstream-style ProgressAction handling (no extra debug logging) - Keep RichProgressDisplay FINISHED/FATAL_ERROR behavior simple: mark the current task completed without hiding other tasks - Align Agents-as-Tools design docs with detached per-call clones and FINISHED-based progress lines (no hide_task API) - Clarify AgentsAsToolsAgent module docstring and helper behavior to match current implementation (_invoke_child_agent, detached clones, usage merge) --- agetns_as_tools_plan_fix.md | 19 ++++++----------- src/fast_agent/core/logging/listeners.py | 26 ++++++++---------------- src/fast_agent/ui/rich_progress.py | 6 ------ 3 files changed, 14 insertions(+), 37 deletions(-) diff --git a/agetns_as_tools_plan_fix.md b/agetns_as_tools_plan_fix.md index caa7f784e..ac6e2ae22 100644 --- a/agetns_as_tools_plan_fix.md +++ b/agetns_as_tools_plan_fix.md @@ -238,26 +238,19 @@ These decisions simplify the fix plan and keep surface area small. **Goal:** keep existing UX (progress lines + names `[i]`) but reduce reliance on private internals. -1. **Add a small public API to `RichProgressDisplay`** +1. **Use only the standard event-based `RichProgressDisplay` API** - - In `rich_progress.py`: - - Add methods: - - `def hide_task(self, task_name: str) -> None:` - - Look up `task_id` via `_taskmap.get(task_name)`. - - If found, set `task.visible = False`. - - Optionally `def ensure_task(self, event: ProgressEvent) -> TaskID:` to encapsulate `add_task` + update logic. + - Avoid any direct access to `_taskmap` or `_progress.tasks` from `AgentsAsToolsAgent`. + - Emit well-formed `ProgressEvent`s instead: `CHATTING` at start and `FINISHED` at the end with `agent_name=instance_name`. - - Refactor `update()` to use `ensure_task()` internally. +2. [x] **Use instance-scoped names in `AgentsAsToolsAgent`** -2. [x] **Use the public API in `AgentsAsToolsAgent`** - - - Replace direct access to `_taskmap` and `_progress.tasks` with: - - `outer_progress_display.hide_task(instance_name)`. + - Ensure we always emit instance-specific names via `agent_name=OriginalName[i]` and `target=OriginalName[i]` in progress events. 3. **Document expected lifetime** - Comment in `AgentsAsToolsAgent`: - - Instance lines are **ephemeral**; they are hidden immediately when each task completes but progress data continues to exist for the duration of the run. + - Instance lines remain visible with a `FINISHED` status for the rest of the run for traceability. **Outcome:** same UI behavior, less fragile coupling to UI internals. diff --git a/src/fast_agent/core/logging/listeners.py b/src/fast_agent/core/logging/listeners.py index 8ab9be803..b486abc01 100644 --- a/src/fast_agent/core/logging/listeners.py +++ b/src/fast_agent/core/logging/listeners.py @@ -28,27 +28,17 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": if not isinstance(event_data, dict): return None - raw_action = event_data.get("progress_action") - if not raw_action: - return None - - try: - action = ( - raw_action - if isinstance(raw_action, ProgressAction) - else ProgressAction(str(raw_action)) - ) - except Exception: + progress_action = event_data.get("progress_action") + if not progress_action: return None # Build target string based on the event type. # Progress display is currently [time] [event] --- [target] [details] namespace = event.namespace agent_name = event_data.get("agent_name") - target = agent_name details = "" - if action == ProgressAction.FATAL_ERROR: + if progress_action == ProgressAction.FATAL_ERROR: details = event_data.get("error_message", "An error occurred") elif "mcp_aggregator" in namespace: server_name = event_data.get("server_name", "") @@ -60,7 +50,7 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": details = f"{server_name}" # For TOOL_PROGRESS, use progress message if available, otherwise keep default - if action == ProgressAction.TOOL_PROGRESS: + if progress_action == ProgressAction.TOOL_PROGRESS: progress_message = event_data.get("details", "") if progress_message: # Only override if message is non-empty details = progress_message @@ -86,20 +76,20 @@ def convert_log_event(event: Event) -> "ProgressEvent | None": if not target: target = event_data.get("target", "unknown") - # Extract streaming token count for STREAMING/THINKING actions + # Extract streaming token count for STREAMING actions streaming_tokens = None - if action == ProgressAction.STREAMING or action == ProgressAction.THINKING: + if progress_action == ProgressAction.STREAMING or progress_action == ProgressAction.THINKING: streaming_tokens = event_data.get("details", "") # Extract progress data for TOOL_PROGRESS actions progress = None total = None - if action == ProgressAction.TOOL_PROGRESS: + if progress_action == ProgressAction.TOOL_PROGRESS: progress = event_data.get("progress") total = event_data.get("total") return ProgressEvent( - action=action, + action=ProgressAction(progress_action), target=target or "unknown", details=details, agent_name=event_data.get("agent_name"), diff --git a/src/fast_agent/ui/rich_progress.py b/src/fast_agent/ui/rich_progress.py index 660d81084..ee8cfaaae 100644 --- a/src/fast_agent/ui/rich_progress.py +++ b/src/fast_agent/ui/rich_progress.py @@ -172,9 +172,6 @@ def update(self, event: ProgressEvent) -> None: details=f" / Elapsed Time {time.strftime('%H:%M:%S', time.gmtime(self._progress.tasks[task_id].elapsed))}", task_name=task_name, ) - for task in self._progress.tasks: - if task.id != task_id: - task.visible = False elif event.action == ProgressAction.FATAL_ERROR: self._progress.update( task_id, @@ -184,8 +181,5 @@ def update(self, event: ProgressEvent) -> None: details=f" / {event.details}", task_name=task_name, ) - for task in self._progress.tasks: - if task.id != task_id: - task.visible = False else: self._progress.reset(task_id) From c5b8856cc2f29ca1e0750199286c37c68dcab0ef Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 04:56:24 +0300 Subject: [PATCH 40/44] Hybrid Agents-as-Tools MCP-aware agent - Make AgentsAsToolsAgent subclass McpAgent instead of ToolAgent - Merge MCP tools and agent-tools into a single list_tools() surface - Route call_tool() to child agents first, then fall back to MCP/local tools - Update run_tools() to split mixed batches into child vs MCP calls and execute child calls via detached clones while delegating remaining tools to McpAgent.run_tools(), merging all results and errors - Keep existing detached per-call clone behavior and progress panel semantics - Update agents-as-tools design doc and module docstrings to describe the hybrid MCP-aware behavior and mark merged MCP + agent-tools view as implemented --- agetns_as_tools_plan_fix.md | 366 ------------------ agetns_as_tools_plan_scratch.md | 199 ++++------ .../agents/workflow/agents_as_tools_agent.py | 193 +++++---- 3 files changed, 204 insertions(+), 554 deletions(-) delete mode 100644 agetns_as_tools_plan_fix.md diff --git a/agetns_as_tools_plan_fix.md b/agetns_as_tools_plan_fix.md deleted file mode 100644 index ac6e2ae22..000000000 --- a/agetns_as_tools_plan_fix.md +++ /dev/null @@ -1,366 +0,0 @@ -# Agents-as-Tools — Fix Plan for Current Implementation - -## 1. Scope - -This document describes how to evolve and harden the current `AgentsAsToolsAgent` implementation in this repo: - -- File: `src/fast_agent/agents/workflow/agents_as_tools_agent.py` -- Wiring: - - `direct_decorators.agent(..., agents=[...])` - - `direct_factory.create_agents_by_type` (BASIC agents with `child_agents`) -- Supporting components: - - `ToolAgent`, `LlmAgent` - - `McpAgent`, `MCPAggregator` - - UI: `RichProgressDisplay`, `ConsoleDisplay`, `history_display`, `usage_display` - - Stats: `UsageAccumulator` - -Goal: keep this implementation **experimental but coherent**, good enough for real workflows and for an upstream-quality PR later. - ---- - -## 2. Recovered Intended Design - -From the module docstring and issue #458: - -- **Concept** - - Parent is a normal tool-calling LLM. - - Each child agent is exposed as a tool: `agent__{child_name}`. - - Parent delegates; it doesn't orchestrate explicitly. - -- **Tool interface** - - `list_tools()` → one tool per child, permissive schema: - - `{ text?: string, json?: object, ... }` - - `call_tool()`: - - Routes tool name → child agent. - - Normalizes arguments to a single `Prompt.user(text)`. - - Executes `child.generate([...])` and returns `CallToolResult`. - -- **Parallelism** - - Parent LLM may emit multiple tool calls in one turn. - - `run_tools()` should: - - Validate tools against `list_tools()`. - - Run all valid calls via `asyncio.gather`. - - Associate each physical tool call with a **virtual instance** index: `[1]`, `[2]`. - -- **Progress panel semantics** (Rich progress, left side) - - Before fan-out: one line per *agent* (parent and children). - - During fan-out: - - Parent line shows `Ready` (waiting on children). - - Each child instance shows its own line, with instance-index-suffixed name: `OriginalName[1]`, `OriginalName[2]`. - - Lines disappear as soon as each instance finishes. - - After fan-in: - - Only base agent lines remain; original names restored. - -- **Chat/log semantics** - - Parent chat should show **tool request/result panels** for each instance. - - Child chat should **not** stream to the panel when invoked as a tool. - - Child **tool usage** (MCP tools, shell, etc.) should still be visible. - -- **MCP initialization semantics** - - Children are real agents (`McpAgent` or similar) with MCP clients & aggregators. - - Multiple instances of the same child **share** one MCP aggregator. - - Parent itself does **not** talk to MCP directly; it only calls children. - -- **Stats semantics** - - Token/tool stats are tracked per *agent* via `UsageAccumulator`. - - Instances are **transient**; they may be visible in progress/chat but stats roll up per agent. - ---- - -## 3. Current Implementation Review - -### 3.1. What's already good - -- **Tool naming & discovery** - - `_make_tool_name(child_name)` → `agent__{child_name}`. - - `list_tools()` returns Tool schemas with the minimal `{ text, json }` interface. - -- **Routing & argument handling** - - `call_tool()` resolves both `agent__Child` and bare `Child`. - - Arguments → `text` precedence, then `json`, then `full args` JSON. - - Child is called via `Prompt.user(...)` + `child.generate([...])`. - -- **Error surfacing** - - If child writes to the `FAST_AGENT_ERROR_CHANNEL`, those blocks are appended to the tool result contents and `CallToolResult.isError` is set. - -- **Parallel fan-out** - - `run_tools()` builds `call_descriptors` and `descriptor_by_id`. - - Uses `asyncio.gather(..., return_exceptions=True)` to execute all calls concurrently. - -- **Instance naming for UI** - - For `pending_count > 1`, collects `original_names[tool_name] = child._name`. - - In `call_with_instance_name()`: - - Computes `instance_name = f"{original}[{instance}]"`. - - Mutates `child._name` and `child._aggregator.agent_name`. - - Emits a synthetic `ProgressEvent(CHATTING, target=instance_name, agent_name=instance_name)` to create a line in the progress panel. - - On completion, hides that line by flipping `task.visible = False` in `RichProgressDisplay`. - -- **Child display suppression** - - `call_tool()` lazily creates: - - `_display_suppression_count: { id(child) -> int }`. - - `_original_display_configs: { id(child) -> ConsoleDisplayConfig }`. - - On first use of a given child, makes a copy of `child.display.config`, sets: - - `logger.show_chat = False` - - `logger.show_tools = True` - - Ensures **children don't spam chat**, but still show their own MCP tool usage. - -- **Top/bottom panels** - - `_show_parallel_tool_calls()` and `_show_parallel_tool_results()` correctly label tools as `tool_name[instance]` in chat panels and bottom status items. - -Overall, the core mechanics of Agents-as-Tools are present and coherent. - -### 3.2. Gaps and fragilities - -1. **Display config restoration logic is incomplete** - - - In `call_tool()` we: - - Always increment `_display_suppression_count[child_id]`. - - In `finally` we **only decrement** the counter, do **not** restore config. - - In `run_tools()` we restore config **only if `pending_count > 1`**: - - For each `child` in `original_names`: - - Delete `_display_suppression_count[child_id]`. - - Restore `display.config` from `_original_display_configs`. - - Problems: - - For a **single tool call** (the most common case!), `pending_count == 1`, so `original_names` is empty and **display configs are never restored**. - - Even for `pending_count > 1`, restoration is decoupled from `_display_suppression_count[child_id]` (no 0→1 / 1→0 semantics). - - **Effect:** once a child is ever used as a tool, its chat may remain permanently suppressed for all subsequent uses, including direct runs, which is surprising. - -2. **Instance naming races on shared child objects** - - - Multiple tool calls to the **same child agent** share a single `child` object and a single `child._aggregator`. - - `call_with_instance_name()` mutates `child._name` and `child._aggregator.agent_name` in each task. - - Under concurrency, whichever task last mutates these fields wins; log lines from the child and from its aggregator may be attributed to the last instance, not this instance. - - **Effect:** progress rows are mostly correct (because we also emit explicit `ProgressEvent`s), but logs and transport stats that come from `MCPAggregator` may mix instance names. - -3. **Direct reliance on private internals of `RichProgressDisplay`** - - - `call_with_instance_name()` accesses: - - `outer_progress_display._taskmap` - - `outer_progress_display._progress.tasks` - - and flips `task.visible = False`. - - **Risk:** this is brittle against internal refactors of the progress UI and difficult to test in isolation. - -4. **`MessageType` import is unused** - - - `from fast_agent.ui.message_primitives import MessageType` is imported but not used. - - Indicates some UI scenarios were planned (e.g. structured tool headers) and not implemented. - -5. **Stats are per-agent only, not per-instance** - - - `UsageAccumulator` is owned by the LLM (via `LlmDecorator.usage_accumulator`). - - Usage is aggregated per **agent** (e.g. `PM-1-DayStatusSummarizer`), not per `[i]` instance. - - This matches the general fast-agent philosophy but does **not** match the stronger requirement separate rows in the stats panel per instance. - - **Current behavior is acceptable**, but the instance-per-row requirement should be documented as **out of scope** for the first implementation. - -6. **Tool availability check and naming** - - - `run_tools()` validates tool names against `list_tools()` of `AgentsAsToolsAgent` (agent-tools only). - - There is no support to **merge MCP tools and agent-tools** in `list_tools()`. - - **Status:** this matches a conservative interpretation of issue #458, but the design doc leaves the door open to unifying MCP tools and agent-tools; that needs an explicit decision. - ---- - -## 4. Design Decisions to Lock In (for this branch) - -Before making changes, clarify the intended semantics for this repo: - -1. **Child chat visibility** - - When a child agent is used as a tool via `AgentsAsToolsAgent`, its chat is **never** shown. - - When a child is run directly (by the user), its chat **is** shown. - -2. **Instance stats vs agent stats** - - For this implementation, stats remain **per agent**, not per instance. - - Instance-level visibility is provided by: - - Progress panel (per-instance lines). - - Chat log (tool headers `tool_name[i]`). - -3. **MCP reuse model** - - Child MCP aggregators are **shared** between all instances and all parents. - - No per-instance MCP clients. - -4. **Tool namespace composition** - - For now, `AgentsAsToolsAgent.list_tools()` returns **only agent-tools**. - - MCP tools, if any, must be accessed via separate agents (not through this orchestrator). - -These decisions simplify the fix plan and keep surface area small. - ---- - -## 5. Step-by-Step Fix Plan - -### 5.1. Fix display suppression and restoration - -**Goal:** implement correct reference counting per-child and always restore display config after the last instance completes, regardless of `pending_count`. - -**Steps:** - -1. [x] **Introduce explicit helpers on `AgentsAsToolsAgent`** - - - Private methods: - - `_ensure_display_maps_initialized()` - - `_suppress_child_display(child)` - - `_release_child_display(child)` - - - Semantics: - - `_suppress_child_display(child)`: - - If `child_id` not in `_display_suppression_count`: - - Snapshot `child.display.config` into `_original_display_configs[child_id]`. - - Install a modified config with `show_chat=False, show_tools=True`. - - Initialize counter to `0`. - - Increment counter. - - `_release_child_display(child)`: - - Decrement counter. - - If counter reaches `0`: - - Restore original config from `_original_display_configs`. - - Delete both entries for this `child_id`. - -2. [x] **Apply helpers in `call_tool()`** - - - Replace direct manipulation with: - - `_suppress_child_display(child)` before `await child.generate(...)`. - - `_release_child_display(child)` in `finally`. - -3. [x] **Remove display restoration from `run_tools()`** - - - The `_display_suppression_count` & `_original_display_configs` clean-up should be **entirely local** to `call_tool()`; `run_tools()` should not know about it. - - This also makes `call_tool()` correct if it's ever used outside of `run_tools()`. - -**Outcome:** display configs are always restored after the last parallel/sequential instance finishes, independent of how many tools or which code path called them. - ---- - -### 5.2. Stabilize instance naming and progress UI - -**Goal:** keep existing UX (progress lines + names `[i]`) but reduce reliance on private internals. - -1. **Use only the standard event-based `RichProgressDisplay` API** - - - Avoid any direct access to `_taskmap` or `_progress.tasks` from `AgentsAsToolsAgent`. - - Emit well-formed `ProgressEvent`s instead: `CHATTING` at start and `FINISHED` at the end with `agent_name=instance_name`. - -2. [x] **Use instance-scoped names in `AgentsAsToolsAgent`** - - - Ensure we always emit instance-specific names via `agent_name=OriginalName[i]` and `target=OriginalName[i]` in progress events. - -3. **Document expected lifetime** - - - Comment in `AgentsAsToolsAgent`: - - Instance lines remain visible with a `FINISHED` status for the rest of the run for traceability. - -**Outcome:** same UI behavior, less fragile coupling to UI internals. - ---- - -### 5.3. Reduce naming races (best-effort for experimental phase) - -Completely eliminating races around `child._name` and `child._aggregator.agent_name` would require: - -- Either a per-instance `MCPAggregator`, or -- Making `MCPAggregator` fully stateless in terms of `agent_name`, or -- Augmenting all tool/progress logs with an explicit correlation/instance id. - -That is a larger refactor than we want for the current experimental implementation. Instead, we can apply a **minimal mitigation**: - -1. [x] **Minimize mutation window** - - - In `call_with_instance_name()`: - - Set `child._name` and `child._aggregator.agent_name` **immediately** before `await self.call_tool(...)`. - - Right after the `await`, restore them to the base `original_names[tool_name]` (inside the same task's `try/finally`). - - `run_tools()` should **no longer perform name restoration** for children; it only needs to restore parent-level names (if we ever mutate them) and handle display. - -2. **Clarify known limitation** - - - In the module docstring, add a short Limitations section explaining: - - Under heavy concurrency, some low-level logs from MCP may still show mixed instance names; the progress panel and chat tool headers are the authoritative view. - -**Outcome:** race window is strictly bounded to the duration of a single tool call in a single task; we no longer keep children renamed after the call completes. - ---- - -### 5.4. Explicitly document stats behavior - -**Goal:** align user expectations with current implementation. - -1. **Update README / docs** (or a dedicated experimental note): - - - Describe that: - - Token and tool usage stats are aggregated **per agent**. - - Agents-as-Tools does **not** create per-instance stats rows; instead: - - Per-instance work is visible in the progress panel. - - Tool calls are visible in the history summary as `tool→` / `result→` rows. - -2. **Optionally tag tool results with instance index in content** - - - For debug clarity, `AgentsAsToolsAgent` could prepend a short header block to each `CallToolResult` content: - - e.g. `"[instance 1]"`. - - This would make the instance index visible in `history_display` even outside the UI tool headers. - - This is optional and can be added behind a config flag if needed. - ---- - -### 5.5. Tests and diagnostics - -1. **Unit tests for `AgentsAsToolsAgent`** - - - Scenarios: - - Single tool call to one child. - - Two sequential tool calls in separate turns. - - Two parallel tool calls to **different** children. - - Two parallel tool calls to the **same** child. - - Tool-not-found error path. - - Assertions: - - `list_tools()` returns expected tool names. - - `call_tool()` forwards `text` and `json` correctly. - - Display suppression: - - `child.display.config.logger.show_chat` toggles to False during calls. - - Restored to original after calls (check for all scenarios). - -2. **Integration-style test with a fake `RichProgressDisplay`** - - - Inject a fake progress display with a deterministic in-memory representation. - - Assert that for parallel calls: - - Parent gets a `READY` event. - - Each instance gets a `CHATTING` event with `target=OriginalName[i]`. - - Each instance eventually receives a `FINISHED` event and remains visible for inspection. - -3. **Manual diagnostic recipe** - - - Document a small `fastagent.config.yaml` example that: - - Defines N children representing mocked projects. - - Defines a parent with `agents: [...]` using Agents-as-Tools. - - Steps to reproduce and visually verify: - - Instance lines in progress panel. - - Tool rows in history summary. - - Stats table showing aggregate per agent. - ---- - -## 6. Future Enhancements (Beyond Fix Plan) - -These are candidates for the from-scratch design rather than this incremental fix: - -- **Per-instance stats** - - Attach a lightweight `InstanceUsage` struct per tool call and aggregate it at run end. - -- **Correlation IDs and structured logging** - - Emit a unique correlation ID for each tool call and propagate it through: - - Parent request → tool_call. - - Child logs and progress events. - - MCPAggregator transport tracking. - -- **Cleaner abstraction boundary** - - Extract an `AgentsAsToolsRuntime` helper that contains **no UI or LLM logic**, only: - - Tool mapping. - - Parallel execution. - - Result collation. - - A separate `AgentsAsToolsDisplayAdapter` layer would handle: - - Progress events. - - Display config changes. - -These ideas are elaborated further in `agetns_as_tools_plan_scratcj.md`. diff --git a/agetns_as_tools_plan_scratch.md b/agetns_as_tools_plan_scratch.md index 07aa8970e..48b9a440d 100644 --- a/agetns_as_tools_plan_scratch.md +++ b/agetns_as_tools_plan_scratch.md @@ -97,28 +97,28 @@ This snapshot should stay in sync with the actual code to document why the detac Location: `src/fast_agent/agents/workflow/agents_as_tools_agent.py`. -Base class: **`ToolAgent`** (not `McpAgent`). +Base class: **`McpAgent`** (inherits `ToolAgent` and manages MCP connections). Responsibilities: -- Adapter between **LLM tool schema** and **child agents**. -- `list_tools()` → synthetic tools for children. -- `call_tool()` → executes the appropriate child. -- `run_tools()` → parallel fan-out + fan-in. -- UI integration via a **small display adapter**, not raw access to progress internals. +- Adapter between **LLM tool schema**, **child agents**, and **MCP tools**. +- `list_tools()` → MCP tools (from `McpAgent`) plus synthetic tools for children. +- `call_tool()` → executes child agents first; falls back to MCP/local tools. +- `run_tools()` → parallel fan-out for child agents plus integration with the base MCP `run_tools` for mixed batches. Constructor: ```python -class AgentsAsToolsAgent(ToolAgent): +class AgentsAsToolsAgent(McpAgent): def __init__( self, config: AgentConfig, agents: list[LlmAgent], context: Context | None = None, + **kwargs: Any, ) -> None: - super().__init__(config=config, tools=[], context=context) - self._children: dict[str, LlmAgent] = {} + super().__init__(config=config, context=context, **kwargs) + self._child_agents: dict[str, LlmAgent] = {} # Maps tool name -> child agent (keys are agent__ChildName) ``` @@ -167,13 +167,13 @@ class AgentsAsToolsAgent(ToolAgent): ``` - Implementation sketch: - - For each child in `self._children`: + - For each child in `self._child_agents`: - Build an `mcp.Tool`: - `name = tool_name` - `description = child.instruction` - `inputSchema = schema_above`. -**Open design choice:** whether to **merge** these tools with MCP tools if the parent is also an MCP-enabled agent. For from-scratch, keep them **separate**: Agents-as-Tools is the *only* tool surface of this agent. +- In the current implementation these child tools are **merged** with the MCP tools exposed by `McpAgent.list_tools()`: `AgentsAsToolsAgent.list_tools()` returns a single combined surface (MCP tools + `agent__Child` tools), adding child tools only when their names do not conflict with existing MCP/local tool names. ### 4.2. Argument mapping (`call_tool`) @@ -239,11 +239,11 @@ Rationale: children can still be run standalone (outside Agents-as-Tools) with f ### 4.4. Parallel `run_tools` semantics -**Goal:** replace `ToolAgent.run_tools` with a parallel implementation that preserves its contract but allows: +**Goal:** replace `ToolAgent.run_tools` with a parallel implementation that preserves its contract but allows: - multiple tool calls per LLM turn; - concurrent execution via `asyncio.gather`; -- clear UI for each virtual instance. +- clear UI for each per-call instance. #### 4.4.1. Data structures @@ -254,81 +254,86 @@ Rationale: children can still be run standalone (outside Agents-as-Tools) with f - `tasks: list[Task[CallToolResult]]`. - `ids_in_order: list[str]` for stable correlation. -#### 4.4.2. Algorithm +#### 4.4.2. Algorithm (current implementation) 1. **Validate tool calls** - Snapshot `available_tools` from `list_tools()`. - For each `request.tool_calls[correlation_id]`: - - If name not in available_tools → create `CallToolResult(isError=True, ...)`, mark descriptor as `status="error"`, skip task. + - If name not in `available_tools` → create `CallToolResult(isError=True, ...)`, mark descriptor as `status="error"`, skip task. - Else → `status="pending"`, add to `ids_in_order`. -2. **Prepare virtual instance names** +2. **Create detached instances and names** - - `pending_count = len(ids_in_order)`. - - If `pending_count <= 1`: - - No instance suffixing; just run sequentially or as a trivial gather. - - Else: - - For each `tool_name` used: - - Capture `original_name = child.name` in a dict for later restoration. + - For each `correlation_id` in `ids_in_order` assign `instance_index = 1..N`. + - Resolve the template child from `_child_agents`. + - Compute `base_name = child.name` and `instance_name = f"{base_name}[{instance_index}]"`. + - Use `instance_name` consistently for: + - the detached clone (`spawn_detached_instance(name=instance_name)`), + - progress events (`agent_name=instance_name`, `target=instance_name`), + - chat/tool headers (`tool_name[instance_index]`). 3. **Instance execution wrapper** - Define: + Conceptually (simplified): ```python - async def _run_instance(tool_name, args, instance_index) -> CallToolResult: - child = self._children[tool_name] - instance_name = f"{child.name}[{instance_index}]" if pending_count > 1 else child.name - # UI: start instance line - self._display_adapter.start_instance(parent=self, child=child, instance_name=instance_name) - try: - return await self.call_tool(tool_name, args) - finally: - self._display_adapter.finish_instance(instance_name) - ``` - -4. **Display adapter abstraction** - -To avoid touching `RichProgressDisplay` internals from this class, introduce a tiny adapter: + async def call_with_instance_name(tool_name, tool_args, instance_index) -> CallToolResult: + child = resolve_template_child(tool_name) + base_name = child.name + instance_name = f"{base_name}[{instance_index}]" -- `AgentsAsToolsDisplayAdapter` (internal helper, same module or `ui/agents_as_tools_display.py`): + clone = await child.spawn_detached_instance(name=instance_name) - - Depends only on: - - `progress_display: RichProgressDisplay` - - `ConsoleDisplay` of the parent agent. + progress_display.update(ProgressEvent( + action=ProgressAction.CHATTING, + target=instance_name, + agent_name=instance_name, + )) - - Responsibilities: - - `start_parent_waiting(original_parent_name)` → emit `ProgressAction.READY`. - - `start_instance(parent, child, instance_name)` → emit `ProgressAction.CHATTING` or `CALLING_TOOL` with `agent_name=instance_name`. - - `finish_instance(instance_name)` → emit `ProgressAction.FINISHED` for the instance and rely on the standard progress UI for visibility. - - `_show_parallel_tool_calls(call_descriptors)` → call `parent.display.show_tool_call` with `[i]` suffixes. - - `_show_parallel_tool_results(ordered_records)` → call `parent.display.show_tool_result` with `[i]` suffixes. - -The `AgentsAsToolsAgent` itself: + try: + # Handles argument → text mapping, display suppression, error channel, etc. + return await self._invoke_child_agent(clone, tool_args) + finally: + await clone.shutdown() + child.merge_usage_from(clone) + progress_display.update(ProgressEvent( + action=ProgressAction.FINISHED, + target=instance_name, + agent_name=instance_name, + details="Completed", + )) + ``` -- Holds a `self._display_adapter` instance. -- Delegates all UI updates to it. + - All interaction with the Rich progress panel goes through `ProgressEvent` objects and the shared `progress_display.update(...)` API. + - `RichProgressDisplay.update` is responsible for marking `FINISHED` lines complete without hiding other tasks. -5. **Parallel execution** +4. **Parallel execution and UI** -- For each `correlation_id` with a valid tool call, create a task: + - For each `correlation_id` with a valid tool call, create a task: - ```python - tasks.append(asyncio.create_task( - _run_instance(tool_name, tool_args, instance_index=i) - )) - ``` + ```python + tasks.append(asyncio.create_task( + call_with_instance_name(tool_name, tool_args, instance_index=i) + )) + ``` -- Show aggregated calls via display adapter. -- `results = await asyncio.gather(*tasks, return_exceptions=True)`. -- Map each result back to `correlation_id`. + - `_show_parallel_tool_calls(call_descriptors)` and `_show_parallel_tool_results(ordered_records)` use `tool_name[i]` labels in the chat panels and bottom status items, but do not touch `RichProgressDisplay` internals. + - `results = await asyncio.gather(*tasks, return_exceptions=True)` collects all results and maps them back to `correlation_id` in input order. -6. **Finalize** +5. **Finalize** - Build ordered `records = [{"descriptor": ..., "result": ...}, ...]` in input order. -- Ask display adapter to show results. +- Call `_show_parallel_tool_results(records)`. - Return `self._finalize_tool_results(tool_results, tool_loop_error)` for consistency with `ToolAgent`. +6. **Mixed MCP + agent-tools batches** + +- If `request.tool_calls` contains both child-agent tools and regular MCP tools: + - Split `tool_calls` into two subsets: child-agent calls and remaining MCP/local tools. + - Run child-agent calls via the parallel `call_with_instance_name(...)` path described above. + - Delegate the remaining tools to the base `McpAgent.run_tools()` implementation. + - Merge `tool_results` and error text from both branches (using the `FAST_AGENT_ERROR_CHANNEL` error channel) into a single `PromptMessageExtended`. + ### 4.5. Stats and history integration - Leave `UsageAccumulator` unchanged. @@ -346,40 +351,14 @@ No new data model types are needed for stats. --- -## 5. Engineering Model & Separation of Concerns - -To make the design understandable and maintainable, structure it into three layers: - -1. **Core runtime (no UI)** - - - Handles: - - Tool name mapping (`agent__Child`). - - `list_tools`, `call_tool`, `run_tools` logic. - - Argument normalization. - - Result collation. - - Exposes hooks: - - `on_tool_call_start(tool_name, instance_index, correlation_id)` - - `on_tool_call_end(tool_name, instance_index, correlation_id, result)` - - No knowledge of Rich, ConsoleDisplay, or MCP. - -2. **UI adapter layer** +## 5. Engineering notes - - Subscribes to core runtime hooks. - - Responsible for: - - Creating/updating progress tasks. - - Formatting tool call & result panels. - - Talks to: - - `RichProgressDisplay` - - Parent agents `ConsoleDisplay`. +In the current implementation `AgentsAsToolsAgent` combines both: -3. **Integration/glue layer (factory + decorators)** +- core runtime concerns (tool mapping, argument normalization, `run_tools` orchestration), and +- UI wiring (progress events and chat/tool panels). - - Binds user-level config/decorators to concrete runtime instances. - - Ensures that: - - Children are created before parents. - - The same context (settings, logs, executor) is reused. - -This layered model allows future refactors such as a **web UI** or a **non-Rich CLI** to adopt the core Agents-as-Tools runtime without touching orchestration logic. +This keeps the surface area small and matches the needs of the CLI UI. A future refactor could still extract a pure runtime helper and a separate UI adapter (see §7), but that split is **not** required for the feature to work today. --- @@ -429,38 +408,22 @@ This layered model allows future refactors such as a **web UI** or a **non-Rich ## 7. Potential Future Extensions -The above design keeps the surface area small. After it is stable, consider these additions: +The current implementation is intentionally minimal. The items below are still **future** additions (not implemented as of Nov 2025). 1. **Per-instance stats & traces** -- Extend core runtime to emit per-instance events with: - - `instance_id` (UUID or (tool_name, index)). - - `start_time`, `end_time`, `duration_ms`. -- Expose hooks so UI can show: - - Per-instance durations. - - Aggregate bars per instance in a detail view. + - Extend the runtime to emit per-instance stats objects with `instance_id`, `start_time`, `end_time`, `duration_ms`. + - Allow a richer UI (CLI or web) to display per-instance timing bars and aggregates. 2. **Recursive Agents-as-Tools** -- Allow children themselves to be `AgentsAsToolsAgent`. -- This already works logically, but we can: - - Make it explicit in docs. - - Ensure UI still renders nested tool calls clearly. - -3. **Merged MCP + agent-tools view** - -- Add an optional mode where `list_tools()` returns: - - All MCP tools from connected servers. - - All agent-tools. -- Provide filters via `AgentConfig.tools` to control which surface is visible per parent. + - Explicitly document and test scenarios where children are themselves `AgentsAsToolsAgent` instances. + - Ensure nested tool calls remain readable in progress and history views. -4. **Correlation-friendly logging** +3. **Correlation-friendly logging** -- Standardize structured log fields for tools: - - `agent_name`, `instance_name`, `correlation_id`, `tool_name`. -- Make `history_display` able to group tool rows per correlation id + instance. - ---- + - Standardize structured log fields for tools (`agent_name`, `instance_name`, `correlation_id`, `tool_name`). + - Make `history_display` able to group tool rows per `(correlation_id, instance)` so parallel runs are easier to inspect. ## 8. Summary diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 666157294..c1ccf97af 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -38,28 +38,31 @@ Algorithm --------- 1. **Initialization** - - Parent agent receives list of child agents - - Each child agent is mapped to a tool name: `agent__{child_name}` - - Tool schemas advertise text/json input capabilities + - `AgentsAsToolsAgent` is itself an `McpAgent` (with its own MCP servers + tools) and receives a list of **child agents**. + - Each child agent is mapped to a synthetic tool name: `agent__{child_name}`. + - Child tool schemas advertise text/json input capabilities. 2. **Tool Discovery (list_tools)** - - Parent LLM receives one tool per child agent - - Each tool schema includes child agent's instruction as description - - LLM decides which tools (child agents) to call based on user request + - `list_tools()` starts from the base `McpAgent.list_tools()` (MCP + local tools). + - Synthetic child tools `agent__ChildName` are added on top when their names do not collide with existing tools. + - The parent LLM therefore sees a **merged surface**: MCP tools and agent-tools in a single list. 3. **Tool Execution (call_tool)** - - Route tool name to corresponding child template - - Convert tool arguments (text or JSON) to child input - - Execution itself is performed by detached clones created inside `run_tools` - - Responses are converted to `CallToolResult` objects (errors propagate as `isError=True`) + - If the requested tool name resolves to a child agent (either `child_name` or `agent__child_name`): + - Convert tool arguments (text or JSON) to a child user message. + - Execute via detached clones created inside `run_tools` (see below). + - Responses are converted to `CallToolResult` objects (errors propagate as `isError=True`). + - Otherwise, delegate to the base `McpAgent.call_tool` implementation (MCP tools, shell, human-input, etc.). 4. **Parallel Execution (run_tools)** - - Collect all tool calls from parent LLM response - - For each call, spawn a detached clone with its own LLM + MCP aggregator and suffixed name - - Emit `ProgressAction.CHATTING` for each instance and keep parent status untouched - - Execute tasks concurrently via `asyncio.gather` - - On completion, mark instance lines `FINISHED` (no hiding) and merge usage back into the template - - Aggregate results and return them to the parent LLM + - Collect all tool calls from the parent LLM response. + - Partition them into **child-agent tools** and **regular MCP/local tools**. + - Child-agent tools are executed in parallel: + - For each child tool call, spawn a detached clone with its own LLM + MCP aggregator and suffixed name. + - Emit `ProgressAction.CHATTING` / `ProgressAction.FINISHED` events for each instance and keep parent status untouched. + - Merge each clone's usage back into the template child after shutdown. + - Remaining MCP/local tools are delegated to `McpAgent.run_tools()`. + - Child and MCP results (and their error text from `FAST_AGENT_ERROR_CHANNEL`) are merged into a single `PromptMessageExtended` that is returned to the parent LLM. Progress Panel Behavior ----------------------- @@ -190,9 +193,10 @@ async def coordinator(): pass from fast_agent.agents.agent_types import AgentConfig from fast_agent.agents.llm_agent import LlmAgent -from fast_agent.agents.tool_agent import ToolAgent +from fast_agent.agents.mcp_agent import McpAgent from fast_agent.core.logging.logger import get_logger from fast_agent.core.prompt import Prompt +from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content, text_content from fast_agent.ui.message_primitives import MessageType from fast_agent.types import PromptMessageExtended, RequestParams @@ -200,13 +204,19 @@ async def coordinator(): pass logger = get_logger(__name__) -class AgentsAsToolsAgent(ToolAgent): - """ - An agent that makes each child agent available as an MCP Tool to the parent LLM. +class AgentsAsToolsAgent(McpAgent): + """MCP-enabled agent that exposes child agents as additional tools. + + This hybrid agent: - - list_tools() advertises one tool per child agent - - call_tool() routes execution to the corresponding child agent - - run_tools() is overridden to process multiple tool calls in parallel + - Inherits all MCP behavior from :class:`McpAgent` (servers, MCP tool discovery, local tools). + - Exposes each child agent as an additional synthetic tool (`agent__ChildName`). + - Merges **MCP tools** and **agent-tools** into a single `list_tools()` surface. + - Routes `call_tool()` to child agents when the name matches a child, otherwise delegates + to the base `McpAgent.call_tool` implementation. + - Overrides `run_tools()` to fan out child-agent tools in parallel using detached clones, + while delegating any remaining MCP/local tools to the base `McpAgent.run_tools` and + merging all results into a single tool-loop response. """ def __init__( @@ -219,13 +229,12 @@ def __init__( """Initialize AgentsAsToolsAgent. Args: - config: Agent configuration + config: Agent configuration for this parent agent (including MCP servers/tools) agents: List of child agents to expose as tools context: Optional context for agent execution - **kwargs: Additional arguments passed to ToolAgent + **kwargs: Additional arguments passed through to :class:`McpAgent` and its bases """ - # Initialize as a ToolAgent but without local FastMCP tools; we'll override list_tools - super().__init__(config=config, tools=[], context=context) + super().__init__(config=config, context=context, **kwargs) self._child_agents: dict[str, LlmAgent] = {} # Build tool name mapping for children @@ -265,13 +274,16 @@ async def shutdown(self) -> None: logger.warning(f"Error shutting down child agent {agent.name}: {e}") async def list_tools(self) -> ListToolsResult: - """List all available tools (one per child agent). - - Returns: - ListToolsResult containing tool schemas for all child agents - """ - tools: list[Tool] = [] + """List MCP tools plus child agents exposed as tools.""" + + base = await super().list_tools() + tools = list(base.tools) + existing_names = {tool.name for tool in tools} + for tool_name, agent in self._child_agents.items(): + if tool_name in existing_names: + continue + input_schema: dict[str, Any] = { "type": "object", "properties": { @@ -287,6 +299,8 @@ async def list_tools(self) -> ListToolsResult: inputSchema=input_schema, ) ) + existing_names.add(tool_name) + return ListToolsResult(tools=tools) def _ensure_display_maps_initialized(self) -> None: @@ -380,20 +394,17 @@ async def _invoke_child_agent( if suppress_display: self._release_child_display(child) + def _resolve_child_agent(self, name: str) -> LlmAgent | None: + return self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name)) + async def call_tool(self, name: str, arguments: dict[str, Any] | None = None) -> CallToolResult: - """Execute a child agent by name. - - Args: - name: Tool name (agent name with prefix) - arguments: Optional arguments to pass to the child agent - - Returns: - CallToolResult containing the child agent's response - """ - child = self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name)) - if child is None: - return CallToolResult(content=[text_content(f"Unknown agent-tool: {name}")], isError=True) - return await self._invoke_child_agent(child, arguments) + """Route tool execution to child agents first, then MCP/local tools.""" + + child = self._resolve_child_agent(name) + if child is not None: + return await self._invoke_child_agent(child, arguments) + + return await super().call_tool(name, arguments) def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: """Display tool call headers for parallel agent execution. @@ -473,17 +484,56 @@ def _show_parallel_tool_results( ) async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtended: - """ - Override ToolAgent.run_tools to execute multiple tool calls in parallel. - """ + """Handle mixed MCP + agent-tool batches.""" + if not request.tool_calls: logger.warning("No tool calls found in request", data=request) return PromptMessageExtended(role="user", tool_results={}) + child_ids: list[str] = [] + for correlation_id, tool_request in request.tool_calls.items(): + if self._resolve_child_agent(tool_request.params.name): + child_ids.append(correlation_id) + + if not child_ids: + return await super().run_tools(request) + + child_results, child_error = await self._run_child_tools(request, set(child_ids)) + + if len(child_ids) == len(request.tool_calls): + return self._finalize_tool_results(child_results, tool_loop_error=child_error) + + # Execute remaining MCP/local tools via base implementation + remaining_ids = [cid for cid in request.tool_calls.keys() if cid not in child_ids] + mcp_request = PromptMessageExtended( + role=request.role, + content=request.content, + tool_calls={cid: request.tool_calls[cid] for cid in remaining_ids}, + ) + mcp_message = await super().run_tools(mcp_request) + mcp_results = mcp_message.tool_results or {} + mcp_error = self._extract_error_text(mcp_message) + + combined_results = {} + combined_results.update(child_results) + combined_results.update(mcp_results) + + tool_loop_error = child_error or mcp_error + return self._finalize_tool_results(combined_results, tool_loop_error=tool_loop_error) + + async def _run_child_tools( + self, + request: PromptMessageExtended, + target_ids: set[str], + ) -> tuple[dict[str, CallToolResult], str | None]: + """Run only the child-agent tool calls from the request.""" + + if not target_ids: + return {}, None + tool_results: dict[str, CallToolResult] = {} tool_loop_error: str | None = None - # Snapshot available tools for validation and UI try: listed = await self.list_tools() available_tools = [t.name for t in listed.tools] @@ -491,13 +541,15 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend logger.warning(f"Failed to list tools before execution: {exc}") available_tools = list(self._child_agents.keys()) - # Build aggregated view of all tool calls call_descriptors: list[dict[str, Any]] = [] descriptor_by_id: dict[str, dict[str, Any]] = {} tasks: list[asyncio.Task] = [] id_list: list[str] = [] - + for correlation_id, tool_request in request.tool_calls.items(): + if correlation_id not in target_ids: + continue + tool_name = tool_request.params.name tool_args = tool_request.params.arguments or {} @@ -521,23 +573,13 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend descriptor["status"] = "pending" id_list.append(correlation_id) - pending_count = len(id_list) - parent_base_names: set[str] = set() - for cid in id_list: - tool_name = descriptor_by_id[cid]["tool"] - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) - if child: - parent_base_names.add(child.name) - - # Import progress_display at outer scope to ensure same instance from fast_agent.event_progress import ProgressAction, ProgressEvent from fast_agent.ui.progress_display import progress_display as outer_progress_display - # Create wrapper coroutine that sets names and emits progress for instance async def call_with_instance_name( tool_name: str, tool_args: dict[str, Any], instance: int ) -> CallToolResult: - child = self._child_agents.get(tool_name) or self._child_agents.get(self._make_tool_name(tool_name)) + child = self._resolve_child_agent(tool_name) if not child: error_msg = f"Unknown agent-tool: {tool_name}" return CallToolResult(content=[text_content(error_msg)], isError=True) @@ -600,17 +642,14 @@ async def call_with_instance_name( agent_name=instance_name, ) ) - - # Create tasks with instance-specific wrappers + for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] tool_args = descriptor_by_id[cid]["args"] tasks.append(asyncio.create_task(call_with_instance_name(tool_name, tool_args, i))) - # Show aggregated tool call(s) self._show_parallel_tool_calls(call_descriptors) - # Execute concurrently if tasks: results = await asyncio.gather(*tasks, return_exceptions=True) for i, result in enumerate(results): @@ -627,9 +666,8 @@ async def call_with_instance_name( tool_results[correlation_id] = result descriptor_by_id[correlation_id]["status"] = "error" if result.isError else "done" - # Show aggregated result(s) ordered_records: list[dict[str, Any]] = [] - for cid in request.tool_calls.keys(): + for cid in id_list: result = tool_results.get(cid) if result is None: continue @@ -638,4 +676,19 @@ async def call_with_instance_name( self._show_parallel_tool_results(ordered_records) - return self._finalize_tool_results(tool_results, tool_loop_error=tool_loop_error) + return tool_results, tool_loop_error + + def _extract_error_text(self, message: PromptMessageExtended) -> str | None: + if not message.channels: + return None + + error_blocks = message.channels.get(FAST_AGENT_ERROR_CHANNEL) + if not error_blocks: + return None + + for block in error_blocks: + text = get_text(block) + if text: + return text + + return None From 8adcd3b446e56ec973a01050c87c54d989406de5 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 05:12:24 +0300 Subject: [PATCH 41/44] =?UTF-8?q?Added=20=C2=A73.3=20=E2=80=9CMinimal=20us?= =?UTF-8?q?age=20sample=20(for=20docs=20and=20examples)=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- agetns_as_tools_plan_scratch.md | 63 +++++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/agetns_as_tools_plan_scratch.md b/agetns_as_tools_plan_scratch.md index 48b9a440d..1d3c9d64a 100644 --- a/agetns_as_tools_plan_scratch.md +++ b/agetns_as_tools_plan_scratch.md @@ -140,6 +140,60 @@ class AgentsAsToolsAgent(McpAgent): - No CLI flags change. - New behavior is activated simply by specifying `agents:` in the decorator/config. +### 3.3. Minimal usage sample (for docs and examples) + +This sample is used as a reference for both local testing and future docs/README updates. +It mirrors the standalone script in the Strato workspace (`fast/agent-as-tools.py`). + +```python +import asyncio +from fast_agent import FastAgent + +fast = FastAgent("Agents-as-Tools demo") + + +@fast.agent( + name="NY-Time", + instruction="Return current time in New York.", + servers=["tm"], # MCP server 'tm' configured in fastagent.config.yaml + model="gpt-5-mini", + tools={"tm": ["get_current_time"]}, +) +@fast.agent( + name="London-Time", + instruction="Return current time in London.", + servers=["tm"], + model="gpt-5-mini", + tools={"tm": ["get_current_time"]}, +) +@fast.agent( + name="time-orchestrator", + instruction="Get current time in New York and London.", + model="gpt-5-mini", + default=True, + agents=[ + "NY-Time", + "London-Time", + ], +) +async def main() -> None: + async with fast.run() as agent: + result = await agent("get time for NY and London") + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Key points: + +- `NY-Time` and `London-Time` are normal MCP-enabled agents using the `tm` server. +- `time-orchestrator` is a BASIC agent with `agents=[...]`; the factory instantiates it + as an `AgentsAsToolsAgent` under the hood, exposing each child as a tool. +- From the LLM's perspective, it simply sees additional tools (`agent__NY-Time`, + `agent__London-Time`) alongside regular MCP tools. + --- ## 4. Detailed Design by Concern @@ -394,11 +448,16 @@ This keeps the surface area small and matches the needs of the CLI UI. A future ### Phase 3 — Documentation & ergonomics -- Add docs page / section: +- Add docs page / section (for example, a `README.md` subsection + "Agents-as-Tools (child agents as tools)"): - Concept explanation. - - Example usage with YAML + decorators. + - Minimal Python example from §3.3 (NY/London time orchestrator). - Comparison with Orchestrator / IterativePlanner / Parallel workflows. +- Keep the code sample in sync with the shipped example script + (currently `fast/agent-as-tools.py` in the Strato workspace, upstream + examples path TBD). + - Add clear notes about: - Stats aggregation semantics. - Reuse of MCP connections. From 43c210ecb40d965b1ed3eda28862705d91f04b06 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 07:02:46 +0300 Subject: [PATCH 42/44] Add PMO Agents-as-Tools examples and tidy AgentsAsToolsAgent - Add simple PMO Agents-as-Tools example (agents_as_tools_simple.py) with NY-Project-Manager and London-Project-Manager using the local `time` MCP server. - Add extended PMO example (agents_as_tools_extended.py) that uses `time` + `fetch`, retries alternative sources on 403/robots.txt, and includes Fast-Agent / BBC / FT hints. - Update README Agents-as-Tools section with the PMO minimal example and a link to the extended workflow file. - Run black and minor style cleanups on AgentsAsToolsAgent without changing behavior. --- README.md | 46 ++++++++++ .../workflows/agents_as_tools_extended.py | 66 ++++++++++++++ examples/workflows/agents_as_tools_simple.py | 40 +++++++++ examples/workflows/fastagent.config.yaml | 3 + .../agents/workflow/agents_as_tools_agent.py | 89 +++++++++++++------ 5 files changed, 216 insertions(+), 28 deletions(-) create mode 100644 examples/workflows/agents_as_tools_extended.py create mode 100644 examples/workflows/agents_as_tools_simple.py diff --git a/README.md b/README.md index c15727479..37acce2f4 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,52 @@ uv run workflow/chaining.py --agent post_writer --message "" Add the `--quiet` switch to disable progress and message display and return only the final response - useful for simple automations. +### Agents-as-Tools (child agents as tools) + +Sometimes one agent needs to call other agents as tools. `fast-agent` supports +this via a hybrid *Agents-as-Tools* agent: + +- You declare a BASIC agent with `agents=[...]`. +- At runtime it is instantiated as an internal `AgentsAsToolsAgent`, which: + - Inherits from `McpAgent` (keeps its own MCP servers/tools). + - Exposes each child agent as a tool (`agent__ChildName`). + - Can mix MCP tools and agent-tools in the same tool loop. + +Minimal example: + +```python +@fast.agent( + name="NY-Project-Manager", + instruction="Return current time and project status.", + servers=["time"], # MCP server 'time' configured in fastagent.config.yaml +) +@fast.agent( + name="London-Project-Manager", + instruction="Return current time and news.", + servers=["time"], +) +@fast.agent( + name="PMO-orchestrator", + instruction="Get reports. Separate call per topic. NY: {OpenAI, Fast-Agent, Anthropic}, London: Economics", + default=True, + agents=[ + "NY-Project-Manager", + "London-Project-Manager", + ], # children are exposed as tools: agent__NY-Project-Manager, agent__London-Project-Manager +) +async def main() -> None: + async with fast.run() as agent: + result = await agent("Get PMO report") + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +Extended example is available in the repository as +`examples/workflows/agents_as_tools_extended.py`. + ## MCP OAuth (v2.1) For SSE and HTTP MCP servers, OAuth is enabled by default with minimal configuration. A local callback server is used to capture the authorization code, with a paste-URL fallback if the port is unavailable. diff --git a/examples/workflows/agents_as_tools_extended.py b/examples/workflows/agents_as_tools_extended.py new file mode 100644 index 000000000..7ab991041 --- /dev/null +++ b/examples/workflows/agents_as_tools_extended.py @@ -0,0 +1,66 @@ +"""Agents-as-Tools example: project managers for NY and London. + +Parent agent ("PMO-orchestrator") calls two child agents +("NY-Project-Manager" and "London-Project-Manager") as tools. Each child uses +the ``time`` MCP server for local time and the ``fetch`` MCP server for a short +news-based update on the given topics. +""" + +import asyncio + +from fast_agent import FastAgent + + +# Create the application +fast = FastAgent("Agents-as-Tools demo") + + +@fast.agent( + name="NY-Project-Manager", + instruction=( + "You are a New York project manager. For each given topic, get the " + "current local time in New York and a brief, project-relevant news " + "summary using the 'time' and 'fetch' MCP servers. If a source returns " + "HTTP 403 or is blocked by robots.txt, try up to five alternative " + "public sources before giving up and clearly state any remaining " + "access limits. Hint: Fast-Agent site: https://fast-agent.ai" + ), + servers=[ + "time", + "fetch", + ], # MCP servers 'time' and 'fetch' configured in fastagent.config.yaml +) +@fast.agent( + name="London-Project-Manager", + instruction=( + "You are a London project manager. For each given topic, get the " + "current local time in London and a brief, project-relevant news " + "summary using the 'time' and 'fetch' MCP servers. If a source returns " + "HTTP 403 or is blocked by robots.txt, try up to five alternative " + "public sources before giving up and clearly state any remaining " + "access limits. Hint: BBC: https://www.bbc.com/ and FT: https://www.ft.com/" + ), + servers=["time", "fetch"], +) +@fast.agent( + name="PMO-orchestrator", + instruction=( + "Get project updates from the New York and London project managers. " + "Ask NY-Project-Manager three times about different projects: Anthropic, " + "evalstate/fast-agent, and OpenAI, and London-Project-Manager for economics review. " + "Return a brief, concise combined summary with clear city/time/topic labels." + ), + default=True, + agents=[ + "NY-Project-Manager", + "London-Project-Manager", + ], # children are exposed as tools: agent__NY-Project-Manager, agent__London-Project-Manager +) +async def main() -> None: + async with fast.run() as agent: + result = await agent("pls send me daily review.") + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/workflows/agents_as_tools_simple.py b/examples/workflows/agents_as_tools_simple.py new file mode 100644 index 000000000..26ff0330e --- /dev/null +++ b/examples/workflows/agents_as_tools_simple.py @@ -0,0 +1,40 @@ +"""Simple Agents-as-Tools PMO example. + +Parent agent ("PMO-orchestrator") calls two child agents ("NY-Project-Manager" +and "London-Project-Manager") as tools. Each child uses the ``time`` MCP +server to include local time in a brief report. +""" + +import asyncio +from fast_agent import FastAgent + +fast = FastAgent("Agents-as-Tools simple demo") + + +@fast.agent( + name="NY-Project-Manager", + instruction="Return current time and project status.", + servers=["time"], # MCP server 'time' configured in fastagent.config.yaml +) +@fast.agent( + name="London-Project-Manager", + instruction="Return current time and news.", + servers=["time"], +) +@fast.agent( + name="PMO-orchestrator", + instruction="Get reports. Separate call per topic. NY: {OpenAI, Fast-Agent, Anthropic}, London: Economics", + default=True, + agents=[ + "NY-Project-Manager", + "London-Project-Manager", + ], # children are exposed as tools: agent__NY-Project-Manager, agent__London-Project-Manager +) +async def main() -> None: + async with fast.run() as agent: + result = await agent("Get PMO report") + print(result) + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/workflows/fastagent.config.yaml b/examples/workflows/fastagent.config.yaml index 714ef9c6c..c73a5f2e6 100644 --- a/examples/workflows/fastagent.config.yaml +++ b/examples/workflows/fastagent.config.yaml @@ -21,3 +21,6 @@ mcp: fetch: command: "uvx" args: ["mcp-server-fetch"] + time: + command: "uvx" + args: ["mcp-server-time"] diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 61208c59c..60b30fdb5 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -197,7 +197,11 @@ async def coordinator(): pass from fast_agent.core.logging.logger import get_logger from fast_agent.core.prompt import Prompt from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL -from fast_agent.mcp.helpers.content_helpers import get_text, is_text_content, text_content +from fast_agent.mcp.helpers.content_helpers import ( + get_text, + is_text_content, + text_content, +) from fast_agent.ui.message_primitives import MessageType from fast_agent.types import PromptMessageExtended, RequestParams @@ -227,7 +231,7 @@ def __init__( **kwargs: Any, ) -> None: """Initialize AgentsAsToolsAgent. - + Args: config: Agent configuration for this parent agent (including MCP servers/tools) agents: List of child agents to expose as tools @@ -248,10 +252,10 @@ def __init__( def _make_tool_name(self, child_name: str) -> str: """Generate a tool name for a child agent. - + Args: child_name: Name of the child agent - + Returns: Prefixed tool name to avoid collisions with MCP tools """ @@ -315,7 +319,11 @@ def _suppress_child_display(self, child: LlmAgent) -> None: child_id = id(child) count = self._display_suppression_count.get(child_id, 0) if 0 == count: - if hasattr(child, "display") and child.display and getattr(child.display, "config", None): + if ( + hasattr(child, "display") + and child.display + and getattr(child.display, "config", None) + ): # Store original config for restoration later self._original_display_configs[child_id] = child.display.config temp_config = copy(child.display.config) @@ -338,7 +346,11 @@ def _release_child_display(self, child: LlmAgent) -> None: if self._display_suppression_count[child_id] <= 0: del self._display_suppression_count[child_id] original_config = self._original_display_configs.pop(child_id, None) - if original_config is not None and hasattr(child, "display") and child.display: + if ( + original_config is not None + and hasattr(child, "display") + and child.display + ): child.display.config = original_config async def _invoke_child_agent( @@ -370,7 +382,9 @@ async def _invoke_child_agent( if suppress_display: self._suppress_child_display(child) - response: PromptMessageExtended = await child.generate([child_request], None) + response: PromptMessageExtended = await child.generate( + [child_request], None + ) # Prefer preserving original content blocks for better UI fidelity content_blocks = list(response.content or []) @@ -395,7 +409,9 @@ async def _invoke_child_agent( self._release_child_display(child) def _resolve_child_agent(self, name: str) -> LlmAgent | None: - return self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name)) + return self._child_agents.get(name) or self._child_agents.get( + self._make_tool_name(name) + ) async def call_tool( self, @@ -419,7 +435,7 @@ async def call_tool( def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: """Display tool call headers for parallel agent execution. - + Args: descriptors: List of tool call descriptors with metadata """ @@ -437,17 +453,17 @@ def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None: tool_name = desc.get("tool", "(unknown)") args = desc.get("args", {}) status = desc.get("status", "pending") - + if status == "error": continue # Skip display for error tools, will show in results - + # Always add individual instance number for clarity display_tool_name = f"{tool_name}[{i}]" - + # Build bottom item for THIS instance only (not all instances) status_label = status_labels.get(status, "pending") bottom_item = f"{display_tool_name} · {status_label}" - + # Show individual tool call with arguments self.display.show_tool_call( name=self.name, @@ -466,11 +482,9 @@ def _summarize_result_text(self, result: CallToolResult) -> str: return text[:180] + "…" if len(text) > 180 else text return "" - def _show_parallel_tool_results( - self, records: list[dict[str, Any]] - ) -> None: + def _show_parallel_tool_results(self, records: list[dict[str, Any]]) -> None: """Display tool result panels for parallel agent execution. - + Args: records: List of result records with descriptor and result data """ @@ -482,11 +496,11 @@ def _show_parallel_tool_results( descriptor = record.get("descriptor", {}) result = record.get("result") tool_name = descriptor.get("tool", "(unknown)") - + if result: # Always add individual instance number for clarity display_tool_name = f"{tool_name}[{i}]" - + # Show individual tool result with full content self.display.show_tool_result( name=self.name, @@ -509,13 +523,19 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend if not child_ids: return await super().run_tools(request) - child_results, child_error = await self._run_child_tools(request, set(child_ids)) + child_results, child_error = await self._run_child_tools( + request, set(child_ids) + ) if len(child_ids) == len(request.tool_calls): - return self._finalize_tool_results(child_results, tool_loop_error=child_error) + return self._finalize_tool_results( + child_results, tool_loop_error=child_error + ) # Execute remaining MCP/local tools via base implementation - remaining_ids = [cid for cid in request.tool_calls.keys() if cid not in child_ids] + remaining_ids = [ + cid for cid in request.tool_calls.keys() if cid not in child_ids + ] mcp_request = PromptMessageExtended( role=request.role, content=request.content, @@ -530,7 +550,9 @@ async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtend combined_results.update(mcp_results) tool_loop_error = child_error or mcp_error - return self._finalize_tool_results(combined_results, tool_loop_error=tool_loop_error) + return self._finalize_tool_results( + combined_results, tool_loop_error=tool_loop_error + ) async def _run_child_tools( self, @@ -572,7 +594,10 @@ async def _run_child_tools( call_descriptors.append(descriptor) descriptor_by_id[correlation_id] = descriptor - if tool_name not in available_tools and self._make_tool_name(tool_name) not in available_tools: + if ( + tool_name not in available_tools + and self._make_tool_name(tool_name) not in available_tools + ): error_message = f"Tool '{tool_name}' is not available" tool_results[correlation_id] = CallToolResult( content=[text_content(error_message)], isError=True @@ -585,7 +610,9 @@ async def _run_child_tools( id_list.append(correlation_id) from fast_agent.event_progress import ProgressAction, ProgressEvent - from fast_agent.ui.progress_display import progress_display as outer_progress_display + from fast_agent.ui.progress_display import ( + progress_display as outer_progress_display, + ) async def call_with_instance_name( tool_name: str, tool_args: dict[str, Any], instance: int @@ -609,7 +636,9 @@ async def call_with_instance_name( "error": str(exc), }, ) - return CallToolResult(content=[text_content(f"Spawn failed: {exc}")], isError=True) + return CallToolResult( + content=[text_content(f"Spawn failed: {exc}")], isError=True + ) progress_started = False try: @@ -657,7 +686,9 @@ async def call_with_instance_name( for i, cid in enumerate(id_list, 1): tool_name = descriptor_by_id[cid]["tool"] tool_args = descriptor_by_id[cid]["args"] - tasks.append(asyncio.create_task(call_with_instance_name(tool_name, tool_args, i))) + tasks.append( + asyncio.create_task(call_with_instance_name(tool_name, tool_args, i)) + ) self._show_parallel_tool_calls(call_descriptors) @@ -675,7 +706,9 @@ async def call_with_instance_name( descriptor_by_id[correlation_id]["error_message"] = msg else: tool_results[correlation_id] = result - descriptor_by_id[correlation_id]["status"] = "error" if result.isError else "done" + descriptor_by_id[correlation_id]["status"] = ( + "error" if result.isError else "done" + ) ordered_records: list[dict[str, Any]] = [] for cid in id_list: From e2779f988f7bfc1a4e16d17b240516d46796f248 Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 07:23:42 +0300 Subject: [PATCH 43/44] Document AgentsAsToolsAgent and polish parallel tool UI - Expand module docstring with Agents-as-Tools rationale, algorithm, and progress/usage semantics. - Add minimal decorator-based usage example showing agents=[...] pattern. - Add GitHub-style links to design doc, docs repo, OpenAI Agents SDK, and issue #458 for future readers. - Keep runtime behavior unchanged apart from clearer structure and black formatting (no logic changes). --- src/fast_agent/agents/workflow/agents_as_tools_agent.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index 60b30fdb5..c3bfd10ee 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -168,7 +168,7 @@ async def writer(): pass @fast.agent( name="coordinator", instruction="Coordinate research and writing", - child_agents=["researcher", "writer"] # Exposes children as tools + agents=["researcher", "writer"], # Exposes children as tools ) async def coordinator(): pass ``` @@ -177,8 +177,10 @@ async def coordinator(): pass References ---------- -- OpenAI Agents SDK: https://openai.github.io/openai-agents-python/tools -- GitHub Issue: https://github.com/evalstate/fast-agent/issues/XXX +- Design doc: ``agetns_as_tools_plan_scratch.md`` (repo root). +- Docs: [`evalstate/fast-agent-docs`](https://github.com/evalstate/fast-agent-docs) (Agents-as-Tools section). +- OpenAI Agents SDK: +- GitHub Issue: [#458](https://github.com/evalstate/fast-agent/issues/458) """ from __future__ import annotations From 57295ffdbc07f14e509cb662cd376c5e54f48ecb Mon Sep 17 00:00:00 2001 From: Valeriy_Pavlovich Date: Mon, 24 Nov 2025 08:58:37 +0300 Subject: [PATCH 44/44] Finalize Agents-as-Tools PMO examples and hybrid agent docs - Add simple and extended PMO Agents-as-Tools workflows using local time/fetch MCP servers. - Document AgentsAsToolsAgent behavior and architecture in README and module docstring. - Wire detached clone support via LlmDecorator.spawn_detached_instance and merge_usage_from. - Fix import ordering and type-checking-only imports so scripts/lint.py passes cleanly. --- examples/workflows/agents_as_tools_extended.py | 1 - examples/workflows/agents_as_tools_simple.py | 1 + src/fast_agent/agents/llm_decorator.py | 2 ++ .../agents/workflow/agents_as_tools_agent.py | 13 +++++++------ 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/examples/workflows/agents_as_tools_extended.py b/examples/workflows/agents_as_tools_extended.py index 7ab991041..c3d951076 100644 --- a/examples/workflows/agents_as_tools_extended.py +++ b/examples/workflows/agents_as_tools_extended.py @@ -10,7 +10,6 @@ from fast_agent import FastAgent - # Create the application fast = FastAgent("Agents-as-Tools demo") diff --git a/examples/workflows/agents_as_tools_simple.py b/examples/workflows/agents_as_tools_simple.py index 26ff0330e..e81671b4e 100644 --- a/examples/workflows/agents_as_tools_simple.py +++ b/examples/workflows/agents_as_tools_simple.py @@ -6,6 +6,7 @@ """ import asyncio + from fast_agent import FastAgent fast = FastAgent("Agents-as-Tools simple demo") diff --git a/src/fast_agent/agents/llm_decorator.py b/src/fast_agent/agents/llm_decorator.py index e1e27bb4a..d619ac4ee 100644 --- a/src/fast_agent/agents/llm_decorator.py +++ b/src/fast_agent/agents/llm_decorator.py @@ -20,6 +20,8 @@ if TYPE_CHECKING: from rich.text import Text + from fast_agent.agents.llm_agent import LlmAgent + from a2a.types import AgentCard from mcp import ListToolsResult, Tool from mcp.types import ( diff --git a/src/fast_agent/agents/workflow/agents_as_tools_agent.py b/src/fast_agent/agents/workflow/agents_as_tools_agent.py index c3bfd10ee..8ebc92fdc 100644 --- a/src/fast_agent/agents/workflow/agents_as_tools_agent.py +++ b/src/fast_agent/agents/workflow/agents_as_tools_agent.py @@ -188,24 +188,25 @@ async def coordinator(): pass import asyncio import json from copy import copy -from typing import Any +from typing import TYPE_CHECKING, Any from mcp import ListToolsResult, Tool from mcp.types import CallToolResult -from fast_agent.agents.agent_types import AgentConfig -from fast_agent.agents.llm_agent import LlmAgent from fast_agent.agents.mcp_agent import McpAgent +from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL from fast_agent.core.logging.logger import get_logger from fast_agent.core.prompt import Prompt -from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL from fast_agent.mcp.helpers.content_helpers import ( get_text, is_text_content, text_content, ) -from fast_agent.ui.message_primitives import MessageType -from fast_agent.types import PromptMessageExtended, RequestParams +from fast_agent.types import PromptMessageExtended + +if TYPE_CHECKING: + from fast_agent.agents.agent_types import AgentConfig + from fast_agent.agents.llm_agent import LlmAgent logger = get_logger(__name__)