Skip to content

feat(observability): subagent runs bypass TrajectoryRecorder #33

@justrach

Description

@justrach

Background

#29 wired `TrajectoryRecorder` into the production code path via `ForgeAPI::chat` — every top-level chat now constructs a recorder and threads it to the orchestrator. #30 wired the parent orchestrator's view of subagent dispatches (Task tool calls produce `tool_call` + `tool_result` rows on the parent's trajectory).

But child agents themselves don't go through `ForgeAPI::chat`. The Task tool path is:

```
orch.execute_tool_calls
→ services.call(...)
→ ToolRegistry::execute_tool_call
→ AgentExecutor::execute // crates/forge_app/src/agent_executor.rs:50
→ let app = crate::ForgeApp::new(self.services.clone()); // line 79
→ app.chat(...) // line 80
```

`AgentExecutor` calls `ForgeApp::new` directly with no `with_trajectory_recorder`. So the child's internal tool calls (and any further nested subagents) never make it into `trajectory_events`.

What `/trace` of a parent conversation shows today:

  • ✅ Parent's tool calls
  • ✅ Parent's view of Task dispatches (`tool_call` / `tool_result` for the Task itself)
  • ❌ Child's internal tool calls under the child's own `agent_id`

Proposed fix

Build a child recorder inside `AgentExecutor::execute` and pass it via `with_trajectory_recorder` before calling `chat`. Two design questions:

  1. Where does AgentExecutor get an `Arc`?

    • Option A: add a `+ TrajectoryRepo` bound to `AgentExecutor` impl. Was rejected for `ForgeApp` due to blast radius (multiple callers); for `AgentExecutor` the only caller is `ToolRegistry` which is itself bounded on Services. Probably tractable here.
    • Option B: thread the `Arc` through AgentExecutor's constructor, set by ForgeApp when it builds its tool registry. No new bound, but extra plumbing.
  2. What's the `parent_agent_id` for the child recorder?

    • The dispatching agent's id. Currently AgentExecutor doesn't know it — it's the agent that invoked the Task tool, which is the parent orchestrator's `self.agent.id`. Would need to be passed through `AgentExecutor::execute`.

Acceptance

  • After a Task dispatch, `SELECT DISTINCT agent_id FROM trajectory_events WHERE conversation_id = …` returns both parent and child agent ids
  • Child rows have `parent_agent_id` set to the parent's agent_id
  • `/trace` (which already walks the parent_agent_id chain — feat(observability): /trace should walk parent_agent_id chain #31) renders the child's tool calls indented under the parent's Task dispatch row
  • Recursive subagents (child spawning grandchild) record correctly with `grandchild.parent_agent_id == child.agent_id`

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions