fast-agent-mcp 0.4.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fast_agent/__init__.py +183 -0
- fast_agent/acp/__init__.py +19 -0
- fast_agent/acp/acp_aware_mixin.py +304 -0
- fast_agent/acp/acp_context.py +437 -0
- fast_agent/acp/content_conversion.py +136 -0
- fast_agent/acp/filesystem_runtime.py +427 -0
- fast_agent/acp/permission_store.py +269 -0
- fast_agent/acp/server/__init__.py +5 -0
- fast_agent/acp/server/agent_acp_server.py +1472 -0
- fast_agent/acp/slash_commands.py +1050 -0
- fast_agent/acp/terminal_runtime.py +408 -0
- fast_agent/acp/tool_permission_adapter.py +125 -0
- fast_agent/acp/tool_permissions.py +474 -0
- fast_agent/acp/tool_progress.py +814 -0
- fast_agent/agents/__init__.py +85 -0
- fast_agent/agents/agent_types.py +64 -0
- fast_agent/agents/llm_agent.py +350 -0
- fast_agent/agents/llm_decorator.py +1139 -0
- fast_agent/agents/mcp_agent.py +1337 -0
- fast_agent/agents/tool_agent.py +271 -0
- fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
- fast_agent/agents/workflow/chain_agent.py +212 -0
- fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
- fast_agent/agents/workflow/iterative_planner.py +652 -0
- fast_agent/agents/workflow/maker_agent.py +379 -0
- fast_agent/agents/workflow/orchestrator_models.py +218 -0
- fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
- fast_agent/agents/workflow/parallel_agent.py +250 -0
- fast_agent/agents/workflow/router_agent.py +353 -0
- fast_agent/cli/__init__.py +0 -0
- fast_agent/cli/__main__.py +73 -0
- fast_agent/cli/commands/acp.py +159 -0
- fast_agent/cli/commands/auth.py +404 -0
- fast_agent/cli/commands/check_config.py +783 -0
- fast_agent/cli/commands/go.py +514 -0
- fast_agent/cli/commands/quickstart.py +557 -0
- fast_agent/cli/commands/serve.py +143 -0
- fast_agent/cli/commands/server_helpers.py +114 -0
- fast_agent/cli/commands/setup.py +174 -0
- fast_agent/cli/commands/url_parser.py +190 -0
- fast_agent/cli/constants.py +40 -0
- fast_agent/cli/main.py +115 -0
- fast_agent/cli/terminal.py +24 -0
- fast_agent/config.py +798 -0
- fast_agent/constants.py +41 -0
- fast_agent/context.py +279 -0
- fast_agent/context_dependent.py +50 -0
- fast_agent/core/__init__.py +92 -0
- fast_agent/core/agent_app.py +448 -0
- fast_agent/core/core_app.py +137 -0
- fast_agent/core/direct_decorators.py +784 -0
- fast_agent/core/direct_factory.py +620 -0
- fast_agent/core/error_handling.py +27 -0
- fast_agent/core/exceptions.py +90 -0
- fast_agent/core/executor/__init__.py +0 -0
- fast_agent/core/executor/executor.py +280 -0
- fast_agent/core/executor/task_registry.py +32 -0
- fast_agent/core/executor/workflow_signal.py +324 -0
- fast_agent/core/fastagent.py +1186 -0
- fast_agent/core/logging/__init__.py +5 -0
- fast_agent/core/logging/events.py +138 -0
- fast_agent/core/logging/json_serializer.py +164 -0
- fast_agent/core/logging/listeners.py +309 -0
- fast_agent/core/logging/logger.py +278 -0
- fast_agent/core/logging/transport.py +481 -0
- fast_agent/core/prompt.py +9 -0
- fast_agent/core/prompt_templates.py +183 -0
- fast_agent/core/validation.py +326 -0
- fast_agent/event_progress.py +62 -0
- fast_agent/history/history_exporter.py +49 -0
- fast_agent/human_input/__init__.py +47 -0
- fast_agent/human_input/elicitation_handler.py +123 -0
- fast_agent/human_input/elicitation_state.py +33 -0
- fast_agent/human_input/form_elements.py +59 -0
- fast_agent/human_input/form_fields.py +256 -0
- fast_agent/human_input/simple_form.py +113 -0
- fast_agent/human_input/types.py +40 -0
- fast_agent/interfaces.py +310 -0
- fast_agent/llm/__init__.py +9 -0
- fast_agent/llm/cancellation.py +22 -0
- fast_agent/llm/fastagent_llm.py +931 -0
- fast_agent/llm/internal/passthrough.py +161 -0
- fast_agent/llm/internal/playback.py +129 -0
- fast_agent/llm/internal/silent.py +41 -0
- fast_agent/llm/internal/slow.py +38 -0
- fast_agent/llm/memory.py +275 -0
- fast_agent/llm/model_database.py +490 -0
- fast_agent/llm/model_factory.py +388 -0
- fast_agent/llm/model_info.py +102 -0
- fast_agent/llm/prompt_utils.py +155 -0
- fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
- fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
- fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
- fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
- fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
- fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
- fast_agent/llm/provider/google/google_converter.py +466 -0
- fast_agent/llm/provider/google/llm_google_native.py +681 -0
- fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
- fast_agent/llm/provider/openai/llm_azure.py +143 -0
- fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
- fast_agent/llm/provider/openai/llm_generic.py +35 -0
- fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
- fast_agent/llm/provider/openai/llm_groq.py +42 -0
- fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
- fast_agent/llm/provider/openai/llm_openai.py +1195 -0
- fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
- fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
- fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
- fast_agent/llm/provider/openai/llm_xai.py +38 -0
- fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
- fast_agent/llm/provider/openai/openai_multipart.py +169 -0
- fast_agent/llm/provider/openai/openai_utils.py +67 -0
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/llm/provider_key_manager.py +139 -0
- fast_agent/llm/provider_types.py +34 -0
- fast_agent/llm/request_params.py +61 -0
- fast_agent/llm/sampling_converter.py +98 -0
- fast_agent/llm/stream_types.py +9 -0
- fast_agent/llm/usage_tracking.py +445 -0
- fast_agent/mcp/__init__.py +56 -0
- fast_agent/mcp/common.py +26 -0
- fast_agent/mcp/elicitation_factory.py +84 -0
- fast_agent/mcp/elicitation_handlers.py +164 -0
- fast_agent/mcp/gen_client.py +83 -0
- fast_agent/mcp/helpers/__init__.py +36 -0
- fast_agent/mcp/helpers/content_helpers.py +352 -0
- fast_agent/mcp/helpers/server_config_helpers.py +25 -0
- fast_agent/mcp/hf_auth.py +147 -0
- fast_agent/mcp/interfaces.py +92 -0
- fast_agent/mcp/logger_textio.py +108 -0
- fast_agent/mcp/mcp_agent_client_session.py +411 -0
- fast_agent/mcp/mcp_aggregator.py +2175 -0
- fast_agent/mcp/mcp_connection_manager.py +723 -0
- fast_agent/mcp/mcp_content.py +262 -0
- fast_agent/mcp/mime_utils.py +108 -0
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/mcp/prompt.py +159 -0
- fast_agent/mcp/prompt_message_extended.py +155 -0
- fast_agent/mcp/prompt_render.py +84 -0
- fast_agent/mcp/prompt_serialization.py +580 -0
- fast_agent/mcp/prompts/__init__.py +0 -0
- fast_agent/mcp/prompts/__main__.py +7 -0
- fast_agent/mcp/prompts/prompt_constants.py +18 -0
- fast_agent/mcp/prompts/prompt_helpers.py +238 -0
- fast_agent/mcp/prompts/prompt_load.py +186 -0
- fast_agent/mcp/prompts/prompt_server.py +552 -0
- fast_agent/mcp/prompts/prompt_template.py +438 -0
- fast_agent/mcp/resource_utils.py +215 -0
- fast_agent/mcp/sampling.py +200 -0
- fast_agent/mcp/server/__init__.py +4 -0
- fast_agent/mcp/server/agent_server.py +613 -0
- fast_agent/mcp/skybridge.py +44 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/tool_execution_handler.py +137 -0
- fast_agent/mcp/tool_permission_handler.py +88 -0
- fast_agent/mcp/transport_tracking.py +634 -0
- fast_agent/mcp/types.py +24 -0
- fast_agent/mcp/ui_agent.py +48 -0
- fast_agent/mcp/ui_mixin.py +209 -0
- fast_agent/mcp_server_registry.py +89 -0
- fast_agent/py.typed +0 -0
- fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
- fast_agent/resources/examples/data-analysis/analysis.py +68 -0
- fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
- fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
- fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
- fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
- fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
- fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
- fast_agent/resources/examples/researcher/researcher.py +36 -0
- fast_agent/resources/examples/tensorzero/.env.sample +2 -0
- fast_agent/resources/examples/tensorzero/Makefile +31 -0
- fast_agent/resources/examples/tensorzero/README.md +56 -0
- fast_agent/resources/examples/tensorzero/agent.py +35 -0
- fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
- fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
- fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
- fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
- fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
- fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
- fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
- fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
- fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
- fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
- fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
- fast_agent/resources/examples/workflows/chaining.py +37 -0
- fast_agent/resources/examples/workflows/evaluator.py +77 -0
- fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
- fast_agent/resources/examples/workflows/graded_report.md +89 -0
- fast_agent/resources/examples/workflows/human_input.py +28 -0
- fast_agent/resources/examples/workflows/maker.py +156 -0
- fast_agent/resources/examples/workflows/orchestrator.py +70 -0
- fast_agent/resources/examples/workflows/parallel.py +56 -0
- fast_agent/resources/examples/workflows/router.py +69 -0
- fast_agent/resources/examples/workflows/short_story.md +13 -0
- fast_agent/resources/examples/workflows/short_story.txt +19 -0
- fast_agent/resources/setup/.gitignore +30 -0
- fast_agent/resources/setup/agent.py +28 -0
- fast_agent/resources/setup/fastagent.config.yaml +65 -0
- fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
- fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +235 -0
- fast_agent/tools/elicitation.py +369 -0
- fast_agent/tools/shell_runtime.py +402 -0
- fast_agent/types/__init__.py +59 -0
- fast_agent/types/conversation_summary.py +294 -0
- fast_agent/types/llm_stop_reason.py +78 -0
- fast_agent/types/message_search.py +249 -0
- fast_agent/ui/__init__.py +38 -0
- fast_agent/ui/console.py +59 -0
- fast_agent/ui/console_display.py +1080 -0
- fast_agent/ui/elicitation_form.py +946 -0
- fast_agent/ui/elicitation_style.py +59 -0
- fast_agent/ui/enhanced_prompt.py +1400 -0
- fast_agent/ui/history_display.py +734 -0
- fast_agent/ui/interactive_prompt.py +1199 -0
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +1004 -0
- fast_agent/ui/mcp_display.py +857 -0
- fast_agent/ui/mcp_ui_utils.py +235 -0
- fast_agent/ui/mermaid_utils.py +169 -0
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/notification_tracker.py +205 -0
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/progress_display.py +10 -0
- fast_agent/ui/rich_progress.py +195 -0
- fast_agent/ui/streaming.py +774 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- fast_agent/ui/tool_display.py +422 -0
- fast_agent/ui/usage_display.py +204 -0
- fast_agent/utils/__init__.py +5 -0
- fast_agent/utils/reasoning_stream_parser.py +77 -0
- fast_agent/utils/time.py +22 -0
- fast_agent/workflow_telemetry.py +261 -0
- fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
- fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
- fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
- fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
- fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agents as Tools Pattern Implementation
|
|
3
|
+
=======================================
|
|
4
|
+
|
|
5
|
+
Overview
|
|
6
|
+
--------
|
|
7
|
+
This module implements the "Agents as Tools" pattern, inspired by OpenAI's Agents SDK
|
|
8
|
+
(https://openai.github.io/openai-agents-python/tools). It allows child agents to be
|
|
9
|
+
exposed as callable tools to a parent agent, enabling hierarchical agent composition
|
|
10
|
+
without the complexity of traditional orchestrator patterns. The current implementation
|
|
11
|
+
goes a step further by spawning **detached per-call clones** of every child so that each
|
|
12
|
+
parallel execution has its own LLM + MCP stack, eliminating name overrides and shared
|
|
13
|
+
state hacks.
|
|
14
|
+
|
|
15
|
+
Rationale
|
|
16
|
+
---------
|
|
17
|
+
Traditional approaches to multi-agent systems often require:
|
|
18
|
+
1. Complex orchestration logic with explicit routing rules
|
|
19
|
+
2. Iterative planning mechanisms that add cognitive overhead
|
|
20
|
+
3. Tight coupling between parent and child agent implementations
|
|
21
|
+
|
|
22
|
+
The "Agents as Tools" pattern simplifies this by:
|
|
23
|
+
- **Treating agents as first-class tools**: Each child agent becomes a tool that the
|
|
24
|
+
parent LLM can call naturally via function calling
|
|
25
|
+
- **Delegation, not orchestration**: The parent LLM decides which child agents to invoke
|
|
26
|
+
based on its instruction and context, without hardcoded routing logic
|
|
27
|
+
- **Parallel execution**: Multiple child agents can run concurrently when the LLM makes
|
|
28
|
+
parallel tool calls
|
|
29
|
+
- **Clean abstraction**: Child agents expose minimal schemas (text or JSON input),
|
|
30
|
+
making them universally composable
|
|
31
|
+
|
|
32
|
+
Benefits over iterative_planner/orchestrator:
|
|
33
|
+
- Simpler codebase: No custom planning loops or routing tables
|
|
34
|
+
- Better LLM utilization: Modern LLMs excel at function calling
|
|
35
|
+
- Natural composition: Agents nest cleanly without special handling
|
|
36
|
+
- Parallel by default: Leverage asyncio.gather for concurrent execution
|
|
37
|
+
|
|
38
|
+
Algorithm
|
|
39
|
+
---------
|
|
40
|
+
1. **Initialization**
|
|
41
|
+
- `AgentsAsToolsAgent` is itself an `McpAgent` (with its own MCP servers + tools) and receives a list of **child agents**.
|
|
42
|
+
- Each child agent is mapped to a synthetic tool name: `agent__{child_name}`.
|
|
43
|
+
- Child tool schemas advertise text/json input capabilities.
|
|
44
|
+
|
|
45
|
+
2. **Tool Discovery (list_tools)**
|
|
46
|
+
- `list_tools()` starts from the base `McpAgent.list_tools()` (MCP + local tools).
|
|
47
|
+
- Synthetic child tools `agent__ChildName` are added on top when their names do not collide with existing tools.
|
|
48
|
+
- The parent LLM therefore sees a **merged surface**: MCP tools and agent-tools in a single list.
|
|
49
|
+
|
|
50
|
+
3. **Tool Execution (call_tool)**
|
|
51
|
+
- If the requested tool name resolves to a child agent (either `child_name` or `agent__child_name`):
|
|
52
|
+
- Convert tool arguments (text or JSON) to a child user message.
|
|
53
|
+
- Execute via detached clones created inside `run_tools` (see below).
|
|
54
|
+
- Responses are converted to `CallToolResult` objects (errors propagate as `isError=True`).
|
|
55
|
+
- Otherwise, delegate to the base `McpAgent.call_tool` implementation (MCP tools, shell, human-input, etc.).
|
|
56
|
+
|
|
57
|
+
4. **Parallel Execution (run_tools)**
|
|
58
|
+
- Collect all tool calls from the parent LLM response.
|
|
59
|
+
- Partition them into **child-agent tools** and **regular MCP/local tools**.
|
|
60
|
+
- Child-agent tools are executed in parallel:
|
|
61
|
+
- For each child tool call, spawn a detached clone with its own LLM + MCP aggregator and suffixed name.
|
|
62
|
+
- Emit `ProgressAction.CHATTING` / `ProgressAction.FINISHED` events for each instance and keep parent status untouched.
|
|
63
|
+
- Merge each clone's usage back into the template child after shutdown.
|
|
64
|
+
- Remaining MCP/local tools are delegated to `McpAgent.run_tools()`.
|
|
65
|
+
- 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.
|
|
66
|
+
|
|
67
|
+
Progress Panel Behavior
|
|
68
|
+
-----------------------
|
|
69
|
+
To provide clear visibility into parallel executions, the progress panel (left status
|
|
70
|
+
table) undergoes dynamic updates:
|
|
71
|
+
|
|
72
|
+
**Before parallel execution:**
|
|
73
|
+
```
|
|
74
|
+
▎▶ Chatting ▎ PM-1-DayStatusSummarizer gpt-5 turn 1
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**During parallel execution (2+ instances):**
|
|
78
|
+
- Parent line stays in whatever lifecycle state it already had; no forced "Ready" flips.
|
|
79
|
+
- New lines appear for each detached instance with suffixed names:
|
|
80
|
+
```
|
|
81
|
+
▎▶ Chatting ▎ PM-1-DayStatusSummarizer[1] gpt-5 turn 2
|
|
82
|
+
▎▶ Calling tool ▎ PM-1-DayStatusSummarizer[2] tg-ro (list_messages)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Key implementation details:**
|
|
86
|
+
- Each clone advertises its own `agent_name` (e.g., `OriginalName[instance_number]`).
|
|
87
|
+
- MCP progress events originate from the clone's aggregator, so tool activity always shows under the suffixed name.
|
|
88
|
+
- Parent status lines remain visible for context while children run.
|
|
89
|
+
|
|
90
|
+
**As each instance completes:**
|
|
91
|
+
- We emit `ProgressAction.FINISHED` with elapsed time, keeping the line in the panel for auditability.
|
|
92
|
+
- Other instances continue showing their independent progress until they also finish.
|
|
93
|
+
|
|
94
|
+
**After all parallel executions complete:**
|
|
95
|
+
- Finished instance lines remain until the parent agent moves on, giving a full record of what ran.
|
|
96
|
+
- Parent and child template names stay untouched because clones carry the suffixed identity.
|
|
97
|
+
|
|
98
|
+
- **Instance line visibility**: We now leave finished instance lines visible (marked `FINISHED`)
|
|
99
|
+
instead of hiding them immediately, preserving a full audit trail of parallel runs.
|
|
100
|
+
- **Chat log separation**: Each parallel instance gets its own tool request/result headers
|
|
101
|
+
with instance numbers [1], [2], etc. for traceability.
|
|
102
|
+
|
|
103
|
+
Stats and Usage Semantics
|
|
104
|
+
-------------------------
|
|
105
|
+
- Each detached clone accrues usage on its own `UsageAccumulator`; after shutdown we
|
|
106
|
+
call `child.merge_usage_from(clone)` so template agents retain consolidated totals.
|
|
107
|
+
- Runtime events (logs, MCP progress, chat headers) use the suffixed clone names,
|
|
108
|
+
ensuring per-instance traceability even though usage rolls up to the template.
|
|
109
|
+
- The CLI *Usage Summary* table still reports one row per template agent
|
|
110
|
+
(for example, `PM-1-DayStatusSummarizer`), not per `[i]` instance; clones are
|
|
111
|
+
runtime-only and do not appear as separate agents in that table.
|
|
112
|
+
|
|
113
|
+
**Chat log display:**
|
|
114
|
+
Tool headers show instance numbers for clarity:
|
|
115
|
+
```
|
|
116
|
+
▎▶ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[1]]
|
|
117
|
+
▎◀ orchestrator [tool result - agent__PM-1-DayStatusSummarizer[1]]
|
|
118
|
+
▎▶ orchestrator [tool request - agent__PM-1-DayStatusSummarizer[2]]
|
|
119
|
+
▎◀ orchestrator [tool result - agent__PM-1-DayStatusSummarizer[2]]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Bottom status bar shows all instances:
|
|
123
|
+
```
|
|
124
|
+
| agent__PM-1-DayStatusSummarizer[1] · running | agent__PM-1-DayStatusSummarizer[2] · running |
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Implementation Notes
|
|
128
|
+
--------------------
|
|
129
|
+
- **Instance naming**: `run_tools` computes `instance_name = f"{child.name}[i]"` inside the
|
|
130
|
+
per-call wrapper and passes it into `spawn_detached_instance`, so the template child object
|
|
131
|
+
keeps its original name while each detached clone owns the suffixed identity.
|
|
132
|
+
- **Progress event routing**: Because each clone's `MCPAggregator` is constructed with the
|
|
133
|
+
suffixed `agent_name`, all MCP/tool progress events naturally use
|
|
134
|
+
`PM-1-DayStatusSummarizer[i]` without mutating base agent fields or using `ContextVar` hacks.
|
|
135
|
+
- **Display suppression with reference counting**: Multiple parallel instances of the same
|
|
136
|
+
child agent share a single agent object. Use reference counting to track active instances:
|
|
137
|
+
- `_display_suppression_count[child_id]`: Count of active parallel instances
|
|
138
|
+
- `_original_display_configs[child_id]`: Stored original config
|
|
139
|
+
- Only modify display config when first instance starts (count 0→1)
|
|
140
|
+
- Only restore display config when last instance completes (count 1→0)
|
|
141
|
+
- Prevents race condition where early-finishing instances restore config while others run
|
|
142
|
+
- **Child agent(s)**
|
|
143
|
+
- Existing agents (typically `McpAgent`-based) with their own MCP servers, skills, tools, etc.
|
|
144
|
+
- Serve as **templates**; `run_tools` now clones them before every tool call via
|
|
145
|
+
`spawn_detached_instance`, so runtime work happens inside short-lived replicas.
|
|
146
|
+
|
|
147
|
+
- **Detached instances**
|
|
148
|
+
- Each tool call gets an actual cloned agent with suffixed name `Child[i]`.
|
|
149
|
+
- Clones own their MCP aggregator/LLM stacks and merge usage back into the template after shutdown.
|
|
150
|
+
- **Chat log separation**: Each parallel instance gets its own tool request/result headers
|
|
151
|
+
with instance numbers [1], [2], etc. for traceability
|
|
152
|
+
|
|
153
|
+
Usage Example
|
|
154
|
+
-------------
|
|
155
|
+
```python
|
|
156
|
+
from fast_agent import FastAgent
|
|
157
|
+
|
|
158
|
+
fast = FastAgent("parent")
|
|
159
|
+
|
|
160
|
+
# Define child agents
|
|
161
|
+
@fast.agent(name="researcher", instruction="Research topics")
|
|
162
|
+
async def researcher(): pass
|
|
163
|
+
|
|
164
|
+
@fast.agent(name="writer", instruction="Write content")
|
|
165
|
+
async def writer(): pass
|
|
166
|
+
|
|
167
|
+
# Define parent with agents-as-tools
|
|
168
|
+
@fast.agent(
|
|
169
|
+
name="coordinator",
|
|
170
|
+
instruction="Coordinate research and writing",
|
|
171
|
+
agents=["researcher", "writer"], # Exposes children as tools
|
|
172
|
+
)
|
|
173
|
+
async def coordinator(): pass
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The parent LLM can now naturally call researcher and writer as tools.
|
|
177
|
+
|
|
178
|
+
References
|
|
179
|
+
----------
|
|
180
|
+
- Design doc: ``agetns_as_tools_plan_scratch.md`` (repo root).
|
|
181
|
+
- Docs: [`evalstate/fast-agent-docs`](https://github.com/evalstate/fast-agent-docs) (Agents-as-Tools section).
|
|
182
|
+
- OpenAI Agents SDK: <https://openai.github.io/openai-agents-python/tools>
|
|
183
|
+
- GitHub Issue: [#458](https://github.com/evalstate/fast-agent/issues/458)
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
from __future__ import annotations
|
|
187
|
+
|
|
188
|
+
import asyncio
|
|
189
|
+
import json
|
|
190
|
+
from contextlib import contextmanager, nullcontext
|
|
191
|
+
from copy import copy
|
|
192
|
+
from dataclasses import dataclass
|
|
193
|
+
from enum import Enum
|
|
194
|
+
from typing import TYPE_CHECKING, Any
|
|
195
|
+
|
|
196
|
+
from mcp import ListToolsResult, Tool
|
|
197
|
+
from mcp.types import CallToolResult
|
|
198
|
+
|
|
199
|
+
from fast_agent.agents.mcp_agent import McpAgent
|
|
200
|
+
from fast_agent.constants import FAST_AGENT_ERROR_CHANNEL
|
|
201
|
+
from fast_agent.core.logging.logger import get_logger
|
|
202
|
+
from fast_agent.core.prompt import Prompt
|
|
203
|
+
from fast_agent.mcp.helpers.content_helpers import get_text, text_content
|
|
204
|
+
from fast_agent.types import PromptMessageExtended
|
|
205
|
+
|
|
206
|
+
if TYPE_CHECKING:
|
|
207
|
+
from fast_agent.agents.agent_types import AgentConfig
|
|
208
|
+
from fast_agent.agents.llm_agent import LlmAgent
|
|
209
|
+
|
|
210
|
+
logger = get_logger(__name__)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class HistoryMode(str, Enum):
|
|
214
|
+
"""History handling for detached child instances."""
|
|
215
|
+
|
|
216
|
+
SCRATCH = "scratch"
|
|
217
|
+
FORK = "fork"
|
|
218
|
+
FORK_AND_MERGE = "fork_and_merge"
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
def from_input(cls, value: Any | None) -> HistoryMode:
|
|
222
|
+
if value is None:
|
|
223
|
+
return cls.FORK
|
|
224
|
+
if isinstance(value, cls):
|
|
225
|
+
return value
|
|
226
|
+
try:
|
|
227
|
+
return cls(str(value))
|
|
228
|
+
except Exception:
|
|
229
|
+
return cls.FORK
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(kw_only=True)
|
|
233
|
+
class AgentsAsToolsOptions:
|
|
234
|
+
"""Configuration knobs for the Agents-as-Tools wrapper.
|
|
235
|
+
|
|
236
|
+
Defaults:
|
|
237
|
+
- history_mode: fork child history (no merge back)
|
|
238
|
+
- max_parallel: None (no cap; caller may set an explicit limit)
|
|
239
|
+
- child_timeout_sec: None (no per-child timeout)
|
|
240
|
+
- max_display_instances: 20 (show first N lines, collapse the rest)
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
history_mode: HistoryMode = HistoryMode.FORK
|
|
244
|
+
max_parallel: int | None = None
|
|
245
|
+
child_timeout_sec: int | None = None
|
|
246
|
+
max_display_instances: int = 20
|
|
247
|
+
|
|
248
|
+
def __post_init__(self) -> None:
|
|
249
|
+
self.history_mode = HistoryMode.from_input(self.history_mode)
|
|
250
|
+
if self.max_parallel is not None and self.max_parallel <= 0:
|
|
251
|
+
raise ValueError("max_parallel must be > 0 when set")
|
|
252
|
+
if self.max_display_instances is not None and self.max_display_instances <= 0:
|
|
253
|
+
raise ValueError("max_display_instances must be > 0")
|
|
254
|
+
if self.child_timeout_sec is not None and self.child_timeout_sec <= 0:
|
|
255
|
+
raise ValueError("child_timeout_sec must be > 0 when set")
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class AgentsAsToolsAgent(McpAgent):
|
|
259
|
+
"""MCP-enabled agent that exposes child agents as additional tools.
|
|
260
|
+
|
|
261
|
+
This hybrid agent:
|
|
262
|
+
|
|
263
|
+
- Inherits all MCP behavior from :class:`McpAgent` (servers, MCP tool discovery, local tools).
|
|
264
|
+
- Exposes each child agent as an additional synthetic tool (`agent__ChildName`).
|
|
265
|
+
- Merges **MCP tools** and **agent-tools** into a single `list_tools()` surface.
|
|
266
|
+
- Routes `call_tool()` to child agents when the name matches a child, otherwise delegates
|
|
267
|
+
to the base `McpAgent.call_tool` implementation.
|
|
268
|
+
- Overrides `run_tools()` to fan out child-agent tools in parallel using detached clones,
|
|
269
|
+
while delegating any remaining MCP/local tools to the base `McpAgent.run_tools` and
|
|
270
|
+
merging all results into a single tool-loop response.
|
|
271
|
+
"""
|
|
272
|
+
|
|
273
|
+
def __init__(
|
|
274
|
+
self,
|
|
275
|
+
config: AgentConfig,
|
|
276
|
+
agents: list[LlmAgent],
|
|
277
|
+
options: AgentsAsToolsOptions | None = None,
|
|
278
|
+
context: Any | None = None,
|
|
279
|
+
**kwargs: Any,
|
|
280
|
+
) -> None:
|
|
281
|
+
"""Initialize AgentsAsToolsAgent.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
config: Agent configuration for this parent agent (including MCP servers/tools)
|
|
285
|
+
agents: List of child agents to expose as tools
|
|
286
|
+
context: Optional context for agent execution
|
|
287
|
+
**kwargs: Additional arguments passed through to :class:`McpAgent` and its bases
|
|
288
|
+
"""
|
|
289
|
+
super().__init__(config=config, context=context, **kwargs)
|
|
290
|
+
self._options = options or AgentsAsToolsOptions()
|
|
291
|
+
self._child_agents: dict[str, LlmAgent] = {}
|
|
292
|
+
self._history_merge_lock = asyncio.Lock()
|
|
293
|
+
self._display_suppression_count: dict[int, int] = {}
|
|
294
|
+
self._original_display_configs: dict[int, Any] = {}
|
|
295
|
+
|
|
296
|
+
for child in agents:
|
|
297
|
+
tool_name = self._make_tool_name(child.name)
|
|
298
|
+
if tool_name in self._child_agents:
|
|
299
|
+
logger.warning(
|
|
300
|
+
f"Duplicate tool name '{tool_name}' for child agent '{child.name}', overwriting"
|
|
301
|
+
)
|
|
302
|
+
self._child_agents[tool_name] = child
|
|
303
|
+
|
|
304
|
+
def _make_tool_name(self, child_name: str) -> str:
|
|
305
|
+
"""Generate a tool name for a child agent.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
child_name: Name of the child agent
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Prefixed tool name to avoid collisions with MCP tools
|
|
312
|
+
"""
|
|
313
|
+
return f"agent__{child_name}"
|
|
314
|
+
|
|
315
|
+
async def initialize(self) -> None:
|
|
316
|
+
"""Initialize this agent and all child agents."""
|
|
317
|
+
await super().initialize()
|
|
318
|
+
for agent in self._child_agents.values():
|
|
319
|
+
if not getattr(agent, "initialized", False):
|
|
320
|
+
await agent.initialize()
|
|
321
|
+
|
|
322
|
+
async def shutdown(self) -> None:
|
|
323
|
+
"""Shutdown this agent and all child agents."""
|
|
324
|
+
await super().shutdown()
|
|
325
|
+
for agent in self._child_agents.values():
|
|
326
|
+
try:
|
|
327
|
+
await agent.shutdown()
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.warning(f"Error shutting down child agent {agent.name}: {e}")
|
|
330
|
+
|
|
331
|
+
async def list_tools(self) -> ListToolsResult:
|
|
332
|
+
"""List MCP tools plus child agents exposed as tools."""
|
|
333
|
+
|
|
334
|
+
base = await super().list_tools()
|
|
335
|
+
tools = list(base.tools)
|
|
336
|
+
existing_names = {tool.name for tool in tools}
|
|
337
|
+
|
|
338
|
+
for tool_name, agent in self._child_agents.items():
|
|
339
|
+
if tool_name in existing_names:
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
input_schema: dict[str, Any] = {
|
|
343
|
+
"type": "object",
|
|
344
|
+
"properties": {
|
|
345
|
+
"text": {"type": "string", "description": "Plain text input"},
|
|
346
|
+
"json": {"type": "object", "description": "Arbitrary JSON payload"},
|
|
347
|
+
},
|
|
348
|
+
"additionalProperties": True,
|
|
349
|
+
}
|
|
350
|
+
tools.append(
|
|
351
|
+
Tool(
|
|
352
|
+
name=tool_name,
|
|
353
|
+
description=agent.instruction,
|
|
354
|
+
inputSchema=input_schema,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
existing_names.add(tool_name)
|
|
358
|
+
|
|
359
|
+
return ListToolsResult(tools=tools)
|
|
360
|
+
|
|
361
|
+
@contextmanager
|
|
362
|
+
def _child_display_suppressed(self, child: LlmAgent):
|
|
363
|
+
"""Context manager to hide child chat while keeping tool logs visible."""
|
|
364
|
+
child_id = id(child)
|
|
365
|
+
count = self._display_suppression_count.get(child_id, 0)
|
|
366
|
+
if count == 0:
|
|
367
|
+
if (
|
|
368
|
+
hasattr(child, "display")
|
|
369
|
+
and child.display
|
|
370
|
+
and getattr(child.display, "config", None)
|
|
371
|
+
):
|
|
372
|
+
self._original_display_configs[child_id] = child.display.config
|
|
373
|
+
temp_config = copy(child.display.config)
|
|
374
|
+
if hasattr(temp_config, "logger"):
|
|
375
|
+
temp_logger = copy(temp_config.logger)
|
|
376
|
+
temp_logger.show_chat = False
|
|
377
|
+
temp_logger.show_tools = True
|
|
378
|
+
temp_config.logger = temp_logger
|
|
379
|
+
child.display.config = temp_config
|
|
380
|
+
self._display_suppression_count[child_id] = count + 1
|
|
381
|
+
try:
|
|
382
|
+
yield
|
|
383
|
+
finally:
|
|
384
|
+
self._display_suppression_count[child_id] -= 1
|
|
385
|
+
if self._display_suppression_count[child_id] <= 0:
|
|
386
|
+
del self._display_suppression_count[child_id]
|
|
387
|
+
original_config = self._original_display_configs.pop(child_id, None)
|
|
388
|
+
if original_config is not None and hasattr(child, "display") and child.display:
|
|
389
|
+
child.display.config = original_config
|
|
390
|
+
|
|
391
|
+
async def _merge_child_history(
|
|
392
|
+
self, target: LlmAgent, clone: LlmAgent, start_index: int
|
|
393
|
+
) -> None:
|
|
394
|
+
"""Append clone history from start_index into target with a global merge lock."""
|
|
395
|
+
async with self._history_merge_lock:
|
|
396
|
+
new_messages = clone.message_history[start_index:]
|
|
397
|
+
target.append_history(new_messages)
|
|
398
|
+
|
|
399
|
+
async def _invoke_child_agent(
|
|
400
|
+
self,
|
|
401
|
+
child: LlmAgent,
|
|
402
|
+
arguments: dict[str, Any] | None = None,
|
|
403
|
+
*,
|
|
404
|
+
suppress_display: bool = True,
|
|
405
|
+
) -> CallToolResult:
|
|
406
|
+
"""Shared helper to execute a child agent with standard serialization and display rules."""
|
|
407
|
+
|
|
408
|
+
args = arguments or {}
|
|
409
|
+
# Serialize arguments to text input
|
|
410
|
+
if isinstance(args.get("text"), str):
|
|
411
|
+
input_text = args["text"]
|
|
412
|
+
elif "json" in args:
|
|
413
|
+
input_text = (
|
|
414
|
+
json.dumps(args["json"], ensure_ascii=False)
|
|
415
|
+
if isinstance(args["json"], dict)
|
|
416
|
+
else str(args["json"])
|
|
417
|
+
)
|
|
418
|
+
else:
|
|
419
|
+
input_text = json.dumps(args, ensure_ascii=False) if args else ""
|
|
420
|
+
|
|
421
|
+
child_request = Prompt.user(input_text)
|
|
422
|
+
|
|
423
|
+
try:
|
|
424
|
+
with self._child_display_suppressed(child) if suppress_display else nullcontext():
|
|
425
|
+
response: PromptMessageExtended = await child.generate([child_request], None)
|
|
426
|
+
content_blocks = list(response.content or [])
|
|
427
|
+
|
|
428
|
+
error_blocks = None
|
|
429
|
+
if response.channels and FAST_AGENT_ERROR_CHANNEL in response.channels:
|
|
430
|
+
error_blocks = response.channels.get(FAST_AGENT_ERROR_CHANNEL) or []
|
|
431
|
+
if error_blocks:
|
|
432
|
+
content_blocks.extend(error_blocks)
|
|
433
|
+
|
|
434
|
+
return CallToolResult(
|
|
435
|
+
content=content_blocks,
|
|
436
|
+
isError=bool(error_blocks),
|
|
437
|
+
)
|
|
438
|
+
except Exception as exc:
|
|
439
|
+
import traceback
|
|
440
|
+
|
|
441
|
+
logger.error(
|
|
442
|
+
"Child agent tool call failed",
|
|
443
|
+
data={
|
|
444
|
+
"agent_name": child.name,
|
|
445
|
+
"error": str(exc),
|
|
446
|
+
"error_type": type(exc).__name__,
|
|
447
|
+
"traceback": traceback.format_exc(),
|
|
448
|
+
},
|
|
449
|
+
)
|
|
450
|
+
return CallToolResult(content=[text_content(f"Error: {exc}")], isError=True)
|
|
451
|
+
|
|
452
|
+
def _resolve_child_agent(self, name: str) -> LlmAgent | None:
|
|
453
|
+
return self._child_agents.get(name) or self._child_agents.get(self._make_tool_name(name))
|
|
454
|
+
|
|
455
|
+
async def call_tool(
|
|
456
|
+
self,
|
|
457
|
+
name: str,
|
|
458
|
+
arguments: dict[str, Any] | None = None,
|
|
459
|
+
tool_use_id: str | None = None,
|
|
460
|
+
) -> CallToolResult:
|
|
461
|
+
"""Route tool execution to child agents first, then MCP/local tools.
|
|
462
|
+
|
|
463
|
+
The signature matches :meth:`McpAgent.call_tool` so that upstream tooling
|
|
464
|
+
can safely pass the LLM's ``tool_use_id`` as a positional argument.
|
|
465
|
+
"""
|
|
466
|
+
|
|
467
|
+
child = self._resolve_child_agent(name)
|
|
468
|
+
if child is not None:
|
|
469
|
+
# Child agents don't currently use tool_use_id, they operate via
|
|
470
|
+
# a plain PromptMessageExtended tool call.
|
|
471
|
+
return await self._invoke_child_agent(child, arguments)
|
|
472
|
+
|
|
473
|
+
return await super().call_tool(name, arguments, tool_use_id)
|
|
474
|
+
|
|
475
|
+
def _show_parallel_tool_calls(self, descriptors: list[dict[str, Any]]) -> None:
|
|
476
|
+
"""Display tool call headers for parallel agent execution.
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
descriptors: List of tool call descriptors with metadata
|
|
480
|
+
"""
|
|
481
|
+
if not descriptors:
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
status_labels = {
|
|
485
|
+
"pending": "running",
|
|
486
|
+
"error": "error",
|
|
487
|
+
"missing": "missing",
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
total = len(descriptors)
|
|
491
|
+
limit = self._options.max_display_instances or total
|
|
492
|
+
|
|
493
|
+
# Show detailed call information for each agent
|
|
494
|
+
for i, desc in enumerate(descriptors[:limit], 1):
|
|
495
|
+
tool_name = desc.get("tool", "(unknown)")
|
|
496
|
+
corr_id = desc.get("id")
|
|
497
|
+
args = desc.get("args", {})
|
|
498
|
+
status = desc.get("status", "pending")
|
|
499
|
+
|
|
500
|
+
if status == "error":
|
|
501
|
+
continue # Skip display for error tools, will show in results
|
|
502
|
+
|
|
503
|
+
# Always add individual instance number for clarity
|
|
504
|
+
suffix = f"[{i}]"
|
|
505
|
+
if corr_id:
|
|
506
|
+
suffix = f"[{i}|{corr_id}]"
|
|
507
|
+
display_tool_name = f"{tool_name}{suffix}"
|
|
508
|
+
|
|
509
|
+
# Build bottom item for THIS instance only (not all instances)
|
|
510
|
+
status_label = status_labels.get(status, "pending")
|
|
511
|
+
bottom_item = f"{display_tool_name} · {status_label}"
|
|
512
|
+
|
|
513
|
+
# Show individual tool call with arguments
|
|
514
|
+
self.display.show_tool_call(
|
|
515
|
+
name=self.name,
|
|
516
|
+
tool_name=display_tool_name,
|
|
517
|
+
tool_args=args,
|
|
518
|
+
bottom_items=[bottom_item], # Only this instance's label
|
|
519
|
+
max_item_length=28,
|
|
520
|
+
metadata={"correlation_id": corr_id, "instance_name": display_tool_name},
|
|
521
|
+
type_label="subagent",
|
|
522
|
+
)
|
|
523
|
+
if total > limit:
|
|
524
|
+
collapsed = total - limit
|
|
525
|
+
label = f"[{limit + 1}..{total}]"
|
|
526
|
+
self.display.show_tool_call(
|
|
527
|
+
name=self.name,
|
|
528
|
+
tool_name=label,
|
|
529
|
+
tool_args={"collapsed": collapsed},
|
|
530
|
+
bottom_items=[f"{label} · {collapsed} more"],
|
|
531
|
+
max_item_length=28,
|
|
532
|
+
type_label="subagent",
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
def _show_parallel_tool_results(self, records: list[dict[str, Any]]) -> None:
|
|
536
|
+
"""Display tool result panels for parallel agent execution.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
records: List of result records with descriptor and result data
|
|
540
|
+
"""
|
|
541
|
+
if not records:
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
total = len(records)
|
|
545
|
+
limit = self._options.max_display_instances or total
|
|
546
|
+
|
|
547
|
+
# Show detailed result for each agent
|
|
548
|
+
for i, record in enumerate(records[:limit], 1):
|
|
549
|
+
descriptor = record.get("descriptor", {})
|
|
550
|
+
result = record.get("result")
|
|
551
|
+
tool_name = descriptor.get("tool", "(unknown)")
|
|
552
|
+
corr_id = descriptor.get("id")
|
|
553
|
+
|
|
554
|
+
if result:
|
|
555
|
+
# Always add individual instance number for clarity
|
|
556
|
+
suffix = f"[{i}]"
|
|
557
|
+
if corr_id:
|
|
558
|
+
suffix = f"[{i}|{corr_id}]"
|
|
559
|
+
display_tool_name = f"{tool_name}{suffix}"
|
|
560
|
+
|
|
561
|
+
# Show individual tool result with full content
|
|
562
|
+
self.display.show_tool_result(
|
|
563
|
+
name=self.name,
|
|
564
|
+
tool_name=display_tool_name,
|
|
565
|
+
type_label="subagent response",
|
|
566
|
+
result=result,
|
|
567
|
+
)
|
|
568
|
+
if total > limit:
|
|
569
|
+
collapsed = total - limit
|
|
570
|
+
label = f"[{limit + 1}..{total}]"
|
|
571
|
+
self.display.show_tool_result(
|
|
572
|
+
name=self.name,
|
|
573
|
+
tool_name=label,
|
|
574
|
+
type_label="subagent response",
|
|
575
|
+
result=CallToolResult(
|
|
576
|
+
content=[text_content(f"{collapsed} more results (collapsed)")],
|
|
577
|
+
isError=False,
|
|
578
|
+
),
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
async def run_tools(self, request: PromptMessageExtended) -> PromptMessageExtended:
|
|
582
|
+
"""Handle mixed MCP + agent-tool batches."""
|
|
583
|
+
|
|
584
|
+
if not request.tool_calls:
|
|
585
|
+
logger.warning("No tool calls found in request", data=request)
|
|
586
|
+
return PromptMessageExtended(role="user", tool_results={})
|
|
587
|
+
|
|
588
|
+
child_ids: list[str] = []
|
|
589
|
+
for correlation_id, tool_request in request.tool_calls.items():
|
|
590
|
+
if self._resolve_child_agent(tool_request.params.name):
|
|
591
|
+
child_ids.append(correlation_id)
|
|
592
|
+
|
|
593
|
+
if not child_ids:
|
|
594
|
+
return await super().run_tools(request)
|
|
595
|
+
|
|
596
|
+
child_results, child_error = await self._run_child_tools(request, set(child_ids))
|
|
597
|
+
|
|
598
|
+
if len(child_ids) == len(request.tool_calls):
|
|
599
|
+
return self._finalize_tool_results(child_results, tool_loop_error=child_error)
|
|
600
|
+
|
|
601
|
+
# Execute remaining MCP/local tools via base implementation
|
|
602
|
+
remaining_ids = [cid for cid in request.tool_calls.keys() if cid not in child_ids]
|
|
603
|
+
mcp_request = PromptMessageExtended(
|
|
604
|
+
role=request.role,
|
|
605
|
+
content=request.content,
|
|
606
|
+
tool_calls={cid: request.tool_calls[cid] for cid in remaining_ids},
|
|
607
|
+
)
|
|
608
|
+
mcp_message = await super().run_tools(mcp_request)
|
|
609
|
+
mcp_results = mcp_message.tool_results or {}
|
|
610
|
+
mcp_error = self._extract_error_text(mcp_message)
|
|
611
|
+
|
|
612
|
+
combined_results = {}
|
|
613
|
+
combined_results.update(child_results)
|
|
614
|
+
combined_results.update(mcp_results)
|
|
615
|
+
|
|
616
|
+
tool_loop_error = child_error or mcp_error
|
|
617
|
+
return self._finalize_tool_results(combined_results, tool_loop_error=tool_loop_error)
|
|
618
|
+
|
|
619
|
+
async def _run_child_tools(
|
|
620
|
+
self,
|
|
621
|
+
request: PromptMessageExtended,
|
|
622
|
+
target_ids: set[str],
|
|
623
|
+
) -> tuple[dict[str, CallToolResult], str | None]:
|
|
624
|
+
"""Run only the child-agent tool calls from the request."""
|
|
625
|
+
|
|
626
|
+
if not target_ids:
|
|
627
|
+
return {}, None
|
|
628
|
+
|
|
629
|
+
tool_results: dict[str, CallToolResult] = {}
|
|
630
|
+
tool_loop_error: str | None = None
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
listed = await self.list_tools()
|
|
634
|
+
available_tools = {t.name for t in listed.tools}
|
|
635
|
+
except Exception as exc:
|
|
636
|
+
logger.warning(f"Failed to list tools before execution: {exc}")
|
|
637
|
+
available_tools = set(self._child_agents.keys())
|
|
638
|
+
|
|
639
|
+
call_descriptors: list[dict[str, Any]] = []
|
|
640
|
+
descriptor_by_id: dict[str, dict[str, Any]] = {}
|
|
641
|
+
tasks: list[asyncio.Task] = []
|
|
642
|
+
id_list: list[str] = []
|
|
643
|
+
|
|
644
|
+
for correlation_id, tool_request in request.tool_calls.items():
|
|
645
|
+
if correlation_id not in target_ids:
|
|
646
|
+
continue
|
|
647
|
+
|
|
648
|
+
tool_name = tool_request.params.name
|
|
649
|
+
tool_args = tool_request.params.arguments or {}
|
|
650
|
+
|
|
651
|
+
descriptor = {
|
|
652
|
+
"id": correlation_id,
|
|
653
|
+
"tool": tool_name,
|
|
654
|
+
"args": tool_args,
|
|
655
|
+
}
|
|
656
|
+
call_descriptors.append(descriptor)
|
|
657
|
+
descriptor_by_id[correlation_id] = descriptor
|
|
658
|
+
|
|
659
|
+
if (
|
|
660
|
+
tool_name not in available_tools
|
|
661
|
+
and self._make_tool_name(tool_name) not in available_tools
|
|
662
|
+
):
|
|
663
|
+
error_message = f"Tool '{tool_name}' is not available"
|
|
664
|
+
tool_results[correlation_id] = CallToolResult(
|
|
665
|
+
content=[text_content(error_message)], isError=True
|
|
666
|
+
)
|
|
667
|
+
tool_loop_error = tool_loop_error or error_message
|
|
668
|
+
descriptor["status"] = "error"
|
|
669
|
+
continue
|
|
670
|
+
|
|
671
|
+
descriptor["status"] = "pending"
|
|
672
|
+
id_list.append(correlation_id)
|
|
673
|
+
|
|
674
|
+
max_parallel = self._options.max_parallel
|
|
675
|
+
if max_parallel and len(id_list) > max_parallel:
|
|
676
|
+
skipped_ids = id_list[max_parallel:]
|
|
677
|
+
id_list = id_list[:max_parallel]
|
|
678
|
+
skip_msg = f"Skipped {len(skipped_ids)} agent-tool calls (max_parallel={max_parallel})"
|
|
679
|
+
tool_loop_error = tool_loop_error or skip_msg
|
|
680
|
+
for cid in skipped_ids:
|
|
681
|
+
tool_results[cid] = CallToolResult(
|
|
682
|
+
content=[text_content(skip_msg)],
|
|
683
|
+
isError=True,
|
|
684
|
+
)
|
|
685
|
+
descriptor_by_id[cid]["status"] = "error"
|
|
686
|
+
descriptor_by_id[cid]["error_message"] = skip_msg
|
|
687
|
+
|
|
688
|
+
from fast_agent.event_progress import ProgressAction, ProgressEvent
|
|
689
|
+
from fast_agent.ui.progress_display import (
|
|
690
|
+
progress_display as outer_progress_display,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
async def call_with_instance_name(
|
|
694
|
+
tool_name: str, tool_args: dict[str, Any], instance: int, correlation_id: str
|
|
695
|
+
) -> CallToolResult:
|
|
696
|
+
child = self._resolve_child_agent(tool_name)
|
|
697
|
+
if not child:
|
|
698
|
+
error_msg = f"Unknown agent-tool: {tool_name}"
|
|
699
|
+
return CallToolResult(content=[text_content(error_msg)], isError=True)
|
|
700
|
+
|
|
701
|
+
base_name = getattr(child, "_name", child.name)
|
|
702
|
+
instance_name = f"{base_name}[{instance}]"
|
|
703
|
+
|
|
704
|
+
try:
|
|
705
|
+
clone = await child.spawn_detached_instance(name=instance_name)
|
|
706
|
+
except Exception as exc:
|
|
707
|
+
logger.error(
|
|
708
|
+
"Failed to spawn dedicated child instance",
|
|
709
|
+
data={
|
|
710
|
+
"tool_name": tool_name,
|
|
711
|
+
"agent_name": base_name,
|
|
712
|
+
"error": str(exc),
|
|
713
|
+
},
|
|
714
|
+
)
|
|
715
|
+
return CallToolResult(content=[text_content(f"Spawn failed: {exc}")], isError=True)
|
|
716
|
+
|
|
717
|
+
# Prepare history according to mode
|
|
718
|
+
history_mode = self._options.history_mode
|
|
719
|
+
base_history = child.message_history
|
|
720
|
+
fork_index = len(base_history)
|
|
721
|
+
try:
|
|
722
|
+
if history_mode == HistoryMode.SCRATCH:
|
|
723
|
+
clone.load_message_history([])
|
|
724
|
+
fork_index = 0
|
|
725
|
+
else:
|
|
726
|
+
clone.load_message_history(base_history)
|
|
727
|
+
except Exception as hist_exc:
|
|
728
|
+
logger.warning(
|
|
729
|
+
"Failed to load history into clone",
|
|
730
|
+
data={"instance_name": instance_name, "error": str(hist_exc)},
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
progress_started = False
|
|
734
|
+
try:
|
|
735
|
+
outer_progress_display.update(
|
|
736
|
+
ProgressEvent(
|
|
737
|
+
action=ProgressAction.CHATTING,
|
|
738
|
+
target=instance_name,
|
|
739
|
+
details="",
|
|
740
|
+
agent_name=instance_name,
|
|
741
|
+
correlation_id=correlation_id,
|
|
742
|
+
instance_name=instance_name,
|
|
743
|
+
tool_name=tool_name,
|
|
744
|
+
)
|
|
745
|
+
)
|
|
746
|
+
progress_started = True
|
|
747
|
+
call_coro = self._invoke_child_agent(clone, tool_args)
|
|
748
|
+
timeout = self._options.child_timeout_sec
|
|
749
|
+
if timeout:
|
|
750
|
+
return await asyncio.wait_for(call_coro, timeout=timeout)
|
|
751
|
+
return await call_coro
|
|
752
|
+
finally:
|
|
753
|
+
try:
|
|
754
|
+
await clone.shutdown()
|
|
755
|
+
except Exception as shutdown_exc:
|
|
756
|
+
logger.warning(
|
|
757
|
+
"Error shutting down dedicated child instance",
|
|
758
|
+
data={
|
|
759
|
+
"instance_name": instance_name,
|
|
760
|
+
"error": str(shutdown_exc),
|
|
761
|
+
},
|
|
762
|
+
)
|
|
763
|
+
try:
|
|
764
|
+
child.merge_usage_from(clone)
|
|
765
|
+
except Exception as merge_exc:
|
|
766
|
+
logger.warning(
|
|
767
|
+
"Failed to merge usage from child instance",
|
|
768
|
+
data={
|
|
769
|
+
"instance_name": instance_name,
|
|
770
|
+
"error": str(merge_exc),
|
|
771
|
+
},
|
|
772
|
+
)
|
|
773
|
+
if history_mode == HistoryMode.FORK_AND_MERGE:
|
|
774
|
+
try:
|
|
775
|
+
await self._merge_child_history(
|
|
776
|
+
target=child, clone=clone, start_index=fork_index
|
|
777
|
+
)
|
|
778
|
+
except Exception as merge_hist_exc:
|
|
779
|
+
logger.warning(
|
|
780
|
+
"Failed to merge child history",
|
|
781
|
+
data={
|
|
782
|
+
"instance_name": instance_name,
|
|
783
|
+
"error": str(merge_hist_exc),
|
|
784
|
+
},
|
|
785
|
+
)
|
|
786
|
+
if progress_started and instance_name:
|
|
787
|
+
outer_progress_display.update(
|
|
788
|
+
ProgressEvent(
|
|
789
|
+
action=ProgressAction.FINISHED,
|
|
790
|
+
target=instance_name,
|
|
791
|
+
details="Completed",
|
|
792
|
+
agent_name=instance_name,
|
|
793
|
+
correlation_id=correlation_id,
|
|
794
|
+
instance_name=instance_name,
|
|
795
|
+
tool_name=tool_name,
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
|
|
799
|
+
for i, cid in enumerate(id_list, 1):
|
|
800
|
+
tool_name = descriptor_by_id[cid]["tool"]
|
|
801
|
+
tool_args = descriptor_by_id[cid]["args"]
|
|
802
|
+
tasks.append(asyncio.create_task(call_with_instance_name(tool_name, tool_args, i, cid)))
|
|
803
|
+
|
|
804
|
+
self._show_parallel_tool_calls(call_descriptors)
|
|
805
|
+
|
|
806
|
+
if tasks:
|
|
807
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
808
|
+
for i, result in enumerate(results):
|
|
809
|
+
correlation_id = id_list[i]
|
|
810
|
+
if isinstance(result, Exception):
|
|
811
|
+
msg = f"Tool execution failed: {result}"
|
|
812
|
+
tool_results[correlation_id] = CallToolResult(
|
|
813
|
+
content=[text_content(msg)], isError=True
|
|
814
|
+
)
|
|
815
|
+
tool_loop_error = tool_loop_error or msg
|
|
816
|
+
descriptor_by_id[correlation_id]["status"] = "error"
|
|
817
|
+
descriptor_by_id[correlation_id]["error_message"] = msg
|
|
818
|
+
else:
|
|
819
|
+
tool_results[correlation_id] = result
|
|
820
|
+
descriptor_by_id[correlation_id]["status"] = (
|
|
821
|
+
"error" if result.isError else "done"
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
ordered_records: list[dict[str, Any]] = []
|
|
825
|
+
for cid in id_list:
|
|
826
|
+
result = tool_results.get(cid)
|
|
827
|
+
if result is None:
|
|
828
|
+
continue
|
|
829
|
+
descriptor = descriptor_by_id.get(cid, {})
|
|
830
|
+
ordered_records.append({"descriptor": descriptor, "result": result})
|
|
831
|
+
|
|
832
|
+
self._show_parallel_tool_results(ordered_records)
|
|
833
|
+
|
|
834
|
+
return tool_results, tool_loop_error
|
|
835
|
+
|
|
836
|
+
def _extract_error_text(self, message: PromptMessageExtended) -> str | None:
|
|
837
|
+
if not message.channels:
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
error_blocks = message.channels.get(FAST_AGENT_ERROR_CHANNEL)
|
|
841
|
+
if not error_blocks:
|
|
842
|
+
return None
|
|
843
|
+
|
|
844
|
+
for block in error_blocks:
|
|
845
|
+
text = get_text(block)
|
|
846
|
+
if text:
|
|
847
|
+
return text
|
|
848
|
+
|
|
849
|
+
return None
|