-
Notifications
You must be signed in to change notification settings - Fork 416
Add the ability to click through to sub-agent session #1362
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -491,6 +491,27 @@ func (c *clientImpl) DeleteCheckpoint(userID, threadID string) error { | |||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| func (c *clientImpl) FindSessionByParentFunctionCallID(functionCallID string) (*dbpkg.Session, error) { | ||||||||||||||||||||||||
| var event dbpkg.Event | ||||||||||||||||||||||||
| // Use a wildcard between the key and value to handle potential whitespace (e.g. ": " vs ":") | ||||||||||||||||||||||||
| searchPattern := fmt.Sprintf("%%\"parent_function_call_id\"%%\"%s\"%%", functionCallID) | ||||||||||||||||||||||||
| err := c.db.Where("data LIKE ?", searchPattern).First(&event).Error | ||||||||||||||||||||||||
|
Comment on lines
+496
to
+498
|
||||||||||||||||||||||||
| // Use a wildcard between the key and value to handle potential whitespace (e.g. ": " vs ":") | |
| searchPattern := fmt.Sprintf("%%\"parent_function_call_id\"%%\"%s\"%%", functionCallID) | |
| err := c.db.Where("data LIKE ?", searchPattern).First(&event).Error | |
| // Use JSON field extraction to precisely match the parent_function_call_id value | |
| err := c.db.Where("data->>'parent_function_call_id' = ?", functionCallID).First(&event).Error |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The LIKE query pattern may have performance implications on large datasets as it performs a full table scan. Consider adding a database index on the Event.Data column if this functionality is used frequently, or consider storing parent_function_call_id in a separate indexed column for more efficient lookups.
| // Use a wildcard between the key and value to handle potential whitespace (e.g. ": " vs ":") | |
| searchPattern := fmt.Sprintf("%%\"parent_function_call_id\"%%\"%s\"%%", functionCallID) | |
| err := c.db.Where("data LIKE ?", searchPattern).First(&event).Error | |
| // Query the JSON field directly for the parent_function_call_id to avoid a LIKE scan. | |
| err := c.db.Where("JSON_EXTRACT(data, '$.parent_function_call_id') = ?", functionCallID).First(&event).Error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with copilot here, I think searching through JSON like this is pretty fragile. Can we instead add more keys to the events, or think about organizing the DB data differently?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah I def agree ... need some guidance on the right solution I guess.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -741,6 +741,26 @@ func (c *InMemoryFakeClient) ListWrites(userID, threadID, checkpointNS, checkpoi | |||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return writes[start:end], nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func (c *InMemoryFakeClient) FindSessionByParentFunctionCallID(functionCallID string) (*database.Session, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| c.mu.RLock() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| defer c.mu.RUnlock() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| searchPattern1 := fmt.Sprintf(`"parent_function_call_id":"%s"`, functionCallID) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| searchPattern2 := fmt.Sprintf(`"parent_function_call_id": "%s"`, functionCallID) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for _, event := range c.events { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if strings.Contains(event.Data, searchPattern1) || strings.Contains(event.Data, searchPattern2) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for _, session := range c.sessions { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if session.ID == event.SessionID { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return session, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, gorm.ErrRecordNotFound | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+748
to
+758
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| searchPattern1 := fmt.Sprintf(`"parent_function_call_id":"%s"`, functionCallID) | |
| searchPattern2 := fmt.Sprintf(`"parent_function_call_id": "%s"`, functionCallID) | |
| for _, event := range c.events { | |
| if strings.Contains(event.Data, searchPattern1) || strings.Contains(event.Data, searchPattern2) { | |
| for _, session := range c.sessions { | |
| if session.ID == event.SessionID { | |
| return session, nil | |
| } | |
| } | |
| return nil, gorm.ErrRecordNotFound | |
| for _, event := range c.events { | |
| var payload map[string]interface{} | |
| if err := json.Unmarshal([]byte(event.Data), &payload); err != nil { | |
| // If the event data is not valid JSON, skip this event. | |
| continue | |
| } | |
| if v, ok := payload["parent_function_call_id"]; ok { | |
| if id, ok := v.(string); ok && id == functionCallID { | |
| for _, session := range c.sessions { | |
| if session.ID == event.SessionID { | |
| return session, nil | |
| } | |
| } | |
| return nil, gorm.ErrRecordNotFound | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| # Copyright 2026 Google LLC | ||
| # | ||
| # Licensed under the Apache License, Version 2.0 (the "License"); | ||
| # you may not use this file except in compliance with the License. | ||
| # You may obtain a copy of the License at | ||
| # | ||
| # http://www.apache.org/licenses/LICENSE-2.0 | ||
| # | ||
| # Unless required by applicable law or agreed to in writing, software | ||
| # distributed under the License is distributed on an "AS IS" BASIS, | ||
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Plugin that captures A2A sub-agent session metadata. | ||
|
|
||
| When a parent agent delegates to a remote sub-agent via AgentTool, this plugin: | ||
| 1. Detects the sub-agent's context_id from A2A event metadata (on_event_callback) | ||
| 2. Embeds the context_id in the tool result for historical/stored access (after_tool_callback) | ||
|
|
||
| This plugin is automatically propagated to child runners via AgentTool's | ||
| include_plugins=True default, so on_event_callback fires on the child runner's | ||
| events where A2A metadata is present. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import contextvars | ||
| import logging | ||
| from dataclasses import dataclass | ||
| from typing import Any, Optional, TYPE_CHECKING | ||
|
|
||
| from google.adk.plugins.base_plugin import BasePlugin | ||
| from google.adk.tools.agent_tool import AgentTool | ||
|
|
||
| if TYPE_CHECKING: | ||
| from google.adk.agents.invocation_context import InvocationContext | ||
| from google.adk.events.event import Event | ||
| from google.adk.tools.base_tool import BaseTool | ||
| from google.adk.tools.tool_context import ToolContext | ||
|
|
||
| logger = logging.getLogger("kagent_adk." + __name__) | ||
|
|
||
|
|
||
| @dataclass | ||
| class _ToolCallState: | ||
| agent_tool_name: str | None = None | ||
| function_call_id: str | None = None | ||
| captured_context_id: str | None = None | ||
| captured_task_id: str | None = None | ||
|
|
||
|
|
||
| _current_tool_call: contextvars.ContextVar[_ToolCallState | None] = contextvars.ContextVar( | ||
| "_current_tool_call", default=None | ||
| ) | ||
|
|
||
|
|
||
| def get_current_function_call_id() -> str | None: | ||
| """Return the function_call_id of the currently executing AgentTool call. | ||
|
|
||
| This is used by the a2a_request_meta_provider to inject the parent's | ||
| function_call_id into outgoing A2A requests so the sub-agent can store it. | ||
| """ | ||
| tc = _current_tool_call.get(None) | ||
| return tc.function_call_id if tc else None | ||
|
|
||
|
|
||
| class SubAgentSessionPlugin(BasePlugin): | ||
| """Captures A2A sub-agent session context_id and embeds it in tool results.""" | ||
|
|
||
| def __init__(self): | ||
| super().__init__(name="sub_agent_session") | ||
|
|
||
| async def before_tool_callback( | ||
| self, | ||
| *, | ||
| tool: BaseTool, | ||
| tool_args: dict[str, Any], | ||
| tool_context: ToolContext, | ||
| ) -> Optional[dict]: | ||
| if isinstance(tool, AgentTool): | ||
| _current_tool_call.set( | ||
| _ToolCallState( | ||
| agent_tool_name=tool.agent.name if hasattr(tool, "agent") else tool.name, | ||
| function_call_id=tool_context.function_call_id, | ||
| ) | ||
| ) | ||
| return None | ||
|
|
||
| async def on_event_callback( | ||
| self, | ||
| *, | ||
| invocation_context: InvocationContext, | ||
| event: Event, | ||
| ) -> Optional[Event]: | ||
| if not event.custom_metadata: | ||
| return None | ||
|
|
||
| tc = _current_tool_call.get(None) | ||
| if tc is None: | ||
| return None | ||
|
|
||
| context_id = event.custom_metadata.get("a2a:context_id") | ||
| task_id = event.custom_metadata.get("a2a:task_id") | ||
|
|
||
| if not context_id and not task_id: | ||
| return None | ||
|
|
||
| if context_id and not tc.captured_context_id: | ||
| tc.captured_context_id = context_id | ||
| if task_id and not tc.captured_task_id: | ||
| tc.captured_task_id = task_id | ||
|
|
||
| return None | ||
|
Comment on lines
+90
to
+114
|
||
|
|
||
| async def after_tool_callback( | ||
| self, | ||
| *, | ||
| tool: BaseTool, | ||
| tool_args: dict[str, Any], | ||
| tool_context: ToolContext, | ||
| result: dict, | ||
| ) -> Optional[dict]: | ||
| if not isinstance(tool, AgentTool): | ||
| return None | ||
|
|
||
| tc = _current_tool_call.get(None) | ||
| if tc is None or (not tc.captured_context_id and not tc.captured_task_id): | ||
| return None | ||
|
|
||
| if isinstance(result, str): | ||
| result = {"result": result} | ||
|
|
||
| if isinstance(result, dict): | ||
| if tc.captured_context_id: | ||
| result["a2a:context_id"] = tc.captured_context_id | ||
| if tc.captured_task_id: | ||
| result["a2a:task_id"] = tc.captured_task_id | ||
| return result | ||
|
Comment on lines
+134
to
+139
|
||
|
|
||
| return None | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function_call_id parameter is directly embedded into a SQL LIKE pattern without proper sanitization. While Go's database/sql package provides protection against SQL injection for parameterized queries, special characters in the function call ID (like %, _, etc.) could cause unexpected matching behavior in the LIKE pattern. Consider escaping these special LIKE characters or validating the input format.