317 lines
12 KiB
Go
317 lines
12 KiB
Go
// Package toolexec is the direct replacement for the monolith's
|
|
// Toolbox.Execute dispatch switch. Where that switch called a method on
|
|
// the same in-process Toolbox for every tool, Executor.Execute calls
|
|
// out over gRPC to whichever bounded context now owns that tool
|
|
// (workspace for every filesystem/bash tool, knowledge for
|
|
// search_codebase) or runs write_todos and solve_ml_math locally (both
|
|
// are pure, in-memory computation with no filesystem/db dependency —
|
|
// write_todos per the monolith's tools.go.writeTodos, and
|
|
// solve_ml_math because pkg/mathkit is a self-contained numerical
|
|
// engine with nothing to call out to; see mlmath.go).
|
|
//
|
|
// Every method here turns a transport-level error (the peer being
|
|
// unreachable, timing out, ...) into an IsError ToolResult rather than
|
|
// letting it propagate as a Go error — exactly the contract
|
|
// Toolbox.Execute already had ("errors executing the tool are turned
|
|
// into an IsError ToolResult... so the agent loop can feed the failure
|
|
// back to the model and let it try again"), now also covering RPC
|
|
// failures, which didn't exist as a failure mode in the monolith.
|
|
package toolexec
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
knowledgev1 "enkly/gen/knowledge/v1"
|
|
workspacev1 "enkly/gen/workspace/v1"
|
|
"enkly/pkg/skillkit"
|
|
"enkly/pkg/vllmkit"
|
|
"enkly/services/agent/internal/infrastructure/agentrun"
|
|
)
|
|
|
|
type Executor struct {
|
|
Workspace workspacev1.WorkspaceServiceClient
|
|
Knowledge knowledgev1.KnowledgeServiceClient
|
|
|
|
// SkillIndex backs the search_office_skills tool (see
|
|
// office_skill.go): an in-memory, lazily-embedded semantic index
|
|
// over pkg/skillkit's office-document skill content. Nil when New
|
|
// was given a nil embedder (e.g. a deployment with no embedding
|
|
// model configured) — search_office_skills reports itself
|
|
// unavailable rather than panicking, the same nil-is-graceful
|
|
// contract Subagent below has.
|
|
SkillIndex *skillkit.Index
|
|
|
|
// Subagent powers both the dispatch_subagent tool (see subagent.go)
|
|
// and dispatch_workflow (see workflow.go), which reuses this same
|
|
// nested Agent for every one of its parallel steps rather than
|
|
// building a separate one per step — sub.Run is safe to call
|
|
// concurrently (its ChatStreamer and Executor are both stateless
|
|
// gRPC/HTTP clients). Left nil by New — main.go sets it in a
|
|
// second step, after building an Executor, because the sub-agent's
|
|
// own executor (toolexec.NewRestricted) needs to wrap this very
|
|
// Executor by pointer. Nil is handled gracefully: both tools just
|
|
// report themselves unavailable rather than panicking.
|
|
Subagent *agentrun.Agent
|
|
|
|
// WorkflowMaxParallel caps how many dispatch_workflow steps may run
|
|
// concurrently in one call, before the resolved effort level's own
|
|
// (smaller, per-level) cap is also applied — see dispatchWorkflow.
|
|
// Zero (the New default) falls back to defaultWorkflowMaxParallel.
|
|
// Set from ENKLY_WORKFLOW_MAX_PARALLEL in main.go; this is the hard
|
|
// operational ceiling for a deployment's hardware, independent of
|
|
// what any single turn's effort level would otherwise allow.
|
|
WorkflowMaxParallel int
|
|
// WorkflowVerifier checks each dispatch_workflow step's report —
|
|
// see the WorkflowVerifier type. Nil (the New default) falls back
|
|
// to defaultWorkflowVerifier.
|
|
WorkflowVerifier WorkflowVerifier
|
|
}
|
|
|
|
// defaultWorkflowMaxParallel is a conservative default for a single
|
|
// vLLM chat replica. Unlike Ollama (which serves one model-inference
|
|
// request at a time in practice, however many HTTP requests arrive
|
|
// concurrently), vLLM's continuous-batching scheduler genuinely
|
|
// services many in-flight requests on one GPU at once, batching new
|
|
// tokens for whichever sequences are ready each scheduler step — so a
|
|
// production vLLM deployment can sustain meaningfully higher
|
|
// concurrent dispatch_workflow fan-out than an Ollama-backed one ever
|
|
// could on the same hardware, bounded in practice by
|
|
// --max-num-seqs/--gpu-memory-utilization on the chat deployment (see
|
|
// k8s/11-vllm.yaml) rather than by the engine serializing requests.
|
|
// This default stays deliberately conservative rather than assuming
|
|
// every deployment's chat replica count and --max-num-seqs can absorb
|
|
// a much larger number — see cmd/server/main.go's
|
|
// ENKLY_WORKFLOW_MAX_PARALLEL for how a deployment with real
|
|
// multi-GPU/multi-replica vLLM capacity raises this toward the
|
|
// "hundreds" a cluster-backed deployment can actually make good use
|
|
// of.
|
|
const defaultWorkflowMaxParallel = 64
|
|
|
|
// New wires an Executor to its peer services plus, if embedder is
|
|
// non-nil, the office-skills semantic index (see SkillIndex). embedder
|
|
// is typically the same *vllmkit.Client already used for chat — it
|
|
// satisfies skillkit.Embedder as-is, no adapter needed.
|
|
func New(workspace workspacev1.WorkspaceServiceClient, knowledge knowledgev1.KnowledgeServiceClient, embedder skillkit.Embedder) *Executor {
|
|
e := &Executor{Workspace: workspace, Knowledge: knowledge}
|
|
if embedder != nil {
|
|
e.SkillIndex = skillkit.NewIndex(embedder)
|
|
}
|
|
return e
|
|
}
|
|
|
|
func rpcFail(name string, err error) vllmkit.ToolResult {
|
|
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf("%s: service unavailable: %v", name, err), IsError: true}
|
|
}
|
|
|
|
// Execute dispatches a single tool call, mirroring Toolbox.Execute's
|
|
// switch one case at a time.
|
|
func (e *Executor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
switch call.Name {
|
|
case "read_file":
|
|
return e.readFile(ctx, call)
|
|
case "write_file":
|
|
return e.writeFile(ctx, call)
|
|
case "edit_file":
|
|
return e.editFile(ctx, call)
|
|
case "multi_edit_file":
|
|
return e.multiEditFile(ctx, call)
|
|
case "list_directory":
|
|
return e.listDirectory(ctx, call)
|
|
case "glob_search":
|
|
return e.globSearch(ctx, call)
|
|
case "grep":
|
|
return e.grep(ctx, call)
|
|
case "write_todos":
|
|
return writeTodos(call)
|
|
case "run_bash_command":
|
|
return e.runBashCommand(ctx, call)
|
|
case "search_codebase":
|
|
return e.searchCodebase(ctx, call)
|
|
case "solve_ml_math":
|
|
return solveMLMath(call)
|
|
case "dispatch_subagent":
|
|
return e.dispatchSubagent(ctx, call)
|
|
case "dispatch_workflow":
|
|
return e.dispatchWorkflow(ctx, call)
|
|
case "read_office_skill":
|
|
return readOfficeSkill(call)
|
|
case "search_office_skills":
|
|
return e.searchOfficeSkills(ctx, call)
|
|
case "create_document":
|
|
return e.createDocument(ctx, call)
|
|
case "read_document":
|
|
return e.readDocument(ctx, call)
|
|
case "edit_document":
|
|
return e.editDocument(ctx, call)
|
|
case "create_spreadsheet":
|
|
return e.createSpreadsheet(ctx, call)
|
|
case "read_spreadsheet":
|
|
return e.readSpreadsheet(ctx, call)
|
|
case "edit_spreadsheet":
|
|
return e.editSpreadsheet(ctx, call)
|
|
case "create_presentation":
|
|
return e.createPresentation(ctx, call)
|
|
case "read_presentation":
|
|
return e.readPresentation(ctx, call)
|
|
case "edit_presentation":
|
|
return e.editPresentation(ctx, call)
|
|
case "read_pdf":
|
|
return e.readPdf(ctx, call)
|
|
default:
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("unknown tool %q", call.Name), IsError: true}
|
|
}
|
|
}
|
|
|
|
func argStr(args map[string]any, key string) string {
|
|
v, _ := args[key].(string)
|
|
return v
|
|
}
|
|
|
|
func argInt(args map[string]any, key string) int {
|
|
switch v := args[key].(type) {
|
|
case float64:
|
|
return int(v)
|
|
case int:
|
|
return v
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func argBool(args map[string]any, key string) bool {
|
|
v, _ := args[key].(bool)
|
|
return v
|
|
}
|
|
|
|
func (e *Executor) readFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
resp, err := e.Workspace.ReadFile(ctx, &workspacev1.ReadFileRequest{
|
|
Path: argStr(call.Arguments, "path"), Offset: int32(argInt(call.Arguments, "offset")), Limit: int32(argInt(call.Arguments, "limit")),
|
|
})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetContent()}
|
|
}
|
|
|
|
func (e *Executor) writeFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
resp, err := e.Workspace.WriteFile(ctx, &workspacev1.WriteFileRequest{
|
|
Path: argStr(call.Arguments, "path"), Content: argStr(call.Arguments, "content"),
|
|
})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d bytes to %s", resp.GetBytesWritten(), argStr(call.Arguments, "path"))}
|
|
}
|
|
|
|
func (e *Executor) editFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
resp, err := e.Workspace.EditFile(ctx, &workspacev1.EditFileRequest{
|
|
Path: argStr(call.Arguments, "path"), OldString: argStr(call.Arguments, "old_string"),
|
|
NewString: argStr(call.Arguments, "new_string"), ReplaceAll: argBool(call.Arguments, "replace_all"),
|
|
})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetDiffPreview()}
|
|
}
|
|
|
|
func (e *Executor) multiEditFile(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
rawEdits, _ := call.Arguments["edits"].([]any)
|
|
edits := make([]*workspacev1.TextEdit, 0, len(rawEdits))
|
|
for _, re := range rawEdits {
|
|
m, ok := re.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
edits = append(edits, &workspacev1.TextEdit{
|
|
OldString: argStr(m, "old_string"), NewString: argStr(m, "new_string"), ReplaceAll: argBool(m, "replace_all"),
|
|
})
|
|
}
|
|
resp, err := e.Workspace.MultiEditFile(ctx, &workspacev1.MultiEditFileRequest{
|
|
Path: argStr(call.Arguments, "path"), Edits: edits,
|
|
})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetDiffPreview()}
|
|
}
|
|
|
|
func (e *Executor) listDirectory(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
resp, err := e.Workspace.ListDirectory(ctx, &workspacev1.ListDirectoryRequest{Path: argStr(call.Arguments, "path")})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetListing()}
|
|
}
|
|
|
|
func (e *Executor) globSearch(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
resp, err := e.Workspace.GlobSearch(ctx, &workspacev1.GlobSearchRequest{Pattern: argStr(call.Arguments, "pattern")})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
|
|
}
|
|
|
|
func (e *Executor) grep(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
resp, err := e.Workspace.Grep(ctx, &workspacev1.GrepRequest{
|
|
Pattern: argStr(call.Arguments, "pattern"), Glob: argStr(call.Arguments, "path_glob"),
|
|
CaseInsensitive: argBool(call.Arguments, "case_insensitive"),
|
|
})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
|
|
}
|
|
|
|
func (e *Executor) runBashCommand(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
resp, err := e.Workspace.RunBashCommand(ctx, &workspacev1.RunBashCommandRequest{Command: argStr(call.Arguments, "command")})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if resp.GetIsError() {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetErrorMessage(), IsError: true}
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: resp.GetOutput()}
|
|
}
|
|
|
|
func (e *Executor) searchCodebase(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
|
query := argStr(call.Arguments, "query")
|
|
if strings.TrimSpace(query) == "" {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: "search_codebase: empty query", IsError: true}
|
|
}
|
|
resp, err := e.Knowledge.Search(ctx, &knowledgev1.SearchRequest{Query: query, TopK: 6})
|
|
if err != nil {
|
|
return rpcFail(call.Name, err)
|
|
}
|
|
if len(resp.GetChunks()) == 0 {
|
|
return vllmkit.ToolResult{Name: call.Name, Content: "no matching code found"}
|
|
}
|
|
var sb strings.Builder
|
|
for _, c := range resp.GetChunks() {
|
|
fmt.Fprintf(&sb, "### %s (chunk %d, distance %.4f)\n```\n%s\n```\n\n", c.GetFilePath(), c.GetChunkIndex(), c.GetDistance(), c.GetContent())
|
|
}
|
|
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
|
}
|