Skip to content

Commit c5a8978

Browse files
committed
feat: add HostTools API to Go ADK for SDK-registered custom tools
Add HostToolDef and ToolHandlerFunc types to the ADK so users can declaratively register custom tools with auto-dispatch handlers. The agent intercepts host tool calls from the harness, invokes the registered handler, and sends results back automatically. Key changes: - HostToolDef type with Name, Description, Parameters, Handler fields - ToolHandlerFunc signature: func(ctx, args) (any, error) - Validation: reject duplicate names, built-in name collisions, nil handlers - Auto-dispatch in Agent.processStep via handleHostToolCall - buildHarnessConfig translates HostTools to proto host_tools - IsHostToolCall flag on connection.Step for detection - 8 new tests (3 dispatch + 5 validation) - adk-host-tools example with get_weather and convert_units tools - Updated docs/adding-tools.md with Go ADK section
1 parent 7207ce7 commit c5a8978

10 files changed

Lines changed: 706 additions & 6 deletions

File tree

adk/agent.go

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ type Agent struct {
3838
logger *slog.Logger
3939
started bool
4040
totalTokens int
41-
seenUsageSteps map[int32]bool // tracks step indices whose Usage has been counted
41+
seenUsageSteps map[int32]bool // tracks step indices whose Usage has been counted
42+
toolHandlers map[string]ToolHandlerFunc // host tool name → handler
4243
}
4344

4445
// NewAgent creates a new Agent with the given configuration.
@@ -89,12 +90,19 @@ func NewAgent(config *LocalAgentConfig) (*Agent, error) {
8990
// Build middleware chain
9091
mwChain := middleware.NewChain(logger, config.Middlewares...)
9192

93+
// Build host tool handler map
94+
toolHandlers := make(map[string]ToolHandlerFunc, len(config.HostTools))
95+
for _, ht := range config.HostTools {
96+
toolHandlers[ht.Name] = ht.Handler
97+
}
98+
9299
return &Agent{
93100
config: config,
94101
hookRunner: runner,
95102
mwChain: mwChain,
96103
logger: logger,
97104
seenUsageSteps: make(map[int32]bool),
105+
toolHandlers: toolHandlers,
98106
}, nil
99107
}
100108

@@ -545,6 +553,11 @@ func (a *Agent) processStep(ctx context.Context, step connection.Step) Step {
545553
a.handleQuestionRequest(ctx, step)
546554
}
547555

556+
// Handle host tool calls — auto-dispatch to registered handlers
557+
if step.IsHostToolCall && step.State == connection.StateWaiting {
558+
a.handleHostToolCall(ctx, step)
559+
}
560+
548561
return result
549562
}
550563

@@ -628,7 +641,57 @@ func (a *Agent) handleQuestionRequest(ctx context.Context, step connection.Step)
628641
}
629642
}
630643

631-
// Close shuts down the agent, releasing all resources.
644+
// handleHostToolCall dispatches a host tool call to the registered handler
645+
// and sends the result back to the harness.
646+
func (a *Agent) handleHostToolCall(ctx context.Context, step connection.Step) {
647+
stepID := fmt.Sprintf("%d", step.Index)
648+
toolName := step.ToolName
649+
650+
handler, ok := a.toolHandlers[toolName]
651+
if !ok {
652+
a.logger.Error("no handler registered for host tool", "tool", toolName)
653+
a.conn.SendToolResult(ctx, stepID, toolName,
654+
fmt.Sprintf(`{"error": "no handler registered for tool %q"}`, toolName), true)
655+
return
656+
}
657+
658+
// Parse tool arguments
659+
var args map[string]any
660+
if step.ToolArgsJSON != "" {
661+
if err := json.Unmarshal([]byte(step.ToolArgsJSON), &args); err != nil {
662+
a.logger.Error("failed to parse host tool args", "tool", toolName, "error", err)
663+
a.conn.SendToolResult(ctx, stepID, toolName,
664+
fmt.Sprintf(`{"error": "failed to parse arguments: %s"}`, err), true)
665+
return
666+
}
667+
}
668+
669+
// Call the handler
670+
result, err := handler(ctx, args)
671+
if err != nil {
672+
a.logger.Warn("host tool handler returned error", "tool", toolName, "error", err)
673+
a.conn.SendToolResult(ctx, stepID, toolName,
674+
fmt.Sprintf(`{"error": %q}`, err.Error()), true)
675+
return
676+
}
677+
678+
// Marshal the result
679+
resultJSON, err := json.Marshal(result)
680+
if err != nil {
681+
a.logger.Error("failed to marshal host tool result", "tool", toolName, "error", err)
682+
a.conn.SendToolResult(ctx, stepID, toolName,
683+
fmt.Sprintf(`{"error": "failed to marshal result: %s"}`, err), true)
684+
return
685+
}
686+
687+
if err := a.conn.SendToolResult(ctx, stepID, toolName, string(resultJSON), false); err != nil {
688+
a.logger.Error("failed to send host tool result",
689+
"tool", toolName,
690+
"error", err,
691+
)
692+
}
693+
}
694+
632695
func (a *Agent) Close() error {
633696
a.logger.Debug("closing agent session")
634697
if a.started {
@@ -741,6 +804,16 @@ func buildHarnessConfig(cfg *LocalAgentConfig) *pb.HarnessConfig {
741804
}
742805
}
743806

807+
// Host tools — SDK-registered custom tools forwarded to SDK handlers
808+
for _, ht := range cfg.HostTools {
809+
schemaJSON, _ := json.Marshal(ht.Parameters)
810+
harnessCfg.HostTools = append(harnessCfg.HostTools, &pb.ToolDef{
811+
Name: ht.Name,
812+
Description: ht.Description,
813+
ParametersJsonSchema: string(schemaJSON),
814+
})
815+
}
816+
744817
// Slash command definitions (sent alongside PromptModules)
745818
for _, cmd := range cfg.SlashCommands {
746819
harnessCfg.SlashCommands = append(harnessCfg.SlashCommands, &pb.SlashCommandDef{

adk/config.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ type LocalAgentConfig struct {
2828
// Capabilities controls which built-in tools are available.
2929
Capabilities CapabilitiesConfig
3030

31+
// HostTools defines custom tools that execute on the SDK side.
32+
// When the LLM calls a host tool, the harness forwards it to the
33+
// registered handler and feeds the result back to the LLM.
34+
// Tool names must be unique and must not collide with built-in harness tools.
35+
HostTools []HostToolDef
36+
3137
// Policies is the list of tool call policies. Evaluated in priority order.
3238
// Default: policy.ConfirmRunCommand()
3339
Policies []policy.Policy
@@ -379,5 +385,66 @@ func (c *LocalAgentConfig) Validate() error {
379385
}
380386
}
381387

388+
// Validate host tools: no nil handlers, no duplicates, no built-in name collisions.
389+
if err := validateHostTools(c.HostTools); err != nil {
390+
return err
391+
}
392+
393+
return nil
394+
}
395+
396+
// reservedToolNames is the set of built-in harness tool names.
397+
// Host tools cannot use these names — harness tools always have priority.
398+
var reservedToolNames = map[string]bool{
399+
"view_file": true,
400+
"write_to_file": true,
401+
"replace_file_content": true,
402+
"multi_replace_file_content": true,
403+
"list_dir": true,
404+
"grep_search": true,
405+
"find_file": true,
406+
"run_command": true,
407+
"manage_task": true,
408+
"finish": true,
409+
"ask_question": true,
410+
"ask_permission": true,
411+
"list_permissions": true,
412+
"search_web": true,
413+
"read_url_content": true,
414+
"schedule": true,
415+
"invoke_subagent": true,
416+
"define_subagent": true,
417+
"manage_subagents": true,
418+
"send_message": true,
419+
"knowledge_read": true,
420+
"knowledge_write": true,
421+
"publish": true,
422+
}
423+
424+
// validateHostTools checks host tool definitions for errors:
425+
// - Each tool must have a non-empty name and a non-nil handler.
426+
// - Tool names must not collide with built-in harness tools.
427+
// - Tool names must be unique (no duplicates).
428+
func validateHostTools(tools []HostToolDef) error {
429+
if len(tools) == 0 {
430+
return nil
431+
}
432+
433+
seen := make(map[string]bool, len(tools))
434+
for _, ht := range tools {
435+
if ht.Name == "" {
436+
return fmt.Errorf("host tool has empty name")
437+
}
438+
if ht.Handler == nil {
439+
return fmt.Errorf("host tool %q has nil handler", ht.Name)
440+
}
441+
if reservedToolNames[ht.Name] {
442+
return fmt.Errorf("host tool %q conflicts with a built-in harness tool", ht.Name)
443+
}
444+
if seen[ht.Name] {
445+
return fmt.Errorf("duplicate host tool name: %q", ht.Name)
446+
}
447+
seen[ht.Name] = true
448+
}
382449
return nil
383450
}

adk/config_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package adk
22

33
import (
4+
"context"
45
"strings"
56
"testing"
67

@@ -153,6 +154,96 @@ func TestValidate_EmptyPolicies_NoWriteTools_Accepted(t *testing.T) {
153154
}
154155
}
155156

157+
// --- Host Tool Validation Tests ---
158+
159+
func dummyHandler(ctx context.Context, args map[string]any) (any, error) {
160+
return nil, nil
161+
}
162+
163+
func TestValidate_HostTools_Valid(t *testing.T) {
164+
cfg := &LocalAgentConfig{
165+
GeminiAPIKey: "key",
166+
HostTools: []HostToolDef{
167+
{Name: "get_weather", Description: "Weather", Handler: dummyHandler},
168+
{Name: "query_db", Description: "Database", Handler: dummyHandler},
169+
},
170+
}
171+
err := cfg.Validate()
172+
if err != nil {
173+
t.Fatalf("expected valid host tools to pass, got: %v", err)
174+
}
175+
}
176+
177+
func TestValidate_HostTools_DuplicateName(t *testing.T) {
178+
cfg := &LocalAgentConfig{
179+
GeminiAPIKey: "key",
180+
HostTools: []HostToolDef{
181+
{Name: "get_weather", Description: "Weather", Handler: dummyHandler},
182+
{Name: "get_weather", Description: "Weather dupe", Handler: dummyHandler},
183+
},
184+
}
185+
err := cfg.Validate()
186+
if err == nil {
187+
t.Fatal("expected error for duplicate host tool name")
188+
}
189+
if !strings.Contains(err.Error(), "duplicate host tool name") {
190+
t.Errorf("expected 'duplicate host tool name' error, got: %v", err)
191+
}
192+
}
193+
194+
func TestValidate_HostTools_BuiltinCollision(t *testing.T) {
195+
builtins := []string{"view_file", "run_command", "grep_search", "finish", "ask_question", "schedule"}
196+
for _, name := range builtins {
197+
t.Run(name, func(t *testing.T) {
198+
cfg := &LocalAgentConfig{
199+
GeminiAPIKey: "key",
200+
HostTools: []HostToolDef{
201+
{Name: name, Description: "Collision", Handler: dummyHandler},
202+
},
203+
}
204+
err := cfg.Validate()
205+
if err == nil {
206+
t.Fatalf("expected error for built-in name collision: %s", name)
207+
}
208+
if !strings.Contains(err.Error(), "conflicts with a built-in harness tool") {
209+
t.Errorf("expected 'conflicts with a built-in harness tool' error, got: %v", err)
210+
}
211+
})
212+
}
213+
}
214+
215+
func TestValidate_HostTools_NilHandler(t *testing.T) {
216+
cfg := &LocalAgentConfig{
217+
GeminiAPIKey: "key",
218+
HostTools: []HostToolDef{
219+
{Name: "broken_tool", Description: "No handler"},
220+
},
221+
}
222+
err := cfg.Validate()
223+
if err == nil {
224+
t.Fatal("expected error for nil handler")
225+
}
226+
if !strings.Contains(err.Error(), "nil handler") {
227+
t.Errorf("expected 'nil handler' error, got: %v", err)
228+
}
229+
}
230+
231+
func TestValidate_HostTools_EmptyName(t *testing.T) {
232+
cfg := &LocalAgentConfig{
233+
GeminiAPIKey: "key",
234+
HostTools: []HostToolDef{
235+
{Name: "", Description: "No name", Handler: dummyHandler},
236+
},
237+
}
238+
err := cfg.Validate()
239+
if err == nil {
240+
t.Fatal("expected error for empty name")
241+
}
242+
if !strings.Contains(err.Error(), "empty name") {
243+
t.Errorf("expected 'empty name' error, got: %v", err)
244+
}
245+
}
246+
156247
// testDecideHook implements PreToolCallDecideHook for testing.
157248
type testDecideHook struct{}
158249

@@ -162,3 +253,4 @@ func (h *testDecideHook) Run(ctx *hooks.HookContext, tc hooks.ToolCall) hooks.Ho
162253

163254
// Verify it implements the interface
164255
var _ hooks.PreToolCallDecideHook = (*testDecideHook)(nil)
256+

adk/connection/connection.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,10 @@ type Step struct {
148148

149149
// ToolResultIsError indicates the tool returned an error result.
150150
ToolResultIsError bool
151+
152+
// IsHostToolCall is true when this step is a host-side (SDK-registered)
153+
// tool call that requires the SDK to execute and return a result.
154+
IsHostToolCall bool
151155
}
152156

153157
// UsageMetadata tracks token consumption for a model call.

adk/connection/local_connection.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,7 @@ func (c *LocalConnection) handleStepUpdate(su *pb.StepUpdate) {
411411
case *pb.StepUpdate_HostToolCall:
412412
step.ToolName = a.HostToolCall.ToolName
413413
step.ToolArgsJSON = a.HostToolCall.ArgsJson
414+
step.IsHostToolCall = true
414415
case *pb.StepUpdate_PermissionRequest:
415416
step.PermissionRequestID = a.PermissionRequest.RequestId
416417
step.PermissionToolName = a.PermissionRequest.ToolName

0 commit comments

Comments
 (0)