Files
enkly-ai/services/agent/internal/infrastructure/toolexec/executor.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

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()}
}