Skip to content

feat(agenttool): group in-process sub-agents by conversation id (not shared session id)#156

Draft
alenkacz wants to merge 4 commits into
mainfrom
av/subagent-conversation-grouping
Draft

feat(agenttool): group in-process sub-agents by conversation id (not shared session id)#156
alenkacz wants to merge 4 commits into
mainfrom
av/subagent-conversation-grouping

Conversation

@alenkacz

Copy link
Copy Markdown
Contributor

What

Alternative to #153. Same goal — make an in-process agenttool sub-agent group with its parent as one conversation in session-oriented observability (Langfuse) — but without overloading the storage session.ID.

#153 made the sub-agent reuse the parent's session.ID. That conflates a storage primary key with a telemetry grouping id, and is safe only while the sub-agent session is never persisted — an invariant nothing enforces:

  • Both session.Store impls (InMemoryStore, Kafka KVStore) key solely on session.ID with whole-record last-write-wins; there is no compound key or merge.
  • AgentTool.New accepts any agent.Agent, so a persisting sub-agent (or future persistence on this path) would write under the shared id.
  • Tools execute concurrently (default 3-way), so multiple sub-agents would adopt the same parent id at once → silent lost-update clobbering of sibling / parent sessions.

#153's own code comment concedes this ("safe ONLY because this session is never loaded or persisted"). This PR removes the hazard instead of documenting it.

How

Decouple the conversation/grouping id from the storage id:

  • agenttool: the sub-agent keeps its own freshly minted, globally unique session.ID (can never collide in a store). When a parent invocation is in ctx, it records the parent's conversation id in session metadata — propagated transitively, so nested sub-agents group under the root conversation — plus parent_invocation_id / agent_path linkage.
  • store/session: new ConversationID(*State) resolver (single source of truth) — returns the conversation id from metadata, else the session's own id. Linkage metadata keys are namespaced under redpanda.agent.* so they can't be confused with caller-supplied metadata (and match the OTel attribute names).
  • plugins/otel: emit gen_ai.conversation.id and the injector SessionID (→ langfuse.session.id) from ConversationID on invocation, model and tool spans, so the whole sub-agent subtree reports one consistent conversation id. Within-trace nesting was already free via ctx propagation; this only aligns the conversation-id value for cross-span/Langfuse session grouping.

Net: grouping is identical to #153 at the telemetry layer, but every storage session.ID stays unique — the "safe only while unpersisted" caveat is gone.

Linkage surface (maps to Claude's model)

Concern Field Claude analog
Grouping ConversationID (metadata redpanda.agent.conversation_id, derived attr) sessionId
Per-sub-agent identity child InvocationID + unique session.ID agentId
Parent back-reference redpanda.agent.parent_invocation_id parentUuid / toolUseId

Tests

tool/agenttool/session_grouping_test.go covers: unique sub-agent id (never the parent's), grouping under the parent's conversation id, transitive propagation of the root id through nesting, context isolation, and no-parent / nil-session fallback. agent/context_test.go (ctx helpers) unchanged. go build, go vet, and the agenttool / store/session / plugins/otel / agent suites pass.

🤖 Generated with Claude Code

alenkacz and others added 4 commits June 16, 2026 16:47
In-process sub-agents (agenttool) minted a fresh `agent-tool-<name>-<nano>`
session id per call, so the OTel conversation id differed from the parent and
the two showed up as separate conversations in session-oriented observability
(e.g. Langfuse), even though the trace tree is already connected via ctx.

When a parent invocation is present in context, the sub-agent now shares the
parent's session id so the two group into one conversation, and stamps linkage
(is_sidechain / parent_invocation_id / agent_path) for reconstruction. Context
stays isolated: the sub-agent's Messages are still built fresh and never loaded,
so it cannot observe the parent's history. Falls back to the minted id when no
parent invocation is in context.

- agent: ContextWithInvocation / InvocationFromContext; llmagent injects the
  invocation into ctx before executing tools
- store/session: canonical Metadata* linkage key constants
- plugins/otel: emit redpanda.agent.* linkage span attributes on the invocation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent_invocation_id

"sidechain" was borrowed from Claude Code's internal transcript field and reads
as jargon outside that context. The boolean was also redundant: parent_invocation_id
is only set on sub-agent runs, so its presence already signals a sub-agent. Drop
the flag and rely on the linkage fields (parent_invocation_id + agent_path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…isted

Preserve the intent of the removed "unique id prevents store collisions" comment:
the id may now be shared with the parent, which is safe only because the sub-agent
session is never loaded or persisted. If a store is ever added on this path, it must
key on the unique invocation id / agent_path, not the shared session id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…session id

The previous approach made an in-process sub-agent reuse its parent's
storage session.ID purely to group them as one conversation in
observability. That overloads session.ID — a store primary key — with a
telemetry-grouping role, and rests on an unenforced "never persisted"
invariant: both Store impls key solely on session.ID with whole-record
last-write-wins, and AgentTool.New accepts any agent.Agent, so a
persisting sub-agent (or future persistence on this path) would silently
clobber the parent / sibling sessions under the shared id. Default 3-way
concurrent tool execution makes the collision concrete.

Decouple the two concepts instead:

- The sub-agent keeps its own freshly minted, globally unique session id,
  so it can never collide in a store. The "safe only while unpersisted"
  caveat is gone.
- Conversation grouping is carried as a derived value: agenttool stamps
  the parent's conversation id into session metadata (propagated
  transitively, so nested sub-agents group under the root), and the otel
  plugin emits it as gen_ai.conversation.id / the injector SessionID on
  invocation, model and tool spans via a single session.ConversationID
  resolver. Within-trace nesting was already free via ctx propagation;
  this only aligns the conversation-id attribute value.
- Linkage metadata keys are namespaced under "redpanda.agent." so they
  can't be confused with caller-supplied metadata, matching the existing
  span attribute names.

Tests updated: sub-agent gets a unique id, groups under the parent's
conversation id, propagates the root id through nesting, and stays
context-isolated. go build, go vet, affected suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@blacksmith-sh

blacksmith-sh Bot commented Jun 19, 2026

Copy link
Copy Markdown

Found 6 test failures on Blacksmith runners:

Failures

Test View Logs
github.com/redpanda-data/ai-sdk-go/providers/anthropic/
TestAnthropicConformance_Integration
View Logs
github.com/redpanda-data/ai-sdk-go/runner/TestOpenAICompatProviders/Anthropic View Logs
github.com/redpanda-data/ai-sdk-go/tool/agenttool/TestOpenAICompatProviders View Logs
github.com/redpanda-data/ai-sdk-go/tool/builtin/todo/
TestAnthropicConformance_Integration/TestAllSupportedModels/
basic_generation_works_for_all_supported_models/model_claude-fable-5
View Logs
github.com/redpanda-data/ai-sdk-go/tool/mcp/TestAnthropicConformance_Integration/
TestAllSupportedModels
View Logs
github.com/redpanda-data/ai-sdk-go/tool/mcp/TestAnthropicConformance_Integration/
TestAllSupportedModels/basic_generation_works_for_all_supported_models
View Logs

Fix in Cursor

// its activity can be told apart from the parent's even though they group
// under the same conversation (gen_ai.conversation.id).
attrAgentParentInvocationID = "redpanda.agent.parent_invocation_id"
attrAgentPath = "redpanda.agent.path"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use agent.id / agent.name ?

// Sub-agent linkage attributes. Emitted on a sub-agent's invocation span so
// its activity can be told apart from the parent's even though they group
// under the same conversation (gen_ai.conversation.id).
attrAgentParentInvocationID = "redpanda.agent.parent_invocation_id"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we go for otel traceparent for assocation this is not needed (we could do it still if needed)

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