Skip to content

Feat: hybrid Agents-as-Tools/MCP-as-tools experimental agent#515

Merged
evalstate merged 110 commits intoevalstate:mainfrom
strato-space:feat/agents-as-tools
Dec 14, 2025
Merged

Feat: hybrid Agents-as-Tools/MCP-as-tools experimental agent#515
evalstate merged 110 commits intoevalstate:mainfrom
strato-space:feat/agents-as-tools

Conversation

@iqdoctor
Copy link
Contributor

@iqdoctor iqdoctor commented Nov 24, 2025

Agents-as-Tools: hybrid agent + examples

Closes #458.

This adds a hybrid Agents-as-Tools/MCP-as-tools experimental agent and two PMO-style examples that show a parent agent calling child agents as tools (with parallel execution), plus a brief design doc and README section.

What’s included

  • Core hybrid agent

    • src/fast_agent/agents/workflow/agents_as_tools_agent.py

      • New AgentsAsToolsAgent(McpAgent):

        • Inherits MCP behavior and tools from McpAgent.

        • Exposes each child agent as an additional tool (agent__ChildName).

        • list_tools() returns MCP tools + agent-tools as one surface.

        • call_tool() routes to child agents first, then falls back to McpAgent.call_tool.

        • run_tools():

          • Splits tool calls into child-agent tools vs MCP/local tools.
          • Runs child-agent tools in parallel via detached clones (spawn_detached_instance), with suffixed names like Child[1].
          • Emits per-instance ProgressEvent updates so each clone appears as its own line in the progress panel.
          • Delegates remaining tools to McpAgent.run_tools and merges all results + error text.
  • Simple PMO example (minimal)

    • examples/workflows/agents_as_tools_simple.py

      • NY-Project-Manager and London-Project-Manager use servers=["time"].

      • PMO-orchestrator has agents=[...] and calls children as tools:

        • NY topics: {OpenAI, Fast-Agent, Anthropic}.
        • London: Economics.
      • Entry point: await agent("Get PMO report").

  • Extended PMO + news example

    • examples/workflows/agents_as_tools_extended.py

      • Same agents, but with servers=["time", "fetch"].

      • NY PM:

        • Time + project-relevant news per topic.
        • On HTTP 403 / robots.txt: try up to 5 alternative public sources; clearly report remaining limits.
        • Hint: Fast-Agent site.
      • London PM:

        • Time + economics/news.
        • Hints to BBC + FT as candidate sources.
      • PMO-orchestrator:

        • Calls NY PM three times (Anthropic, evalstate/fast-agent, OpenAI).
        • Calls London PM for an economics review.
        • Returns a concise combined summary, labeled by city/time/topic.
  • Docs & design

    • README.md

      • New Agents-as-Tools section:

        • Explains the pattern in a few bullets.

        • Shows the minimal PMO example (simple file).

        • Brief “Architecture (brief)” list:

          • New class, example files, design doc location.
        • Links to the extended workflow file.

    • agetns_as_tools_plan_scratch.md

      • From-scratch design plan:

        • Conceptual model, detached per-call clones.
        • Parallel run_tools algorithm.
        • Display/usage semantics and future extensions.

How to run

From repo root:

cd examples/workflows

# Minimal PMO example (time only)
uv run agents_as_tools_simple.py

# Extended PMO example (time + news via 'fetch')
uv run agents_as_tools_extended.py

iqdoctor and others added 30 commits November 8, 2025 12:07
…ldren 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
docs: rename plantype references to plan_type (evalstate#456)
… 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
- 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 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
- 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'
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.
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
- 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
- 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.
- 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
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.
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).
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.
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.
Ensures child agent tool calls remain visible in chat log by explicitly
setting show_tools = True when creating temporary config.
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.
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
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.
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.
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.
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.
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
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}'
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.
…ying 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.
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.
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.
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.
…lel 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
@iqdoctor
Copy link
Contributor Author

Agents-as-Tools — PR #515 update summary

  • Merged upstream/main and fix/vertex-prio-and-models into feat/agents-as-tools, retaining Agents-as-Tools customizations. Vertex ADC/no-key path plus provider key manager skip and vertex tests are included.
  • Options: keyword-only AgentsAsToolsOptions (history_mode scratch/fork/fork_and_merge, max_parallel, child_timeout_sec, max_display_instances) wired through decorator/factory. Defaults: fork, no timeout, 20 visible, no parallel cap unless set.
  • History: clones load parent history; fork_and_merge appends only post-fork messages back under per-target asyncio locks via load_message_history/append_history. Scratch starts empty.
  • Limits/timeouts: max_parallel processes first N calls and marks the rest skipped with clear errors; optional per-child timeout wraps each clone call via asyncio.wait_for.
  • Progress/UI: standard CHATTING/FINISHED events; collapse after top N (default 20) with “[N..M]”; tool headers/results keep suffixed [i] names. Display suppression via refcounted _child_display_suppressed.
  • Tool surface: list_tools() merges MCP tools with agent-tools; run_tools() validates against the combined surface.
  • Correlation metadata: ProgressEvent and tool displays include correlation_id, instance_name, tool_name for tracing parallel calls.
  • Upstream reuse: clones via spawn_detached_instance, usage rollup via merge_usage_from, history via public accessors; no custom attach logic.
  • Docs/UX: README/examples document options and merged surface; plan docs marked complete; speculative per-instance futures removed.
  • Tests: unit coverage for list/call/run, error-channel propagation, max_parallel/timeout behavior, nested Agents-as-Tools labeling, plus vertex config tests. Recent runs: tests/unit/fast_agent/agents/workflow/test_agents_as_tools_agent.py and tests/unit/fast_agent/llm/providers/test_llm_google_vertex.py passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature: Agents-as-Tools — expose child agents as callable tools with parallel execution

6 participants