85 lines
3.6 KiB
Go
85 lines
3.6 KiB
Go
// subagent.go implements the dispatch_subagent tool: the agent
|
|
// service's equivalent of Claude Code's Task tool. The main agent
|
|
// hands off a self-contained investigation to a nested agentrun.Agent
|
|
// (see agentrun.NewSubagent) that runs with its own, much smaller tool
|
|
// list and reports back in one string, so a broad exploration ("find
|
|
// every place X happens") spends *its* context budget, not the
|
|
// conversation's.
|
|
package toolexec
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"enkly/pkg/vllmkit"
|
|
"enkly/services/agent/internal/infrastructure/agentrun"
|
|
"enkly/services/agent/internal/infrastructure/toolcatalog"
|
|
)
|
|
|
|
// restrictedExecutor wraps an *Executor but only forwards calls whose
|
|
// name is in toolcatalog's read-only whitelist, failing closed on
|
|
// everything else (including, explicitly, dispatch_subagent itself —
|
|
// a sub-agent can't spawn another one). This is the second of two
|
|
// enforcement layers: toolcatalog.SubagentDefs() controls what the
|
|
// model is *offered*, this controls what actually runs if the model
|
|
// (or a future bug) asks for something outside that list anyway.
|
|
type restrictedExecutor struct {
|
|
inner *Executor
|
|
}
|
|
|
|
// NewRestricted returns a ToolExecutor that only ever runs the
|
|
// read-only tools in toolcatalog.SubagentDefs(), whatever inner
|
|
// supports. Used to build the dispatch_subagent sub-agent's executor.
|
|
func NewRestricted(inner *Executor) *restrictedExecutor {
|
|
return &restrictedExecutor{inner: inner}
|
|
}
|
|
|
|
func (r *restrictedExecutor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
if !toolcatalog.IsSubagentTool(call.Name) {
|
|
return vllmkit.ToolResult{
|
|
Name: call.Name,
|
|
Content: fmt.Sprintf("%s: not available to a sub-agent (read-only tools only)", call.Name),
|
|
IsError: true,
|
|
}
|
|
}
|
|
return r.inner.Execute(ctx, call)
|
|
}
|
|
|
|
// dispatchSubagent runs the dispatch_subagent tool call: it launches
|
|
// e.Subagent on a single user message (the "task" argument) and feeds
|
|
// its final answer back as this tool call's result. e.Subagent is nil
|
|
// until main.go wires it (see its comment there for why that can't
|
|
// happen inside toolexec.New) — guarded here so a misconfigured
|
|
// deployment degrades to a clear tool error instead of a nil-pointer
|
|
// panic reaching the model's request.
|
|
func (e *Executor) dispatchSubagent(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
if e.Subagent == nil {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: sub-agent not configured on this deployment", IsError: true}
|
|
}
|
|
task := strings.TrimSpace(argStr(call.Arguments, "task"))
|
|
if task == "" {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: task is required", IsError: true}
|
|
}
|
|
|
|
report, _, err := e.Subagent.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: task}}, agentrun.Callbacks{})
|
|
if err != nil {
|
|
// A cancelled sub-agent (the whole turn was stopped, or it hit
|
|
// its own round cap mid-investigation) still gets a useful
|
|
// result back: whatever it had produced, plus a clear note —
|
|
// not a bare tool error that discards partial progress.
|
|
report = strings.TrimSpace(report)
|
|
note := fmt.Sprintf("\n\n[sub-agent stopped early: %v]", err)
|
|
if report == "" {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_subagent: " + err.Error(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: report + note}
|
|
}
|
|
|
|
report = strings.TrimSpace(report)
|
|
if report == "" {
|
|
report = "(the sub-agent finished without writing a report — try a more specific task description)"
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: report}
|
|
}
|