Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,13 +478,22 @@ pnpm exec tsx examples/claudeAgents.ts
- Regression coverage must exercise both the calls-based and OTel integrations
with nonzero cache-read and cache-creation counts.
- The OTel-selected Claude Agent SDK path composes the Python GenAI
`Conversation`, `Turn`, `LLM`, and `Tool` handles; it must not create raw
OTel spans or call the low-level GenAI attribute builders itself. Use
`set_attributes()` only for semantic fields the typed handles do not expose.
The legacy calls-based path remains separate.
`Conversation`, `Turn`, `LLM`, `Tool`, and `SubAgent` handles; it must not
create raw OTel spans or call the low-level GenAI attribute builders itself.
Use `set_attributes()` only for semantic fields the typed handles do not
expose. The legacy calls-based path remains separate.
- Tap Python `AsyncIterable[dict]` prompts without consuming or cloning them.
Map Claude text and base64/URL image blocks to GenAI text, blob, and URI
parts; media payloads remain data for `content_refs`, not chat prose.
- Treat `Agent` and legacy `Task` tool calls as subagents keyed by tool-use ID.
Route nested assistant messages and tools through `parent_tool_use_id`, and
close each subagent on its matching tool result.
- Start those subagents with `SubAgent.start(set_current=False)`, never
`__enter__`. Parallel delegations close in completion order, and `end()`
detaches through `ContextVar.reset`, so an out-of-LIFO close leaves the
ambient OTel context pointing at an ended span — user code running between
streamed messages would nest under it. Children nest either way, because
`start_llm` / `start_tool` / `start_subagent` thread an explicit parent.
- Stream adapters can create child handles when work starts, then enter them
with a normal `with` when the completion message arrives. Preserve logical
timing with `LLM.started_at` and an explicit `Tool.started_at` instead of
Expand Down
57 changes: 57 additions & 0 deletions tests/conversation/test_subagent_nesting.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import threading

import pytest
from opentelemetry import trace as otel_trace
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter

from weave.conversation.conversation import (
Expand Down Expand Up @@ -333,3 +334,59 @@ def worker() -> None:
llm_span = _by_prefix(spans, "chat")
assert llm_span.parent is not None
assert llm_span.parent.span_id == sa_span.context.span_id


# ---------------------------------------------------------------------------
# Concurrent sub-agents: SubAgent.start(set_current=False)
# ---------------------------------------------------------------------------


class TestConcurrentSubagents:
"""``end()`` detaches via ``ContextVar.reset``, so overlapping sub-agents
that finish out of LIFO order corrupt the ambient OTel context. Adapters
that run sub-agents concurrently start them with ``set_current=False``.
"""

@pytest.mark.parametrize("close_order", [("a", "b"), ("b", "a")])
def test_ambient_context_survives_any_close_order(
self, otel_spans: InMemorySpanExporter, close_order: tuple[str, str]
) -> None:
with Conversation(conversation_id="s"), Turn(agent_name="bot") as turn:
subagents = {
"a": turn.start_subagent(name="alpha").start(set_current=False),
"b": turn.start_subagent(name="beta").start(set_current=False),
}
for key in close_order:
subagents[key].end()
assert (
otel_trace.get_current_span().get_span_context().span_id
== turn._otel_span.get_span_context().span_id
)

spans = otel_spans.get_finished_spans()
turn_span = _by_agent_name(spans, "bot")
for name in ("alpha", "beta"):
assert _by_agent_name(spans, name).parent.span_id == (
turn_span.context.span_id
)

def test_children_still_nest_under_a_non_current_subagent(
self, otel_spans: InMemorySpanExporter
) -> None:
with Conversation(conversation_id="s"), Turn(agent_name="bot") as turn:
first = turn.start_subagent(name="alpha").start(set_current=False)
second = turn.start_subagent(name="beta").start(set_current=False)
with first.start_llm(model="gpt-4o"):
pass
with second.start_tool(name="grep"):
pass
first.end()
second.end()

spans = otel_spans.get_finished_spans()
assert _by_prefix(spans, "chat").parent.span_id == (
_by_agent_name(spans, "alpha").context.span_id
)
assert _by_prefix(spans, "execute_tool").parent.span_id == (
_by_agent_name(spans, "beta").context.span_id
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
[
{
"type": "system",
"subtype": "init",
"session_id": "s-parallel001"
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "I will delegate both questions."
},
{
"type": "tool_use",
"id": "toolu_agent_a",
"name": "Agent",
"input": {
"subagent_type": "geographer",
"description": "Answer geography questions",
"prompt": "Find the capital of France."
}
},
{
"type": "tool_use",
"id": "toolu_agent_b",
"name": "Agent",
"input": {
"subagent_type": "astronomer",
"description": "Answer astronomy questions",
"prompt": "Find the largest planet."
}
}
],
"model": "claude-sonnet-4-6"
},
"parent_tool_use_id": null
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "The capital of France is Paris."
}
],
"model": "claude-haiku-4-5"
},
"parent_tool_use_id": "toolu_agent_a"
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "The largest planet is Jupiter."
}
],
"model": "claude-haiku-4-5"
},
"parent_tool_use_id": "toolu_agent_b"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_agent_a",
"content": "The capital of France is Paris.",
"is_error": false
}
]
},
"parent_tool_use_id": null
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_agent_b",
"content": "The largest planet is Jupiter.",
"is_error": false
}
]
},
"parent_tool_use_id": null
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "Paris and Jupiter."
}
],
"model": "claude-sonnet-4-6"
},
"parent_tool_use_id": null
},
{
"type": "result",
"subtype": "success",
"duration_ms": 12,
"duration_api_ms": 10,
"is_error": false,
"num_turns": 1,
"session_id": "s-parallel001",
"total_cost_usd": 0.001,
"usage": {
"input_tokens": 20,
"output_tokens": 10
},
"result": "Paris and Jupiter."
}
]
123 changes: 123 additions & 0 deletions tests/integrations/claude_agent_sdk/cassettes/subagent_response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
[
{
"type": "system",
"subtype": "init",
"session_id": "s-subagent001"
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "I will delegate this research."
},
{
"type": "tool_use",
"id": "toolu_agent_01",
"name": "Agent",
"input": {
"subagent_type": "researcher",
"description": "Research factual questions",
"prompt": "Find the capital of France."
}
}
],
"model": "claude-sonnet-4-6"
},
"parent_tool_use_id": null
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "I will verify the answer."
},
{
"type": "tool_use",
"id": "toolu_bash_01",
"name": "Bash",
"input": {
"command": "printf Paris"
}
}
],
"model": "claude-haiku-4-5"
},
"parent_tool_use_id": "toolu_agent_01"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_bash_01",
"content": "Paris"
}
]
},
"parent_tool_use_id": "toolu_agent_01"
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "The capital of France is Paris."
}
],
"model": "claude-haiku-4-5"
},
"parent_tool_use_id": "toolu_agent_01"
},
{
"type": "user",
"message": {
"content": [
{
"type": "tool_result",
"tool_use_id": "toolu_agent_01",
"content": [
{
"type": "text",
"text": "The capital of France is Paris."
}
]
}
]
},
"parent_tool_use_id": null
},
{
"type": "assistant",
"message": {
"content": [
{
"type": "text",
"text": "The researcher confirmed that Paris is the capital of France."
}
],
"model": "claude-sonnet-4-6"
},
"parent_tool_use_id": null
},
{
"type": "result",
"subtype": "result",
"duration_ms": 1800,
"duration_api_ms": 1400,
"is_error": false,
"num_turns": 1,
"session_id": "s-subagent001",
"total_cost_usd": 0.006,
"usage": {
"input_tokens": 120,
"output_tokens": 45
},
"result": "The researcher confirmed that Paris is the capital of France."
}
]
Loading
Loading