feat: support custom tools in subagents#962
feat: support custom tools in subagents#962ductrung-nguyen wants to merge 1 commit intogithub:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR enables Go SDK sessions to handle custom tool calls originating from CLI-created subagent (child) sessions by resolving child session IDs back to the parent session and enforcing per-agent tool allowlists.
Changes:
- Track child→parent (and child→agent) session lineage from
subagent.*lifecycle events and resolve incoming RPC requests against the parent session. - Enforce
CustomAgentConfig.Toolsallowlists for tool calls coming from child sessions. - Add Go docs plus unit/integration tests and placeholder replay snapshots for future E2E capture.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
go/client.go |
Adds child-session lineage tracking, resolveSession, and allowlist enforcement for child-originated tool calls; updates request handlers to resolve via parent. |
go/session.go |
Stores custom agent configs on the session and adds an onDestroy callback for client-side cleanup. |
go/client_subagent_test.go |
Adds unit tests covering session resolution, allowlists, subagent tracking, cleanup, and concurrency safety. |
go/internal/e2e/subagent_tool_test.go |
Adds integration-tagged tests exercising real CLI subagent tool invocation and deny behavior. |
go/README.md |
Documents subagent custom tool routing and tool access control semantics. |
test/snapshots/subagent_tool/subagent_invokes_parent_custom_tool.yaml |
Placeholder snapshot intended to be captured from a real CLI session for subagent custom tool flow. |
test/snapshots/subagent_tool/subagent_denied_unlisted_tool_returns_unsupported.yaml |
Placeholder snapshot intended to be captured from a real CLI session for denied-tool flow. |
Comments suppressed due to low confidence (3)
go/client.go:1706
- Now that child session IDs are resolved to the parent session via
resolveSession, the user-input handler invoked viasession.handleUserInputRequest(...)will receiveUserInputInvocation.SessionID == parentSessionID(because the invocation is built insideSession). This loses the child session context for subagent-originated prompts. Consider plumbing the originalreq.SessionIDthrough so handlers can distinguish parent vs child/subagent requests.
session, _, err := c.resolveSession(req.SessionID)
if err != nil {
return nil, &jsonrpc2.Error{Code: -32602, Message: err.Error()}
}
response, err := session.handleUserInputRequest(UserInputRequest{
Question: req.Question,
Choices: req.Choices,
AllowFreeform: req.AllowFreeform,
})
go/client.go:1726
- Similar to user input: after resolving a child session to the parent,
session.handleHooksInvoke(...)will buildHookInvocation.SessionIDfrom the parent session ID. That makes hook handlers unable to attribute hook invocations to the originating child/subagent session. Consider passing the originalreq.SessionIDthrough (or extending the invocation type) so hook consumers can enforce policy per subagent session when needed.
session, _, err := c.resolveSession(req.SessionID)
if err != nil {
return nil, &jsonrpc2.Error{Code: -32602, Message: err.Error()}
}
output, err := session.handleHooksInvoke(req.Type, req.Input)
if err != nil {
go/client.go:1853
- After
resolveSessionenables permission requests from child sessions,PermissionInvocation.SessionIDis still populated from the resolved parent session (session.SessionID). That prevents permission handlers from knowing which child/subagent session requested the permission. Consider using the originalreq.SessionID(and/or addingParentSessionID) so permission handlers can implement subagent-specific policy.
session, _, err := c.resolveSession(req.SessionID)
if err != nil {
return nil, &jsonrpc2.Error{Code: -32602, Message: err.Error()}
}
handler := session.getPermissionHandler()
if handler == nil {
| hooksMux sync.RWMutex | ||
| transformCallbacks map[string]SectionTransformFn | ||
| transformMu sync.Mutex | ||
| onDestroy func() // set by Client when session is created; called by Destroy() |
There was a problem hiding this comment.
onDestroy is documented as being called by Destroy(), but it is actually invoked from Disconnect() (and Destroy() only delegates to Disconnect()). Update the comment to reflect the actual call site to avoid confusion when reading the lifecycle logic.
| onDestroy func() // set by Client when session is created; called by Destroy() | |
| onDestroy func() // set by Client when session is created; called by Disconnect() (Destroy() delegates to Disconnect()) |
| config := s.customAgents[i] | ||
| return &config |
There was a problem hiding this comment.
getAgentConfig returns a pointer to a local copy (config := ...; return &config). That pointer is valid, but it disconnects callers from the underlying slice element (future mutations won’t affect the stored config) and adds an avoidable allocation. Prefer returning &s.customAgents[i] (or return the value instead of a pointer) to avoid subtle bugs later.
| config := s.customAgents[i] | |
| return &config | |
| return &s.customAgents[i] |
| toolCallID := derefStr(event.Data.ToolCallID) | ||
| agentName := derefStr(event.Data.AgentName) | ||
| childSessionID := derefStr(event.Data.RemoteSessionID) | ||
|
|
||
| c.sessionsMux.Lock() | ||
| defer c.sessionsMux.Unlock() | ||
|
|
||
| // Track instance by toolCallID (unique per launch) | ||
| if c.subagentInstances[parentSessionID] == nil { | ||
| c.subagentInstances[parentSessionID] = make(map[string]*subagentInstance) | ||
| } | ||
| c.subagentInstances[parentSessionID][toolCallID] = &subagentInstance{ | ||
| agentName: agentName, | ||
| toolCallID: toolCallID, | ||
| childSessionID: childSessionID, | ||
| startedAt: event.Timestamp, | ||
| } |
There was a problem hiding this comment.
onSubagentStarted uses toolCallID (which is optional in the event schema) as the map key without guarding against it being empty. If the server omits toolCallId, multiple launches will collide on the empty-string key and overwrite tracking state. Consider skipping subagentInstances tracking when toolCallID == "" or generating a unique key so instances can’t collide.
| func(params struct { | ||
| Result string `json:"result" jsonschema:"The result to save"` | ||
| }, inv copilot.ToolInvocation) (string, error) { | ||
| toolInvoked <- params.Result |
There was a problem hiding this comment.
The tool handler sends into toolInvoked using a buffered channel of size 1. If the model retries or invokes the tool more than once, the second send will block and can deadlock the test run. Consider increasing the buffer, draining in a goroutine, or using a non-blocking send/select to make the test resilient to multiple invocations.
| toolInvoked <- params.Result | |
| select { | |
| case toolInvoked <- params.Result: | |
| default: | |
| } |
| if answer.Data.Content != nil { | ||
| t.Logf("Response: %s", *answer.Data.Content) | ||
| } |
There was a problem hiding this comment.
The test case name says "returns unsupported" but the assertions only ensure the restricted tool handler wasn’t called; it doesn’t verify that the SDK returned the expected unsupported-tool result/message. Add an assertion (e.g., checking the final assistant message contains the expected error, or inspecting events/tool results) so the test actually validates the intended behavior.
| if answer.Data.Content != nil { | |
| t.Logf("Response: %s", *answer.Data.Content) | |
| } | |
| if answer.Data.Content == nil { | |
| t.Fatalf("Expected assistant message content but got nil") | |
| } | |
| content := strings.ToLower(*answer.Data.Content) | |
| t.Logf("Response: %s", content) | |
| if !strings.Contains(content, "unsupported") { | |
| t.Fatalf("Expected assistant response to indicate unsupported tool, got: %q", content) | |
| } |
All three SDKs (Go, Python, Node) use per-session tool handler lookup keyed to the exact session ID. When the CLI creates child sessions for subagents, those child session IDs are never registered in the SDK's
sessionsmap. Tool calls arriving with a child session ID fail with"unknown session <id>".This PR only brings the ability to call custom tools for sub-agent in Go, by following the proposal in #947