Skip to content

Commit b3d2ada

Browse files
iqdoctorevalstateusamaJ17claudebandinopla
authored
Feat: hybrid Agents-as-Tools/MCP-as-tools experimental agent (#515)
* workflow: Agents as Tools — BASIC agents with child_agents expose children as tools with parallel execution - 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 * 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 * 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 * 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 * 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' * 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. * 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 * 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 * 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. * 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 * 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. * fix: show individual instance numbers [1], [2] in tool headers 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). * 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. * 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. * 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. * 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. * docs: add comprehensive README for agents-as-tools pattern 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 * 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. * 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. * fix: prevent display config race conditions in parallel instances 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. * 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. * fix: duplicate labels, final logs without instance index 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 * fix: label truncation and display config restoration 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}' * 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. * 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. * 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. * 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. * 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. * 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 * 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. * 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. * 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. * 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. * 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. * 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. * feat: detach agents-as-tools instances and harden MCP task groups - 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. * feat: detach agents-as-tools instances and harden MCP task groups - 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-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 * 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) * 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 * Added §3.3 “Minimal usage sample (for docs and examples)” * 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. * 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). * 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. * Add Vertex ADC support and preview model fallback * Add vertex config tests for Google provider * Cover Vertex dict config client init and preview fallback * remove ESC key handling complexity; ctrl+c still cancels generation (#519) * remove ESC key handling complexity; ctrl+c still cancels generation * opus 4.5 support * opus 4.5 * version bump * integration test (#521) * update tool timing saving, including transport channel (#523) more sensitive markdown detection * allow absolute paths for skills directories (#524) * Feat/model env option (#526) * model environment variable option * model env var * lint * Feat/reasoning streaming (#529) * upgrade skills, export load_prompt for convenience * stream reasoning tokens * Implement Agent Client Protocol tools for CLI (#528) * Add ACP tool call permissions with persistence Implement tool permission system for ACP mode: - PermissionStore: Persist allow_always/reject_always decisions in .fast-agent/auths.md (human-readable markdown format) - ACPToolPermissionManager: Request permissions from ACP clients via session/request_permission, with support for allow_once, allow_always, reject_once, reject_always options - ToolPermissionHandler: Protocol for MCP aggregator integration, enabling permission checking before tool execution - Fail-safe: Default to DENY on any error during permission checks CLI changes: - Add --no-permissions flag to serve and acp commands to disable permission requests (allows all tool executions) ACP compliance: - Send ToolCall object with permission request per ACP spec - Support all permission option kinds (allow_once, allow_always, reject_once, reject_always) - Persist 'always' decisions across sessions via auths.md file Tests: - Unit tests for PermissionStore and PermissionResult - Unit tests for _infer_tool_kind function - Integration tests for permission flow (queued for later verification) * Add permission checks to ACP terminal and filesystem runtimes Extend permission checking to cover ACP external runtimes: - ACPTerminalRuntime: Check permission before executing shell commands - ACPFilesystemRuntime: Check permission before reading/writing files The permission handler is now injected into both runtimes from the ACP server during session setup. This ensures all tool executions go through the permission system, not just MCP server tools. Fail-safe: deny execution if permission check fails. * Fix ACP tool permission test failures - Fix FakeOutcome constructor parameter name (option_id -> optionId) - Update integration tests to check notifications instead of non-existent PromptResponse.message attribute - Add --no-permissions flag to filesystem/telemetry tests that don't test permissions - Add test doubles and edge case tests for ACPToolPermissionManager * improve ACP permissioning * fix: Centralize robust API retry logic in FastAgentLLM & preserve context (#517) * fix: Add retry loop for transient API errors (Rate Limits/5xx) * fix: Add robust retry loop for transient API errors & preserve context * fix: Implement robust retry logic for transient API errors in agent execution (for all scenarios) * lint * test, config file, non-error path reinstated. * test? * tests? * loop diagnosis * root cause? --------- Co-authored-by: evalstate <1936278+evalstate@users.noreply.github.com> * Dev/0.4.2 (#530) * upgrade skills, export load_prompt for convenience * reasoning improvements * simplify streaming etc. * return reasoning_content for models such as kimi-k2-thinking and glm-4.6 with thinking on * reasoning_content as string * type safety, hf provider display & acp * otel off * switch off otel * fix test * acp tool streaming, openai diags * improve tool streaming * simplify perms for streaming, env flag for openai trace * only update title after 20 chunks * update unit test * update streaming titles (completion) * parallel tool calling for ACP * gpt-oss reasoning/tool interleaving support * fix linter * replaced with env option * Compare session termination handling implementations (#532) * feat: Add reconnect_on_disconnect option for handling server session termination When a remote StreamableHTTP MCP server restarts, the session becomes invalid and the server returns a 404 error. This change adds support for automatic reconnection when this happens. Changes: - Add `reconnect_on_disconnect` config option to MCPServerSettings (default: false) - Add ServerSessionTerminatedError exception with SESSION_TERMINATED_CODE = -32600 - Detect MCP SDK error code -32600 (session terminated) in MCPAgentClientSession with fallback to checking "session terminated" in error message - Add reconnect_server() method to MCPConnectionManager - Handle session terminated errors in MCPAggregator with reconnection support - Refactor connection error handling into dedicated helper methods - Add comprehensive unit tests for config, exception, and detection logic The reconnection flow: 1. Session error detected (404 -> MCP error code -32600) 2. If reconnect_on_disconnect is enabled for the server: - Disconnect the current server connection - Re-establish a fresh connection with new session - Retry the failed operation 3. If disabled, show a helpful tip about enabling the option Usage in fastagent.config.yaml: ```yaml mcp: servers: my-server: url: https://example.com/mcp reconnect_on_disconnect: true ``` * fix: Allow ServerSessionTerminatedError to pass through try_execute for reconnection The inner try_execute function was catching all exceptions except ConnectionError and converting them to error results, preventing ServerSessionTerminatedError from reaching the reconnection handler. Now both ConnectionError and ServerSessionTerminatedError pass through to the outer exception handlers. * fix: Prevent infinite reconnection loop on persistent session termination Added explicit handling for ServerSessionTerminatedError in retry paths to prevent infinite loops when the server keeps returning session terminated errors even after reconnection. Changes: - Catch ServerSessionTerminatedError separately in _handle_session_terminated retry path - return clear error message instead of retrying again - Catch ServerSessionTerminatedError in _handle_connection_error retry path - Add descriptive error messages explaining what happened * fix: Improve session terminated detection with string-based fallback The detection was failing because it only checked for McpError type with specific error code or message field. Now it first checks the string representation of ANY exception for "session terminated", which is more robust and handles cases where the exception type or structure differs. This ensures the reconnection logic is triggered regardless of the exact exception type used by the MCP SDK. * fix: Use correct positive 32600 error code for session terminated The MCP SDK streamable_http.py uses positive 32600 for session terminated errors, not the standard JSON-RPC negative -32600 (INVALID_REQUEST). This was discovered by reviewing the SDK source code: https://github.com/modelcontextprotocol/python-sdk/blob/main/src/mcp/client/streamable_http.py#L366 The error code detection should now work correctly for McpError instances. The string-based fallback remains as an additional safety net. * refactor: Simplify session terminated detection Now that we have the correct error code (32600), removed unnecessary complexity: - Removed string-based fallback detection - Simplified _is_session_terminated_error to just check McpError code - Reduced tests from 18 to 8 focused tests * fix: Ensure session terminated detection by always using overridden send_request When call_tool, read_resource, or get_prompt were called without _meta, they bypassed our overridden send_request() and called super() directly. This meant the McpError from the SDK transport layer never reached our _is_session_terminated_error() detection code, causing reconnection to never trigger. Now all three methods always construct the request themselves and call self.send_request() to ensure exceptions flow through our detection logic. * fix: Handle exceptions during session/transport cleanup in lifecycle task When disconnecting a server for reconnection, the session or transport cleanup might throw exceptions (e.g., if the session was already terminated). These exceptions were propagating to the shared task group and causing "unhandled errors in a TaskGroup" errors. Added nested exception handling around both session and transport context manager exits to catch and log cleanup errors gracefully. * debug: Add warning-level logging to diagnose session terminated detection * debug: Add stderr prints to trace exception handling * debug: Use rich console for debug output * feat: Clean up session termination handling and add reconnect counter - Remove debug logging from session termination detection - Add reconnect_count to ServerStats to track successful reconnections - Add reconnect_count to ServerStatus for /mcp display - Display reconnect count in /mcp output when count > 0 The reconnection feature is now production-ready: - Detects MCP error code 32600 (session terminated from 404) - Attempts reconnection when reconnect_on_disconnect is enabled - Prevents infinite retry loops - Reports failures to the model - Tracks reconnection statistics * default reconnect to true for session termination * forward progress token * Feat/auth status acp (#533) * Add /status auth and /status authreset ACP slash commands - /status auth: displays content of ./fast-agent/auths.md or "No permissions set" - /status authreset: removes the auths.md file - Updated hint to show available options [system|auth|authreset] * Show resolved path in /status auth and authreset output Helps debug path resolution issues by displaying the absolute path that was checked in all response scenarios. * Fix auths.md path: use .fast-agent hidden directory --------- Co-authored-by: Claude <noreply@anthropic.com> * Improve acp progress (#534) * fix: Include rawInput in ACP permission request toolCall Per the ACP specification, the ToolCall in a RequestPermissionRequest should include rawInput so clients can display the full tool arguments when asking users for permission. Changes: - Add rawInput=arguments to ToolCall in permission requests - Remove non-existent 'prompt' field from RequestPermissionRequest - Include argument summary in toolCall.title for better UX - Update test fake to parse title with argument suffixes - Add test assertions to verify rawInput is included * feat: Update tool title with MCP progress info Add progress percentage and message to tool call titles during MCP progress notifications. This gives users better visibility into long-running tool operations. Changes: - Add _base_titles dict to track base titles by tool_call_id - Store base title in on_tool_start for both streaming and non-streaming paths - Update on_tool_progress to build title like "server/tool(args) [50%] - message" - Clean up _base_titles on completion and session cleanup - Add tests verifying progress title updates with percentage and message * fix: Improve progress title display and image removal messaging Two improvements: 1. Progress title simplification: - Use simple title (server/tool) instead of full title with args during MCP progress updates for cleaner display - e.g., "server/tool [50%] - Downloading..." instead of "server/tool(arg=val) [50%] - Downloading..." 2. Image content removal placeholder: - Add placeholder text when unsupported content (images, documents) is removed due to model limitations - Prevents empty content which could cause hangs - Message: "[Vision content (image/png) was removed - model does not support this content type]" * fix: Rename message to progress_message in logger to avoid argument conflict * fix: Remove content from progress updates since title now shows message * debug: Add logging to diagnose tool completion hang * debug: Add more logging for tool completion diagnosis * fix: Ensure permission request uses same toolCallId as streaming notification When a tool call notification is sent early during streaming, the permission request must reference the same toolCallId so the client can correlate them. Changes: - Add get_tool_call_id_for_tool_use() method to ACPToolProgressManager - Update ACPToolPermissionAdapter to accept tool_handler reference - Look up existing ACP toolCallId before creating permission request - Pass tool_handler when creating permission adapter in ACP server * fix: Await streaming task before looking up toolCallId for permission The streaming notification task runs asynchronously and might not complete before permission is checked. This caused the toolCallId lookup to fail, resulting in permission requests using a different ID than the tool call notification. Changes: - Make get_tool_call_id_for_tool_use async - Wait for pending stream task to complete before looking up toolCallId - Add fallback to check _tool_call_id_to_external_id mapping - Update adapter to await the async method * fix: Update progress format to show progress/total per MCP spec Changed progress title format from percentage ([50%]) to progress/total format ([50/100]) to align with MCP specification. The MCP spec states that progress values may be floating point and increment even when total is unknown - showing raw progress values is more accurate than computing percentages. * fix: Only add placeholder text when ALL content is removed The previous fix added a placeholder for every removed content block, which broke tests expecting to find image content in channels. Now placeholder is only added when content would otherwise be completely empty, which was the original intent to prevent ACP client hangs. * fix: Truncate long content in /status error channel display Base64 content (like images) in error channels was being displayed in full, which could be very long. Now truncates to first 60 characters and shows total length (e.g., "...60 chars... (12780 characters)"). * fix: Restore full title with parameters on tool completion When a tool completes, the title was showing the last stale progress update (e.g., "[50/100]"). Now stores the full title with parameters at tool start and restores it when the tool completes, so completed tools show their arguments rather than progress indicators. * test: Update content filter tests for placeholder-only-when-empty behavior Tests now expect: - No placeholder when some content remains (just keeps valid content) - Placeholder only when ALL content is removed (e.g., tool results) - Detailed mime type info in error channel, not in placeholder text * Simplify /status error handling output when no errors (#535) Show "_No errors recorded_" instead of verbose channel details when there are no error entries. Co-authored-by: Claude <noreply@anthropic.com> * version bump * Add custom refinement instruction on @fast.evaluator_optimizer (#538) * add an instruction for the refinement agent * added refinment instruction in the refinement prompt also * remove debugging nonesense * remove testing print * restore as it was * feat: Add video support for Google Gemini provider (#537) * feat: Add video support for Google Gemini provider - Add video MIME type handling in GoogleConverter - Add unit tests for video resource conversion - Update README with multimodal support details - Document 20MB inline data limit * lint * prep launch * test, example etc. (stuck on model overload messages) --------- Co-authored-by: evalstate <1936278+evalstate@users.noreply.github.com> * Feat/acp sdk update (#543) * upgrade to 0.7.0 and fix a couple of small things * missed file * update * MCP SEP-1330: Elicitation schema updates for Enums (#324) * WIP: PoC demonstrating new enum schemas + multi-selection * Cleanup checkbox impl * add bare enum support * Add missing type field in multi-select schema (minor fix for SEP-1330 compliance) * bump mcp sdk with support for 1330 * update demo --------- Co-authored-by: Tapan Chugh <tapanc@cs.washington.edu> Co-authored-by: evalstate <1936278+evalstate@users.noreply.github.com> * tidy up root; sdk bumps * OpenAI Providers custom HTTP Headers (#544) * feat: Add custom headers support for OpenAI-compatible providers Add support for configuring custom HTTP headers via `default_headers` in provider settings. This enables use cases like Portkey integration and other API gateways that require custom headers. Changes: - Add `default_headers` field to all OpenAI-compatible provider settings - Add `_default_headers()` method to OpenAILLM base class - Override `_default_headers()` in each provider to read from config - Pass headers to AsyncOpenAI client via `default_headers` param - Add comprehensive unit tests for header configuration Providers with custom header support: - OpenAI, DeepSeek, Groq, xAI, Google (OAI), OpenRouter - Generic, TensorZero, HuggingFace, Aliyun Example configuration: ```yaml openai: api_key: sk-xxx default_headers: x-portkey-config: "config-id" x-custom-header: "value" ``` * lint * simplify header management --------- Co-authored-by: Claude <noreply@anthropic.com> * otel off * fix assertions * version bump * Review ACP implementation with new SDK Union types (#549) * refactor: use ACP SDK's ContentBlock Union type for cleaner type handling - Import ContentBlock from acp.helpers instead of manually defining ACPContentBlock Union in content_conversion.py - Update agent_acp_server.py prompt method signature to use ACPContentBlock - Refactor tool_progress.py to use match statements for MCP to ACP content conversion (more pythonic) - Use SDK's resource_block helper for embedded resource conversion - Extract tool kind patterns into class-level constant for cleaner code - Simplify annotation conversion with getattr() instead of hasattr checks - Remove redundant Union imports All 133 tests pass (78 unit + 55 integration). * lint --------- * feat: detach agents-as-tools instances and harden MCP task groups - 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-as-Tools: options struct, history safety, call_tool compat, UI collapse * Agents-as-Tools: options plumbing and limits * Agents-as-Tools: options keyword-only * Agents-as-Tools: compact display suppression and cleanup * Agents-as-Tools: implement history fork/merge modes * Agents-as-Tools: simplify display suppression setup * Agents-as-Tools: trim redundant import and reuse totals * Docs/options: clarify AgentsAsTools defaults; move decorator kwargs * Agents-as-Tools: rename tool options payload * Agents-as-Tools: drop legacy tool_options fallback * Docs: mark Agents-as-Tools plan items completed * Docs: move completion markers to start * Docs: drop per-instance futures from Agents-as-Tools plan * Docs: document Agents-as-Tools options and merged tool surface * Tests: cover Agents-as-Tools list/run/error paths * Docs: mark tool merge complete in Fix plan * Tests: cover nested Agents-as-Tools instance labeling * Agents-as-Tools: add correlation metadata for progress/tool logs * Chore: fix imports after lint * remove debug print * update tool display to show subagent label * updated lockfile * chore: remove agents-as-tools plan docs * revert aggregator tool handling change (this looks like an llm modification error) * remove debug file writing * exception trace logging * simplify lock behavior to avoid racy - concurrent to agent instance --------- Co-authored-by: shaun smith <1936278+evalstate@users.noreply.github.com> Co-authored-by: usama <76848490+usamaJ17@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: bandinopla <71508858+bandinopla@users.noreply.github.com> Co-authored-by: John Cyriac <lucidprogrammer@users.noreply.github.com> Co-authored-by: Tapan Chugh <chugh.tapan@gmail.com> Co-authored-by: Tapan Chugh <tapanc@cs.washington.edu>
1 parent 73baea3 commit b3d2ada

18 files changed

Lines changed: 1679 additions & 300 deletions

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,61 @@ uv run workflow/chaining.py --agent post_writer --message "<url>"
162162

163163
Add the `--quiet` switch to disable progress and message display and return only the final response - useful for simple automations.
164164

165+
### Agents-as-Tools (child agents as tools)
166+
167+
Sometimes one agent needs to call other agents as tools. `fast-agent` supports
168+
this via a hybrid *Agents-as-Tools* agent:
169+
170+
- You declare a BASIC agent with `agents=[...]`.
171+
- At runtime it is instantiated as an internal `AgentsAsToolsAgent`, which:
172+
- Inherits from `McpAgent` (keeps its own MCP servers/tools).
173+
- Exposes each child agent as a tool (`agent__ChildName`).
174+
- Merges MCP tools and agent-tools in a single `list_tools()` surface.
175+
- Supports history/parallel controls:
176+
- `history_mode` (default `fork`; `fork_and_merge` to merge clone history back)
177+
- `max_parallel` (default unlimited), `child_timeout_sec` (default none)
178+
- `max_display_instances` (default 20; collapse progress after top-N)
179+
180+
Minimal example:
181+
182+
```python
183+
@fast.agent(
184+
name="NY-Project-Manager",
185+
instruction="Return current time and project status.",
186+
servers=["time"], # MCP server 'time' configured in fastagent.config.yaml
187+
)
188+
@fast.agent(
189+
name="London-Project-Manager",
190+
instruction="Return current time and news.",
191+
servers=["time"],
192+
)
193+
@fast.agent(
194+
name="PMO-orchestrator",
195+
instruction="Get reports. Separate call per topic. NY: {OpenAI, Fast-Agent, Anthropic}, London: Economics",
196+
default=True,
197+
agents=[
198+
"NY-Project-Manager",
199+
"London-Project-Manager",
200+
], # children are exposed as tools: agent__NY-Project-Manager, agent__London-Project-Manager
201+
# optional knobs:
202+
# history_mode=HistoryMode.FORK_AND_MERGE to merge clone history back
203+
# max_parallel=8 to cap parallel agent-tools
204+
# child_timeout_sec=600 to bound each child call
205+
# max_display_instances=10 to collapse progress UI after top-N
206+
)
207+
async def main() -> None:
208+
async with fast.run() as agent:
209+
result = await agent("Get PMO report")
210+
print(result)
211+
212+
213+
if __name__ == "__main__":
214+
asyncio.run(main())
215+
```
216+
217+
Extended example is available in the repository as
218+
`examples/workflows/agents_as_tools_extended.py`.
219+
165220
## MCP OAuth (v2.1)
166221

167222
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.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Agents-as-Tools example: project managers for NY and London.
2+
3+
Parent agent ("PMO-orchestrator") calls two child agents
4+
("NY-Project-Manager" and "London-Project-Manager") as tools. Each child uses
5+
the ``time`` MCP server for local time and the ``fetch`` MCP server for a short
6+
news-based update on the given topics.
7+
8+
Defaults: clones fork parent history (no merge-back), no timeout, no parallel cap,
9+
and collapses progress display after the first 20 instances.
10+
To change behavior, pass decorator args such as
11+
`history_mode=HistoryMode.FORK_AND_MERGE`, `child_timeout_sec=600`,
12+
`max_parallel=8`, `max_display_instances=10`
13+
(HistoryMode import: fast_agent.agents.workflow.agents_as_tools_agent).
14+
"""
15+
16+
import asyncio
17+
18+
from fast_agent import FastAgent
19+
20+
# Create the application
21+
fast = FastAgent("Agents-as-Tools demo")
22+
23+
24+
@fast.agent(
25+
name="NY-Project-Manager",
26+
instruction=(
27+
"You are a New York project manager. For each given topic, get the "
28+
"current local time in New York and a brief, project-relevant news "
29+
"summary using the 'time' and 'fetch' MCP servers. If a source returns "
30+
"HTTP 403 or is blocked by robots.txt, try up to five alternative "
31+
"public sources before giving up and clearly state any remaining "
32+
"access limits. Hint: Fast-Agent site: https://fast-agent.ai"
33+
),
34+
servers=[
35+
"time",
36+
"fetch",
37+
], # MCP servers 'time' and 'fetch' configured in fastagent.config.yaml
38+
)
39+
@fast.agent(
40+
name="London-Project-Manager",
41+
instruction=(
42+
"You are a London project manager. For each given topic, get the "
43+
"current local time in London and a brief, project-relevant news "
44+
"summary using the 'time' and 'fetch' MCP servers. If a source returns "
45+
"HTTP 403 or is blocked by robots.txt, try up to five alternative "
46+
"public sources before giving up and clearly state any remaining "
47+
"access limits. Hint: BBC: https://www.bbc.com/ and FT: https://www.ft.com/"
48+
),
49+
servers=["time", "fetch"],
50+
)
51+
@fast.agent(
52+
name="PMO-orchestrator",
53+
instruction=(
54+
"Get project updates from the New York and London project managers. "
55+
"Ask NY-Project-Manager three times about different projects: Anthropic, "
56+
"evalstate/fast-agent, and OpenAI, and London-Project-Manager for economics review. "
57+
"Return a brief, concise combined summary with clear city/time/topic labels."
58+
),
59+
default=True,
60+
agents=[
61+
"NY-Project-Manager",
62+
"London-Project-Manager",
63+
], # children are exposed as tools: agent__NY-Project-Manager, agent__London-Project-Manager
64+
# optional: history_mode="fork_and_merge", child_timeout_sec=600, max_parallel=8, max_display_instances=10
65+
)
66+
async def main() -> None:
67+
async with fast.run() as agent:
68+
result = await agent("pls send me daily review.")
69+
print(result)
70+
71+
72+
if __name__ == "__main__":
73+
asyncio.run(main())
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""Simple Agents-as-Tools PMO example.
2+
3+
Parent agent ("PMO-orchestrator") calls two child agents ("NY-Project-Manager"
4+
and "London-Project-Manager") as tools. Each child uses the ``time`` MCP
5+
server to include local time in a brief report.
6+
7+
Defaults: clones fork parent history (no merge-back), no timeout, no parallel cap,
8+
and collapses progress display after the first 20 instances.
9+
If you want merge-back or other limits, pass decorator args:
10+
`history_mode=HistoryMode.FORK_AND_MERGE`, `child_timeout_sec=600`,
11+
`max_parallel=8`, `max_display_instances=10`
12+
(HistoryMode import: fast_agent.agents.workflow.agents_as_tools_agent).
13+
"""
14+
15+
import asyncio
16+
17+
from fast_agent import FastAgent
18+
19+
fast = FastAgent("Agents-as-Tools simple demo")
20+
21+
22+
@fast.agent(
23+
name="NY-Project-Manager",
24+
instruction="Return current time and project status.",
25+
servers=["time"], # MCP server 'time' configured in fastagent.config.yaml
26+
)
27+
@fast.agent(
28+
name="London-Project-Manager",
29+
instruction="Return current time and news.",
30+
servers=["time"],
31+
)
32+
@fast.agent(
33+
name="PMO-orchestrator",
34+
instruction="Get reports. Separate call per topic. NY: {OpenAI, Fast-Agent, Anthropic}, London: Economics",
35+
default=True,
36+
agents=[
37+
"NY-Project-Manager",
38+
"London-Project-Manager",
39+
], # children are exposed as tools: agent__NY-Project-Manager, agent__London-Project-Manager
40+
# optional: history_mode="fork_and_merge", child_timeout_sec=600, max_parallel=8, max_display_instances=10
41+
)
42+
async def main() -> None:
43+
async with fast.run() as agent:
44+
result = await agent("Get PMO report")
45+
await agent.interactive()
46+
print(result)
47+
48+
49+
if __name__ == "__main__":
50+
asyncio.run(main())

examples/workflows/fastagent.config.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,6 @@ mcp:
2121
fetch:
2222
command: "uvx"
2323
args: ["mcp-server-fetch"]
24+
time:
25+
command: "uvx"
26+
args: ["mcp-server-time"]

src/fast_agent/agents/llm_decorator.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import json
66
from collections import Counter, defaultdict
7+
from copy import deepcopy
78
from dataclasses import dataclass
89
from typing import (
910
TYPE_CHECKING,
@@ -19,6 +20,8 @@
1920
if TYPE_CHECKING:
2021
from rich.text import Text
2122

23+
from fast_agent.agents.llm_agent import LlmAgent
24+
2225
from a2a.types import AgentCard
2326
from mcp import ListToolsResult, Tool
2427
from mcp.types import (
@@ -187,6 +190,8 @@ def __init__(
187190
# Initialize the LLM to None (will be set by attach_llm)
188191
self._llm: FastAgentLLMProtocol | None = None
189192
self._initialized = False
193+
self._llm_factory_ref: LLMFactoryProtocol | None = None
194+
self._llm_attach_kwargs: dict[str, Any] | None = None
190195

191196
@property
192197
def context(self) -> Context | None:
@@ -257,8 +262,71 @@ async def attach_llm(
257262
agent=self, request_params=effective_params, context=self._context, **additional_kwargs
258263
)
259264

265+
# Store attachment details for future cloning
266+
self._llm_factory_ref = llm_factory
267+
attach_kwargs: dict[str, Any] = dict(additional_kwargs)
268+
attach_kwargs["request_params"] = deepcopy(effective_params)
269+
self._llm_attach_kwargs = attach_kwargs
270+
260271
return self._llm
261272

273+
def _clone_constructor_kwargs(self) -> dict[str, Any]:
274+
"""Hook for subclasses/mixins to supply constructor kwargs when cloning."""
275+
return {}
276+
277+
async def spawn_detached_instance(self, *, name: str | None = None) -> "LlmAgent":
278+
"""Create a fresh agent instance with its own MCP/LLM stack."""
279+
280+
new_config = deepcopy(self.config)
281+
if name:
282+
new_config.name = name
283+
284+
constructor_kwargs = self._clone_constructor_kwargs()
285+
clone = type(self)(config=new_config, context=self.context, **constructor_kwargs)
286+
await clone.initialize()
287+
288+
if self._llm_factory_ref is not None:
289+
if self._llm_attach_kwargs is None:
290+
raise RuntimeError(
291+
"LLM attachment parameters missing despite factory being available"
292+
)
293+
294+
attach_kwargs = dict(self._llm_attach_kwargs)
295+
request_params = attach_kwargs.pop("request_params", None)
296+
if request_params is not None:
297+
request_params = deepcopy(request_params)
298+
299+
await clone.attach_llm(
300+
self._llm_factory_ref,
301+
request_params=request_params,
302+
**attach_kwargs,
303+
)
304+
305+
return clone
306+
307+
def merge_usage_from(self, other: "LlmAgent") -> None:
308+
"""Merge LLM usage metrics from another agent instance into this one."""
309+
310+
if not hasattr(self, "_llm") or not hasattr(other, "_llm"):
311+
return
312+
313+
source_llm = getattr(other, "_llm", None)
314+
target_llm = getattr(self, "_llm", None)
315+
if not source_llm or not target_llm:
316+
return
317+
318+
source_usage = getattr(source_llm, "usage_accumulator", None)
319+
target_usage = getattr(target_llm, "usage_accumulator", None)
320+
if not source_usage or not target_usage:
321+
return
322+
323+
for turn in source_usage.turns:
324+
try:
325+
target_usage.add_turn(turn.model_copy(deep=True))
326+
except AttributeError:
327+
# Fallback if turn doesn't provide model_copy
328+
target_usage.add_turn(turn)
329+
262330
async def __call__(
263331
self,
264332
message: Union[
@@ -915,6 +983,22 @@ def _template_prefix_messages(self) -> list[PromptMessageExtended]:
915983
break
916984
return prefix
917985

986+
def load_message_history(self, messages: list[PromptMessageExtended] | None) -> None:
987+
"""Replace message history with a deep copy of supplied messages (or empty list)."""
988+
msgs = messages or []
989+
self._message_history = [
990+
msg.model_copy(deep=True) if hasattr(msg, "model_copy") else msg for msg in msgs
991+
]
992+
993+
def append_history(self, messages: list[PromptMessageExtended] | None) -> None:
994+
"""Append messages to history as deep copies."""
995+
if not messages:
996+
return
997+
for msg in messages:
998+
self._message_history.append(
999+
msg.model_copy(deep=True) if hasattr(msg, "model_copy") else msg
1000+
)
1001+
9181002
def pop_last_message(self) -> PromptMessageExtended | None:
9191003
"""Remove and return the most recent message from the conversation history."""
9201004
if self.llm:

0 commit comments

Comments
 (0)