Skip to content

feat: preserve Claude subagent tool lineage for tracing - #10959

Merged
ysolanky merged 2 commits into
mainfrom
feat/claude-ext-agent-tracing-pre-req
Oct 11, 2026
Merged

ysolanky merged 2 commits into
mainfrom
feat/claude-ext-agent-tracing-pre-req

Conversation

@kausmeows

@kausmeows kausmeows commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Claude SDK messages include parent_tool_use_id, but the adapter previously discarded it when creating Agno tool events. This prevented tracing integrations from distinguishing subagent tools from top-level tools.

  • Add optional parent_tool_call_id to ToolExecution, including serialization and deserialization.
  • Preserve Claude’s parent IDs in streaming tool events and non-streamed tool records.
  • Carry lineage from tool-start events into completion events when completion messages omit it.
  • Preserve the SDK’s tool-error flag in streaming completion events.
  • Add regression tests and a cookbook that verifies nested tool spans persisted in Agno’s tracing database.

With the corresponding OpenInference update, traces can represent:

ClaudeAgent.run
├── Agent
│   ├── Bash
│   └── Read
└── Bash

tracing update on openinference- Arize-ai/openinference#4018

to test locally use uv pip install -e ../openinference/python/instrumentation/openinference-instrumentation-agno/

Quick cookbook (not adding in the PR files since the openinference update needs to be released first, so the cookbook cant go on main yet.-

mport asyncio
from pathlib import Path
from tempfile import TemporaryDirectory
from uuid import uuid4

from claude_agent_sdk import AgentDefinition
from opentelemetry import trace

from agno.agents.claude import ClaudeAgent
from agno.db.sqlite import SqliteDb
from agno.run.agent import RunOutput
from agno.tracing import setup_tracing


async def main() -> None:
    db = SqliteDb(db_file="tmp/claude-nested-traces.db")
    setup_tracing(db=db)
    run_id = str(uuid4())
    with TemporaryDirectory(prefix="agno-trace-probe-") as directory:
        Path(directory, "probe.txt").write_text("nested_read_probe\n")
        agent = ClaudeAgent(
            name="Claude trace probe",
            model="claude-sonnet-4-6",
            db=db,
            cwd=directory,
            allowed_tools=["Agent", "Bash", "Read"],
            max_turns=8,
            max_budget_usd=1.0,
            options_kwargs={
                "agents": {
                    "trace-probe": AgentDefinition(
                        description="Run the tracing verification tools.",
                        prompt=(
                            "Run Bash with `printf nested_shell_probe` once, then Read probe.txt "
                            "in the working directory. Report both outputs. Do not modify files."
                        ),
                        tools=["Bash", "Read"],
                        model="haiku",
                    )
                }
            },
        )
        output = None
        async for event in agent.arun(
            "Use the trace-probe subagent to perform its verification. After it finishes, "
            "run Bash yourself with `printf main_shell_probe` once. Then report the three outputs.",
            stream=True,
            yield_run_output=True,
            run_id=run_id,
            session_id=run_id,
            user_id="tracing-demo",
        ):
            if isinstance(event, RunOutput):
                output = event

    trace.get_tracer_provider().force_flush()
    assert output is not None and output.status.value.lower() == "completed", (
        "Claude run did not complete"
    )
    record = db.get_trace(run_id=run_id)
    assert record is not None, "No trace persisted; check the instrumenter installation"
    spans = db.get_spans(trace_id=record.trace_id)
    root = next(span for span in spans if span.parent_span_id is None)
    tool_spans = {
        span.attributes["tool_call.id"]: span
        for span in spans
        if "tool_call.id" in span.attributes
    }
    nested = [
        span
        for span in tool_spans.values()
        if span.attributes.get("agno.tool.parent_call_id")
    ]
    assert nested, (
        "No nested tools recorded; check both Agno and the instrumenter have lineage support"
    )
    for span in nested:
        parent = tool_spans[span.attributes["agno.tool.parent_call_id"]]
        assert span.parent_span_id == parent.span_id
    assert any(
        span.name == "Bash" and span.parent_span_id == root.span_id
        for span in tool_spans.values()
    )
    assert {"Bash", "Read"}.issubset({span.name for span in nested})
    assert all(span.status_code == "OK" for span in spans)

    def show_tree(parent_id: str | None, depth: int = 0) -> None:
        for span in sorted(spans, key=lambda span: span.start_time):
            if span.parent_span_id == parent_id:
                print("  " * depth + span.name)
                show_tree(span.span_id, depth + 1)

    show_tree(None)
    print("Trace ID:", record.trace_id)
    print(
        "Verified nested tool spans and top-level sibling in tmp/claude-nested-traces.db"
    )


if __name__ == "__main__":
    asyncio.run(asyncio.wait_for(main(), timeout=120))

@kausmeows
kausmeows requested a review from a team as a code owner October 10, 2026 19:49
@ysolanky
ysolanky merged commit df4a586 into main Oct 11, 2026
12 checks passed
@ysolanky
ysolanky deleted the feat/claude-ext-agent-tracing-pre-req branch October 11, 2026 00:22
@kausmeows kausmeows mentioned this pull request Oct 11, 2026
kausmeows added a commit that referenced this pull request Oct 12, 2026
# Changelog

## New Features:

- **Durable External Agent Sessions**: `ClaudeAgent` sessions now resume
on any replica. The Claude SDK session id is stored on the Agno session
(#10903), and with a database that supports transcript storage
(`PostgresDb`, `AsyncPostgresDb`, `SqliteDb`, `AsyncSqliteDb`) every
transcript line is mirrored into the new `agno_transcripts` table, so a
later turn on another machine resumes from the database instead of a
local file. A stored session that cannot be resumed falls back to a
fresh SDK session seeded with the Agno history. Claude and Codex
non-streaming runs now keep tool calls and results in run history. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/transcript_store.py).
(#10904)
- **Background Runs & Cancellation for External Agents**: `ClaudeAgent`
and `CodexAgent` support `background=True`, durable-queue run ids,
resumable indexed SSE and cancellation through
`/agents/{id}/runs/{run_id}/cancel`, sharing the background task, slot
and event-stream infrastructure of native agents. Claude uses a per-run
SDK client and Codex retains turn handles, so the owning process can
interrupt either SDK. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/background_cancel.py).
(#10905)
- **Step Replay for ClaudeAgent**: `ClaudeAgent.continue_run` /
`acontinue_run` take the same `continue_from`, `fork` and `input`
arguments as `Agent.continue_run`, with `stream=True` and
`background=True`. Each tool result is a checkpoint listed by `GET
/agents/{id}/runs/{run_id}/checkpoints`; continuing forks the stored SDK
transcript at that message, so the model only remembers what happened up
to it, and `continue_from="last_user"` replays the turn. A finished run
is never rewritten in place: its continuation is a new sibling run. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/continue_from/01_continue_finished_run.py).
(#10917)
- **Retries for External Agents**: `ClaudeAgent` and `CodexAgent` take
`retries`, `delay_between_retries` and `exponential_backoff` with the
same defaults as `Agent`. A failed attempt is retried with the same run
id and resumes the SDK session or Codex thread it started; limits
(`max_turns`, `max_budget_usd`, Codex budget and usage limits) and
permanent errors (authentication, billing, bad requests, context window,
policy) are not retried. Tool calls from failed attempts are kept
exactly once, streaming consumers get a `type: "retry"` warning event
between attempts, a retried run records `metadata["attempts"]`, and the
durable queue fails a job at once when the agent marks its error
non-retryable, including across worker restarts. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/claude_retries.py).
(#10937)
- **Metrics for External Agents**: Every `ClaudeAgent` and `CodexAgent`
run reports tokens, cache usage and cost (Claude) on
`RunOutput.metrics`, with one entry per model in
`metrics.details["model"]`, plus Agno's wall-clock `duration` and
`time_to_first_token`. Completed runs accumulate into the session's
`session_metrics`, which AgentOS shows in the sessions list, the session
view and `/metrics`. Codex sums every model request in a turn, not only
the last one. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/metrics_agentos.py).
(#10925)
- **Compaction for External Agents**: Claude Code's `/compact` works
through the database-backed session store, so a compacted session
resumes from its summary on any replica, and `CodexAgent.compact` /
`acompact` compact the Codex thread behind an Agno session. Compaction
refuses while a run on the session is in flight. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/codex/codex_compaction.py).
(#10924)
- **Stored Events for External Agents**: `BaseExternalAgent` takes
`store_events` and `events_to_skip` like `Agent`. AgentOS turns it on
for external agents, so the run view and
`/agents/{id}/runs/{run_id}/resume` replay a finished run from the
database once the event stream's retention has expired, in order and
honouring `last_event_index`. (#10957, #10958)
- **Structured Output for External Agents**: `output_schema` on
`ClaudeAgent` and `CodexAgent` takes a Pydantic model or a JSON Schema
dict, on the agent or per run, and the run's content is the parsed model
(or dict) with `content_type` set, as for `Agent`. Claude delivers the
object through its structured output tool, which is not recorded as a
tool call; Codex receives a strict schema. The schema must describe a
JSON object; an array or scalar root is refused before the run. The
AgentOS per-request `output_schema` form field now applies to external
agents. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/structured_output.py).
(#10965)
- **Media for External Agents**: Images and files can be attached to
`ClaudeAgent` and `CodexAgent` runs, directly and through the AgentOS
API and UI. Attachments are staged under
`<cwd>/.agno/uploads/<run_id>/`, named in the prompt (Codex also
receives images natively), recorded on the run's input, restored for
later turns on any replica and removed after the run unless
`keep_uploads=True`. Audio and video are rejected with a 400 instead of
a server error. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/claude_media.py).
(#10926)
- **External Agent Identity on Session APIs**: `GET /sessions`, `GET
/sessions/{session_id}` and the session run reads carry the same
`metadata.sdk` value as the agents API (`"codex"`,
`"claude-agent-sdk"`), overlaid at read time from the `agent_data`
external agents already persist, so existing sessions are labelled with
no migration and stored metadata is untouched. Native, team and workflow
sessions are unchanged. `RunSchema` gains a `metadata` field, which also
surfaces run metadata such as retry `attempts` and `warnings`. (#11020)
- **AnakinTools**: New toolkit for Anakin.io's scrape, crawl, map and
search APIs as four flag-gated methods, polling asynchronous jobs until
they complete so the model receives page content or links rather than a
job id. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/91_tools/anakin_tools.py).
(#10650)

## Improvements:

- **External Agent APIs**: `ClaudeAgent` and `CodexAgent` take typed
native options (tools, permissions, MCP servers, thread and turn
options) instead of untyped `_kwargs` dictionaries, which still work
with a warning. Adapter constructors are keyword-only, `agent.sdk` names
the SDK (API and session metadata emit only `sdk`; the `framework` alias
remains on the Python object), and `print_response` / `aprint_response`
return the final run and raise on failure. Framework cookbooks were
reworked with native SDK comparisons, a sample project and a
reproducible reliability kit. (#10926)
- **Model Identity for External Agents**: `ClaudeAgent` and `CodexAgent`
runs record `model` and `model_provider` like native agents, so they
count on the Models Runs card of the AgentOS metrics page. When no model
is set on the agent or in its options, `model` defaults to
`claude-sonnet-4-6` for Claude and `gpt-5.6-luna` for Codex, as a native
model has a default id; the default is what the SDK is asked to run. A
model set in options still wins. Previously an unset model left the
choice to the CLI. (#11018)
- **Queued Turns Per Session**: Queued external-agent turns on one
session no longer claim attempts together and burn them on
`SessionBusyError`. Each built-in queue store (Postgres, Redis,
in-memory) elects the next turn for a session before it spends an
attempt or a worker slot. (#10974)
- **Harness Cookbooks**: The Claude and Codex cookbooks were refreshed
after Phase 1 acceptance testing with typed native options throughout, a
corrected reliability runner, and live results recorded in each folder's
test log. (#10973)
- **One Turn Per Session**: Claude Code and Codex keep one conversation
per session on their side, so a second turn started while one is in
flight is now refused with `SessionBusyError` (HTTP 409, `error_type:
session_busy`) instead of being accepted and failing later. Cancelling a
run left pending or running by a crash frees its session. (#10958)
- **Subagent Tool Lineage**: Tool calls made by Claude subagents carry
`parent_tool_call_id` on `ToolExecution` and on tool events, so tracing
shows nested tool spans under the delegating tool. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/continue_from/07_subagent_checkpoints.py).
(#10959)
- **Development Setup**: `scripts/dev_setup.sh` and
`scripts/demo_setup.sh` install the same dependencies, so cookbooks run
from either environment. (#10920)

## Bug Fixes:

- **Session Busy Keep-Alive**: The 409 for a busy external-agent session
is answered by a dedicated `SessionBusyError` handler instead of the
catch-all, so the keep-alive connection stays open and the conflict is
logged as a warning rather than a traceback. The response body is
unchanged and no other exception takes the new path. See
[cookbook](https://github.com/agno-agi/agno/blob/main/cookbook/frameworks/claude-agent-sdk/session_busy_agentos.py).
(#11017)
- **Attachment Staging**: Files attached to external-agent runs are
staged and restored only under the active workspace, with the harness
told when historical paths moved. A failed staging rolls back every file
it created, including partial writes and with `keep_uploads=True`, while
preexisting files are preserved. Traversal run ids and symlinked upload
paths are rejected. (#10971)
- **Run Input Validation**: Malformed JSON `images`, `audio`, `videos`
and `input_files` on run requests are rejected with HTTP 422 before
dispatch instead of vanishing or failing mid-run, and a malformed or
scalar-root `output_schema` is rejected instead of being silently
dropped. (#11005)
- **CodexAgent**: Failed tool calls (a non-zero exit, a declined
command, an MCP tool error) are stored with `tool_call_error=True`
instead of `null`, on streamed and non-streamed runs. (#10958)
- **CodexAgent**: When a thread cannot be resumed, the history replayed
into the fresh thread now includes earlier tool calls and their results,
truncated to 2000 characters each, instead of only user and assistant
text. (#10909)
- **External Agent Resume**: `/resume` on a run submitted with
`stream=false` reports the run's real status and says to poll the run
endpoint, instead of claiming the run completed while it was still
running. (#10958)
- **Tests**: The partial-tool collision test no longer depends on
FastMCP's tool naming. (#10914)

Co-authored-by: Sannya Singal <32308435+sannya-singal@users.noreply.github.com>
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.

2 participants