From 6a9061900b2e8874528ce79c1fb762acb594e1d7 Mon Sep 17 00:00:00 2001 From: Yashraj Shukla Date: Sun, 5 Jul 2026 13:28:54 +0000 Subject: [PATCH] fix(go-adk): add per-call session isolation for Agent tools Signed-off-by: Yashraj Shukla --- go/adk/pkg/agent/agent.go | 7 +- go/adk/pkg/tools/remote_a2a_tool.go | 62 +++- go/adk/pkg/tools/remote_a2a_tool_test.go | 31 ++ go/api/adk/types.go | 6 + .../config/crd/bases/kagent.dev_agents.yaml | 22 ++ .../crd/bases/kagent.dev_sandboxagents.yaml | 22 ++ go/api/v1alpha2/agent_types.go | 21 ++ go/api/v1alpha2/zz_generated.deepcopy.go | 5 + .../controller/translator/agent/compiler.go | 9 +- .../agent_with_isolated_session_tool.yaml | 49 +++ .../agent_with_isolated_session_tool.json | 301 ++++++++++++++++++ .../templates/kagent.dev_agents.yaml | 22 ++ .../templates/kagent.dev_sandboxagents.yaml | 22 ++ .../kagent-adk/src/kagent/adk/types.py | 5 + 14 files changed, 565 insertions(+), 19 deletions(-) create mode 100644 go/core/internal/controller/translator/agent/testdata/inputs/agent_with_isolated_session_tool.yaml create mode 100644 go/core/internal/controller/translator/agent/testdata/outputs/agent_with_isolated_session_tool.json diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index fa9d633d14..40e6d1f728 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -64,10 +64,15 @@ 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(remoteAgent.Name, remoteAgent.Description, remoteAgent.Url, nil, remoteAgent.Headers, propagateToken, remoteAgent.IsolateSessions) if err != nil { return nil, nil, fmt.Errorf("failed to create remote A2A tool for %s: %w", remoteAgent.Name, err) } + // Isolated tools mint a fresh context_id per call, so there is no + // single pre-known session id to stamp function_call parts with; + // NewKAgentRemoteA2ATool returns "" in that case and we skip the + // map entry. The UI instead links each sub-agent card via the + // per-call subagent_session_id in the tool's function_response. if sessionID != "" { subagentSessionIDs[remoteAgent.Name] = sessionID } diff --git a/go/adk/pkg/tools/remote_a2a_tool.go b/go/adk/pkg/tools/remote_a2a_tool.go index c56b787dbd..8ab5142acf 100644 --- a/go/adk/pkg/tools/remote_a2a_tool.go +++ b/go/adk/pkg/tools/remote_a2a_tool.go @@ -149,30 +149,44 @@ type remoteA2AState struct { initOnce sync.Once initErr error + // lastContextID is the stable A2A context_id used for every call to this + // sub-agent when isolateSessions is false (the default): all calls land + // in one shared sub-agent session, giving stateful sub-agents session + // continuity across calls. lastContextID string + + // isolateSessions mints a fresh context_id per call (see nextContextID) + // instead of reusing lastContextID, so each call runs in its own isolated + // sub-agent session. Required for parallel fan-out: without it, N + // parallel calls in one turn collapse into a single shared sub-agent + // session. See go/api/v1alpha2.Tool.IsolateSessions. + isolateSessions bool } // NewKAgentRemoteA2ATool creates a function tool that calls a remote A2A agent and // propagates HITL state. It returns: // - the tool.Tool to register with the agent config -// - the initial A2A context/session ID for subagent session stamping +// - the initial A2A context/session ID for subagent session stamping, or "" +// when isolateSessions is true (there is no single pre-known session id +// to stamp in that case; see remoteA2AState.isolateSessions) // // The agent card is fetched lazily from baseURL/.well-known/agent.json. // If httpClient is nil, a default client is created. The client's transport is // wrapped with otelhttp to propagate W3C trace context to subagents. -func NewKAgentRemoteA2ATool(name, description, baseURL string, httpClient *http.Client, extraHeaders map[string]string, propagateToken bool) (tool.Tool, string, error) { +func NewKAgentRemoteA2ATool(name, description, baseURL string, httpClient *http.Client, extraHeaders map[string]string, propagateToken, isolateSessions bool) (tool.Tool, string, error) { if httpClient == nil { httpClient = &http.Client{} } httpClient = withOTelTransport(httpClient) state := &remoteA2AState{ - name: name, - description: description, - baseURL: baseURL, - httpClient: httpClient, - extraHeaders: extraHeaders, - propagateToken: propagateToken, - lastContextID: a2atype.NewContextID(), + name: name, + description: description, + baseURL: baseURL, + httpClient: httpClient, + extraHeaders: extraHeaders, + propagateToken: propagateToken, + lastContextID: a2atype.NewContextID(), + isolateSessions: isolateSessions, } ft, err := functiontool.New(functiontool.Config{ Name: name, @@ -183,9 +197,23 @@ func NewKAgentRemoteA2ATool(name, description, baseURL string, httpClient *http. if err != nil { return nil, "", fmt.Errorf("failed to create remote A2A function tool for %s: %w", name, err) } + if state.isolateSessions { + return ft, "", nil + } return ft, state.lastContextID, nil } +// nextContextID returns the A2A context_id to stamp on the next outbound +// call: a fresh id when isolateSessions is enabled (isolated per-call +// session), or the tool's stable lastContextID otherwise (shared session for +// the lifetime of the tool). +func (s *remoteA2AState) nextContextID() string { + if s.isolateSessions { + return a2atype.NewContextID() + } + return s.lastContextID +} + // ensureClient lazily resolves the agent card and initialises the A2A client. // Initialization is protected by sync.Once to avoid races under concurrent use. func (s *remoteA2AState) ensureClient(ctx context.Context) (*a2aclient.Client, error) { @@ -257,11 +285,12 @@ func (s *remoteA2AState) handleFirstCall(ctx adkagent.ToolContext, requestText s return map[string]any{"error": err.Error()}, nil } + contextID := s.nextContextID() message := a2atype.NewMessage( a2atype.MessageRoleUser, a2atype.TextPart{Text: requestText}, ) - message.ContextID = s.lastContextID + message.ContextID = contextID sendCtx := context.WithValue(ctx, userIDContextKey{}, ctx.UserID()) sendCtx = context.WithValue(sendCtx, parentContextIDContextKey{}, ctx.SessionID()) @@ -271,7 +300,7 @@ func (s *remoteA2AState) handleFirstCall(ctx adkagent.ToolContext, requestText s return map[string]any{"error": fmt.Sprintf("Remote agent '%s' request failed: %v", s.name, err)}, nil } - return s.processResult(ctx, result) + return s.processResult(ctx, contextID, result) } // handleResume is Phase 2: forward the user's decision to the remote agent's pending task. @@ -322,7 +351,7 @@ func (s *remoteA2AState) handleResume(ctx adkagent.ToolContext) (map[string]any, return map[string]any{"error": fmt.Sprintf("Remote agent '%s' resume failed: %v", subagentName, err)}, nil } - ret, retErr := s.processResult(ctx, result) + ret, retErr := s.processResult(ctx, contextID, result) // Prefer the context_id from the confirmation payload (the original subagent // session) over the pre-generated one. Mirrors Python's: // "subagent_session_id": context_id or self._last_context_id @@ -337,7 +366,12 @@ func (s *remoteA2AState) handleResume(ctx adkagent.ToolContext) (map[string]any, } // processResult converts a SendMessageResult into a tool return value. -func (s *remoteA2AState) processResult(ctx adkagent.ToolContext, result a2atype.SendMessageResult) (map[string]any, error) { +// contextID is the A2A context_id this call was sent under (from +// nextContextID, or the confirmation payload on resume) and is reported back +// as subagent_session_id so the UI's AgentCallDisplay links the card to the +// session that actually ran the call — critical when isolateSessions is true +// and every call has a different id. +func (s *remoteA2AState) processResult(ctx adkagent.ToolContext, contextID string, result a2atype.SendMessageResult) (map[string]any, error) { switch r := result.(type) { case *a2atype.Message: return map[string]any{"result": extractTextFromMessage(r)}, nil @@ -358,7 +392,7 @@ func (s *remoteA2AState) processResult(ctx adkagent.ToolContext, result a2atype. text := extractTextFromTask(r) ret := map[string]any{ "result": text, - "subagent_session_id": s.lastContextID, + "subagent_session_id": contextID, } if usage := extractUsageFromTask(r); usage != nil { ret["kagent_usage_metadata"] = usage diff --git a/go/adk/pkg/tools/remote_a2a_tool_test.go b/go/adk/pkg/tools/remote_a2a_tool_test.go index 14bcd8f3a7..7b2c0687a6 100644 --- a/go/adk/pkg/tools/remote_a2a_tool_test.go +++ b/go/adk/pkg/tools/remote_a2a_tool_test.go @@ -124,3 +124,34 @@ func assertSingleHeader(t *testing.T, req *a2aclient.Request, key, want string) t.Errorf("%s: got %q, want %q", key, got[0], want) } } + +// TestNextContextID_IsolateSessions covers the EP#2137 fix: isolated tools +// mint a fresh context_id per call so parallel/serial calls to the same +// sub-agent land in independent sessions, while non-isolated tools keep +// reusing one context_id for session continuity. +func TestNextContextID_IsolateSessions(t *testing.T) { + t.Run("isolated: each call gets a distinct, non-empty context_id", func(t *testing.T) { + s := &remoteA2AState{isolateSessions: true, lastContextID: "stable-id"} + + first := s.nextContextID() + second := s.nextContextID() + + if first == "" || second == "" { + t.Fatalf("expected non-empty context ids, got %q and %q", first, second) + } + if first == second { + t.Errorf("expected distinct context ids for isolated calls, got the same id %q twice", first) + } + }) + + t.Run("not isolated: every call reuses the stable lastContextID", func(t *testing.T) { + s := &remoteA2AState{isolateSessions: false, lastContextID: "stable-id"} + + first := s.nextContextID() + second := s.nextContextID() + + if first != "stable-id" || second != "stable-id" { + t.Errorf("expected both calls to reuse lastContextID %q, got %q and %q", "stable-id", first, second) + } + }) +} diff --git a/go/api/adk/types.go b/go/api/adk/types.go index 502a1cde3a..e5133f3fc5 100644 --- a/go/api/adk/types.go +++ b/go/api/adk/types.go @@ -379,6 +379,12 @@ type RemoteAgentConfig struct { Url string `json:"url"` Headers map[string]string `json:"headers,omitempty"` Description string `json:"description,omitempty"` + // IsolateSessions requests a fresh A2A context_id (and therefore a fresh + // sub-agent session) on every call to this remote agent, instead of the + // default single shared session per tool lifetime. Honored by the Go + // declarative runtime (go/adk/pkg/tools/remote_a2a_tool.go); accepted by + // the Python config model for schema parity only (python/packages/kagent-adk). + IsolateSessions bool `json:"isolate_sessions,omitempty"` } // EmbeddingConfig is the embedding model config for memory tools. diff --git a/go/api/config/crd/bases/kagent.dev_agents.yaml b/go/api/config/crd/bases/kagent.dev_agents.yaml index a3646b2c7a..0d671ac129 100644 --- a/go/api/config/crd/bases/kagent.dev_agents.yaml +++ b/go/api/config/crd/bases/kagent.dev_agents.yaml @@ -13222,6 +13222,26 @@ spec: rule: (has(self.value) && !has(self.valueFrom)) || (!has(self.value) && has(self.valueFrom)) type: array + isolateSessions: + description: |- + IsolateSessions controls per-call session isolation for Agent-type tools. + Only valid when Type is Agent. + + When unset or false (default), every call this agent makes to the + referenced sub-agent reuses the same A2A context_id, so all calls land + in one shared sub-agent session (session continuity for stateful + sub-agents). + + When true, each call mints a fresh context_id, so every invocation runs + in its own isolated sub-agent session. This is required for parallel + fan-out to a sub-agent: without it, N parallel calls in one turn + collapse into a single shared sub-agent session instead of N + independent ones. + + Cross-turn/conversation continuity for stateful sub-agents does not + depend on this flag; it rides the x-kagent-root-context-id header, + which stays stable regardless of IsolateSessions. + type: boolean mcpServer: properties: allowedHeaders: @@ -13292,6 +13312,8 @@ spec: rule: '!(has(self.agent) && self.type != ''Agent'')' - message: type.agent must be specified for Agent filter.type rule: '!(!has(self.agent) && self.type == ''Agent'')' + - message: isolateSessions can only be set when type is Agent + rule: '!(has(self.isolateSessions) && self.type != ''Agent'')' maxItems: 20 type: array type: object diff --git a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml index 8262b8ba21..77474ce754 100644 --- a/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml +++ b/go/api/config/crd/bases/kagent.dev_sandboxagents.yaml @@ -10879,6 +10879,26 @@ spec: rule: (has(self.value) && !has(self.valueFrom)) || (!has(self.value) && has(self.valueFrom)) type: array + isolateSessions: + description: |- + IsolateSessions controls per-call session isolation for Agent-type tools. + Only valid when Type is Agent. + + When unset or false (default), every call this agent makes to the + referenced sub-agent reuses the same A2A context_id, so all calls land + in one shared sub-agent session (session continuity for stateful + sub-agents). + + When true, each call mints a fresh context_id, so every invocation runs + in its own isolated sub-agent session. This is required for parallel + fan-out to a sub-agent: without it, N parallel calls in one turn + collapse into a single shared sub-agent session instead of N + independent ones. + + Cross-turn/conversation continuity for stateful sub-agents does not + depend on this flag; it rides the x-kagent-root-context-id header, + which stays stable regardless of IsolateSessions. + type: boolean mcpServer: properties: allowedHeaders: @@ -10949,6 +10969,8 @@ spec: rule: '!(has(self.agent) && self.type != ''Agent'')' - message: type.agent must be specified for Agent filter.type rule: '!(!has(self.agent) && self.type == ''Agent'')' + - message: isolateSessions can only be set when type is Agent + rule: '!(has(self.isolateSessions) && self.type != ''Agent'')' maxItems: 20 type: array type: object diff --git a/go/api/v1alpha2/agent_types.go b/go/api/v1alpha2/agent_types.go index 9c5ed41dd6..f3e323afa8 100644 --- a/go/api/v1alpha2/agent_types.go +++ b/go/api/v1alpha2/agent_types.go @@ -474,6 +474,7 @@ const ( // +kubebuilder:validation:XValidation:message="type.mcpServer must be specified for McpServer filter.type",rule="!(!has(self.mcpServer) && self.type == 'McpServer')" // +kubebuilder:validation:XValidation:message="type.agent must be nil if the type is not Agent",rule="!(has(self.agent) && self.type != 'Agent')" // +kubebuilder:validation:XValidation:message="type.agent must be specified for Agent filter.type",rule="!(!has(self.agent) && self.type == 'Agent')" +// +kubebuilder:validation:XValidation:message="isolateSessions can only be set when type is Agent",rule="!(has(self.isolateSessions) && self.type != 'Agent')" type Tool struct { // +optional Type ToolProviderType `json:"type,omitempty"` @@ -482,6 +483,26 @@ type Tool struct { // +optional Agent *TypedReference `json:"agent,omitempty"` + // IsolateSessions controls per-call session isolation for Agent-type tools. + // Only valid when Type is Agent. + // + // When unset or false (default), every call this agent makes to the + // referenced sub-agent reuses the same A2A context_id, so all calls land + // in one shared sub-agent session (session continuity for stateful + // sub-agents). + // + // When true, each call mints a fresh context_id, so every invocation runs + // in its own isolated sub-agent session. This is required for parallel + // fan-out to a sub-agent: without it, N parallel calls in one turn + // collapse into a single shared sub-agent session instead of N + // independent ones. + // + // Cross-turn/conversation continuity for stateful sub-agents does not + // depend on this flag; it rides the x-kagent-root-context-id header, + // which stays stable regardless of IsolateSessions. + // +optional + IsolateSessions *bool `json:"isolateSessions,omitempty"` + // HeadersFrom specifies a list of configuration values to be added as // headers to requests sent to the Tool from this agent. The value of // each header is resolved from either a Secret or ConfigMap in the same diff --git a/go/api/v1alpha2/zz_generated.deepcopy.go b/go/api/v1alpha2/zz_generated.deepcopy.go index 810acf54e4..4c923ded58 100644 --- a/go/api/v1alpha2/zz_generated.deepcopy.go +++ b/go/api/v1alpha2/zz_generated.deepcopy.go @@ -1929,6 +1929,11 @@ func (in *Tool) DeepCopyInto(out *Tool) { *out = new(TypedReference) **out = **in } + if in.IsolateSessions != nil { + in, out := &in.IsolateSessions, &out.IsolateSessions + *out = new(bool) + **out = **in + } if in.HeadersFrom != nil { in, out := &in.HeadersFrom, &out.HeadersFrom *out = make([]ValueRef, len(*in)) diff --git a/go/core/internal/controller/translator/agent/compiler.go b/go/core/internal/controller/translator/agent/compiler.go index 595a13a419..4179cb4680 100644 --- a/go/core/internal/controller/translator/agent/compiler.go +++ b/go/core/internal/controller/translator/agent/compiler.go @@ -350,10 +350,11 @@ func (a *adkApiTranslator) translateInlineAgent(ctx context.Context, agent v1alp } cfg.RemoteAgents = append(cfg.RemoteAgents, adk.RemoteAgentConfig{ - Name: utils.ConvertToPythonIdentifier(utils.GetObjectRef(toolAgent)), - Url: targetURL, - Headers: headers, - Description: toolSpec.Description, + Name: utils.ConvertToPythonIdentifier(utils.GetObjectRef(toolAgent)), + Url: targetURL, + Headers: headers, + Description: toolSpec.Description, + IsolateSessions: tool.IsolateSessions != nil && *tool.IsolateSessions, }) default: return nil, nil, nil, fmt.Errorf("unknown agent type: %s", toolSpec.Type) diff --git a/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_isolated_session_tool.yaml b/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_isolated_session_tool.yaml new file mode 100644 index 0000000000..74aa6c41da --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/inputs/agent_with_isolated_session_tool.yaml @@ -0,0 +1,49 @@ +operation: translateAgent +targetObject: coordinator-agent +namespace: test +objects: + - apiVersion: v1 + kind: Secret + metadata: + name: openai-secret + namespace: test + data: + api-key: c2stdGVzdC1hcGkta2V5 # base64 encoded "sk-test-api-key" + - apiVersion: kagent.dev/v1alpha2 + kind: ModelConfig + metadata: + name: nested-model + namespace: test + spec: + provider: OpenAI + model: gpt-4o + apiKeySecret: openai-secret + apiKeySecretKey: api-key + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: worker-agent + namespace: test + spec: + type: Declarative + declarative: + description: A worker agent that can be called in parallel by the coordinator + systemMessage: You are a worker agent. Complete the assigned task and report back. + modelConfig: nested-model + tools: [] + - apiVersion: kagent.dev/v1alpha2 + kind: Agent + metadata: + name: coordinator-agent + namespace: test + spec: + type: Declarative + declarative: + description: A coordinator agent that fans out isolated parallel calls to a worker + systemMessage: You are a coordinating agent that delegates tasks to a worker agent, potentially in parallel. + modelConfig: nested-model + tools: + - type: Agent + agent: + name: worker-agent + isolateSessions: true diff --git a/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_isolated_session_tool.json b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_isolated_session_tool.json new file mode 100644 index 0000000000..d1db9984d9 --- /dev/null +++ b/go/core/internal/controller/translator/agent/testdata/outputs/agent_with_isolated_session_tool.json @@ -0,0 +1,301 @@ +{ + "agentCard": { + "capabilities": { + "streaming": true + }, + "defaultInputModes": [ + "text" + ], + "defaultOutputModes": [ + "text" + ], + "description": "", + "name": "coordinator_agent", + "skills": null, + "supportedInterfaces": [ + { + "protocolBinding": "JSONRPC", + "protocolVersion": "0.3", + "url": "http://coordinator-agent.test:8080" + }, + { + "protocolBinding": "JSONRPC", + "protocolVersion": "1.0", + "url": "http://coordinator-agent.test:8080" + } + ], + "version": "" + }, + "config": { + "description": "", + "instruction": "You are a coordinating agent that delegates tasks to a worker agent, potentially in parallel.", + "model": { + "base_url": "", + "model": "gpt-4o", + "type": "openai" + }, + "remote_agents": [ + { + "isolate_sessions": true, + "name": "test__NS__worker_agent", + "url": "http://worker-agent.test:8080" + } + ], + "stream": false + }, + "manifest": [ + { + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "coordinator-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "coordinator-agent" + }, + "name": "coordinator-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "coordinator-agent", + "uid": "" + } + ] + }, + "stringData": { + "agent-card.json": "{\n \"defaultInputModes\": [\n \"text\"\n ],\n \"defaultOutputModes\": [\n \"text\"\n ],\n \"description\": \"\",\n \"name\": \"coordinator_agent\",\n \"version\": \"\",\n \"skills\": [],\n \"capabilities\": {\n \"streaming\": true\n },\n \"supportedInterfaces\": [\n {\n \"url\": \"http://coordinator-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"0.3\"\n },\n {\n \"url\": \"http://coordinator-agent.test:8080\",\n \"protocolBinding\": \"JSONRPC\",\n \"protocolVersion\": \"1.0\"\n }\n ],\n \"url\": \"http://coordinator-agent.test:8080\",\n \"protocolVersion\": \"0.3\",\n \"preferredTransport\": \"JSONRPC\"\n}", + "config.json": "{\"model\":{\"type\":\"openai\",\"model\":\"gpt-4o\",\"base_url\":\"\"},\"description\":\"\",\"instruction\":\"You are a coordinating agent that delegates tasks to a worker agent, potentially in parallel.\",\"remote_agents\":[{\"name\":\"test__NS__worker_agent\",\"url\":\"http://worker-agent.test:8080\",\"isolate_sessions\":true}],\"stream\":false}" + } + }, + { + "apiVersion": "v1", + "kind": "ServiceAccount", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "coordinator-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "coordinator-agent" + }, + "name": "coordinator-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "coordinator-agent", + "uid": "" + } + ] + } + }, + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "coordinator-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "coordinator-agent" + }, + "name": "coordinator-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "coordinator-agent", + "uid": "" + } + ] + }, + "spec": { + "selector": { + "matchLabels": { + "app": "kagent", + "kagent": "coordinator-agent" + } + }, + "strategy": { + "rollingUpdate": { + "maxSurge": 1, + "maxUnavailable": 0 + }, + "type": "RollingUpdate" + }, + "template": { + "metadata": { + "annotations": { + "kagent.dev/config-hash": "12596745338766631722" + }, + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "coordinator-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "coordinator-agent" + } + }, + "spec": { + "containers": [ + { + "args": [ + "--host", + "0.0.0.0", + "--port", + "8080", + "--filepath", + "/config" + ], + "env": [ + { + "name": "OPENAI_API_KEY", + "valueFrom": { + "secretKeyRef": { + "key": "api-key", + "name": "openai-secret" + } + } + }, + { + "name": "KAGENT_NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace" + } + } + }, + { + "name": "KAGENT_NAME", + "value": "coordinator-agent" + }, + { + "name": "KAGENT_URL", + "value": "http://kagent-controller.kagent:8083" + } + ], + "image": "cr.kagent.dev/kagent-dev/kagent/app@sha256:test-app", + "imagePullPolicy": "IfNotPresent", + "name": "kagent", + "ports": [ + { + "containerPort": 8080, + "name": "http" + } + ], + "readinessProbe": { + "httpGet": { + "path": "/.well-known/agent-card.json", + "port": "http" + }, + "initialDelaySeconds": 15, + "periodSeconds": 15, + "timeoutSeconds": 15 + }, + "resources": { + "limits": { + "cpu": "2", + "memory": "1Gi" + }, + "requests": { + "cpu": "100m", + "memory": "384Mi" + } + }, + "volumeMounts": [ + { + "mountPath": "/config", + "name": "config" + }, + { + "mountPath": "/var/run/secrets/tokens", + "name": "kagent-token" + } + ] + } + ], + "serviceAccountName": "coordinator-agent", + "volumes": [ + { + "name": "config", + "secret": { + "secretName": "coordinator-agent" + } + }, + { + "name": "kagent-token", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "audience": "kagent", + "expirationSeconds": 3600, + "path": "kagent-token" + } + } + ] + } + } + ] + } + } + }, + "status": {} + }, + { + "apiVersion": "v1", + "kind": "Service", + "metadata": { + "labels": { + "app": "kagent", + "app.kubernetes.io/managed-by": "kagent", + "app.kubernetes.io/name": "coordinator-agent", + "app.kubernetes.io/part-of": "kagent", + "kagent": "coordinator-agent" + }, + "name": "coordinator-agent", + "namespace": "test", + "ownerReferences": [ + { + "apiVersion": "kagent.dev/v1alpha2", + "blockOwnerDeletion": true, + "controller": true, + "kind": "Agent", + "name": "coordinator-agent", + "uid": "" + } + ] + }, + "spec": { + "ports": [ + { + "name": "http", + "port": 8080, + "targetPort": 8080 + } + ], + "selector": { + "app": "kagent", + "kagent": "coordinator-agent" + }, + "type": "ClusterIP" + }, + "status": { + "loadBalancer": {} + } + } + ] +} \ No newline at end of file diff --git a/helm/kagent-crds/templates/kagent.dev_agents.yaml b/helm/kagent-crds/templates/kagent.dev_agents.yaml index a3646b2c7a..0d671ac129 100644 --- a/helm/kagent-crds/templates/kagent.dev_agents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_agents.yaml @@ -13222,6 +13222,26 @@ spec: rule: (has(self.value) && !has(self.valueFrom)) || (!has(self.value) && has(self.valueFrom)) type: array + isolateSessions: + description: |- + IsolateSessions controls per-call session isolation for Agent-type tools. + Only valid when Type is Agent. + + When unset or false (default), every call this agent makes to the + referenced sub-agent reuses the same A2A context_id, so all calls land + in one shared sub-agent session (session continuity for stateful + sub-agents). + + When true, each call mints a fresh context_id, so every invocation runs + in its own isolated sub-agent session. This is required for parallel + fan-out to a sub-agent: without it, N parallel calls in one turn + collapse into a single shared sub-agent session instead of N + independent ones. + + Cross-turn/conversation continuity for stateful sub-agents does not + depend on this flag; it rides the x-kagent-root-context-id header, + which stays stable regardless of IsolateSessions. + type: boolean mcpServer: properties: allowedHeaders: @@ -13292,6 +13312,8 @@ spec: rule: '!(has(self.agent) && self.type != ''Agent'')' - message: type.agent must be specified for Agent filter.type rule: '!(!has(self.agent) && self.type == ''Agent'')' + - message: isolateSessions can only be set when type is Agent + rule: '!(has(self.isolateSessions) && self.type != ''Agent'')' maxItems: 20 type: array type: object diff --git a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml index 8262b8ba21..77474ce754 100644 --- a/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml +++ b/helm/kagent-crds/templates/kagent.dev_sandboxagents.yaml @@ -10879,6 +10879,26 @@ spec: rule: (has(self.value) && !has(self.valueFrom)) || (!has(self.value) && has(self.valueFrom)) type: array + isolateSessions: + description: |- + IsolateSessions controls per-call session isolation for Agent-type tools. + Only valid when Type is Agent. + + When unset or false (default), every call this agent makes to the + referenced sub-agent reuses the same A2A context_id, so all calls land + in one shared sub-agent session (session continuity for stateful + sub-agents). + + When true, each call mints a fresh context_id, so every invocation runs + in its own isolated sub-agent session. This is required for parallel + fan-out to a sub-agent: without it, N parallel calls in one turn + collapse into a single shared sub-agent session instead of N + independent ones. + + Cross-turn/conversation continuity for stateful sub-agents does not + depend on this flag; it rides the x-kagent-root-context-id header, + which stays stable regardless of IsolateSessions. + type: boolean mcpServer: properties: allowedHeaders: @@ -10949,6 +10969,8 @@ spec: rule: '!(has(self.agent) && self.type != ''Agent'')' - message: type.agent must be specified for Agent filter.type rule: '!(!has(self.agent) && self.type == ''Agent'')' + - message: isolateSessions can only be set when type is Agent + rule: '!(has(self.isolateSessions) && self.type != ''Agent'')' maxItems: 20 type: array type: object diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index 766e7136b4..54958ffae5 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -237,6 +237,11 @@ class RemoteAgentConfig(BaseModel): headers: dict[str, Any] | None = None timeout: float = DEFAULT_TIMEOUT description: str = "" + # isolate_sessions: accepted for schema parity with the Go declarative + # runtime (see go/api/v1alpha2.Tool.IsolateSessions). The Python low-level + # tool (KAgentRemoteA2AToolset / _remote_a2a_tool.py) does not yet honor + # this flag — it only affects agents running on runtime: go. + isolate_sessions: bool = False class BaseLLM(BaseModel):