Skip to content
Merged
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
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,20 @@ The current root implementation maps `wait` to a durable paused Run and resumes

## Tool Gateway

`internal/toolgateway/registry.go` is the static registration point for built-in
tools. Each entry binds its definition, action class, payload normalizer and
Gateway handler, together with its Supervisor and project-config eligibility.
Definition lookup, the CLI schema catalog, model tool membership and Gateway
dispatch derive from this registry. Adding a tool to an existing family reuses
that family's normalizer and handler instead of adding another dispatch branch.

Registration describes the tool; current capabilities determine whether the
model receives it. Skill catalogs, language servers, MCP tool fingerprints,
command adapters and browser backends still provide their runtime schemas and
authority checks. The registry preserves model advertisement order and phase
selection. Historical ledger names and migration SQL remain independent so
stored calls can still be read after a tool is retired.

Every tool invocation uses one pipeline:

```text
Expand Down
10 changes: 10 additions & 0 deletions internal/app/structured_tool_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ func TestStructuredToolCLIListsSchemasAndCreatesRunMemory(t *testing.T) {
!strings.Contains(schemas, `"additionalProperties": false`) {
t.Fatalf("structured tool schemas are unavailable: code=%d stderr=%s output=%s", code, stderr, schemas)
}
for _, name := range []string{"code_workspace_symbols", "browser_scroll", "browser_key"} {
if !strings.Contains(schemas, `"name": "`+name+`"`) {
t.Fatalf("tool schema catalog is missing %s", name)
}
schema, stderr, code := executeTestCommand(t, "tool", "schema", name)
if code != 0 || !strings.Contains(schema, `"name": "`+name+`"`) ||
!strings.Contains(schema, `"input_schema": {`) {
t.Fatalf("schema lookup for %s failed: code=%d stderr=%s output=%s", name, code, stderr, schema)
}
}

workPayload := `{"title":"Inspect parser","description":"Use strict JSON","priority":"high","acceptance_criteria":["tests pass"]}`
createdWork, stderr, code := executeTestCommand(t,
Expand Down
2 changes: 1 addition & 1 deletion internal/app/tool_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ func (a *App) toolSchema(args []string) error {
return err
}
if fs.NArg() > 1 {
return errors.New("usage: cyberagent tool schema [work_item_create|note_create|specialist_delegation_propose|plan_delivery_propose]")
return errors.New("usage: cyberagent tool schema [tool]")
}
var value any = toolgateway.AllSupervisorToolDefinitions()
if fs.NArg() == 1 {
Expand Down
22 changes: 16 additions & 6 deletions internal/application/supervisor_tool_permission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -382,20 +382,30 @@ func TestSupervisorCodeIntelRequiresPinnedSnapshotAuthorityAndCodeScope(t *testi
}
}

func TestSupervisorRetiredCommandToolsAreNeitherAdvertisedNorAccepted(t *testing.T) {
func TestSupervisorUnsupportedToolsAreNeitherAdvertisedNorAccepted(t *testing.T) {
for _, mode := range []domain.RunExecutionPermissionMode{domain.RunExecutionPermissionAsk, domain.RunExecutionPermissionAuto, domain.RunExecutionPermissionFull,
domain.RunExecutionPermissionConservative, domain.RunExecutionPermissionApproval, domain.RunExecutionPermissionWorkspaceAccess, domain.RunExecutionPermissionFullAccess, domain.RunExecutionPermissionDebug} {
for _, phase := range []domain.ExecutionPhase{domain.ExecutionPhasePlan, domain.ExecutionPhaseDeliver} {
for _, name := range []toolgateway.ToolName{toolgateway.ControlledCommandProposeTool, toolgateway.OneShotCommandProposeTool, toolgateway.HostCommandProposeTool} {
for _, name := range []toolgateway.ToolName{
"unknown_tool",
toolgateway.ControlledCommandProposeTool,
toolgateway.OneShotCommandProposeTool,
toolgateway.HostCommandProposeTool,
toolgateway.ReadFileTool,
toolgateway.ListWorkspaceTool,
toolgateway.ShellTool,
toolgateway.ReplaceFileTool,
toolgateway.ScriptProcessTool,
} {
for _, spec := range supervisorStructuredToolSpecs(domain.ExecutionSurfaceCode, phase, mode, false, false) {
if spec.Name == string(name) {
t.Fatalf("retired tool %s advertised in %s/%s", name, mode, phase)
t.Fatalf("unsupported tool %s advertised in %s/%s", name, mode, phase)
}
}
_, err := prepareSupervisorToolCalls([]llm.ToolCall{{ID: "retired-call", Name: string(name), Arguments: json.RawMessage(`{}`)}}, "run-retired", 1, 1,
_, err := prepareSupervisorToolCalls([]llm.ToolCall{{ID: "unsupported-call", Name: string(name), Arguments: json.RawMessage(`{}`)}}, "run-unsupported", 1, 1,
domain.ExecutionSurfaceCode, phase, mode, false, false)
if err == nil {
t.Fatalf("retired tool %s accepted in %s/%s", name, mode, phase)
if err == nil || !strings.Contains(err.Error(), "provider requested unsupported supervisor tool") {
t.Fatalf("unsupported tool %s prepare error = %v in %s/%s", name, err, mode, phase)
}
}
}
Expand Down
16 changes: 2 additions & 14 deletions internal/application/supervisor_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,20 +263,8 @@ func prepareSupervisorToolCalls(calls []llm.ToolCall, runID string, turn int, ro
seen := make(map[string]struct{}, len(normalized))
for index, call := range normalized {
name := toolgateway.ToolName(call.Name)
if name != toolgateway.WorkItemCreateTool && name != toolgateway.NoteCreateTool &&
!toolgateway.IsHistoryRecallTool(name) && name != toolgateway.SkillReadTool &&
name != toolgateway.SpecialistDelegationProposeTool &&
name != toolgateway.ChildTaskProposeTool &&
name != toolgateway.PlanDeliveryProposeTool &&
name != toolgateway.DockerSandboxRunProposeTool &&
name != toolgateway.SkillCandidateProposeTool &&
name != toolgateway.DebugTerminalTool &&
name != toolgateway.CommandRuntimeTool && name != toolgateway.MCPToolCallTool &&
!toolgateway.IsAgentCodeTool(name) && !toolgateway.IsCodeIntelTool(name) &&
!toolgateway.IsWebEvidenceTool(name) {
if !toolgateway.IsBrowserActionTool(name) {
return nil, fmt.Errorf("provider requested unsupported supervisor tool %q", call.Name)
}
if !toolgateway.IsSupervisorTool(name) {
return nil, fmt.Errorf("provider requested unsupported supervisor tool %q", call.Name)
}
if toolgateway.IsAgentCodeTool(name) {
available := false
Expand Down
2 changes: 1 addition & 1 deletion internal/store/supervisor_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,7 +223,7 @@ func normalizeSupervisorToolCallsForStore(calls []llm.ToolCall, runID string, tu
"supervisor tool payload exceeds its durable limit")
}
normalized[index].Arguments = append(json.RawMessage(nil), safe...)
if toolgateway.IsAgentCodeTool(name) {
if toolgateway.IsAgentCodeTool(name) || toolgateway.IsCodeIntelTool(name) {
authority, authorityErr := toolgateway.DecodeAgentCodeCallAuthority(
normalized[index].Authority)
if authorityErr != nil || authority.RunID != runID {
Expand Down
98 changes: 98 additions & 0 deletions internal/store/supervisor_tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,104 @@ func TestSupervisorAgentCodeAuthorityIsDurableAndRequired(t *testing.T) {
}
}

func TestSupervisorCodeIntelAuthorityIsDurableCanonicalAndRunBound(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "supervisor-code-intel.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
ctx := context.Background()
mission, run := createStructuredToolTestRun(t, ctx, st, "durable code intel authority")
if _, err := application.NewRunService(st).Start(ctx, run.ID); err != nil {
t.Fatal(err)
}
turn, err := st.BeginSupervisorTurn(ctx,
acquireTestRunExecutionLease(t, ctx, st, run.ID), "inspect code hover")
if err != nil {
t.Fatal(err)
}
permission, err := st.GetRunExecutionPermission(ctx, run.ID)
if err != nil {
t.Fatal(err)
}
scope := toolgateway.AgentCodeCapabilityContext{RunID: run.ID, MissionID: mission.ID,
RootAgentID: turn.Agent.ID, WorkspaceID: mission.WorkspaceID,
RootFingerprint: strings.Repeat("c", 64), Surface: turn.Mode.Surface,
Phase: turn.Mode.Phase, Role: turn.Agent.Role, Profile: turn.Agent.Profile,
PermissionMode: permission.Mode, ModeRevision: turn.Mode.Revision,
PermissionRevision: permission.Revision}
authority, err := toolgateway.NewAgentCodeCallAuthority(scope, run.SessionID)
if err != nil {
t.Fatal(err)
}
canonicalAuthority, err := toolgateway.EncodeAgentCodeCallAuthority(authority)
if err != nil {
t.Fatal(err)
}
authorityInput, err := json.MarshalIndent(authority, "", " ")
if err != nil {
t.Fatal(err)
}
otherScope := scope
otherScope.RunID = "run-code-intel-other"
otherAuthority, err := toolgateway.NewAgentCodeCallAuthority(otherScope, run.SessionID)
if err != nil {
t.Fatal(err)
}
otherAuthorityJSON, err := toolgateway.EncodeAgentCodeCallAuthority(otherAuthority)
if err != nil {
t.Fatal(err)
}
payload, err := toolgateway.NormalizeSupervisorToolPayload(toolgateway.CodeHoverTool,
mustStructuredPayload(t, toolgateway.CodeIntelPayload{
Version: toolgateway.CodeIntelProtocolVersion, ServerID: "gopls",
ServerGeneration: strings.Repeat("a", 64), CapabilityFingerprint: strings.Repeat("b", 64),
Path: "main.go", Line: 1, Character: 2, Limit: 20,
}))
if err != nil {
t.Fatal(err)
}
operationKey := runmutation.SupervisorToolOperationKey(run.ID,
turn.Checkpoint.NextTurn, string(toolgateway.CodeHoverTool), string(payload))
callID, err := runmutation.SupervisorToolCallID(operationKey, 1)
if err != nil {
t.Fatal(err)
}
attempt := llm.ModelAttempt{Number: 1, TransportAttempt: 1, MaxAttempts: 1,
Provider: "test", Model: "model"}
if inserted, err := st.RecordSupervisorModelStarted(ctx, turn.Checkpoint, attempt); err != nil || !inserted {
t.Fatalf("start model attempt: inserted=%t err=%v", inserted, err)
}
attempt.Outcome = llm.OutcomeSuccess
response := llm.ChatResponse{Provider: "test", Model: "model",
Usage: llm.Usage{InputTokens: 1, OutputTokens: 1, TotalTokens: 2},
ToolCalls: []llm.ToolCall{{ID: callID, Name: string(toolgateway.CodeHoverTool), Arguments: payload}}}
for _, rejected := range []struct {
name string
authority json.RawMessage
}{{name: "missing"}, {name: "another Run", authority: otherAuthorityJSON}} {
response.ToolCalls[0].Authority = rejected.authority
if _, err := st.RecordSupervisorModelCompleted(ctx, turn.Checkpoint, attempt, response); apperror.CodeOf(err) != apperror.CodeInvalidArgument {
t.Fatalf("code hover with %s authority was accepted: %v", rejected.name, err)
}
if rounds, err := st.ListSupervisorToolRounds(ctx, turn.Checkpoint); err != nil || len(rounds) != 0 {
t.Fatalf("rejected authority left durable calls: %#v err=%v", rounds, err)
}
}
response.ToolCalls[0].Authority = authorityInput
checkpoint, err := st.RecordSupervisorModelCompleted(ctx, turn.Checkpoint, attempt, response)
if err != nil {
t.Fatalf("code hover with exact Go-issued authority was rejected: %v", err)
}
rounds, err := st.ListSupervisorToolRounds(ctx, checkpoint)
if err != nil || len(rounds) != 1 || len(rounds[0].Calls) != 1 ||
rounds[0].Calls[0].ToolName != string(toolgateway.CodeHoverTool) ||
rounds[0].Calls[0].CallID != callID || rounds[0].Calls[0].PayloadJSON != string(payload) ||
rounds[0].Calls[0].AuthorityJSON != string(canonicalAuthority) {
t.Fatalf("code hover authority and intent were not durable and canonical: %#v err=%v", rounds, err)
}
}

func TestSupervisorCommandRuntimeAuthorityIsCanonicalAndRequired(t *testing.T) {
runID := "run-command-runtime-authority"
payload, err := toolgateway.NormalizeSupervisorToolPayload(
Expand Down
10 changes: 0 additions & 10 deletions internal/toolgateway/agent_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
"unicode/utf8"
Expand Down Expand Up @@ -869,15 +868,6 @@ func (g *Gateway) invokeAgentCode(ctx context.Context, call ToolCall) (Outcome,
return validateOutcome(outcome, captureErr)
}

func agentCodeToolNames() []ToolName {
names := make([]ToolName, 0, len(agentCodeDefinitions))
for _, definition := range agentCodeDefinitions {
names = append(names, definition.Name)
}
sort.Slice(names, func(i, j int) bool { return names[i] < names[j] })
return names
}

func isAgentCodeTool(name ToolName) bool {
_, found := AgentCodeToolDefinition(name)
return found
Expand Down
10 changes: 0 additions & 10 deletions internal/toolgateway/code_intel.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"io"
"sort"
"strings"
"time"
"unicode/utf8"
Expand Down Expand Up @@ -126,15 +125,6 @@ func CodeIntelToolDefinition(name ToolName) (ToolDefinition, bool) {
return ToolDefinition{}, false
}

func codeIntelToolNames() []ToolName {
result := make([]ToolName, 0, len(codeIntelDefinitions))
for _, definition := range codeIntelDefinitions {
result = append(result, definition.Name)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}

func IsCodeIntelTool(name ToolName) bool {
_, found := CodeIntelToolDefinition(name)
return found
Expand Down
49 changes: 3 additions & 46 deletions internal/toolgateway/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,54 +264,11 @@ func (g *Gateway) Invoke(ctx context.Context, call ToolCall) (outcome Outcome, r
normalized.InvocationID = usage.LastCharge
}
}
switch normalized.Name {
case SkillReadTool:
return g.invokeSkillRead(ctx, normalized)
case HistorySearchTool, HistoryReadTool:
return g.invokeHistoryRecall(ctx, normalized)
case WorkspaceListTool, WorkspaceReadTool, WorkspaceGlobTool, WorkspaceGrepTool,
WorkspaceChangeTool, WorkspaceApplyTool, WorkspaceDeleteTool:
return g.invokeAgentCode(ctx, normalized)
case CodeWorkspaceSymbolsTool, CodeDocumentSymbolsTool, CodeDefinitionTool,
CodeReferencesTool, CodeImplementationTool, CodeHoverTool,
CodeSignatureHelpTool, CodeDiagnosticsTool, CodeCallHierarchyTool,
CodeTypeHierarchyTool:
return g.invokeCodeIntel(ctx, normalized)
case ReadFileTool, ListWorkspaceTool:
return g.invokeWorkspaceRead(ctx, normalized)
case ShellTool:
return g.invokeShellProposal(ctx, normalized)
case ReplaceFileTool:
return g.invokeFileEditProposal(ctx, normalized)
case WorkItemCreateTool, NoteCreateTool:
return g.invokeStructuredMemory(ctx, normalized)
case SpecialistDelegationProposeTool:
return g.invokeSpecialistDelegation(ctx, normalized)
case ChildTaskProposeTool:
return g.invokeChildTaskProposal(ctx, normalized)
case PlanDeliveryProposeTool:
return g.invokePlanDelivery(ctx, normalized)
case DockerSandboxRunProposeTool:
return g.invokeDockerSandboxProposal(ctx, normalized)
case SkillCandidateProposeTool:
return g.invokeSkillCandidate(ctx, normalized)
case DebugTerminalTool:
return g.invokeDebugTerminal(ctx, normalized)
case CommandRuntimeTool:
return g.invokeCommandRuntime(ctx, normalized)
case MCPToolCallTool:
return g.invokeMCP(ctx, normalized)
case WebSearchTool, SourceSearchTool, WebFetchTool, WebCitationTool:
return g.invokeWebEvidence(ctx, normalized)
case BrowserStatusTool, BrowserNavigateTool, BrowserSnapshotTool,
BrowserClickTool, BrowserTypeTool, BrowserScreenshotTool, BrowserScrollTool, BrowserKeyTool:
if IsAgentBrowserPayload(normalized.Payload) {
return g.invokeAgentBrowser(ctx, normalized)
}
return g.invokeBrowserAction(ctx, normalized)
default:
registration, found := lookupTool(normalized.Name)
if !found || registration.invoke == nil {
return Outcome{}, fmt.Errorf("unsupported tool %q", normalized.Name)
}
return registration.invoke(g, ctx, normalized)
}

func (g *Gateway) Review(ctx context.Context, request ReviewRequest) (Outcome, error) {
Expand Down
Loading
Loading