Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
230 changes: 230 additions & 0 deletions design/EP-2153-a2a-session-isolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
# EP-2153: Per-call session isolation + robust sub-agent session reporting

* Issue: [#2153](https://github.com/kagent-dev/kagent/issues/2153)
* Upstream PR: https://github.com/kagent-dev/kagent/pull/2153

Status: implemented

## Goal

Let a declarative agent fan out **parallel, isolated** sub-agent calls, and make
each of those calls **observable** in the UI by always reporting the sub-agent
session it ran in.

Two capabilities, delivered together:

1. **Per-call session isolation** (`isolateSessions`) — mint a fresh A2A
`context_id` per sub-agent call so parallel fan-out no longer collapses into
one shared sub-agent session.
2. **A single typed response contract** — every remote A2A tool outcome
(success, `input_required`, failure) returns the per-call `context_id` as
`subagent_session_id`, so the UI can always link a sub-agent card to the
session that produced it.

## Problem

### Shared sub-agent session (isolation)

`go/adk/pkg/tools/remote_a2a_tool.go` generated one `context_id` **once**, at
tool construction (pod startup), and stamped it on **every** outbound A2A
message:

```go
state := &remoteA2AState{ /* ... */ lastContextID: a2atype.NewContextID() }
// ...
message.ContextID = s.lastContextID // same id for every call, forever
```

The worker derives its session id straight from that context id
(`go/adk/pkg/a2a/executor.go`: `sessionID := reqCtx.ContextID`). So N parallel
calls → 1 shared worker session, serialized/interleaved rather than N parallel
sessions.

Observed: a coordinator emitted 11 sub-agent calls in one model turn; all 11
collapsed into a single worker session (11 A2A tasks in one context) instead of
11 isolated sessions.

### Session id silently dropped on some branches (reporting)

The tool returned each outcome as an ad-hoc `map[string]any` literal, built
independently per branch. Nothing forced a branch to include
`subagent_session_id`, and several omitted it (direct-message result,
`input_required`, and the no-result fallback). With isolation on there is no
stable, pre-known session id to fall back to, so any branch that drops the
per-call id leaves the UI unable to link that sub-agent card to a session.

## Design

### `isolateSessions` flag

A per-Agent-tool boolean.

- **`false` (default):** one stable `context_id` per tool for its lifetime →
session continuity for stateful sub-agents. Unchanged behavior.
- **`true`:** `context_id` is minted per call via `a2atype.NewContextID()` →
each call is an isolated sub-agent session; parallel fan-out no longer shares
state/history.

```go
// contextIDForCall returns the A2A context_id (== sub-agent session id) to stamp
// on this call. With isolation enabled every call gets a fresh id; otherwise the
// stable per-tool id is reused.
func (s *remoteA2AState) contextIDForCall() string {
if s.isolateSessions {
return a2atype.NewContextID()
}
return s.lastContextID
}
```

Cross-conversation/turn continuity that stateful workers need does **not**
depend on the message `context_id`: it rides the `x-kagent-root-context-id`
header (`lineageHeadersInterceptor`), which stays stable per conversation
regardless of this flag. So isolation only changes which sub-agent *session* a
turn is recorded in.

### UI linking

The executor can pre-stamp outgoing `function_call` parts with a pre-known
`subagent_session_id` from a startup map (`subagentSessionIDs`, keyed by tool
name). That model only works when a tool has exactly one session for its whole
lifetime — it breaks down under `isolateSessions`, where the real session is
per call. So an isolated tool contributes **no** map entry (its constructor
returns an empty stamp id), and the UI links each sub-agent card via the
per-call `subagent_session_id` returned in the tool's `function_response`
(consumed by `AgentCallDisplay`).

For this to hold, that response field must be present on **every** branch — see
below.

### Typed response contract (the reporting fix)

Every outcome returns one shared struct instead of a per-branch map literal, so
`SubagentSessionID` (the per-call `context_id`) is a field every path sets and
cannot be forgotten in one branch while present in another:

```go
// remoteA2AResponse is the single typed return value for every remote A2A tool
// outcome — success, input_required, and failure alike. functiontool.New infers
// the tool's output schema from this type.
type remoteA2AResponse struct {
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorType string `json:"error_type,omitempty"`
ErrorStep string `json:"error_step,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
RemediationHint string `json:"remediation_hint,omitempty"`
Status string `json:"status,omitempty"`
WaitingFor string `json:"waiting_for,omitempty"`
Subagent string `json:"subagent,omitempty"`
SubagentSessionID string `json:"subagent_session_id,omitempty"`
SubagentTaskID string `json:"subagent_task_id,omitempty"`
SubagentTaskState string `json:"subagent_task_state,omitempty"`
KAgentUsageMetadata map[string]any `json:"kagent_usage_metadata,omitempty"`
}
```

`subagent_session_id` reporting per branch:

| Outcome | Reported session id |
|---|---|
| completed | per-call `context_id` |
| direct `*Message` result | per-call `context_id` |
| `input_required` (incl. nil-task guard) | paused task's `context_id`, else per-call |
| failed / canceled / rejected | task's `context_id`, else per-call |
| non-terminal after call end | per-call `context_id` |
| request error | per-call `context_id` |
| no result returned | per-call `context_id` |

`processResult` switches **explicitly** on the terminal task state: only
`completed` is a success; `failed`/`canceled`/`rejected` become typed errors
with sub-agent linkage (`subagent_task_id`, `subagent_task_state`); a task left
in a non-terminal state (`working`/`submitted`/…) after the call ends is never
treated as success. Child-provided structured failure fields (`error_type`,
`error_step`, `error_message`, `remediation_hint`) are overlaid onto the struct
— read from the failed task's status-message data parts, or from a JSON object
embedded in the failure text (optionally fenced) — with the child's value
winning over the generic `subagent_<state>` default.

## Relationship to PR #2153

PR #2153 is the upstream origin of both ideas. This branch implements them as a
clean, self-contained change on top of `main`:

| Aspect | Upstream PR #2153 | This branch |
|---|---|---|
| `isolateSessions` per-call `context_id` | ✅ | ✅ |
| Typed `remoteA2AResponse`, always report `subagent_session_id` | ✅ | ✅ (extended: task-id/state + structured failure passthrough) |
| Explicit terminal-state switch in `processResult` | partial | ✅ (non-terminal is never success) |
| Tool constructor | positional params | `NewKAgentRemoteA2ATool(RemoteA2AToolConfig)` — config-struct, booleans can't be transposed |
| `subagentSessionIDs` stamp-map plumbing | removed | retained (empty entry for isolated tools; still serves non-isolated tools) |

## Changes

| File | Change |
|------|--------|
| `go/api/v1alpha2/agent_types.go` | `IsolateSessions *bool` on `Tool` (CEL: `type=Agent` only) |
| `go/api/adk/types.go` | `IsolateSessions bool` (`isolate_sessions`) on `RemoteAgentConfig` |
| `go/core/internal/controller/translator/agent/compiler.go` | Copy `tool.IsolateSessions` → `RemoteAgentConfig.IsolateSessions` |
| `go/adk/pkg/agent/agent.go` | Pass flag via `RemoteA2AToolConfig`; skip stamp-map entry when isolated |
| `go/adk/pkg/tools/remote_a2a_tool.go` | `isolateSessions` field + `contextIDForCall`; **typed `remoteA2AResponse` on every branch, always setting `subagent_session_id`** |
| `python/packages/kagent-adk/src/kagent/adk/types.py` | `isolate_sessions` field for config-schema parity (Go runtime honors it) |
| generated | CRD YAML (`api/config/crd/bases` + `helm/kagent-crds/templates`) + DeepCopy via `controller-gen` |

## Deployment / configuration

```yaml
apiVersion: kagent.dev/v1alpha2
kind: Agent
metadata:
name: coordinator
spec:
declarative:
runtime: go
tools:
- type: Agent
agent:
name: worker
isolateSessions: true # each worker call = its own isolated session
```

- Unset / `false` → current behavior (one shared sub-agent session).
- `true` → fresh sub-agent session per call; enables parallel isolated fan-out.

## Scope / non-goals

- **Runtime:** honored by the **Go** declarative runtime. The field is accepted
by the Python config model (schema parity) but the Python low-level tool is
unchanged.
- **Not** a concurrency limiter — capping parallel invocations per pod is a
separate, complementary mechanism.
- **Not** a streaming relay or per-call timeout — this change is limited to
session isolation and the response contract. Those are possible follow-ups.
- No change to HITL resume: resume still targets the original sub-agent session
via the `context_id` stored in the confirmation payload.

## Behavior notes & caveats

- Stateful sub-agents that rely on **reused** session history across calls must
keep `isolateSessions: false` (or key state on `x-kagent-root-context-id`).
- With isolation on, a sub-agent's per-call sessions have `source='agent'` and
are hidden from the worker's sidebar by design; they render inline in the
parent chat.
- Parallelism is bounded by how many tool calls the parent LLM emits in a turn
and by any `KAGENT_MAX_CONCURRENCY` on the worker pod.
- The per-call `subagent_session_id` in the `function_response` is now the
single source of truth for UI linkage on both isolated and non-isolated tools.

## Testing

- Unit (`go/adk/pkg/tools`): `contextIDForCall` — shared reuses one id, isolated
mints a fresh non-empty id per call; constructor stamp-id is empty for
isolated tools and non-empty otherwise.
- Unit: every `processResult` branch (message, completed, input_required incl.
nil-task, failed/canceled/rejected, non-terminal, no-result) returns a
non-empty `subagent_session_id`.
- Unit: structured failure passthrough (data part, fenced JSON text, plain-text
default) and `parseJSONObject` edge cases.
- Unit (`go/core/.../translator/agent`): `Tool.IsolateSessions` is carried into
`RemoteAgentConfig.IsolateSessions` (true / false / unset→false).
- Regression: existing lineage-header propagation tests stay green.
11 changes: 10 additions & 1 deletion go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,19 @@ func CreateGoogleADKAgentWithSubagentSessionIDs(ctx context.Context, agentConfig
log.Info("Skipping remote agent with empty URL", "name", remoteAgent.Name)
continue
}
remoteTool, sessionID, err := tools.NewKAgentRemoteA2ATool(remoteAgent.Name, remoteAgent.Description, remoteAgent.Url, nil, remoteAgent.Headers, propagateToken)
remoteTool, sessionID, err := tools.NewKAgentRemoteA2ATool(tools.RemoteA2AToolConfig{
Name: remoteAgent.Name,
Description: remoteAgent.Description,
BaseURL: remoteAgent.Url,
ExtraHeaders: remoteAgent.Headers,
PropagateToken: propagateToken,
IsolateSessions: remoteAgent.IsolateSessions,
})
if err != nil {
return nil, nil, fmt.Errorf("failed to create remote A2A tool for %s: %w", remoteAgent.Name, err)
}
// Isolated tools return an empty stamp id (each call reports its own
// session id in the function_response instead), so they add no map entry.
if sessionID != "" {
subagentSessionIDs[remoteAgent.Name] = sessionID
}
Expand Down
Loading
Loading