This commit is contained in:
16
services/agent/Dockerfile
Normal file
16
services/agent/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
# See services/iam/Dockerfile for the pattern explanation.
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum* ./
|
||||
COPY gen/ gen/
|
||||
COPY pkg/ pkg/
|
||||
COPY services/agent/ services/agent/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=0 go build -o /out/agent-server ./services/agent/cmd/server
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
WORKDIR /app
|
||||
COPY --from=build /out/agent-server ./agent-server
|
||||
ENV ENKLY_AGENT_GRPC_ADDR=:9006
|
||||
EXPOSE 9006
|
||||
ENTRYPOINT ["./agent-server"]
|
||||
254
services/agent/cmd/server/main.go
Normal file
254
services/agent/cmd/server/main.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// Command server runs the agent microservice: a gRPC server exposing
|
||||
// api/proto/agent/v1, orchestrating vLLM-served chat + tool calls
|
||||
// across the workspace, knowledge, billing and conversation services.
|
||||
// Replaces internal/llm/agent.go + client.go (chat half) and the
|
||||
// chat-run sections of internal/handlers/chat.go.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
|
||||
agentv1 "enkly/gen/agent/v1"
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/pkg/effort"
|
||||
"enkly/pkg/entitlements"
|
||||
"enkly/pkg/envkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/application"
|
||||
"enkly/services/agent/internal/domain"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
"enkly/services/agent/internal/infrastructure/grpcclients"
|
||||
"enkly/services/agent/internal/infrastructure/toolcatalog"
|
||||
"enkly/services/agent/internal/infrastructure/toolexec"
|
||||
grpcsrv "enkly/services/agent/internal/interfaces/grpc"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
|
||||
if err := envkit.LoadDotEnv(".env"); err != nil {
|
||||
logger.Error("load .env", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var (
|
||||
grpcAddr = envkit.String("ENKLY_AGENT_GRPC_ADDR", ":9006")
|
||||
|
||||
workspaceAddr = envkit.String("ENKLY_WORKSPACE_GRPC_ADDR", "workspace:9004")
|
||||
knowledgeAddr = envkit.String("ENKLY_KNOWLEDGE_GRPC_ADDR", "knowledge:9005")
|
||||
billingAddr = envkit.String("ENKLY_BILLING_GRPC_ADDR", "billing:9002")
|
||||
conversationAddr = envkit.String("ENKLY_CONVERSATION_GRPC_ADDR", "conversation:9003")
|
||||
|
||||
// vllmChatURL / vllmEmbedURL point at the two vLLM
|
||||
// OpenAI-compatible deployments this service calls — a
|
||||
// chat-serving replica (Qwen, tool-calling + reasoning
|
||||
// enabled) and an embedding-serving replica, each its own vLLM
|
||||
// engine process since one vLLM server hosts exactly one
|
||||
// model. See k8s/11-vllm.yaml for how those are deployed (GPU
|
||||
// requests, --served-model-name, probes) and vllmkit.Client's
|
||||
// doc comment for why the two are separate fields rather than
|
||||
// one shared host the way a single local Ollama daemon used to
|
||||
// be.
|
||||
vllmChatURL = envkit.String("ENKLY_VLLM_CHAT_URL", "http://localhost:8000/v1")
|
||||
vllmEmbedURL = envkit.String("ENKLY_VLLM_EMBED_URL", "http://localhost:8001/v1")
|
||||
chatModel = envkit.String("ENKLY_CHAT_MODEL", vllmkit.DefaultChatModel)
|
||||
embedModel = envkit.String("ENKLY_EMBED_MODEL", vllmkit.DefaultEmbedModel)
|
||||
// vllmAPIKey is sent as a bearer token on every request to both
|
||||
// deployments (vLLM's --api-key flag). Required once either
|
||||
// deployment is reachable from outside the cluster/mesh; leave
|
||||
// unset for a fully cluster-internal deployment with no auth in
|
||||
// front of it. See k8s/02-secrets.yaml.
|
||||
vllmAPIKey = envkit.String("ENKLY_VLLM_API_KEY", "")
|
||||
|
||||
// chatThink controls vLLM/Qwen3's extended-thinking toggle
|
||||
// (vllmkit.Client.Think — "" leaves it to the chat template's
|
||||
// own default, "true"/"false" forces it, and "low"/"medium"/
|
||||
// "high" are also accepted for compatibility with pkg/effort's
|
||||
// vocabulary, though the wire protocol itself has no
|
||||
// graded-effort equivalent — see Think's doc comment).
|
||||
// chatMaxTokens/chatTemperature/chatTopP are the per-request
|
||||
// sampling knobs vLLM actually exposes over HTTP; context
|
||||
// length and GPU/tensor-parallel layout are NOT request-time
|
||||
// options under vLLM the way Ollama's num_ctx/num_gpu/
|
||||
// num_thread were — those are now fixed at server start via
|
||||
// --max-model-len/--tensor-parallel-size/
|
||||
// --gpu-memory-utilization on the vLLM deployment itself (see
|
||||
// k8s/11-vllm.yaml), so there is no client-side equivalent to
|
||||
// wire up here.
|
||||
chatThink = envkit.String("ENKLY_CHAT_THINK", "")
|
||||
chatMaxTokens = envkit.Int("ENKLY_CHAT_MAX_TOKENS", 0)
|
||||
chatTemperature = envkit.Float("ENKLY_CHAT_TEMPERATURE", 0)
|
||||
chatTopP = envkit.Float("ENKLY_CHAT_TOP_P", 0)
|
||||
|
||||
// Effort control / adaptive thinking (see pkg/effort and
|
||||
// agentrun.AdaptiveThinkPolicy). defaultEffort seeds every
|
||||
// Agent's fallback level when a turn carries no /effort
|
||||
// directive (see turnopts) and no WithEffort override;
|
||||
// adaptiveThinking turns on content-driven escalation above
|
||||
// that level's own floor. Both default to this deployment
|
||||
// behaving exactly as it did before effort control existed
|
||||
// (effort.Default == Medium, matching the old hardcoded
|
||||
// MaxRounds=12/DefaultThinkPolicy shape) unless explicitly
|
||||
// overridden.
|
||||
defaultEffort = envkit.String("ENKLY_DEFAULT_EFFORT", string(effort.Default))
|
||||
adaptiveThinking = envkit.Bool("ENKLY_ADAPTIVE_THINKING", true)
|
||||
|
||||
// Dynamic workflows (see dispatch_workflow / toolexec/workflow.go).
|
||||
// workflowMaxParallel is the hard operational ceiling for this
|
||||
// deployment's vLLM chat capacity; dynamicWorkflowPlans is the
|
||||
// comma-separated plan allowlist (see pkg/entitlements) —
|
||||
// "premium" today, see entitlements' package doc comment for why.
|
||||
workflowMaxParallel = envkit.Int("ENKLY_WORKFLOW_MAX_PARALLEL", 0)
|
||||
dynamicWorkflowPlans = envkit.String("ENKLY_DYNAMIC_WORKFLOW_PLANS", "")
|
||||
|
||||
// Fast mode (see /fast, Service.FastAgent). Unset
|
||||
// fastChatModel means "fast mode isn't configured on this
|
||||
// deployment" — StartTurn then just runs a /fast turn on the
|
||||
// normal Agent, exactly as if fast mode had never been
|
||||
// requested (see Service.FastAgent's doc comment). A
|
||||
// deployment that has, say, a smaller/distilled model served
|
||||
// by its own vLLM replica (or by the same replica under a
|
||||
// second --served-model-name via vLLM's multi-LoRA/multi-model
|
||||
// support) points fastChatModel at it to actually unlock the
|
||||
// latency/cost win fast mode promises; fastChatURL lets that
|
||||
// replica live on an entirely different host, which is the
|
||||
// common case (a smaller model usually means a
|
||||
// smaller/cheaper GPU shape, i.e. a different Deployment).
|
||||
fastChatModel = envkit.String("ENKLY_FAST_CHAT_MODEL", "")
|
||||
fastChatURL = envkit.String("ENKLY_FAST_VLLM_CHAT_URL", vllmChatURL)
|
||||
fastChatThink = envkit.String("ENKLY_FAST_CHAT_THINK", "low")
|
||||
fastChatMaxTokens = envkit.Int("ENKLY_FAST_CHAT_MAX_TOKENS", 700)
|
||||
)
|
||||
|
||||
clients, err := grpcclients.Dial(workspaceAddr, knowledgeAddr, billingAddr, conversationAddr)
|
||||
if err != nil {
|
||||
logger.Error("dial peer services", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer clients.Close()
|
||||
|
||||
llmClient := vllmkit.New(vllmChatURL, vllmEmbedURL, chatModel, embedModel)
|
||||
llmClient.APIKey = vllmAPIKey
|
||||
llmClient.Think = chatThink
|
||||
llmClient.ChatOptions = vllmkit.Options{
|
||||
Temperature: chatTemperature, TopP: chatTopP, MaxTokens: chatMaxTokens,
|
||||
}
|
||||
|
||||
// llmClient also backs search_office_skills' semantic index (see
|
||||
// toolexec.Executor.SkillIndex): the same vLLM-backed client
|
||||
// already used for chat satisfies skillkit.Embedder as-is.
|
||||
executor := toolexec.New(clients.Workspace, clients.Knowledge, llmClient)
|
||||
executor.WorkflowMaxParallel = workflowMaxParallel // 0 means "use toolexec's own default"
|
||||
|
||||
// dispatch_subagent (and, built on the same nested Agent,
|
||||
// dispatch_workflow's parallel steps — see toolexec/workflow.go):
|
||||
// a nested Agent sharing the same vLLM-backed client but with a
|
||||
// read-only tool list (toolcatalog.SubagentDefs()) and a
|
||||
// restricted executor (toolexec.NewRestricted) that enforces that
|
||||
// list even if the model asks for anything else. Wired as a second
|
||||
// step, after executor exists, because the restricted executor has
|
||||
// to wrap executor by pointer — see toolexec.Executor.Subagent's
|
||||
// doc comment.
|
||||
executor.Subagent = agentrun.NewSubagent(llmClient, toolexec.NewRestricted(executor), toolcatalog.SubagentDefs())
|
||||
|
||||
agent := agentrun.New(llmClient, executor, toolcatalog.Defs())
|
||||
|
||||
// Effort control / adaptive thinking: an unrecognized
|
||||
// ENKLY_DEFAULT_EFFORT value is logged and ignored, leaving New's
|
||||
// own effort.Default (Medium) in force — a misconfigured env var
|
||||
// degrading to "the agent's long-standing default behavior" is
|
||||
// always a safe failure mode, the same stance effort.Level.Resolve
|
||||
// itself takes for an invalid value reaching it at runtime.
|
||||
if lvl, err := effort.Parse(defaultEffort); err != nil {
|
||||
logger.Warn("invalid ENKLY_DEFAULT_EFFORT, using default", "value", defaultEffort, "err", err)
|
||||
} else {
|
||||
agent.Effort = lvl
|
||||
}
|
||||
if adaptiveThinking {
|
||||
agent.AdaptiveThink = agentrun.AdaptiveThinkPolicy
|
||||
}
|
||||
|
||||
// ENKLY.md project memory: if the workspace root has one, its
|
||||
// content is folded into the system prompt on every turn (see
|
||||
// agentrun.Agent.buildSystemPrompt) — the same idea as Claude Code
|
||||
// reading a CLAUDE.md. A missing file or an unreachable workspace
|
||||
// service both just mean "no project memory today"; this must never
|
||||
// fail a turn over it, so the error is swallowed here, not returned.
|
||||
const projectMemoryPath = "ENKLY.md"
|
||||
agent.MemoryLoader = func(ctx context.Context) string {
|
||||
resp, err := clients.Workspace.ReadFile(ctx, &workspacev1.ReadFileRequest{Path: projectMemoryPath})
|
||||
if err != nil || resp.GetIsError() {
|
||||
return ""
|
||||
}
|
||||
return resp.GetContent()
|
||||
}
|
||||
|
||||
// Fast mode: only wired up when ENKLY_FAST_CHAT_MODEL names a
|
||||
// distinct served model — see fastChatModel's doc comment above and
|
||||
// application.Service.FastAgent's. fastAgent shares executor (and
|
||||
// therefore the same dispatch_subagent/dispatch_workflow nested
|
||||
// Agent) with the normal agent; only the ChatStreamer differs.
|
||||
var fastAgent *agentrun.Agent
|
||||
if fastChatModel != "" {
|
||||
fastClient := vllmkit.New(fastChatURL, vllmEmbedURL, fastChatModel, embedModel)
|
||||
fastClient.APIKey = vllmAPIKey
|
||||
fastClient.Think = fastChatThink
|
||||
fastClient.ChatOptions = vllmkit.Options{MaxTokens: fastChatMaxTokens}
|
||||
fastAgent = agentrun.New(fastClient, executor, toolcatalog.Defs())
|
||||
fastAgent.MemoryLoader = agent.MemoryLoader
|
||||
fastAgent.Effort = effort.Low
|
||||
logger.Info("fast mode configured", "fast_chat_model", fastChatModel, "fast_chat_url", fastChatURL)
|
||||
}
|
||||
|
||||
svc := &application.Service{
|
||||
Streams: domain.NewManager(),
|
||||
Agent: agent,
|
||||
FastAgent: fastAgent,
|
||||
Billing: clients.Billing,
|
||||
Conv: clients.Conversation,
|
||||
Logger: logger,
|
||||
// Same window/backoff as the monolith's ChatLimiter — a gentle
|
||||
// per-account throttle that "only bites a tight scripted loop".
|
||||
ChatLimiter: authsupport.NewLimiter(time.Minute, 200*time.Millisecond, 4*time.Second),
|
||||
Entitlements: entitlements.ParsePlanList(dynamicWorkflowPlans),
|
||||
}
|
||||
|
||||
lis, err := net.Listen("tcp", grpcAddr)
|
||||
if err != nil {
|
||||
logger.Error("listen", "addr", grpcAddr, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
grpcServer := grpc.NewServer(grpc.MaxRecvMsgSize(24<<20), grpc.MaxSendMsgSize(24<<20))
|
||||
agentv1.RegisterAgentServiceServer(grpcServer, grpcsrv.New(svc))
|
||||
healthSrv := health.NewServer()
|
||||
healthpb.RegisterHealthServer(grpcServer, healthSrv)
|
||||
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
reflection.Register(grpcServer)
|
||||
|
||||
go func() {
|
||||
logger.Info("agent service listening", "addr", grpcAddr, "chat_model", chatModel)
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
logger.Error("serve", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
<-ctx.Done()
|
||||
logger.Info("shutting down")
|
||||
grpcServer.GracefulStop()
|
||||
}
|
||||
457
services/agent/internal/application/service.go
Normal file
457
services/agent/internal/application/service.go
Normal file
@@ -0,0 +1,457 @@
|
||||
// Package application holds the agent service's use cases — the
|
||||
// transplant of internal/handlers/chat.go's Handlers.Chat (minus HTTP
|
||||
// parsing/rendering) plus its runAgent background goroutine. This is a
|
||||
// saga coordinator: every use case here calls out over gRPC to
|
||||
// billing, conversation, workspace (via toolexec) and knowledge rather
|
||||
// than touching a database of its own.
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
"enkly/pkg/authsupport"
|
||||
"enkly/pkg/entitlements"
|
||||
"enkly/pkg/i18n"
|
||||
"enkly/pkg/turnopts"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/domain"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
"enkly/services/agent/internal/infrastructure/toolexec"
|
||||
)
|
||||
|
||||
const (
|
||||
maxPromptChars = 32_000
|
||||
historyMessages = 60 // most recent user/assistant messages replayed to the model
|
||||
|
||||
// defaultConversationTitle mirrors the conversation service's own
|
||||
// DefaultConversationTitle — duplicated as a literal rather than
|
||||
// imported, since depending on another service's internal package
|
||||
// would violate the service boundary; see conversation's
|
||||
// migrations/0001_init.sql column default, which is the actual
|
||||
// source of truth both sides must agree with.
|
||||
defaultConversationTitle = "New conversation"
|
||||
|
||||
maxRunDuration = 30 * time.Minute
|
||||
sseKeepAliveInterval = 15 * time.Second
|
||||
sseCoalesceDelay = 30 * time.Millisecond
|
||||
)
|
||||
|
||||
// Outcome mirrors agentv1.StartTurnOutcome without importing the
|
||||
// generated package into this layer — see internal/interfaces/grpc for
|
||||
// the translation.
|
||||
type Outcome int
|
||||
|
||||
const (
|
||||
OutcomeOK Outcome = iota
|
||||
OutcomeConversationNotFound
|
||||
OutcomeConversationBusy
|
||||
OutcomeTooManyStreams
|
||||
OutcomeRateLimited
|
||||
OutcomeQuotaExceeded
|
||||
OutcomeInternalError
|
||||
)
|
||||
|
||||
type StartTurnResult struct {
|
||||
Outcome Outcome
|
||||
Message string
|
||||
StreamID string
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Streams *domain.Manager
|
||||
Agent *agentrun.Agent
|
||||
Billing billingv1.BillingServiceClient
|
||||
Conv conversationv1.ConversationServiceClient
|
||||
Logger *slog.Logger
|
||||
|
||||
// ChatLimiter replaces Handlers.ChatLimiter: a gentle per-account
|
||||
// throttle that "only bites a tight scripted loop".
|
||||
ChatLimiter *authsupport.Limiter
|
||||
|
||||
// FastAgent, when non-nil, is used instead of Agent for a turn that
|
||||
// requests fast mode (see turnopts' /fast directive) — typically
|
||||
// wired in main.go to an *agentrun.Agent sharing Executor but built
|
||||
// around a second, smaller/quantized *vllmkit.Client configured
|
||||
// for lower latency and cost per the "2.5x faster... 3x cheaper"
|
||||
// fast-mode brief. A deployment that hasn't configured a distinct
|
||||
// fast model leaves this nil, and a /fast request just runs on
|
||||
// Agent like any other turn — fast mode degrading to "no change"
|
||||
// rather than erroring is deliberate, since it's meant to be
|
||||
// available on every plan with zero configuration required.
|
||||
FastAgent *agentrun.Agent
|
||||
|
||||
// Entitlements resolves which plans unlock dynamic workflows (see
|
||||
// pkg/entitlements) — built once in main.go from
|
||||
// ENKLY_DYNAMIC_WORKFLOW_PLANS and consulted on every StartTurn
|
||||
// from the plan name billing's own CheckAndIncrementUsage call
|
||||
// already returns, so this needs no gRPC call of its own. The zero
|
||||
// Catalog (an unconfigured Service, e.g. in a test) resolves every
|
||||
// plan as not entitled — see entitlements.Catalog.Resolve.
|
||||
Entitlements entitlements.Catalog
|
||||
}
|
||||
|
||||
func cleanTitle(s string, max int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if utf8.RuneCountInString(s) <= max {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return strings.TrimSpace(string(runes[:max-1])) + "…"
|
||||
}
|
||||
|
||||
// titleFromPrompt replaces handlers.titleFromPrompt.
|
||||
func titleFromPrompt(prompt string) string {
|
||||
first := prompt
|
||||
if i := strings.IndexByte(prompt, '\n'); i >= 0 {
|
||||
first = prompt[:i]
|
||||
}
|
||||
if t := cleanTitle(first, 60); t != "" {
|
||||
return t
|
||||
}
|
||||
return defaultConversationTitle
|
||||
}
|
||||
|
||||
// buildHistory replaces handlers.buildHistory: earlier turns' raw tool
|
||||
// results are deliberately left out — their assistant tool-call
|
||||
// messages aren't persisted, so replaying the results would present
|
||||
// the model with orphaned tool output; the assistant's own summary of
|
||||
// what it found is already in the text.
|
||||
func buildHistory(msgs []*conversationv1.Message) []vllmkit.Message {
|
||||
out := make([]vllmkit.Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
role := fromProtoRole(m.GetRole())
|
||||
if (role == vllmkit.RoleUser || role == vllmkit.RoleAssistant) && strings.TrimSpace(m.GetContent()) != "" {
|
||||
out = append(out, vllmkit.Message{Role: role, Content: m.GetContent()})
|
||||
}
|
||||
}
|
||||
if len(out) > historyMessages {
|
||||
out = out[len(out)-historyMessages:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fromProtoRole(r conversationv1.Role) vllmkit.Role {
|
||||
switch r {
|
||||
case conversationv1.Role_ROLE_USER:
|
||||
return vllmkit.RoleUser
|
||||
case conversationv1.Role_ROLE_ASSISTANT:
|
||||
return vllmkit.RoleAssistant
|
||||
case conversationv1.Role_ROLE_TOOL:
|
||||
return vllmkit.RoleTool
|
||||
default:
|
||||
return vllmkit.RoleSystem
|
||||
}
|
||||
}
|
||||
|
||||
func toProtoRole(r vllmkit.Role) conversationv1.Role {
|
||||
switch r {
|
||||
case vllmkit.RoleUser:
|
||||
return conversationv1.Role_ROLE_USER
|
||||
case vllmkit.RoleAssistant:
|
||||
return conversationv1.Role_ROLE_ASSISTANT
|
||||
case vllmkit.RoleTool:
|
||||
return conversationv1.Role_ROLE_TOOL
|
||||
default:
|
||||
return conversationv1.Role_ROLE_SYSTEM
|
||||
}
|
||||
}
|
||||
|
||||
// StartTurn replaces Handlers.Chat's synchronous prefix, launching the
|
||||
// same work Handlers.runAgent did in a background goroutine.
|
||||
//
|
||||
// lang is the visitor's resolved UI locale (see the gateway's
|
||||
// localeFrom / langFromIncomingContext in interfaces/grpc/server.go)
|
||||
// — every message StartTurn itself returns is looked up in that
|
||||
// locale's catalog (see pkg/i18n), and it's also threaded onto runCtx
|
||||
// via agentrun.WithPreferredLanguage so the model's system prompt
|
||||
// carries it as an explicit, standing language preference alongside
|
||||
// its own per-message detection (see agentrun's "## Language"
|
||||
// section).
|
||||
func (s *Service) StartTurn(ctx context.Context, userID, convID int64, prompt string, lang i18n.Locale) StartTurnResult {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.type_message_first")}
|
||||
}
|
||||
if len([]rune(prompt)) > maxPromptChars {
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.message_too_long", maxPromptChars)}
|
||||
}
|
||||
|
||||
// Leading /effort, /fast and /system directive lines (see
|
||||
// turnopts' doc comment for exactly why this, rather than new
|
||||
// proto fields, is today's working surface for those three
|
||||
// features) are parsed off and stripped before the prompt is
|
||||
// persisted or sent to the model — a directive is a control signal
|
||||
// for this service, not part of the conversation the model or a
|
||||
// human reviewing history needs to see. prompt itself is left
|
||||
// untouched if there were no directives, so the overwhelming
|
||||
// majority of turns pay no cost here.
|
||||
opts, prompt := turnopts.Parse(prompt)
|
||||
if opts.HadDirectives {
|
||||
prompt = strings.TrimSpace(prompt)
|
||||
if prompt == "" {
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.type_message_first")}
|
||||
}
|
||||
}
|
||||
|
||||
if !throttle(ctx, s.ChatLimiter, "chat:"+strconv.FormatInt(userID, 10)) {
|
||||
return StartTurnResult{Outcome: OutcomeRateLimited, Message: i18n.T(lang, "chat.rate_limited")}
|
||||
}
|
||||
|
||||
// Plan-based daily message cap: a lookup failure fails open (logged,
|
||||
// not blocking), matching the monolith's stance that a transient
|
||||
// billing hiccup shouldn't lock someone out of chat. planName stays
|
||||
// "" on a failed lookup, which entitlements.Catalog.Resolve treats
|
||||
// as "not entitled" for dynamic workflows — the opposite failure
|
||||
// mode from the quota check just above, and deliberately so: see
|
||||
// entitlements.Catalog.Resolve's doc comment on failing closed for
|
||||
// a gated paid feature versus failing open for message quota.
|
||||
var planName string
|
||||
if usage, err := s.Billing.CheckAndIncrementUsage(ctx, &billingv1.CheckAndIncrementUsageRequest{UserId: userID}); err != nil {
|
||||
s.Logger.Warn("check usage", "err", err, "user_id", userID)
|
||||
} else if !usage.GetAllowed() {
|
||||
return StartTurnResult{Outcome: OutcomeQuotaExceeded, Message: i18n.T(lang, "billing.quota_exceeded", usage.GetPlanName(), usage.GetLimit())}
|
||||
} else {
|
||||
planName = usage.GetPlanName()
|
||||
}
|
||||
|
||||
conv, err := s.Conv.GetConversation(ctx, &conversationv1.GetConversationRequest{UserId: userID, ConversationId: convID})
|
||||
if err != nil {
|
||||
return StartTurnResult{Outcome: OutcomeConversationNotFound, Message: i18n.T(lang, "chat.conversation_gone")}
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(context.Background(), maxRunDuration)
|
||||
runCtx = agentrun.WithPreferredLanguage(runCtx, lang)
|
||||
if opts.Effort.Valid() {
|
||||
runCtx = agentrun.WithEffort(runCtx, opts.Effort)
|
||||
}
|
||||
if len(opts.SystemEntries) > 0 {
|
||||
runCtx = agentrun.WithSystemEntries(runCtx, toAgentrunSystemEntries(opts.SystemEntries))
|
||||
}
|
||||
runCtx = toolexec.WithEntitlements(runCtx, s.Entitlements.Resolve(planName))
|
||||
|
||||
// Fast mode (see /fast and Service.FastAgent's doc comment) picks
|
||||
// which Agent actually runs this turn — everything else about the
|
||||
// run (context, callbacks, persistence) is identical either way.
|
||||
runAgent := s.Agent
|
||||
if opts.Fast && s.FastAgent != nil {
|
||||
runAgent = s.FastAgent
|
||||
}
|
||||
|
||||
stream, err := s.Streams.Create(userID, conv.GetId(), cancel)
|
||||
if err != nil {
|
||||
cancel()
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrConversationBusy):
|
||||
return StartTurnResult{Outcome: OutcomeConversationBusy, Message: i18n.T(lang, "chat.conversation_busy")}
|
||||
case errors.Is(err, domain.ErrTooManyStreams):
|
||||
return StartTurnResult{Outcome: OutcomeTooManyStreams, Message: i18n.T(lang, "chat.too_many_streams")}
|
||||
default:
|
||||
s.Logger.Error("create stream", "err", err)
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "errors.generic")}
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := s.Conv.AddMessage(ctx, &conversationv1.AddMessageRequest{
|
||||
ConversationId: conv.GetId(), Role: conversationv1.Role_ROLE_USER, Content: prompt,
|
||||
}); err != nil {
|
||||
cancel()
|
||||
stream.Finish("")
|
||||
s.Streams.ScheduleRemoval(stream)
|
||||
s.Logger.Error("save user message", "err", err)
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.save_failed")}
|
||||
}
|
||||
if _, err := s.Conv.AutoTitleConversation(ctx, &conversationv1.AutoTitleConversationRequest{
|
||||
UserId: userID, ConversationId: conv.GetId(), Title: titleFromPrompt(prompt),
|
||||
}); err != nil {
|
||||
s.Logger.Warn("auto-title conversation", "err", err)
|
||||
}
|
||||
|
||||
msgsResp, err := s.Conv.ListMessages(ctx, &conversationv1.ListMessagesRequest{ConversationId: conv.GetId()})
|
||||
if err != nil {
|
||||
cancel()
|
||||
stream.Finish("")
|
||||
s.Streams.ScheduleRemoval(stream)
|
||||
s.Logger.Error("load history", "err", err)
|
||||
return StartTurnResult{Outcome: OutcomeInternalError, Message: i18n.T(lang, "chat.load_failed")}
|
||||
}
|
||||
|
||||
go s.runTurn(runCtx, cancel, stream, conv.GetId(), buildHistory(msgsResp.GetMessages()), runAgent)
|
||||
|
||||
return StartTurnResult{Outcome: OutcomeOK, StreamID: stream.ID}
|
||||
}
|
||||
|
||||
// toAgentrunSystemEntries translates turnopts.SystemEntry (a
|
||||
// transport-agnostic shape pkg/turnopts owns so it doesn't need to
|
||||
// import the agent service's internal agentrun package) to
|
||||
// agentrun.SystemEntry — the one call site that actually needs both
|
||||
// types in scope.
|
||||
func toAgentrunSystemEntries(in []turnopts.SystemEntry) []agentrun.SystemEntry {
|
||||
out := make([]agentrun.SystemEntry, 0, len(in))
|
||||
for _, e := range in {
|
||||
var kind agentrun.SystemEntryKind
|
||||
switch e.Kind {
|
||||
case turnopts.SystemEntryPermission:
|
||||
kind = agentrun.SystemEntryPermission
|
||||
case turnopts.SystemEntryBudget:
|
||||
kind = agentrun.SystemEntryBudget
|
||||
default:
|
||||
kind = agentrun.SystemEntryInstruction
|
||||
}
|
||||
out = append(out, agentrun.SystemEntry{Kind: kind, Content: e.Content})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recordedCall mirrors handlers.recordedCall — used to match a
|
||||
// persisted tool message back to its structured call/result for the
|
||||
// tool_calls audit log, since callbacks can fire out of order when
|
||||
// tools run concurrently.
|
||||
type recordedCall struct {
|
||||
call vllmkit.ToolCallRequest
|
||||
result vllmkit.ToolResult
|
||||
used bool
|
||||
}
|
||||
|
||||
// runTurn replaces Handlers.runAgent. runAgent is the *agentrun.Agent
|
||||
// StartTurn selected for this turn — s.Agent normally, or s.FastAgent
|
||||
// when fast mode was requested and configured (see StartTurn) — rather
|
||||
// than a hardcoded s.Agent, so fast mode can swap in a differently
|
||||
// configured Agent with zero other change to how a turn runs.
|
||||
func (s *Service) runTurn(ctx context.Context, cancel context.CancelFunc, stream *domain.Stream, convID int64, history []vllmkit.Message, runAgent *agentrun.Agent) {
|
||||
defer cancel()
|
||||
defer s.Streams.ScheduleRemoval(stream)
|
||||
|
||||
defer func() {
|
||||
if rec := recover(); rec != nil {
|
||||
s.Logger.Error("panic in agent run", "panic", rec, "conversation_id", convID)
|
||||
stream.Finish("⚠️ Something went wrong while running the agent.")
|
||||
}
|
||||
}()
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
calls []*recordedCall
|
||||
)
|
||||
cb := agentrun.Callbacks{
|
||||
OnToken: stream.SetToken,
|
||||
OnThinking: stream.SetThinking,
|
||||
OnThinkingRound: stream.AddThinkingRound,
|
||||
OnToolCall: func(call vllmkit.ToolCallRequest, result vllmkit.ToolResult) {
|
||||
mu.Lock()
|
||||
calls = append(calls, &recordedCall{call: call, result: result})
|
||||
mu.Unlock()
|
||||
stream.AddTool(domain.ToolCallResult{
|
||||
Name: result.Name, ArgumentsJSON: argsJSON(call.Arguments), Result: result.Content, IsError: result.IsError,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
final, toPersist, runErr := runAgent.Run(ctx, history, cb)
|
||||
|
||||
pctx, pcancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer pcancel()
|
||||
|
||||
note := ""
|
||||
switch {
|
||||
case runErr == nil:
|
||||
case agentrun.IsCancelled(runErr) && errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
note = fmt.Sprintf("⏱ Stopped: this reply hit the %s time limit.", maxRunDuration)
|
||||
case agentrun.IsCancelled(runErr):
|
||||
note = "⏹ Stopped."
|
||||
case vllmkit.IsUpstreamUnavailable(runErr):
|
||||
// The vLLM chat deployment is unreachable (Pending/restarting/
|
||||
// mid-rollout) — a raw "dial tcp ...: connection refused"
|
||||
// tells the end user nothing actionable, so show a short
|
||||
// status message instead and keep the real error in the logs
|
||||
// for whoever runs k8s/scripts/gpu-preflight.sh.
|
||||
s.Logger.Error("agent run failed: vllm upstream unavailable", "err", runErr, "conversation_id", convID)
|
||||
note = "⚠️ The AI model service is starting up or temporarily unavailable. Please try again in a minute."
|
||||
default:
|
||||
s.Logger.Error("agent run failed", "err", runErr, "conversation_id", convID)
|
||||
note = "⚠️ " + runErr.Error()
|
||||
}
|
||||
if note != "" {
|
||||
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: note})
|
||||
}
|
||||
|
||||
for _, m := range toPersist {
|
||||
resp, err := s.Conv.AddMessage(pctx, &conversationv1.AddMessageRequest{
|
||||
ConversationId: convID, Role: toProtoRole(m.Role), Content: m.Content, ToolName: m.ToolName,
|
||||
})
|
||||
if err != nil {
|
||||
s.Logger.Error("persist message", "err", err, "conversation_id", convID)
|
||||
continue
|
||||
}
|
||||
if m.Role != vllmkit.RoleTool {
|
||||
continue
|
||||
}
|
||||
mu.Lock()
|
||||
for _, rc := range calls {
|
||||
if !rc.used && rc.call.Name == m.ToolName && rc.result.Content == m.Content {
|
||||
rc.used = true
|
||||
if _, err := s.Conv.AddToolCall(pctx, &conversationv1.AddToolCallRequest{
|
||||
MessageId: resp.GetId(), ToolName: rc.call.Name, ArgumentsJson: argsJSON(rc.call.Arguments),
|
||||
Result: rc.result.Content, IsError: rc.result.IsError,
|
||||
}); err != nil {
|
||||
s.Logger.Warn("persist tool call", "err", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
display := strings.TrimSpace(final)
|
||||
if note != "" {
|
||||
if display != "" {
|
||||
display += "\n\n"
|
||||
}
|
||||
display += note
|
||||
}
|
||||
if display == "" {
|
||||
display = "*The model returned an empty response.*"
|
||||
}
|
||||
|
||||
var toolCalls []domain.ToolCallResult
|
||||
mu.Lock()
|
||||
for _, rc := range calls {
|
||||
toolCalls = append(toolCalls, domain.ToolCallResult{
|
||||
Name: rc.call.Name, ArgumentsJSON: argsJSON(rc.call.Arguments), Result: rc.result.Content, IsError: rc.result.IsError,
|
||||
})
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
stream.FinishWithToolCalls(display, toolCalls)
|
||||
}
|
||||
|
||||
// StopTurn replaces Handlers.StopStream. Returns false if streamID
|
||||
// doesn't belong to userID (or doesn't exist), exactly like the
|
||||
// monolith's http.NotFound check.
|
||||
func (s *Service) StopTurn(userID int64, streamID string) bool {
|
||||
stream := s.Streams.Get(streamID)
|
||||
if stream == nil || stream.UserID != userID {
|
||||
return false
|
||||
}
|
||||
stream.Cancel()
|
||||
return true
|
||||
}
|
||||
|
||||
func argsJSON(args map[string]any) string {
|
||||
// Matches llm.ArgsJSON's fallback-to-"{}" behavior exactly.
|
||||
b, err := json.Marshal(args)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
42
services/agent/internal/application/throttle.go
Normal file
42
services/agent/internal/application/throttle.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"enkly/pkg/authsupport"
|
||||
)
|
||||
|
||||
// throttle replaces Handlers.throttle (see the same helper in the iam
|
||||
// service's application package for the full doc comment — identical
|
||||
// behavior, duplicated rather than shared because it's five lines and
|
||||
// not worth a dependency between two bounded contexts).
|
||||
func throttle(ctx context.Context, l *authsupport.Limiter, keys ...string) bool {
|
||||
var wait time.Duration
|
||||
for _, k := range keys {
|
||||
if d := l.Delay(k); d > wait {
|
||||
wait = d
|
||||
}
|
||||
}
|
||||
if !sleepCtx(ctx, wait) {
|
||||
return false
|
||||
}
|
||||
for _, k := range keys {
|
||||
l.RecordFailure(k)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-t.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
203
services/agent/internal/domain/stream.go
Normal file
203
services/agent/internal/domain/stream.go
Normal file
@@ -0,0 +1,203 @@
|
||||
// Package domain holds the agent service's own aggregate: one in-flight
|
||||
// (or just-finished) agent run. Transplanted from
|
||||
// internal/handlers/chat.go's StreamManager/Stream, with one
|
||||
// deliberate change: where the monolith's Stream stored pre-rendered
|
||||
// HTML (because the same process also owned templates), this one
|
||||
// stores plain text and structured tool calls — rendering now happens
|
||||
// in the gateway, the only service that imports pkg/renderkit. See
|
||||
// ARCHITECTURE.md's "Where rendering lives" note.
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxRunDuration = 30 * time.Minute // hard cap on one agent turn
|
||||
MaxStreamsPerUser = 3 // concurrent in-flight replies per account
|
||||
FinishedStreamTTL = 10 * time.Minute // how long a finished stream stays replayable
|
||||
)
|
||||
|
||||
var (
|
||||
ErrConversationBusy = errors.New("this conversation is still answering")
|
||||
ErrTooManyStreams = errors.New("too many replies in progress")
|
||||
)
|
||||
|
||||
// ToolCallResult is one completed tool call, as recorded on a Stream.
|
||||
type ToolCallResult struct {
|
||||
Name string
|
||||
ArgumentsJSON string
|
||||
Result string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
// ThinkingRound is one agent round's own standalone reasoning trace, as
|
||||
// recorded on a Stream — the round-indexed counterpart to ThinkText's
|
||||
// single running total. See agentrun.Callbacks.OnThinkingRound.
|
||||
type ThinkingRound struct {
|
||||
Round int
|
||||
Thinking string
|
||||
}
|
||||
|
||||
// Stream is the observable state of one agent run. Like the monolith's
|
||||
// version, it keeps the latest snapshot rather than a log of every
|
||||
// token event (token text is cumulative anyway), plus an append-only
|
||||
// list of completed tool calls, keeping memory bounded and letting a
|
||||
// late attacher (StreamTurn reconnecting) cheaply catch up.
|
||||
type Stream struct {
|
||||
ID string
|
||||
UserID int64
|
||||
ConvID int64
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
changed chan struct{} // closed & replaced on every update (broadcast)
|
||||
tokenText string // latest cumulative reply
|
||||
thinkText string // latest cumulative reasoning trace
|
||||
tools []ToolCallResult
|
||||
thinkingRounds []ThinkingRound // append-only, one per round that produced reasoning — see AddThinkingRound
|
||||
finalText string
|
||||
// finalToolCalls is every tool call made during the run (successful
|
||||
// or not), handed to the gateway alongside finalText so it can
|
||||
// build deliverable cards the same way
|
||||
// internal/handlers/deliverables.go did — see TurnDone in
|
||||
// api/proto/agent/v1/agent.proto.
|
||||
finalToolCalls []ToolCallResult
|
||||
done bool
|
||||
}
|
||||
|
||||
type StreamSnapshot struct {
|
||||
TokenText, ThinkText, FinalText string
|
||||
Tools []ToolCallResult
|
||||
ThinkingRounds []ThinkingRound
|
||||
FinalToolCalls []ToolCallResult
|
||||
Done bool
|
||||
}
|
||||
|
||||
func (s *Stream) update(fn func()) {
|
||||
s.mu.Lock()
|
||||
fn()
|
||||
close(s.changed)
|
||||
s.changed = make(chan struct{})
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Stream) Snapshot() (StreamSnapshot, <-chan struct{}) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return StreamSnapshot{
|
||||
TokenText: s.tokenText, ThinkText: s.thinkText, FinalText: s.finalText,
|
||||
Tools: s.tools[:len(s.tools):len(s.tools)], // append-only; safe to share
|
||||
ThinkingRounds: s.thinkingRounds[:len(s.thinkingRounds):len(s.thinkingRounds)], // append-only; safe to share
|
||||
FinalToolCalls: s.finalToolCalls,
|
||||
Done: s.done,
|
||||
}, s.changed
|
||||
}
|
||||
|
||||
func (s *Stream) IsDone() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.done
|
||||
}
|
||||
|
||||
func (s *Stream) SetToken(text string) { s.update(func() { s.tokenText = text }) }
|
||||
func (s *Stream) SetThinking(text string) { s.update(func() { s.thinkText = text }) }
|
||||
func (s *Stream) AddTool(tc ToolCallResult) { s.update(func() { s.tools = append(s.tools, tc) }) }
|
||||
|
||||
// AddThinkingRound records one round's standalone reasoning trace —
|
||||
// the round-indexed counterpart to SetThinking's running total. See
|
||||
// agentrun.Callbacks.OnThinkingRound.
|
||||
func (s *Stream) AddThinkingRound(round int, thinking string) {
|
||||
s.update(func() { s.thinkingRounds = append(s.thinkingRounds, ThinkingRound{Round: round, Thinking: thinking}) })
|
||||
}
|
||||
func (s *Stream) Finish(finalText string) {
|
||||
s.update(func() { s.finalText = finalText; s.done = true })
|
||||
}
|
||||
|
||||
// FinishWithToolCalls is Finish plus the full tool-call list for this
|
||||
// run, used by the normal (non-panic, non-empty-history) completion
|
||||
// path in runTurn.
|
||||
func (s *Stream) FinishWithToolCalls(finalText string, toolCalls []ToolCallResult) {
|
||||
s.update(func() { s.finalText = finalText; s.finalToolCalls = toolCalls; s.done = true })
|
||||
}
|
||||
|
||||
func (s *Stream) Cancel() { s.cancel() }
|
||||
|
||||
// Manager tracks in-flight (and just-finished) agent runs — identical
|
||||
// in behavior to the monolith's StreamManager, including the "second
|
||||
// concurrent run on the same conversation is refused, not queued"
|
||||
// invariant (their messages would interleave) and the per-user
|
||||
// concurrency cap.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
streams map[string]*Stream
|
||||
byConv map[int64]*Stream
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{streams: map[string]*Stream{}, byConv: map[int64]*Stream{}}
|
||||
}
|
||||
|
||||
func (m *Manager) Create(userID, convID int64, cancel context.CancelFunc) (*Stream, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if s, ok := m.byConv[convID]; ok && !s.IsDone() {
|
||||
return nil, ErrConversationBusy
|
||||
}
|
||||
active := 0
|
||||
for _, s := range m.streams {
|
||||
if s.UserID == userID && !s.IsDone() {
|
||||
active++
|
||||
}
|
||||
}
|
||||
if active >= MaxStreamsPerUser {
|
||||
return nil, ErrTooManyStreams
|
||||
}
|
||||
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Stream{ID: hex.EncodeToString(b[:]), UserID: userID, ConvID: convID, cancel: cancel, changed: make(chan struct{})}
|
||||
m.streams[s.ID] = s
|
||||
m.byConv[convID] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(id string) *Stream {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.streams[id]
|
||||
}
|
||||
|
||||
func (m *Manager) ActiveFor(convID int64) *Stream {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if s, ok := m.byConv[convID]; ok && !s.IsDone() {
|
||||
return s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) CancelConversation(convID int64) {
|
||||
if s := m.ActiveFor(convID); s != nil {
|
||||
s.Cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) ScheduleRemoval(s *Stream) {
|
||||
time.AfterFunc(FinishedStreamTTL, func() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
delete(m.streams, s.ID)
|
||||
if m.byConv[s.ConvID] == s {
|
||||
delete(m.byConv, s.ConvID)
|
||||
}
|
||||
})
|
||||
}
|
||||
924
services/agent/internal/infrastructure/agentrun/agent.go
Normal file
924
services/agent/internal/infrastructure/agentrun/agent.go
Normal file
@@ -0,0 +1,924 @@
|
||||
// Package agentrun is the agent service's core: the same round-trip
|
||||
// loop as the monolith's internal/llm/agent.go (send history to the
|
||||
// chat model, execute any requested tool calls, feed results back, repeat
|
||||
// until a final answer), with two changes forced by the split into
|
||||
// services: tool execution goes through a ToolExecutor (an interface
|
||||
// satisfied by toolexec.Executor, which calls out to the workspace and
|
||||
// knowledge services over gRPC) instead of an in-process Toolbox, and
|
||||
// the SystemPrompt's tool descriptions now live in
|
||||
// internal/infrastructure/toolcatalog rather than being read directly
|
||||
// off the Toolbox. The round-trip algorithm itself — compactToolHistory,
|
||||
// MaxRounds, the concurrent-tool-call fan-out — is unchanged.
|
||||
package agentrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
"enkly/pkg/i18n"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// SystemPrompt is prepended to every main-agent conversation. It is not
|
||||
// persisted; Agent rebuilds it fresh on every turn (plus whatever
|
||||
// MemoryLoader contributes — see buildSystemPrompt) — identical text
|
||||
// to the monolith's, since the model has been prompted and tested
|
||||
// against this precise wording.
|
||||
const SystemPrompt = `You are enkly, a local, open-source agentic coding assistant modeled after Claude Code.
|
||||
|
||||
## Tools
|
||||
- list_directory, glob_search — orient yourself: see what's there before guessing paths.
|
||||
- grep — exact/lexical search (a symbol, an error string, a TODO). Fast and precise.
|
||||
- search_codebase — fuzzy, conceptual search ("where do we validate a session") over the
|
||||
pgvector-indexed codebase. Prefer grep when you already know the literal text you want.
|
||||
- read_file — always read a file (or the specific lines you need, via offset/limit on a
|
||||
large one) before editing it. Never edit blind.
|
||||
- edit_file — the default way to change an EXISTING file: give an exact old_string (with
|
||||
enough surrounding context to be unique) and its replacement. It fails loudly instead of
|
||||
guessing if that text isn't found or isn't unique, so a failure means "go read the file
|
||||
again," not "try something else and hope." Returns a diff preview — read it to confirm
|
||||
the change landed where you meant.
|
||||
- multi_edit_file — several edit_file-style replacements applied to ONE file as a single
|
||||
atomic operation: if any edit in the list fails, none of them are written. Prefer this
|
||||
over multiple separate edit_file calls on the same file (e.g. renaming a symbol at its
|
||||
definition and every call site) — one round-trip, and it can never leave the file
|
||||
half-edited if a later edit in the batch turns out to be wrong. Edits apply in order, so
|
||||
a later one may rely on text an earlier one just introduced.
|
||||
- write_file — only for brand-new files, or a genuine full rewrite you've deliberately
|
||||
chosen over a series of edits. Never use it to make a small change to an existing file;
|
||||
that risks silently dropping unrelated content.
|
||||
- run_bash_command — builds, tests, formatters, linters, git. After any nontrivial edit,
|
||||
prefer running the relevant build/tests here over asserting the change is correct.
|
||||
- write_todos — for a nontrivial request (roughly: more than ~3 steps, or touching more
|
||||
than one file), write a short plan as todos before touching anything, mark exactly one
|
||||
item in_progress at a time as you work through it, and mark it completed before moving
|
||||
on. Skip this entirely for a small, single-step request — planning overhead a user
|
||||
didn't need to see is worse than no plan.
|
||||
- solve_ml_math — for any concrete, numeric ML-math question (linear algebra, a
|
||||
derivative/gradient/integral, a probability or statistics computation, gradient descent
|
||||
or root finding, an activation or loss function, fitting a least-squares linear
|
||||
regression), compute it with this tool instead of doing the arithmetic yourself in
|
||||
prose. It runs real Go numerical code, so it won't silently slip a digit the way working
|
||||
it out by hand can. Explain the result in plain terms after the call; don't just paste
|
||||
the raw tool output.
|
||||
- read_office_skill — optional background reading for the document tools below, not a
|
||||
required first step: create_document/create_spreadsheet/create_presentation's own
|
||||
descriptions already cover what you need for an ordinary document (a CV, a memo, a
|
||||
one-sheet table), so for one of those, call the create_*/edit_* tool directly — don't
|
||||
spend a round reading a skill file first. Reach for this before something that leans on
|
||||
a format's sharper edges (an Excel formula or multi-sheet workbook, a multi-slide
|
||||
PowerPoint deck, edit_document's replace_text run-splitting caveat) or when you're not
|
||||
sure a feature someone's asking for is even supported. Start with the "overview" skill
|
||||
if you're not sure which format a request needs. No need to re-read a skill you've
|
||||
already read earlier in this same conversation.
|
||||
- search_office_skills — a retrieval-augmented complement to read_office_skill: give it a
|
||||
specific question ("can a cell hold a formula", "how do I bold part of a paragraph")
|
||||
and it returns just the matching section(s) across the office skills, ranked by semantic
|
||||
similarity, instead of a whole skill file. Reach for this when you have one narrow
|
||||
question mid-task rather than re-reading a full skill; reach for read_office_skill when
|
||||
you want a format's complete guidance before starting.
|
||||
- create_document/read_document/edit_document, create_spreadsheet/read_spreadsheet/
|
||||
edit_spreadsheet, create_presentation/read_presentation/edit_presentation — Word, Excel
|
||||
and PowerPoint document management. Give plain structured fields (paragraph text and
|
||||
style, a grid of cell values, a slide's title and bullets) — never write raw OOXML/XML
|
||||
yourself; these tools render a real, valid .docx/.xlsx/.pptx file from what you give
|
||||
them. Read a document/spreadsheet/presentation back before editing it if you're not sure
|
||||
what it currently contains.
|
||||
- dispatch_subagent — hand off a broad or open-ended investigation ("find every place X
|
||||
happens", "map out how Y flows through this service") to a read-only sub-agent that
|
||||
explores on its own and returns one written report, instead of chaining many of your own
|
||||
search/read calls. Its exploration never touches your context — only its final report
|
||||
does — so this is how you keep a long investigation from eating the context budget you
|
||||
need for the actual edit. It cannot write files or run commands; dispatch work to it, not
|
||||
changes. For a single targeted lookup, just call the tool yourself — dispatching has
|
||||
overhead of its own.
|
||||
- dispatch_workflow — the large-scale version of dispatch_subagent: give it a batch of
|
||||
independent (or dependency-ordered) steps and it runs them in parallel, verifies each
|
||||
report against its own step, and hands you back a per-step status so you can see at a
|
||||
glance what needs attention. Reach for this on a job that's really the same investigation
|
||||
repeated across many files, services, or call sites (a migration, a repo-wide rename, "map
|
||||
what every handler in this package does") instead of looping dispatch_subagent calls
|
||||
yourself one at a time — that serializes work this tool runs concurrently. It carries the
|
||||
same read-only boundary dispatch_subagent has (investigate and report, never write or run),
|
||||
so plan the actual edits as a follow-up once you've read every step's findings. Not
|
||||
available on every plan; if it reports itself unavailable, fall back to dispatch_subagent.
|
||||
|
||||
## How to work
|
||||
- If the workspace root has an ENKLY.md file, its contents appear below under "Project
|
||||
memory" — read it like a project README written specifically for you (conventions,
|
||||
gotchas, how to run things here) and weigh it alongside, not instead of, what you find by
|
||||
actually looking at the code; it can go stale.
|
||||
- Investigate before acting: for anything beyond a one-line, obvious fix, look at the
|
||||
relevant code (list_directory/glob_search/grep/search_codebase, then read_file) before
|
||||
proposing or making a change, rather than pattern-matching from the file names alone.
|
||||
- Independent reads (several files, a search plus a directory listing) can be requested in
|
||||
the same round — they'll run concurrently — but don't parallelize a write with anything
|
||||
that depends on reading first.
|
||||
- Prefer several small, verifiable edits over one large speculative rewrite. Verify with
|
||||
run_bash_command (build/tests) when you can, rather than only asserting the change works.
|
||||
- State your plan briefly before calling tools, and summarize what actually happened
|
||||
afterward — including when a tool failed and what you did about it. Don't narrate every
|
||||
intermediate tool call at length; keep the reasoning proportional to the task.
|
||||
- If a tool call fails, read the error, adjust, and retry rather than repeating the same
|
||||
call or silently giving up. If you're genuinely blocked (missing credentials, an
|
||||
ambiguous requirement), say so plainly instead of guessing.
|
||||
- Decide with a tool call, don't describe one: if a request needs a file read, a search run,
|
||||
or a change made, call the matching tool in this same turn rather than writing "I'll read
|
||||
X" or "let's check Y" as plain text and stopping — that line of prose isn't the action, and
|
||||
leaving it unpaired with the actual call wastes a round. Conversely, don't call a tool out
|
||||
of habit when you already have what you need (e.g. re-reading a file you read two rounds
|
||||
ago and haven't touched since) — pick the smallest set of calls that actually moves the
|
||||
task forward, in parallel when they're independent of each other (see above).
|
||||
- Say what you actually found, including the parts that don't look good: if a change you
|
||||
made doesn't fully address the request, if tests fail and you couldn't get them passing, if
|
||||
you noticed a bug or a risk while working that's outside what was asked, or if you're not
|
||||
sure an edit is correct, say so plainly in your summary rather than describing the work as
|
||||
more complete or more certain than it is. A clearly flagged limitation is useful; a clean-
|
||||
sounding summary that quietly omits a known problem is not — the user can only act on what
|
||||
you tell them, so an unreported bug you noticed is strictly worse than a reported one you
|
||||
didn't have time to fix.
|
||||
|
||||
## Language
|
||||
- Detect the language the user is writing in and reply in that same language by default —
|
||||
don't ask them to pick one, and don't switch to English just because the codebase or your
|
||||
tools are in English. This covers everything you say to them: prose, plans, write_todos
|
||||
items, and summaries of what you changed.
|
||||
- If they switch languages mid-conversation, follow the switch on your next reply. If a
|
||||
message mixes languages (e.g. a pasted English stack trace inside an otherwise Spanish
|
||||
question), reply in the language of their own prose and leave quoted code, paths,
|
||||
commands, and error text exactly as given — don't translate those.
|
||||
- Code stays in English regardless of conversation language: identifiers, source comments,
|
||||
commit messages, and code-review feedback written inline in a diff follow the codebase's
|
||||
existing convention. Only your own explanations to the user switch languages.
|
||||
- If the user explicitly asks for replies in a specific language, use it from then on even
|
||||
if their own messages continue in a different one.`
|
||||
|
||||
// preferredLanguageKey is the context.Context key WithPreferredLanguage
|
||||
// stores a locale under. An unexported type (not string) so no other
|
||||
// package's context.WithValue key can collide with it — the standard
|
||||
// Go idiom for a context key.
|
||||
type preferredLanguageKey struct{}
|
||||
|
||||
// WithPreferredLanguage attaches the visitor's resolved UI locale to
|
||||
// ctx, for Run to fold into the system prompt (see buildSystemPrompt's
|
||||
// languageDirective). This is a second, standing signal layered on
|
||||
// top of the model's own per-message language detection (the
|
||||
// "## Language" section of SystemPrompt above) rather than a
|
||||
// replacement for it:
|
||||
//
|
||||
// - The caller (application.Service.StartTurn) always has SOME
|
||||
// locale by this point — an explicit cookie from the language
|
||||
// switcher, or failing that enkly's best guess from the visitor's
|
||||
// browser Accept-Language header, or failing that i18n.Default
|
||||
// ("en") — see the gateway's localeFrom and grpc/server.go's
|
||||
// langFromIncomingContext, which is where that resolution actually
|
||||
// happens; nothing in this package re-derives it. So this directive
|
||||
// is present on effectively every run, not only for a visitor who
|
||||
// has explicitly touched the language switcher — which is exactly
|
||||
// why languageDirective's wording below says "is using", not "has
|
||||
// set": it's honest about covering both an explicit choice and a
|
||||
// negotiated guess.
|
||||
// - What it's FOR is a reply to a short or ambiguous message (a bare
|
||||
// greeting, or any message before the visitor has typed enough for
|
||||
// detection alone to confidently place) — the same role Claude
|
||||
// Code's own `/language` setting plays alongside its automatic
|
||||
// detection, just seeded from a browser header when no explicit
|
||||
// choice exists yet rather than left unset.
|
||||
// - SystemPrompt's own rule still wins for the actual conversation:
|
||||
// if the visitor starts typing in a language that disagrees with
|
||||
// this directive, follow what they're typing (see "If they switch
|
||||
// languages mid-conversation…" above) — this is a default for when
|
||||
// there's nothing stronger to go on, never an override of what's
|
||||
// in front of the model right now.
|
||||
//
|
||||
// context.Value plumbing (rather than a field on Agent or a Run
|
||||
// parameter) was the natural fit here because MemoryLoader already
|
||||
// establishes the same pattern one field up: a per-run signal threaded
|
||||
// through ctx and read back inside buildSystemPrompt, not stored on
|
||||
// the shared, concurrently-used Agent struct. dispatch_subagent passes
|
||||
// its own derived context straight through to the sub-agent's Run
|
||||
// (see toolexec.Executor.dispatchSubagent), so a sub-agent's report
|
||||
// lands in the same language as the conversation that spawned it with
|
||||
// no extra plumbing needed.
|
||||
func WithPreferredLanguage(ctx context.Context, lang i18n.Locale) context.Context {
|
||||
return context.WithValue(ctx, preferredLanguageKey{}, lang)
|
||||
}
|
||||
|
||||
// preferredLanguageFrom reads back what WithPreferredLanguage stored,
|
||||
// or "" if nothing was ever set (no gateway call set it, a direct
|
||||
// internal call to Run, or a test).
|
||||
func preferredLanguageFrom(ctx context.Context) i18n.Locale {
|
||||
lang, _ := ctx.Value(preferredLanguageKey{}).(i18n.Locale)
|
||||
return lang
|
||||
}
|
||||
|
||||
// languageDirective turns a non-empty preferred locale into the extra
|
||||
// system-prompt paragraph buildSystemPrompt appends. Kept separate
|
||||
// from buildSystemPrompt itself mainly so the exact wording — and the
|
||||
// reasoning behind it — has one obvious place to live and to test.
|
||||
//
|
||||
// "is using", deliberately not "has set": per WithPreferredLanguage's
|
||||
// doc comment, lang is almost always present and may be an explicit
|
||||
// choice from enkly's language switcher or just a browser-negotiated
|
||||
// guess — the model shouldn't be told a visitor "set" something they
|
||||
// may never have touched.
|
||||
func languageDirective(lang i18n.Locale) string {
|
||||
if lang == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
"\n\n## Interface language\nThe user is using %s as enkly's interface language. "+
|
||||
"Treat that as your default reply language for this conversation — including a first reply to a "+
|
||||
"short greeting or an ambiguous message you can't otherwise place. The \"## Language\" rules above "+
|
||||
"still apply on top of this: if the user is actually typing in a different language, follow what "+
|
||||
"they're typing, not this setting.",
|
||||
i18n.NameOf(lang),
|
||||
)
|
||||
}
|
||||
|
||||
// SubagentSystemPrompt is prepended instead of SystemPrompt for a run
|
||||
// launched by the main agent's dispatch_subagent tool (see
|
||||
// NewSubagent). The sub-agent only ever gets one user message — the
|
||||
// "task" string the main agent wrote — and no ongoing conversation, so
|
||||
// this prompt is written around a single investigate-then-report turn
|
||||
// rather than an open-ended back-and-forth.
|
||||
const SubagentSystemPrompt = `You are a focused, read-only investigation sub-agent spawned by enkly (an agentic coding
|
||||
assistant) to answer exactly one question about this workspace, then report back. You were
|
||||
given a single task description as your only input — there is no further conversation, so
|
||||
make your report self-contained and complete the first time.
|
||||
|
||||
## Tools
|
||||
You have list_directory, glob_search, grep, read_file and search_codebase — nothing that
|
||||
writes a file, edits a file, or runs a command. You cannot change anything in this
|
||||
workspace, and nothing you do here needs to be undone.
|
||||
|
||||
## How to work
|
||||
- Orient first (list_directory/glob_search), narrow with grep (exact/lexical) or
|
||||
search_codebase (fuzzy/conceptual), then read_file the specific files that matter. Don't
|
||||
read files wholesale "just in case" — every call you make costs context that doesn't
|
||||
benefit the agent that dispatched you, only you, so stay targeted.
|
||||
- Investigate until you can answer the task with evidence, not guesses, then stop — more
|
||||
rounds than necessary just delays the report.
|
||||
- Write your final answer as a standalone report: lead with the direct answer, then the
|
||||
specific evidence (file paths, line-level detail, short quoted fragments) that backs it
|
||||
up. The agent reading your report has none of the context you gathered getting there, so
|
||||
name files and locations explicitly rather than saying "the file I found."
|
||||
- If the task is ambiguous or you can't find what it asks for, say so plainly and report
|
||||
what you did find instead of inventing an answer.
|
||||
- Write your report in the language the task description was written in — the agent that
|
||||
dispatched you will relay or translate it for the user as needed.`
|
||||
|
||||
// ToolExecutor runs one tool call and returns its result. Satisfied by
|
||||
// *toolexec.Executor; kept as an interface here purely so agentrun
|
||||
// doesn't import toolexec directly (and so a test can supply a fake).
|
||||
type ToolExecutor interface {
|
||||
Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult
|
||||
}
|
||||
|
||||
// ChatStreamer is the subset of *vllmkit.Client Agent needs. The
|
||||
// think parameter is a per-call effort override — see
|
||||
// vllmkit.Client.ChatStream and Agent.ThinkPolicy.
|
||||
type ChatStreamer interface {
|
||||
ChatStream(ctx context.Context, messages []vllmkit.Message, tools []vllmkit.ToolDef, think string) (<-chan vllmkit.ChatEvent, error)
|
||||
}
|
||||
|
||||
// Callbacks lets the caller (the application-layer StartTurn use case)
|
||||
// observe an in-flight run so it can push updates onto a domain.Stream.
|
||||
type Callbacks struct {
|
||||
OnToken func(cumulativeText string)
|
||||
OnThinking func(cumulativeThinking string)
|
||||
OnToolCall func(call vllmkit.ToolCallRequest, result vllmkit.ToolResult)
|
||||
// OnThinkingRound, if set, fires once per completed round with just
|
||||
// that round's own reasoning trace (round is 0-indexed), rather
|
||||
// than the running cumulative text OnThinking gets — the same
|
||||
// per-step granularity Claude's interleaved thinking blocks give a
|
||||
// UI: one distinct block of reasoning per tool-use step, not one
|
||||
// blob for the whole turn. Optional; nil is a no-op.
|
||||
OnThinkingRound func(round int, thinking string)
|
||||
}
|
||||
|
||||
// ThinkPolicy decides the per-round thinking-effort override (see
|
||||
// vllmkit.Client.ChatStream's think parameter: "", "true", "false",
|
||||
// "low", "medium", "high") passed for one round of an agent run. round
|
||||
// is 0-indexed; retrying is true when this call is a same-round retry
|
||||
// after a template-parse hiccup (see Run's attempt loop). Returning ""
|
||||
// defers entirely to the ChatStreamer's own configured default.
|
||||
type ThinkPolicy func(round int, retrying bool) string
|
||||
|
||||
// DefaultThinkPolicy asks for deeper reasoning on round 0 — the
|
||||
// planning step where the model has only the user's request and no
|
||||
// tool results yet, so investing more thinking effort up front pays
|
||||
// off across the whole turn — and otherwise leaves effort at the
|
||||
// client's configured default. A retry of the same round (after a
|
||||
// template-parse hiccup) asks for "low" instead: the goal of a retry
|
||||
// is a fast, simple re-attempt, not another expensive reasoning pass
|
||||
// over content that's already a known troublemaker.
|
||||
func DefaultThinkPolicy(round int, retrying bool) string {
|
||||
if retrying {
|
||||
return "low"
|
||||
}
|
||||
if round == 0 {
|
||||
return "high"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Agent ties together the LLM client, the tool executor, and bounds how
|
||||
// many tool-call round-trips one turn may take.
|
||||
type Agent struct {
|
||||
Client ChatStreamer
|
||||
Executor ToolExecutor
|
||||
Tools []vllmkit.ToolDef
|
||||
// MaxRounds matches the monolith's NewAgent default.
|
||||
MaxRounds int
|
||||
// SystemPrompt is prepended to every run this Agent executes. Set by
|
||||
// New (SystemPrompt) or NewSubagent (SubagentSystemPrompt); exported
|
||||
// so a caller can override it entirely if it ever needs to (tests,
|
||||
// a future third agent flavor) without touching this package.
|
||||
SystemPrompt string
|
||||
// MemoryLoader, if set, is called once at the start of every Run and
|
||||
// its (trimmed) return value is appended to the system prompt under
|
||||
// a "Project memory" heading — the mechanism behind the main
|
||||
// agent's ENKLY.md support (see main.go's wiring). Returning "" (the
|
||||
// file doesn't exist, or the read failed) adds nothing; a non-nil
|
||||
// error from the read itself is swallowed by the loader, not
|
||||
// reported here, so a transient infra hiccup never blocks a turn.
|
||||
// Left nil for the subagent flavor — a sub-agent gets its task as
|
||||
// its one input, not the project's standing notes.
|
||||
MemoryLoader func(ctx context.Context) string
|
||||
// ThinkPolicy picks the thinking-effort override for each round (see
|
||||
// the ThinkPolicy type). Defaults to DefaultThinkPolicy in New and
|
||||
// NewSubagent; a caller (or a test) can override it, and setting it
|
||||
// to nil falls back to always passing "" (i.e. the ChatStreamer's
|
||||
// own configured default for every round). When Effort or a
|
||||
// per-call WithEffort(ctx, ...) is in force, Run derives the actual
|
||||
// per-round floor from EffortThinkPolicy(profile) instead of using
|
||||
// this field directly — see resolveEffort and effectiveLimits.
|
||||
// ThinkPolicy remains the fallback for a run with no effort level
|
||||
// resolved at all (effort.Default always resolves to one, in
|
||||
// practice, so this mainly matters for tests that call Run
|
||||
// directly without going through the Effort machinery).
|
||||
ThinkPolicy ThinkPolicy
|
||||
|
||||
// Effort is this Agent's default reasoning-depth level (see pkg/
|
||||
// effort) — used whenever a Run call's ctx carries no WithEffort
|
||||
// override. New and NewSubagent set it to effort.Default, so an
|
||||
// Agent that never touches effort control behaves exactly as it
|
||||
// did before this field existed (EffortThinkPolicy(effort.Default.
|
||||
// Resolve()) reproduces DefaultThinkPolicy's own round-0/retry
|
||||
// shape, just also carrying Medium's MaxRounds/ContextBudgetChars).
|
||||
Effort effort.Level
|
||||
// AdaptiveThink, if set, replaces the per-round floor from Effort
|
||||
// with AdaptiveThinkPolicy(EffortThinkPolicy(profile)) — see that
|
||||
// function's doc comment. Left nil by New/NewSubagent; main.go
|
||||
// turns it on by setting this to agentrun.AdaptiveThinkPolicy (the
|
||||
// function value, not a call to it — Run calls it per-run with the
|
||||
// resolved profile's floor). A deployment that wants every turn's
|
||||
// thinking effort fixed exactly at its level, with no
|
||||
// content-driven escalation, leaves this nil.
|
||||
AdaptiveThink func(floor ThinkPolicy) func(round int, retrying bool, sig RoundSignal) string
|
||||
// ContextBudgetChars, when nonzero, overrides both the package
|
||||
// default (contextBudgetChars) and whatever pkg/effort's resolved
|
||||
// profile would otherwise set for a run — see effectiveLimits. Set
|
||||
// this on a deployment that has measured its own model's usable
|
||||
// context and wants a hard ceiling regardless of the effort level
|
||||
// a particular turn happens to run at. Zero (the New/NewSubagent
|
||||
// default) means "defer entirely to the resolved effort profile".
|
||||
ContextBudgetChars int
|
||||
}
|
||||
|
||||
func New(client ChatStreamer, executor ToolExecutor, tools []vllmkit.ToolDef) *Agent {
|
||||
return &Agent{Client: client, Executor: executor, Tools: tools, MaxRounds: 12, SystemPrompt: SystemPrompt, ThinkPolicy: DefaultThinkPolicy, Effort: effort.Default}
|
||||
}
|
||||
|
||||
// NewSubagent builds the restricted Agent a dispatch_subagent call
|
||||
// runs. It shares the ChatStreamer (vLLM chat-completions requests are
|
||||
// independent, concurrency-safe round trips) but gets its own tool list — pass
|
||||
// toolcatalog.SubagentDefs() — its own executor — pass
|
||||
// toolexec.NewRestricted(mainExecutor) so the read-only boundary is
|
||||
// enforced even if a future tool list edit slips up — and a smaller
|
||||
// MaxRounds, since a sub-agent's job is one bounded investigation, not
|
||||
// an open-ended session.
|
||||
func NewSubagent(client ChatStreamer, executor ToolExecutor, tools []vllmkit.ToolDef) *Agent {
|
||||
return &Agent{Client: client, Executor: executor, Tools: tools, MaxRounds: 8, SystemPrompt: SubagentSystemPrompt, ThinkPolicy: DefaultThinkPolicy, Effort: effort.Default}
|
||||
}
|
||||
|
||||
// thinkSelector is a per-round think-effort function that, unlike
|
||||
// ThinkPolicy, also takes the live RoundSignal Run computed from the
|
||||
// round that just finished — the hook AdaptiveThinkPolicy needs and a
|
||||
// plain ThinkPolicy doesn't.
|
||||
type thinkSelector func(round int, retrying bool, sig RoundSignal) string
|
||||
|
||||
// effectiveLimits resolves the MaxRounds/ContextBudgetChars/thinkSelector
|
||||
// this Run call should actually use: the effort level in force (see
|
||||
// resolveEffort) sizes all three, a.ContextBudgetChars overrides the
|
||||
// level's own context budget when explicitly set, and a.MaxRounds is
|
||||
// used as a hard ceiling the resolved level's MaxRounds can only lower,
|
||||
// never raise — an operator-configured MaxRounds (e.g. a smaller
|
||||
// deployment capping every turn at 8 rounds regardless of level) always
|
||||
// wins over a caller asking for Max effort's 32.
|
||||
func (a *Agent) effectiveLimits(ctx context.Context) (maxRounds, contextBudget int, think thinkSelector) {
|
||||
// An Agent that has never been touched by effort control at all —
|
||||
// built by hand with neither ThinkPolicy nor Effort ever set,
|
||||
// rather than via New/NewSubagent (both of which always set both)
|
||||
// — keeps the exact pre-effort-control behavior: a.MaxRounds with
|
||||
// no clamping, the package's own default context budget, and no
|
||||
// per-round think override whatsoever. This is what lets
|
||||
// TestRun_NilThinkPolicyMeansNoOverride-style minimal test/caller
|
||||
// Agents keep working unchanged; any Agent built via New,
|
||||
// NewSubagent, or with Effort/ThinkPolicy set some other way opts
|
||||
// into the full effort-resolved behavior below.
|
||||
_, ctxHasEffort := effortFrom(ctx)
|
||||
if a.ThinkPolicy == nil && !a.Effort.Valid() && !ctxHasEffort {
|
||||
return a.MaxRounds, contextBudgetChars, func(int, bool, RoundSignal) string { return "" }
|
||||
}
|
||||
|
||||
profile := a.resolveEffort(ctx).Resolve()
|
||||
|
||||
maxRounds = profile.MaxRounds
|
||||
if a.MaxRounds > 0 && a.MaxRounds < maxRounds {
|
||||
maxRounds = a.MaxRounds
|
||||
}
|
||||
|
||||
contextBudget = profile.ContextBudgetChars
|
||||
if a.ContextBudgetChars > 0 {
|
||||
contextBudget = a.ContextBudgetChars
|
||||
}
|
||||
if contextBudget <= 0 {
|
||||
contextBudget = contextBudgetChars
|
||||
}
|
||||
|
||||
floor := EffortThinkPolicy(profile)
|
||||
if a.AdaptiveThink != nil && profile.AdaptiveThinking {
|
||||
think = a.AdaptiveThink(floor)
|
||||
} else {
|
||||
think = func(round int, retrying bool, _ RoundSignal) string { return floor(round, retrying) }
|
||||
}
|
||||
return maxRounds, contextBudget, think
|
||||
}
|
||||
|
||||
// signalFromPriorRound builds the RoundSignal for the round about to
|
||||
// run, from the round immediately before it. round0 (nothing has
|
||||
// happened yet) always gets the zero RoundSignal — there is nothing
|
||||
// yet to react to.
|
||||
func signalFromPriorRound(round, toolCallsSoFar int, priorResults []vllmkit.ToolResult) RoundSignal {
|
||||
sig := RoundSignal{Round: round, ToolCallsSoFar: toolCallsSoFar}
|
||||
for _, r := range priorResults {
|
||||
if r.IsError {
|
||||
sig.PriorRoundFailed = true
|
||||
}
|
||||
if len(r.Content) > sig.PriorToolResultBytes {
|
||||
sig.PriorToolResultBytes = len(r.Content)
|
||||
}
|
||||
}
|
||||
return sig
|
||||
}
|
||||
|
||||
// memoryPromptChars caps how much of ENKLY.md gets folded into the
|
||||
// system prompt on every single turn — generous enough for a real
|
||||
// project-notes file, small enough that a mis-sized or accidentally
|
||||
// binary-ish file can't blow out the context budget every round.
|
||||
const memoryPromptChars = 8_000
|
||||
|
||||
// buildSystemPrompt returns a.SystemPrompt, plus a "Project memory"
|
||||
// section from a.MemoryLoader when one is set and returns non-empty
|
||||
// content.
|
||||
func (a *Agent) buildSystemPrompt(ctx context.Context) string {
|
||||
prompt := a.SystemPrompt
|
||||
|
||||
if a.MemoryLoader != nil {
|
||||
if mem := strings.TrimSpace(a.MemoryLoader(ctx)); mem != "" {
|
||||
if len(mem) > memoryPromptChars {
|
||||
omitted := len(mem) - memoryPromptChars
|
||||
mem = fmt.Sprintf("%s\n\n… [%d chars omitted — ENKLY.md is long; ask to read_file it directly if you need the rest]", safeHeadCut(mem, memoryPromptChars), omitted)
|
||||
}
|
||||
prompt += "\n\n## Project memory (ENKLY.md, workspace root)\n" + mem
|
||||
}
|
||||
}
|
||||
|
||||
prompt += languageDirective(preferredLanguageFrom(ctx))
|
||||
prompt += renderSystemEntries(systemEntriesFrom(ctx))
|
||||
|
||||
return prompt
|
||||
}
|
||||
|
||||
// Context budget for a single agent run — see compactToolHistory.
|
||||
const (
|
||||
contextBudgetChars = 60_000
|
||||
keepRecentToolMsgs = 4
|
||||
compactedHeadChars = 400
|
||||
compactedTailChars = 400
|
||||
)
|
||||
|
||||
// safeHeadCut and safeTailCut truncate a string to (at most) n bytes
|
||||
// without two kinds of damage a blind c[:n]/c[len(c)-n:] byte slice
|
||||
// can do: splitting a multi-byte UTF-8 rune in half (produces invalid
|
||||
// UTF-8), and — the more consequential one here — splitting an
|
||||
// angle-bracket tag in half (e.g. a file's own "<div" with no closing
|
||||
// ">"). This defensive cut point predates the move to vLLM: an
|
||||
// Ollama-served Qwen deployment's tool-calling chat template wrapped
|
||||
// tool calls/results in its own XML-ish delimiters and parsed the
|
||||
// result with Go's encoding/xml on its side, and a dangling,
|
||||
// unbalanced "<...>" left behind by a mid-tag cut was exactly the kind
|
||||
// of malformed markup that tripped its parser ("ollama error: XML
|
||||
// syntax error ... element closed by"). vLLM's OpenAI-compatible
|
||||
// tool-calling wire format carries tool content as a plain JSON string
|
||||
// field rather than XML-ish markup threaded through the chat template,
|
||||
// so that specific parse failure can no longer happen — but snapping
|
||||
// the cut to the nearest preceding '<' (for a head cut) or following
|
||||
// '>' (for a tail cut) is still cheap, harmless content hygiene (a
|
||||
// truncated tool result never hands the model a half-open tag either
|
||||
// way), so it stays rather than being ripped out for a backend that
|
||||
// no longer strictly requires it.
|
||||
func safeHeadCut(s string, n int) string {
|
||||
if n >= len(s) {
|
||||
return s
|
||||
}
|
||||
cut := n
|
||||
for cut > 0 && !isRuneBoundary(s, cut) {
|
||||
cut--
|
||||
}
|
||||
if lt := strings.LastIndexByte(s[:cut], '<'); lt >= 0 && cut-lt < 200 {
|
||||
cut = lt
|
||||
}
|
||||
return s[:cut]
|
||||
}
|
||||
|
||||
func safeTailCut(s string, n int) string {
|
||||
if n >= len(s) {
|
||||
return s
|
||||
}
|
||||
start := len(s) - n
|
||||
for start < len(s) && !isRuneBoundary(s, start) {
|
||||
start++
|
||||
}
|
||||
if gt := strings.IndexByte(s[start:], '>'); gt >= 0 && gt < 200 {
|
||||
start += gt + 1
|
||||
}
|
||||
return s[start:]
|
||||
}
|
||||
|
||||
func isRuneBoundary(s string, i int) bool {
|
||||
return i == 0 || i == len(s) || (s[i]&0xC0) != 0x80
|
||||
}
|
||||
|
||||
// degradeRecentToolMessages is the last resort in the tool-call-parse
|
||||
// self-heal (see the retry loop in Run and vllmkit.IsToolCallParseError):
|
||||
// when a couple of fresh retries of the exact same round still hit the
|
||||
// same tool-call parse failure, the most likely culprit is
|
||||
// specifically the newest tool result(s) in history — whatever just got
|
||||
// appended is, by construction, the one thing about this request that's
|
||||
// different from the last request that worked. This blanks the content
|
||||
// of the last n tool-role messages (in place, in this Run call's local
|
||||
// messages slice only — never what gets persisted to future turns) down
|
||||
// to a short placeholder, the same "it's gone, re-call the tool if you
|
||||
// need it" shape compactToolHistory already uses for ordinary context
|
||||
// compaction, and returns how many it actually touched.
|
||||
func degradeRecentToolMessages(messages []vllmkit.Message, n int) int {
|
||||
degraded := 0
|
||||
for i := len(messages) - 1; i >= 0 && degraded < n; i-- {
|
||||
if messages[i].Role != vllmkit.RoleTool {
|
||||
continue
|
||||
}
|
||||
messages[i].Content = fmt.Sprintf(
|
||||
"[this %s result was removed after it caused a tool-call parsing error; re-call the tool if you need it again]",
|
||||
messages[i].ToolName,
|
||||
)
|
||||
degraded++
|
||||
}
|
||||
return degraded
|
||||
}
|
||||
|
||||
// compactToolHistory mutates messages in place, shrinking older large
|
||||
// tool-result messages once the running total gets large against
|
||||
// budget (pass contextBudgetChars for the package default; Run passes
|
||||
// the per-effort-level budget resolved by effectiveLimits instead — see
|
||||
// Long context management below). Same core algorithm the monolith had
|
||||
// — unchanged by the service split since it only affects what gets
|
||||
// resent to the chat model within one run, never what's persisted — now
|
||||
// returns a one-line description of each message it actually trimmed
|
||||
// this call, for the caller (Run) to fold into a running compaction
|
||||
// ledger (see upsertCompactionLedger): "fewer drifts on long agentic
|
||||
// traces" means a turn that has compacted away its 30th tool result
|
||||
// should still be working from a stable map of what every earlier
|
||||
// round *did*, not just silently missing content with no trace it was
|
||||
// ever there.
|
||||
func compactToolHistory(messages []vllmkit.Message, budget int) []string {
|
||||
total := 0
|
||||
for _, m := range messages {
|
||||
total += len(m.Content)
|
||||
}
|
||||
if total <= budget {
|
||||
return nil
|
||||
}
|
||||
|
||||
var toolIdx []int
|
||||
for i, m := range messages {
|
||||
if m.Role == vllmkit.RoleTool {
|
||||
toolIdx = append(toolIdx, i)
|
||||
}
|
||||
}
|
||||
if len(toolIdx) <= keepRecentToolMsgs {
|
||||
return nil
|
||||
}
|
||||
|
||||
var trimmed []string
|
||||
for _, i := range toolIdx[:len(toolIdx)-keepRecentToolMsgs] {
|
||||
c := messages[i].Content
|
||||
if len(c) <= compactedHeadChars+compactedTailChars+64 {
|
||||
continue
|
||||
}
|
||||
omitted := len(c) - compactedHeadChars - compactedTailChars
|
||||
messages[i].Content = fmt.Sprintf(
|
||||
"%s\n\n… [%d chars omitted to save context — this was an earlier %s result; re-call it if you need the full output again] …\n\n%s",
|
||||
safeHeadCut(c, compactedHeadChars), omitted, messages[i].ToolName, safeTailCut(c, compactedTailChars),
|
||||
)
|
||||
// vllmkit.Message carries no IsError for a persisted tool
|
||||
// message (only ToolResult does, before it's folded into
|
||||
// history), so the ledger records what ran and how much was
|
||||
// trimmed, not success/failure — Run's own RoundSignal
|
||||
// tracking (see signalFromPriorRound) is the accurate source
|
||||
// for failure-driven adaptive thinking; this is purely a
|
||||
// "what happened, in order" map for the model to re-orient by.
|
||||
trimmed = append(trimmed, fmt.Sprintf("%s (%d chars omitted)", messages[i].ToolName, omitted))
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
// compactionLedgerMarker prefixes the single, stable system message
|
||||
// upsertCompactionLedger maintains, so Run can find and update it
|
||||
// again on a later round instead of inserting a new one each time.
|
||||
const compactionLedgerMarker = "## Compaction ledger (auto-maintained)"
|
||||
|
||||
// upsertCompactionLedger folds newly-trimmed descriptions into
|
||||
// messages' running compaction ledger: a single system-role message,
|
||||
// inserted immediately after the main system prompt (index 0) the
|
||||
// first time anything is compacted, and simply re-rendered with a
|
||||
// larger list on every later round that compacts more — never
|
||||
// duplicated, never reordered, so a long turn's prefix up through the
|
||||
// ledger entry is still only ever growing by appended lines, not being
|
||||
// restructured. entries accumulates across the whole Run call (the
|
||||
// caller owns and grows the slice); this function only ever rewrites
|
||||
// the one ledger message's Content from the current full list.
|
||||
func upsertCompactionLedger(messages []vllmkit.Message, entries []string) []vllmkit.Message {
|
||||
if len(entries) == 0 {
|
||||
return messages
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(compactionLedgerMarker)
|
||||
b.WriteString("\nEarlier tool results below were shortened to save context. This is the full list of what ran, in " +
|
||||
"order, so you don't lose track of earlier steps even once their output is gone — re-call a tool if you need " +
|
||||
"its full output again, don't guess at it from this list.\n")
|
||||
for i, e := range entries {
|
||||
fmt.Fprintf(&b, "%d. %s\n", i+1, e)
|
||||
}
|
||||
content := strings.TrimRight(b.String(), "\n")
|
||||
|
||||
for i := range messages {
|
||||
if messages[i].Role == vllmkit.RoleSystem && strings.HasPrefix(messages[i].Content, compactionLedgerMarker) {
|
||||
messages[i].Content = content
|
||||
return messages
|
||||
}
|
||||
}
|
||||
ledgerMsg := vllmkit.Message{Role: vllmkit.RoleSystem, Content: content}
|
||||
if len(messages) == 0 {
|
||||
return append(messages, ledgerMsg)
|
||||
}
|
||||
out := make([]vllmkit.Message, 0, len(messages)+1)
|
||||
out = append(out, messages[0], ledgerMsg)
|
||||
out = append(out, messages[1:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// compactThinkingHistory mutates messages in place, blanking the
|
||||
// Thinking field on every assistant message except the most recent one
|
||||
// that still carries both thinking and tool calls. A round's reasoning
|
||||
// trace has already done its job once its tool call was issued and the
|
||||
// result appended to history — the model doesn't need round 3's
|
||||
// thinking resent to it while it's reasoning about round 7, any more
|
||||
// than Claude's interleaved thinking requires thinking blocks from
|
||||
// completed turns to be resent (the API only needs the block
|
||||
// immediately preceding the in-flight tool use). Re-sending every
|
||||
// round's full reasoning trace on every subsequent round would also
|
||||
// grow strictly with MaxRounds for no benefit, competing with
|
||||
// compactToolHistory's tool-result budget for the same context window.
|
||||
// Unlike compactToolHistory this isn't gated on a size threshold: it
|
||||
// runs every round, since the only cost of keeping old thinking around
|
||||
// is wasted context, never lost information the model still needs.
|
||||
func compactThinkingHistory(messages []vllmkit.Message) {
|
||||
kept := false
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
m := &messages[i]
|
||||
if m.Role != vllmkit.RoleAssistant || m.Thinking == "" {
|
||||
continue
|
||||
}
|
||||
if !kept {
|
||||
kept = true
|
||||
continue
|
||||
}
|
||||
m.Thinking = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Run executes one full agent turn — same control-flow shape the
|
||||
// monolith's Agent.Run always had (send history, execute any requested
|
||||
// tools, feed results back, repeat), now also sizing itself to the
|
||||
// effort level in force (see effectiveLimits), escalating a round's
|
||||
// thinking effort on a concrete difficulty signal when adaptive
|
||||
// thinking is enabled (see signalFromPriorRound/AdaptiveThinkPolicy),
|
||||
// and maintaining a compaction ledger across however many rounds a
|
||||
// long run ends up compacting (see upsertCompactionLedger) instead of
|
||||
// just silently shrinking older tool output with no trace it existed.
|
||||
func (a *Agent) Run(ctx context.Context, history []vllmkit.Message, cb Callbacks) (finalText string, toPersist []vllmkit.Message, err error) {
|
||||
messages := make([]vllmkit.Message, len(history))
|
||||
copy(messages, history)
|
||||
messages = append([]vllmkit.Message{{Role: vllmkit.RoleSystem, Content: a.buildSystemPrompt(ctx)}}, messages...)
|
||||
|
||||
maxRounds, contextBudget, think := a.effectiveLimits(ctx)
|
||||
|
||||
var cumulative, cumulativeThinking string
|
||||
var ledger []string // running "what ran, in order" entries — see upsertCompactionLedger
|
||||
var priorResults []vllmkit.ToolResult // the just-finished round's tool outcomes, for this round's RoundSignal
|
||||
toolCallsSoFar := 0
|
||||
|
||||
for round := 0; round < maxRounds; round++ {
|
||||
if trimmed := compactToolHistory(messages, contextBudget); len(trimmed) > 0 {
|
||||
ledger = append(ledger, trimmed...)
|
||||
messages = upsertCompactionLedger(messages, ledger)
|
||||
}
|
||||
compactThinkingHistory(messages)
|
||||
|
||||
sig := signalFromPriorRound(round, toolCallsSoFar, priorResults)
|
||||
|
||||
var roundContent, roundThinking string
|
||||
var roundToolCalls []vllmkit.ToolCallRequest
|
||||
var roundErr error
|
||||
|
||||
// attemptChat runs one ChatStream call against the current
|
||||
// messages and streams it into roundContent/roundThinking/
|
||||
// roundToolCalls (resetting them first) and cumulative/
|
||||
// cumulativeThinking via cb, exactly as a single round always
|
||||
// has — factored out so the retry and degrade steps below can
|
||||
// each just call it again against a modified messages, instead
|
||||
// of duplicating this loop. retrying selects this round's
|
||||
// thinking-effort override via think (see effectiveLimits,
|
||||
// EffortThinkPolicy, AdaptiveThinkPolicy) — false for the first
|
||||
// attempt at this round, true for the template-parse self-heal
|
||||
// retries below.
|
||||
attemptChat := func(retrying bool) error {
|
||||
reqThink := ""
|
||||
if think != nil {
|
||||
reqThink = think(round, retrying, sig)
|
||||
}
|
||||
events, err := a.Client.ChatStream(ctx, messages, a.Tools, reqThink)
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent: round %d: %w", round, err)
|
||||
}
|
||||
roundContent, roundThinking, roundToolCalls = "", "", nil
|
||||
for ev := range events {
|
||||
if ev.Err != nil {
|
||||
return fmt.Errorf("agent: round %d: %w", round, ev.Err)
|
||||
}
|
||||
if ev.Thinking != "" {
|
||||
roundThinking += ev.Thinking
|
||||
if cumulativeThinking != "" && roundThinking == ev.Thinking {
|
||||
cumulativeThinking += "\n\n"
|
||||
}
|
||||
cumulativeThinking += ev.Thinking
|
||||
if cb.OnThinking != nil {
|
||||
cb.OnThinking(cumulativeThinking)
|
||||
}
|
||||
}
|
||||
if ev.Token != "" {
|
||||
roundContent += ev.Token
|
||||
cumulative += ev.Token
|
||||
if cb.OnToken != nil {
|
||||
cb.OnToken(cumulative)
|
||||
}
|
||||
}
|
||||
if len(ev.ToolCalls) > 0 {
|
||||
roundToolCalls = append(roundToolCalls, ev.ToolCalls...)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// templateParseRetries bounds the self-heal retry below (see
|
||||
// vllmkit.IsToolCallParseError's doc comment): a transient
|
||||
// tool-call parsing hiccup gets a couple of fresh
|
||||
// attempts at the exact same round before the turn gives up,
|
||||
// instead of failing on the first occurrence. Any tokens a
|
||||
// failed attempt already streamed to cb.OnToken/cumulative stay
|
||||
// visible (there's no clean way to un-stream them from the UI),
|
||||
// but roundContent/roundToolCalls are reset at the top of each
|
||||
// attemptChat call, so nothing from a broken attempt leaks into
|
||||
// messages/toPersist below.
|
||||
const templateParseRetries = 2
|
||||
for attempt := 0; ; attempt++ {
|
||||
roundErr = attemptChat(attempt > 0)
|
||||
if roundErr == nil || !vllmkit.IsToolCallParseError(roundErr) || attempt >= templateParseRetries || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// The plain retries above resend the exact same request, on the
|
||||
// theory that the failure was a one-off. If it wasn't — the same
|
||||
// template error survives every attempt — the newest tool
|
||||
// result(s) in history are the most likely deterministic cause
|
||||
// (see degradeRecentToolMessages), so blank those and try the
|
||||
// round fresh exactly once more before giving up the whole turn.
|
||||
if roundErr != nil && vllmkit.IsToolCallParseError(roundErr) && ctx.Err() == nil {
|
||||
if degradeRecentToolMessages(messages, keepRecentToolMsgs) > 0 {
|
||||
roundErr = attemptChat(true)
|
||||
}
|
||||
}
|
||||
if roundErr != nil {
|
||||
return cumulative, toPersist, roundErr
|
||||
}
|
||||
|
||||
if roundThinking != "" && cb.OnThinkingRound != nil {
|
||||
cb.OnThinkingRound(round, roundThinking)
|
||||
}
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
if roundContent != "" {
|
||||
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent})
|
||||
}
|
||||
return cumulative, toPersist, err
|
||||
}
|
||||
|
||||
if len(roundToolCalls) == 0 {
|
||||
if roundContent != "" {
|
||||
assistantMsg := vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent}
|
||||
toPersist = append(toPersist, assistantMsg)
|
||||
messages = append(messages, assistantMsg)
|
||||
}
|
||||
return cumulative, toPersist, nil
|
||||
}
|
||||
|
||||
if roundContent != "" {
|
||||
toPersist = append(toPersist, vllmkit.Message{Role: vllmkit.RoleAssistant, Content: roundContent})
|
||||
}
|
||||
messages = append(messages, vllmkit.Message{
|
||||
Role: vllmkit.RoleAssistant, Content: roundContent, Thinking: roundThinking, ToolCalls: roundToolCalls,
|
||||
})
|
||||
|
||||
results := a.executeToolCalls(ctx, roundToolCalls, cb)
|
||||
for i := range roundToolCalls {
|
||||
result := results[i]
|
||||
toolMsg := vllmkit.Message{Role: vllmkit.RoleTool, Content: result.Content, ToolName: result.Name}
|
||||
toPersist = append(toPersist, toolMsg)
|
||||
messages = append(messages, toolMsg)
|
||||
}
|
||||
priorResults = results
|
||||
toolCallsSoFar += len(roundToolCalls)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return cumulative, toPersist, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
return cumulative, toPersist, fmt.Errorf("agent: exceeded max rounds (%d) without a final answer", maxRounds)
|
||||
}
|
||||
|
||||
// executeToolCalls runs every call concurrently via the Executor,
|
||||
// identical fan-out to the monolith's.
|
||||
func (a *Agent) executeToolCalls(ctx context.Context, calls []vllmkit.ToolCallRequest, cb Callbacks) []vllmkit.ToolResult {
|
||||
results := make([]vllmkit.ToolResult, len(calls))
|
||||
if len(calls) == 1 {
|
||||
results[0] = a.Executor.Execute(ctx, calls[0])
|
||||
if cb.OnToolCall != nil {
|
||||
cb.OnToolCall(calls[0], results[0])
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
for i := range calls {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
call := calls[i]
|
||||
result := a.Executor.Execute(ctx, call)
|
||||
results[i] = result
|
||||
if cb.OnToolCall != nil {
|
||||
mu.Lock()
|
||||
cb.OnToolCall(call, result)
|
||||
mu.Unlock()
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// IsCancelled reports whether err represents the run being deliberately
|
||||
// stopped rather than a real failure.
|
||||
func IsCancelled(err error) bool {
|
||||
return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded)
|
||||
}
|
||||
247
services/agent/internal/infrastructure/agentrun/agent_test.go
Normal file
247
services/agent/internal/infrastructure/agentrun/agent_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package agentrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
func TestDefaultThinkPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
round int
|
||||
retrying bool
|
||||
want string
|
||||
}{
|
||||
{"round 0, fresh attempt: deep thinking", 0, false, "high"},
|
||||
{"round 0, retry: fast and simple, not deep", 0, true, "low"},
|
||||
{"later round, fresh attempt: defer to client default", 3, false, ""},
|
||||
{"later round, retry: still fast and simple", 5, true, "low"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := DefaultThinkPolicy(tc.round, tc.retrying); got != tc.want {
|
||||
t.Errorf("DefaultThinkPolicy(%d, %v) = %q, want %q", tc.round, tc.retrying, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactThinkingHistory(t *testing.T) {
|
||||
msgs := []vllmkit.Message{
|
||||
{Role: vllmkit.RoleSystem, Content: "sys"},
|
||||
{Role: vllmkit.RoleUser, Content: "do the thing"},
|
||||
{Role: vllmkit.RoleAssistant, Content: "", Thinking: "round0 reasoning", ToolCalls: []vllmkit.ToolCallRequest{{Name: "grep"}}},
|
||||
{Role: vllmkit.RoleTool, Content: "grep result", ToolName: "grep"},
|
||||
{Role: vllmkit.RoleAssistant, Content: "", Thinking: "round1 reasoning", ToolCalls: []vllmkit.ToolCallRequest{{Name: "read_file"}}},
|
||||
{Role: vllmkit.RoleTool, Content: "file contents", ToolName: "read_file"},
|
||||
}
|
||||
|
||||
compactThinkingHistory(msgs)
|
||||
|
||||
if msgs[2].Thinking != "" {
|
||||
t.Errorf("round0's thinking should have been stripped once round1's exists, got %q", msgs[2].Thinking)
|
||||
}
|
||||
if msgs[4].Thinking != "round1 reasoning" {
|
||||
t.Errorf("most recent round's thinking should survive, got %q", msgs[4].Thinking)
|
||||
}
|
||||
// Non-thinking fields must be untouched.
|
||||
if len(msgs[2].ToolCalls) != 1 || msgs[2].ToolCalls[0].Name != "grep" {
|
||||
t.Errorf("compaction must not touch ToolCalls, got %+v", msgs[2].ToolCalls)
|
||||
}
|
||||
if msgs[3].Content != "grep result" {
|
||||
t.Errorf("compaction must not touch tool messages, got %q", msgs[3].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactThinkingHistory_NoThinkingIsNoop(t *testing.T) {
|
||||
msgs := []vllmkit.Message{
|
||||
{Role: vllmkit.RoleUser, Content: "hi"},
|
||||
{Role: vllmkit.RoleAssistant, Content: "hello"},
|
||||
}
|
||||
compactThinkingHistory(msgs)
|
||||
if msgs[1].Content != "hello" {
|
||||
t.Errorf("content should be untouched, got %q", msgs[1].Content)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeChatStreamer scripts one vllmkit.ChatEvent sequence per call,
|
||||
// in order, and records the think override and a deep-ish snapshot of
|
||||
// the messages it was called with so tests can assert on both the
|
||||
// adaptive-thinking policy and the thinking-history compaction working
|
||||
// together across a multi-round Run.
|
||||
type fakeChatStreamer struct {
|
||||
t *testing.T
|
||||
rounds [][]vllmkit.ChatEvent
|
||||
call int
|
||||
gotThink []string
|
||||
gotMsgs [][]vllmkit.Message
|
||||
}
|
||||
|
||||
func (f *fakeChatStreamer) ChatStream(ctx context.Context, messages []vllmkit.Message, tools []vllmkit.ToolDef, think string) (<-chan vllmkit.ChatEvent, error) {
|
||||
if f.call >= len(f.rounds) {
|
||||
f.t.Fatalf("unexpected extra ChatStream call #%d", f.call)
|
||||
}
|
||||
f.gotThink = append(f.gotThink, think)
|
||||
snapshot := make([]vllmkit.Message, len(messages))
|
||||
copy(snapshot, messages)
|
||||
f.gotMsgs = append(f.gotMsgs, snapshot)
|
||||
|
||||
ch := make(chan vllmkit.ChatEvent, len(f.rounds[f.call])+1)
|
||||
for _, ev := range f.rounds[f.call] {
|
||||
ch <- ev
|
||||
}
|
||||
close(ch)
|
||||
f.call++
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type fakeExecutor struct{}
|
||||
|
||||
func (fakeExecutor) Execute(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "ok: " + call.Name}
|
||||
}
|
||||
|
||||
// TestRun_AdaptiveThinkingAndCompaction drives a 3-round turn (two
|
||||
// tool-call rounds, then a final answer) and checks that: (1)
|
||||
// ThinkPolicy's per-round effort actually reaches each ChatStream call,
|
||||
// and (2) by the time round 2's request is built, round 0's thinking
|
||||
// has been compacted away while round 1's (the most recent) survives —
|
||||
// i.e. the adaptive-effort and history-compaction pieces work together
|
||||
// exactly as agent.go wires them.
|
||||
func TestRun_AdaptiveThinkingAndCompaction(t *testing.T) {
|
||||
fake := &fakeChatStreamer{
|
||||
t: t,
|
||||
rounds: [][]vllmkit.ChatEvent{
|
||||
{ // round 0: thinks, then calls a tool
|
||||
{Thinking: "round0 reasoning"},
|
||||
{ToolCalls: []vllmkit.ToolCallRequest{{Name: "grep", Arguments: map[string]any{}}}},
|
||||
},
|
||||
{ // round 1: thinks, then calls another tool
|
||||
{Thinking: "round1 reasoning"},
|
||||
{ToolCalls: []vllmkit.ToolCallRequest{{Name: "read_file", Arguments: map[string]any{}}}},
|
||||
},
|
||||
{ // round 2: final answer, no tool calls, no thinking
|
||||
{Token: "done"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agent := New(fake, fakeExecutor{}, nil)
|
||||
agent.MemoryLoader = nil
|
||||
|
||||
var thinkingRounds []int
|
||||
var thinkingTexts []string
|
||||
cb := Callbacks{
|
||||
OnThinkingRound: func(round int, thinking string) {
|
||||
thinkingRounds = append(thinkingRounds, round)
|
||||
thinkingTexts = append(thinkingTexts, thinking)
|
||||
},
|
||||
}
|
||||
|
||||
final, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "investigate X"}}, cb)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if final != "done" {
|
||||
t.Errorf("final = %q, want %q", final, "done")
|
||||
}
|
||||
|
||||
// ThinkPolicy effort: round 0 fresh attempt -> "high"; round 1 and 2
|
||||
// (not retries) -> "" (defer to client default), per
|
||||
// DefaultThinkPolicy.
|
||||
wantThink := []string{"high", "", ""}
|
||||
if len(fake.gotThink) != len(wantThink) {
|
||||
t.Fatalf("got %d ChatStream calls, want %d (think=%v)", len(fake.gotThink), len(wantThink), fake.gotThink)
|
||||
}
|
||||
for i, want := range wantThink {
|
||||
if fake.gotThink[i] != want {
|
||||
t.Errorf("round %d: think override = %q, want %q", i, fake.gotThink[i], want)
|
||||
}
|
||||
}
|
||||
|
||||
// OnThinkingRound fired once per round that actually thought.
|
||||
if len(thinkingRounds) != 2 || thinkingRounds[0] != 0 || thinkingRounds[1] != 1 {
|
||||
t.Errorf("thinkingRounds = %v, want [0 1]", thinkingRounds)
|
||||
}
|
||||
if thinkingTexts[0] != "round0 reasoning" || thinkingTexts[1] != "round1 reasoning" {
|
||||
t.Errorf("thinkingTexts = %v", thinkingTexts)
|
||||
}
|
||||
|
||||
// History compaction: by round 2's request, round 0's assistant
|
||||
// message (sent on calls 0 and 1, before round1 even existed) is
|
||||
// long gone from the picture — what matters is round 2's own
|
||||
// snapshot, which must carry round1's thinking (still the most
|
||||
// recent) but not round0's (superseded).
|
||||
round2Msgs := fake.gotMsgs[2]
|
||||
var sawRound0Thinking, sawRound1Thinking bool
|
||||
for _, m := range round2Msgs {
|
||||
if m.Thinking == "round0 reasoning" {
|
||||
sawRound0Thinking = true
|
||||
}
|
||||
if m.Thinking == "round1 reasoning" {
|
||||
sawRound1Thinking = true
|
||||
}
|
||||
}
|
||||
if sawRound0Thinking {
|
||||
t.Errorf("round0's thinking should have been compacted away by round 2's request, got messages: %+v", round2Msgs)
|
||||
}
|
||||
if !sawRound1Thinking {
|
||||
t.Errorf("round1's thinking (most recent) should still be present in round 2's request, got messages: %+v", round2Msgs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_NilThinkPolicyMeansNoOverride confirms a nil ThinkPolicy (an
|
||||
// Agent built by hand rather than via New/NewSubagent) falls back to
|
||||
// always passing "" rather than panicking.
|
||||
func TestRun_NilThinkPolicyMeansNoOverride(t *testing.T) {
|
||||
fake := &fakeChatStreamer{
|
||||
t: t,
|
||||
rounds: [][]vllmkit.ChatEvent{
|
||||
{{Token: "hi"}},
|
||||
},
|
||||
}
|
||||
agent := &Agent{Client: fake, Executor: fakeExecutor{}, Tools: nil, MaxRounds: 4, SystemPrompt: SystemPrompt}
|
||||
|
||||
_, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "hi"}}, Callbacks{})
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if len(fake.gotThink) != 1 || fake.gotThink[0] != "" {
|
||||
t.Errorf("gotThink = %v, want [\"\"]", fake.gotThink)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildSystemPrompt_PreferredLanguage confirms a locale attached
|
||||
// via WithPreferredLanguage reaches the model as an appended
|
||||
// "## Interface language" section naming that locale's own display
|
||||
// name, and that a run with no preferred language set (the common
|
||||
// case — see WithPreferredLanguage's doc comment) sends the base
|
||||
// SystemPrompt completely unchanged, byte for byte.
|
||||
func TestBuildSystemPrompt_PreferredLanguage(t *testing.T) {
|
||||
fake := &fakeChatStreamer{t: t, rounds: [][]vllmkit.ChatEvent{{{Token: "hi"}}}}
|
||||
agent := &Agent{Client: fake, Executor: fakeExecutor{}, MaxRounds: 4, SystemPrompt: SystemPrompt}
|
||||
|
||||
ctx := WithPreferredLanguage(context.Background(), "fr")
|
||||
if _, _, err := agent.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "bonjour"}}, Callbacks{}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
got := fake.gotMsgs[0][0].Content
|
||||
if !strings.Contains(got, "## Interface language") || !strings.Contains(got, "Français") {
|
||||
t.Errorf("system prompt missing French interface-language directive, got suffix: %q", got[len(SystemPrompt):])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSystemPrompt_NoPreferredLanguageLeavesPromptUnchanged(t *testing.T) {
|
||||
fake := &fakeChatStreamer{t: t, rounds: [][]vllmkit.ChatEvent{{{Token: "hi"}}}}
|
||||
agent := &Agent{Client: fake, Executor: fakeExecutor{}, MaxRounds: 4, SystemPrompt: SystemPrompt}
|
||||
|
||||
if _, _, err := agent.Run(context.Background(), []vllmkit.Message{{Role: vllmkit.RoleUser, Content: "hi"}}, Callbacks{}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := fake.gotMsgs[0][0].Content; got != SystemPrompt {
|
||||
t.Errorf("system prompt changed with no preferred language set")
|
||||
}
|
||||
}
|
||||
291
services/agent/internal/infrastructure/agentrun/effort.go
Normal file
291
services/agent/internal/infrastructure/agentrun/effort.go
Normal file
@@ -0,0 +1,291 @@
|
||||
// effort.go adds three related capabilities to the round-trip loop in
|
||||
// agent.go, all modeled on Claude's agentic-coding feature set:
|
||||
//
|
||||
// - Effort control: a caller picks one of pkg/effort's five levels
|
||||
// for a turn (EffortThinkPolicy, and Run reading a.Effort to size
|
||||
// MaxRounds/ContextBudgetChars — see Agent.effectiveLimits).
|
||||
// - Adaptive thinking: instead of (or layered on top of) a fixed
|
||||
// per-level floor, the model's *own* per-round signal — did the
|
||||
// last tool call fail? is this a late round in a long turn? — can
|
||||
// push a round's thinking effort up, so token spend tracks actual
|
||||
// difficulty rather than one static setting for the whole turn
|
||||
// (AdaptiveThinkPolicy, RoundSignal).
|
||||
// - System entries: a caller can hand Run() additional instruction/
|
||||
// permission/budget text to fold into the system prompt for this
|
||||
// run, without touching SystemPrompt itself — see WithSystemEntries
|
||||
// and its doc comment for exactly what "without breaking cache"
|
||||
// can and can't mean against a vLLM-served backend.
|
||||
package agentrun
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Effort control
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// effortKey is the context.Context key WithEffort stores a level under
|
||||
// — same unexported-struct-key idiom as preferredLanguageKey.
|
||||
type effortKey struct{}
|
||||
|
||||
// WithEffort attaches the caller's chosen reasoning-depth level to ctx
|
||||
// for Run to read (see Agent.effectiveLimits and EffortThinkPolicy). An
|
||||
// unset context (effortFrom returns "", false) means "use the Agent's
|
||||
// own configured Effort field" — see New's default of effort.Medium —
|
||||
// so a caller that never touches this gets exactly the behavior the
|
||||
// agent had before effort control existed.
|
||||
func WithEffort(ctx context.Context, level effort.Level) context.Context {
|
||||
return context.WithValue(ctx, effortKey{}, level)
|
||||
}
|
||||
|
||||
func effortFrom(ctx context.Context) (effort.Level, bool) {
|
||||
l, ok := ctx.Value(effortKey{}).(effort.Level)
|
||||
return l, ok
|
||||
}
|
||||
|
||||
// EffortFromContext returns the effort level in force on ctx (see
|
||||
// WithEffort), or effort.Default when none was set. Exported — unlike
|
||||
// effortFrom — for callers outside this package that need to read the
|
||||
// resolved level without going through an *Agent (e.g. toolexec's
|
||||
// dispatch_workflow orchestrator sizing its own parallelism from
|
||||
// effort.Level.Resolve().MaxParallelSubagents).
|
||||
func EffortFromContext(ctx context.Context) effort.Level {
|
||||
if l, ok := effortFrom(ctx); ok && l.Valid() {
|
||||
return l
|
||||
}
|
||||
return effort.Default
|
||||
}
|
||||
|
||||
// resolveEffort picks the level actually in force for one Run call:
|
||||
// an explicit WithEffort on ctx wins, otherwise the Agent's own
|
||||
// configured default (a.Effort), otherwise effort.Default.
|
||||
func (a *Agent) resolveEffort(ctx context.Context) effort.Level {
|
||||
if l, ok := effortFrom(ctx); ok && l.Valid() {
|
||||
return l
|
||||
}
|
||||
if a.Effort.Valid() {
|
||||
return a.Effort
|
||||
}
|
||||
return effort.Default
|
||||
}
|
||||
|
||||
// EffortThinkPolicy returns a ThinkPolicy that applies profile.Think as
|
||||
// a floor on every round — never asking for *less* thinking than the
|
||||
// level calls for — while still preserving DefaultThinkPolicy's two
|
||||
// situational overrides: round 0 still gets "high" (planning benefits
|
||||
// from deep thinking regardless of level, unless the level's own floor
|
||||
// is already "high" or above) and a same-round retry still asks for
|
||||
// "low" (a fast, simple re-attempt is always what a retry wants, even
|
||||
// at Max effort — spending more reasoning on a request that just broke
|
||||
// the chat template isn't going to fix it).
|
||||
func EffortThinkPolicy(profile effort.Profile) ThinkPolicy {
|
||||
return func(round int, retrying bool) string {
|
||||
if retrying {
|
||||
return "low"
|
||||
}
|
||||
if round == 0 && !thinkAtLeast(profile.Think, "high") {
|
||||
return "high"
|
||||
}
|
||||
return profile.Think
|
||||
}
|
||||
}
|
||||
|
||||
// thinkRank orders the agent's think settings from least to most
|
||||
// effort, for thinkAtLeast's comparison. Bare "true" (thinking on, no
|
||||
// explicit level) is treated as equivalent to "medium" — vLLM/Qwen3
|
||||
// has no server-side notion of graded reasoning depth (see
|
||||
// vllmkit.Client.Think's doc comment: every non-empty, non-"false"
|
||||
// setting just toggles chat_template_kwargs.enable_thinking on), so
|
||||
// this ranking exists purely for agentrun's own floor/escalation
|
||||
// bookkeeping, not because the wire protocol distinguishes the levels.
|
||||
var thinkRank = map[string]int{"": 0, "false": 0, "low": 1, "true": 2, "medium": 2, "high": 3}
|
||||
|
||||
func thinkAtLeast(setting, floor string) bool {
|
||||
return thinkRank[strings.ToLower(strings.TrimSpace(setting))] >= thinkRank[floor]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Adaptive thinking
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// RoundSignal summarizes what just happened in the previous round of
|
||||
// the same turn, for AdaptiveThinkPolicy to read when deciding this
|
||||
// round's effort. Computed by Run from the round it just finished —
|
||||
// see adaptiveSignalFrom.
|
||||
type RoundSignal struct {
|
||||
// Round is the round about to run (0-indexed) — i.e. len(previous
|
||||
// rounds), not the round the signal describes.
|
||||
Round int
|
||||
// PriorRoundFailed is true when the immediately preceding round's
|
||||
// tool call(s) included at least one IsError result — a concrete
|
||||
// sign the model is dealing with something that didn't go as
|
||||
// planned and may benefit from more deliberate reasoning about
|
||||
// what to try next, rather than a quick pattern-matched retry.
|
||||
PriorRoundFailed bool
|
||||
// PriorToolResultBytes is the size of the largest tool result the
|
||||
// previous round produced. A very large result (a big file, a wide
|
||||
// grep) is a signal the model now has a lot to digest before its
|
||||
// next move is obvious.
|
||||
PriorToolResultBytes int
|
||||
// ToolCallsSoFar is the running count of tool calls issued earlier
|
||||
// in this same turn.
|
||||
ToolCallsSoFar int
|
||||
}
|
||||
|
||||
// adaptiveDeepRoundBytes is the PriorToolResultBytes threshold above
|
||||
// which AdaptiveThinkPolicy treats "a lot just happened" as a reason to
|
||||
// think harder about it — calibrated against compactedHeadChars +
|
||||
// compactedTailChars (800): a result bigger than what compaction would
|
||||
// eventually trim is, by definition, a substantial one.
|
||||
const adaptiveDeepRoundBytes = compactedHeadChars + compactedTailChars
|
||||
|
||||
// adaptiveLateRoundThreshold is how many tool calls into a turn
|
||||
// "this is getting long, don't lose the thread" starts to apply.
|
||||
const adaptiveLateRoundThreshold = 8
|
||||
|
||||
// AdaptiveThinkPolicy wraps a floor policy (normally
|
||||
// EffortThinkPolicy(profile)) so a round whose RoundSignal looks
|
||||
// difficult gets escalated to "high" even if the floor would have
|
||||
// allowed less — "the model automatically determines whether to use
|
||||
// turn-based deep thinking" from a concrete, legible signal rather than
|
||||
// the model guessing about its own difficulty, since vLLM's
|
||||
// OpenAI-compatible chat API gives no hook for the model to request
|
||||
// more effort mid-turn on its own. A round that looks ordinary just gets whatever floor already
|
||||
// says, so this never spends *less* than the configured level, only
|
||||
// sometimes more — "optimizing token usage" by reserving the escalation
|
||||
// for rounds that actually show a difficulty signal, instead of every
|
||||
// round paying a high-effort tax regardless of how routine it is.
|
||||
func AdaptiveThinkPolicy(floor ThinkPolicy) func(round int, retrying bool, sig RoundSignal) string {
|
||||
return func(round int, retrying bool, sig RoundSignal) string {
|
||||
base := ""
|
||||
if floor != nil {
|
||||
base = floor(round, retrying)
|
||||
}
|
||||
if retrying {
|
||||
return base // never escalate a retry — see EffortThinkPolicy's own reasoning
|
||||
}
|
||||
if sig.PriorRoundFailed || sig.PriorToolResultBytes > adaptiveDeepRoundBytes || sig.ToolCallsSoFar > adaptiveLateRoundThreshold {
|
||||
if !thinkAtLeast(base, "high") {
|
||||
return "high"
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// System entries: mid-task instruction/permission/budget updates
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// SystemEntryKind labels what a SystemEntry is updating, purely for the
|
||||
// heading it renders under in the system prompt — the model doesn't
|
||||
// enforce any of these as hard constraints beyond what the prose itself
|
||||
// says (there's no separate permissions engine here — see toolexec for
|
||||
// where real enforcement, e.g. the dispatch_subagent read-only
|
||||
// whitelist, actually lives).
|
||||
type SystemEntryKind string
|
||||
|
||||
const (
|
||||
// SystemEntryInstruction is a standing instruction added mid-turn
|
||||
// — e.g. "also update the changelog for every file you touch from
|
||||
// now on."
|
||||
SystemEntryInstruction SystemEntryKind = "instruction"
|
||||
// SystemEntryPermission narrows or widens what the model should
|
||||
// consider itself allowed to do — e.g. "do not run_bash_command
|
||||
// against anything under /prod" or "you may now run destructive
|
||||
// migrations in this workspace, it's a throwaway clone."
|
||||
SystemEntryPermission SystemEntryKind = "permission"
|
||||
// SystemEntryBudget communicates a resource constraint — e.g. "you
|
||||
// have 4 tool calls left before this session's budget runs out,
|
||||
// wrap up."
|
||||
SystemEntryBudget SystemEntryKind = "budget"
|
||||
)
|
||||
|
||||
func (k SystemEntryKind) heading() string {
|
||||
switch k {
|
||||
case SystemEntryPermission:
|
||||
return "Permission update"
|
||||
case SystemEntryBudget:
|
||||
return "Budget update"
|
||||
default:
|
||||
return "Instruction update"
|
||||
}
|
||||
}
|
||||
|
||||
// SystemEntry is one mid-conversation update a caller can inject into a
|
||||
// run via WithSystemEntries.
|
||||
type SystemEntry struct {
|
||||
Kind SystemEntryKind
|
||||
Content string
|
||||
}
|
||||
|
||||
// systemEntriesKey is WithSystemEntries' context key.
|
||||
type systemEntriesKey struct{}
|
||||
|
||||
// WithSystemEntries attaches additional system-level entries to ctx for
|
||||
// Run to fold into the system prompt, appended after everything else
|
||||
// buildSystemPrompt already assembles (base SystemPrompt, project
|
||||
// memory, language directive) — see buildSystemPrompt. This is enkly's
|
||||
// answer to "update instructions, permissions or token budgets in the
|
||||
// middle of a conversation... without breaking cache": be honest about
|
||||
// what that can mean here. vLLM has no equivalent of the Anthropic
|
||||
// API's explicit prompt-cache breakpoints; what it actually does is
|
||||
// automatic prefix caching (--enable-prefix-caching on the chat
|
||||
// deployment) — reusing cached KV-cache blocks for however much of the
|
||||
// new request's token prefix is byte-identical, block-for-block, to a
|
||||
// previously served request's, across any request that lands on the
|
||||
// same vLLM replica. The guarantee this function and
|
||||
// buildSystemPrompt together provide is the one piece of that which is
|
||||
// actually in enkly's control: every entry is appended, never inserted
|
||||
// or edited in place, and always after the fixed SystemPrompt + memory
|
||||
// + language block — so a conversation's prefix up to "## Live session
|
||||
// updates" stays byte-identical turn over turn whether or not new
|
||||
// entries have been added, maximizing how much of it a KV-cache
|
||||
// implementation *can* reuse, rather than scattering updates throughout
|
||||
// the prompt in a way that would invalidate everything after the first
|
||||
// change on every single turn.
|
||||
func WithSystemEntries(ctx context.Context, entries []SystemEntry) context.Context {
|
||||
if len(entries) == 0 {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, systemEntriesKey{}, entries)
|
||||
}
|
||||
|
||||
func systemEntriesFrom(ctx context.Context) []SystemEntry {
|
||||
entries, _ := ctx.Value(systemEntriesKey{}).([]SystemEntry)
|
||||
return entries
|
||||
}
|
||||
|
||||
// maxSystemEntryChars caps one entry's rendered length, for the same
|
||||
// reason memoryPromptChars caps ENKLY.md: a caller-supplied string
|
||||
// shouldn't be able to blow out the context budget of every single
|
||||
// round for the rest of the turn.
|
||||
const maxSystemEntryChars = 2_000
|
||||
|
||||
// renderSystemEntries turns entries into the "## Live session updates"
|
||||
// section buildSystemPrompt appends. Returns "" for no entries, so
|
||||
// callers can unconditionally concatenate it.
|
||||
func renderSystemEntries(entries []SystemEntry) string {
|
||||
if len(entries) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\n\n## Live session updates\nThe following were added to this session after it started; they take " +
|
||||
"precedence over anything above that they conflict with, for the remainder of this conversation.\n")
|
||||
for _, e := range entries {
|
||||
content := strings.TrimSpace(e.Content)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
if len(content) > maxSystemEntryChars {
|
||||
content = safeHeadCut(content, maxSystemEntryChars) + " […truncated]"
|
||||
}
|
||||
fmt.Fprintf(&b, "\n### %s\n%s\n", e.Kind.heading(), content)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package grpcclients dials every other bounded context the agent
|
||||
// service's saga needs and exposes each as its generated client
|
||||
// interface. One file, one place to see the agent service's entire
|
||||
// synchronous fan-out — see ARCHITECTURE.md's context map diagram.
|
||||
package grpcclients
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
billingv1 "enkly/gen/billing/v1"
|
||||
conversationv1 "enkly/gen/conversation/v1"
|
||||
knowledgev1 "enkly/gen/knowledge/v1"
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
)
|
||||
|
||||
// Clients bundles every gRPC client connection the agent service holds
|
||||
// open for the lifetime of the process (one connection each, reused
|
||||
// across requests — grpc.ClientConn is safe for concurrent use and
|
||||
// multiplexes calls over HTTP/2 itself, so there's no pool to manage).
|
||||
type Clients struct {
|
||||
Workspace workspacev1.WorkspaceServiceClient
|
||||
Knowledge knowledgev1.KnowledgeServiceClient
|
||||
Billing billingv1.BillingServiceClient
|
||||
Conversation conversationv1.ConversationServiceClient
|
||||
|
||||
conns []*grpc.ClientConn
|
||||
}
|
||||
|
||||
// Dial connects to every peer service at startup. Addrs are host:port
|
||||
// (e.g. "workspace:9004"); see cmd/server/main.go for the env vars that
|
||||
// supply them. Fails fast — the agent service cannot do anything
|
||||
// useful without all four peers, so there's no point starting the gRPC
|
||||
// server if one is unreachable (docker compose's restart policy
|
||||
// handles the retry loop).
|
||||
func Dial(workspaceAddr, knowledgeAddr, billingAddr, conversationAddr string) (*Clients, error) {
|
||||
c := &Clients{}
|
||||
dial := func(addr string) (*grpc.ClientConn, error) {
|
||||
// Workspace responses can carry a full file's bytes/content
|
||||
// (uploads, downloads, large reads) — match the server-side
|
||||
// limit raised in services/workspace/cmd/server/main.go.
|
||||
conn, err := grpc.NewClient(addr,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(24<<20), grpc.MaxCallSendMsgSize(24<<20)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
c.conns = append(c.conns, conn)
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
wsConn, err := dial(workspaceAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kConn, err := dial(knowledgeAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bConn, err := dial(billingAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cConn, err := dial(conversationAddr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.Workspace = workspacev1.NewWorkspaceServiceClient(wsConn)
|
||||
c.Knowledge = knowledgev1.NewKnowledgeServiceClient(kConn)
|
||||
c.Billing = billingv1.NewBillingServiceClient(bConn)
|
||||
c.Conversation = conversationv1.NewConversationServiceClient(cConn)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Clients) Close() {
|
||||
for _, conn := range c.conns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
750
services/agent/internal/infrastructure/toolcatalog/catalog.go
Normal file
750
services/agent/internal/infrastructure/toolcatalog/catalog.go
Normal file
@@ -0,0 +1,750 @@
|
||||
// Package toolcatalog holds the JSON-schema tool definitions sent to
|
||||
// the vLLM-hosted chat model on every chat request — the exact content of the monolith's
|
||||
// Toolbox.Defs(), unchanged (the model has been prompted and tested
|
||||
// against this precise wording; see agent.go's SystemPrompt for the
|
||||
// companion prose). Presenting tool schemas is the agent service's job
|
||||
// now, not the workspace service's — the workspace service only
|
||||
// executes tools once the model has picked one (see
|
||||
// internal/infrastructure/toolexec).
|
||||
package toolcatalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/mathkit"
|
||||
"enkly/pkg/skillkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// Defs returns every tool the agent may call, in the fixed order the
|
||||
// monolith declared them.
|
||||
func Defs() []vllmkit.ToolDef {
|
||||
return []vllmkit.ToolDef{
|
||||
{
|
||||
Name: "read_file",
|
||||
Description: "Read a text file in the project workspace, with 1-based line numbers prefixed (like `cat -n`). Large files are paginated: use offset/limit to page through them instead of guessing what's relevant.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"offset": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "1-based line number to start reading from. Omit to start at line 1.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "Maximum number of lines to return. Omit for the default (2000).",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_file",
|
||||
Description: "Create a new file, or fully overwrite an existing one, with new content. For changes to an EXISTING file, prefer edit_file — it's cheaper and far less likely to accidentally drop unrelated content than rewriting the whole file.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"content": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The full contents to write to the file.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "content"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_file",
|
||||
Description: "Make a precise, surgical edit to an existing text file by replacing one exact occurrence of old_string with new_string. old_string must match the file's current content exactly (read the file with read_file first, and include enough surrounding context to make it uniquely identify one location) and must not equal new_string. Fails loudly instead of guessing if old_string is missing or ambiguous, which is the whole point: it can't silently clobber the rest of the file the way write_file can. Returns a unified-diff preview of exactly what changed — check it before assuming the edit did what you intended.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"old_string": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The exact text to find. Must appear exactly once in the file unless replace_all is true.",
|
||||
},
|
||||
"new_string": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The text to replace it with.",
|
||||
},
|
||||
"replace_all": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "If true, replace every occurrence of old_string instead of requiring exactly one. Defaults to false.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "old_string", "new_string"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "multi_edit_file",
|
||||
Description: "Apply several edit_file-style replacements to ONE file as a single atomic operation: every edit is validated (found, and unique unless its own replace_all is set) against the file as it would look after the preceding edits in the list, and if ANY edit fails, nothing is written — the file is left untouched, exactly as if none of the edits had been attempted. Prefer this over several separate edit_file calls when you already know multiple changes a single file needs (e.g. renaming a symbol at its definition and at each call site), since it's one round-trip instead of several and never leaves a file half-edited if a later edit in the batch turns out to be wrong. Returns a diff preview per edit, in order.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Path to the file, relative to the workspace root.",
|
||||
},
|
||||
"edits": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The edits to apply, in order. Each is the same shape as edit_file's arguments (minus path).",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"old_string": map[string]any{"type": "string", "description": "The exact text to find."},
|
||||
"new_string": map[string]any{"type": "string", "description": "The text to replace it with."},
|
||||
"replace_all": map[string]any{"type": "boolean", "description": "If true, replace every occurrence of this edit's old_string. Defaults to false."},
|
||||
},
|
||||
"required": []string{"old_string", "new_string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "edits"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_directory",
|
||||
Description: "List files and subdirectories under a path in the project workspace, as a sorted tree (directories first). Use this to orient yourself in an unfamiliar project before reading or searching files.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Directory to list, relative to the workspace root. Omit or use \".\" for the workspace root.",
|
||||
},
|
||||
"max_depth": map[string]any{
|
||||
"type": "integer",
|
||||
"description": "How many directory levels deep to recurse. Defaults to 3.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "glob_search",
|
||||
Description: "Find files by name pattern (e.g. \"**/*.go\", \"internal/**/handlers/*.go\", \"*.md\"). `*` matches within one path segment, `**` matches across segments. Faster and more precise than list_directory when you know roughly what filename or extension you're looking for.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Glob pattern, relative to the workspace root.",
|
||||
},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "grep",
|
||||
Description: "Search file contents across the workspace for a regular expression, returning matching file:line:text results. Use this for exact/lexical lookups (a symbol name, an error string, a TODO) — use search_codebase instead for fuzzy, conceptual ('where do we handle X') queries.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{
|
||||
"type": "string",
|
||||
"description": "RE2 regular expression to search for.",
|
||||
},
|
||||
"path_glob": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Optional glob to restrict which files are searched, e.g. \"*.go\".",
|
||||
},
|
||||
"case_insensitive": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "Defaults to false.",
|
||||
},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "write_todos",
|
||||
Description: "Write or replace your working plan as a structured todo list, shown to the user as a checklist. Use it to break a nontrivial, multi-step request (more than ~3 steps, or anything touching several files) into tracked steps *before* starting work, and call it again to flip items to in_progress/completed as you go. Skip it for a single small, obvious change — it adds overhead, not clarity, on a one-step task.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"todos": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The full current plan (this replaces any previous list, so include every item, not just the ones that changed).",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"content": map[string]any{"type": "string"},
|
||||
"status": map[string]any{
|
||||
"type": "string",
|
||||
"enum": []string{"pending", "in_progress", "completed"},
|
||||
},
|
||||
},
|
||||
"required": []string{"content", "status"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"todos"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "run_bash_command",
|
||||
Description: "Run one command in the project workspace and return its combined stdout/stderr. Commands run directly WITHOUT a shell: no pipes, redirects, globbing, chaining or substitution (quote arguments containing special characters with single quotes). Only allowlisted programs run (go build/test/vet/fmt/list, git read-only subcommands, ls, cat, grep, find, gofmt); all paths must stay inside the workspace. After editing code, prefer running the relevant build/tests here over asserting the change is correct.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A single command line, e.g. `go test ./...` or `grep -rn TODO .`.",
|
||||
},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search_codebase",
|
||||
Description: "Semantic search over the indexed codebase using pgvector. Returns the most relevant code chunks for a natural-language, conceptual query.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Natural-language description of the code you're looking for.",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "solve_ml_math",
|
||||
Description: "Solve a concrete mathematics problem from the four pillars that come up when building, " +
|
||||
"debugging or explaining ML code: linear algebra, calculus (derivatives/gradients/integrals), " +
|
||||
"probability & statistics, numerical optimization, plus ML-specific primitives (activations, loss " +
|
||||
"functions, least-squares linear regression). Computes an exact or numerically-verified answer " +
|
||||
"locally — pure Go arithmetic, not the model's own mental math — so prefer this over working " +
|
||||
"something out by hand whenever a request has an actual numeric or symbolic-in-form-but-numeric " +
|
||||
"answer (\"what's the gradient of this loss at this point\", \"invert this matrix\", \"is this " +
|
||||
"activation numerically stable\", \"fit a line through these points\"). Not for code generation or " +
|
||||
"explaining a concept in prose — just for computing an answer. Supported \"operation\" values: " +
|
||||
strings.Join(mathkit.Operations(), ", ") + ". Each operation's required parameters:\n" +
|
||||
"- evaluate{expression, variables?}: arithmetic/function expression, e.g. \"sigmoid(w*x+b)\".\n" +
|
||||
"- matrix_add/matrix_sub/matrix_mul{a, b}, matrix_transpose/matrix_determinant/matrix_inverse/" +
|
||||
"matrix_rank/matrix_trace{a}: 2D arrays, e.g. a=[[1,2],[3,4]].\n" +
|
||||
"- dot_product{a, b}, vector_norm{a, p?}: 1D arrays; p defaults to 2 (Euclidean).\n" +
|
||||
"- dominant_eigen{a, iterations?}: largest-eigenvalue/eigenvector via power iteration.\n" +
|
||||
"- derivative{expression, variable, at|value, h?}, gradient{expression, variables, at}, " +
|
||||
"integral{expression, variable, from, to, steps?}: at is {\"name\": number, ...}.\n" +
|
||||
"- gradient_descent{expression, variables, start, learning_rate, iterations?, tolerance?}: " +
|
||||
"runs plain gradient descent and returns the trajectory.\n" +
|
||||
"- newton_root{expression, variable, start, iterations?, tolerance?}: finds a root via Newton's method.\n" +
|
||||
"- mean/variance/std_dev/median{data, population?}, covariance/correlation{x, y, population?}, " +
|
||||
"z_score_normalize/min_max_normalize{data}, normal_pdf/normal_cdf{x, mean?, std_dev?}, " +
|
||||
"binomial_pmf{k, n, p}.\n" +
|
||||
"- sigmoid/relu/tanh{x}: x is a number or an array. softmax{x}: array. " +
|
||||
"mse_loss/cross_entropy_loss{predictions, targets}. " +
|
||||
"linear_regression{x, y}: x is a 2D feature matrix (rows=observations), y is the target vector; " +
|
||||
"fits by ordinary least squares and reports coefficients, R², and residuals.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"operation": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Which calculation to run. See the tool description for the full list and each one's required fields.",
|
||||
},
|
||||
"expression": map[string]any{
|
||||
"type": "string",
|
||||
"description": "An infix math expression for operations that take one (evaluate, derivative, gradient, integral, gradient_descent, newton_root). Supports + - * / ^, parentheses, variables, the constants pi/e, and functions sin/cos/tan/asin/acos/atan/sinh/cosh/tanh/exp/sqrt/abs/ln/log/log2/log10/floor/ceil/round/sigmoid/relu/sign/max/min/pow/mod/atan2.",
|
||||
},
|
||||
"variable": map[string]any{"type": "string", "description": "The single variable of differentiation/integration/root-finding (derivative, integral, newton_root)."},
|
||||
"variables": map[string]any{
|
||||
"description": "Either the ordered list of variable names for a multivariate operation (gradient, gradient_descent — an array of strings), OR a name->value object of extra bound variables an expression needs (evaluate, integral).",
|
||||
},
|
||||
"at": map[string]any{"type": "object", "description": "name->number bindings at which to evaluate a derivative/gradient, e.g. {\"x\": 1, \"y\": 2}."},
|
||||
"value": map[string]any{"type": "number", "description": "Shorthand for \"at\" when derivative has exactly one variable."},
|
||||
"h": map[string]any{"type": "number", "description": "Finite-difference step size for derivative/gradient. Omit for a sensible default."},
|
||||
"from": map[string]any{"type": "number", "description": "Lower bound of a definite integral."},
|
||||
"to": map[string]any{"type": "number", "description": "Upper bound of a definite integral."},
|
||||
"steps": map[string]any{"type": "integer", "description": "Subintervals for integral (Simpson's rule). Omit for a sensible default."},
|
||||
"start": map[string]any{"description": "Starting point: an array of numbers (gradient_descent, one per variable) or a single number (newton_root)."},
|
||||
"learning_rate": map[string]any{"type": "number", "description": "Step size for gradient_descent. Required, must be positive."},
|
||||
"iterations": map[string]any{"type": "integer", "description": "Max steps for gradient_descent/newton_root/dominant_eigen. Omit for a sensible default."},
|
||||
"tolerance": map[string]any{"type": "number", "description": "Early-stop threshold for gradient_descent (gradient norm) or newton_root (|f(x)|)."},
|
||||
"a": map[string]any{"description": "A matrix (2D array, e.g. [[1,2],[3,4]]) or vector (1D array), depending on the operation."},
|
||||
"b": map[string]any{"description": "A second matrix or vector, same shape rules as \"a\"."},
|
||||
"p": map[string]any{"type": "number", "description": "Which norm for vector_norm: 1, 2 (default), or a large number to approximate infinity."},
|
||||
"data": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "A 1D array of numbers for a statistics operation."},
|
||||
"x": map[string]any{
|
||||
"description": "Context-dependent: a number or array for sigmoid/relu/tanh/softmax/normal_pdf/normal_cdf, a data array for covariance/correlation, or a 2D feature matrix for linear_regression.",
|
||||
},
|
||||
"y": map[string]any{"description": "A data array for covariance/correlation, or the target vector for linear_regression."},
|
||||
"population": map[string]any{
|
||||
"type": "boolean",
|
||||
"description": "variance/std_dev/covariance: true for the population formula (divide by n), false (default) for the unbiased sample formula (divide by n-1).",
|
||||
},
|
||||
"mean": map[string]any{"type": "number", "description": "Distribution mean for normal_pdf/normal_cdf. Defaults to 0."},
|
||||
"std_dev": map[string]any{"type": "number", "description": "Distribution standard deviation for normal_pdf/normal_cdf. Defaults to 1."},
|
||||
"k": map[string]any{"type": "integer", "description": "Number of successes for binomial_pmf."},
|
||||
"n": map[string]any{"type": "integer", "description": "Number of trials for binomial_pmf."},
|
||||
"predictions": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Model outputs for mse_loss/cross_entropy_loss."},
|
||||
"targets": map[string]any{"type": "array", "items": map[string]any{"type": "number"}, "description": "Ground-truth values for mse_loss/cross_entropy_loss."},
|
||||
},
|
||||
"required": []string{"operation"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_office_skill",
|
||||
Description: officeSkillToolDescription(),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{
|
||||
"type": "string",
|
||||
"enum": skillkit.Names(),
|
||||
"description": "Which skill to read. Start with \"overview\" if you're not sure which document format applies.",
|
||||
},
|
||||
},
|
||||
"required": []string{"name"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "search_office_skills",
|
||||
Description: "Semantically search the office-document skills (docx/xlsx/pptx/overview) for the specific " +
|
||||
"sections most relevant to a question — e.g. \"can a cell hold a formula\" or \"how do I bold part " +
|
||||
"of a paragraph\" — instead of reading a whole skill file to find one answer. Returns the top " +
|
||||
"matching sections, each labeled with the skill and heading it came from, ranked by relevance. " +
|
||||
"Prefer this over read_office_skill for a narrow, specific question; prefer read_office_skill when " +
|
||||
"you want a skill's complete guidance before a multi-step document task.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "A specific question or short description of what you need to know."},
|
||||
"skill": map[string]any{
|
||||
"type": "string",
|
||||
"enum": skillkit.Names(),
|
||||
"description": "Optional: restrict the search to one skill. Omit to search across all of them.",
|
||||
},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
// --- Document management (Word/Excel/PowerPoint), modeled on
|
||||
// Claude Code's document-editing skills: the model only ever
|
||||
// supplies plain structured fields (titles, paragraph text,
|
||||
// cell values, bullet lines) — never raw OOXML/XML markup. All
|
||||
// nine tools below are implemented by pkg/officekit, which does
|
||||
// 100% of the actual XML generation/parsing server-side using
|
||||
// Go's typed XML encoder (always well-formed, always correctly
|
||||
// escaped). This is a deliberate safety property, not just a
|
||||
// convenience: an earlier, Ollama-backed version of this service
|
||||
// saw XML text round-tripped through a tool-call argument or tool
|
||||
// result trigger "ollama error: XML syntax error ... element
|
||||
// closed by" — Qwen's tool-calling chat template wrapped tool
|
||||
// calls/results in its own XML-ish delimiters that Ollama parsed
|
||||
// on its side, and a stray or truncated angle bracket in echoed
|
||||
// content could break that parse. The vLLM OpenAI-compatible
|
||||
// tool-calling wire format this service now speaks (pkg/vllmkit)
|
||||
// carries tool calls/results as JSON instead, so that exact
|
||||
// failure mode is gone, but keeping documents structured-in/
|
||||
// structured-out remains the right call regardless of transport:
|
||||
// the model never needs to emit markup at all. See
|
||||
// agentrun/agent.go's compactToolHistory and vllmkit/client.go's
|
||||
// IsToolCallParseError for the matching defensive fixes on
|
||||
// whatever transport-level parse failures remain.
|
||||
{
|
||||
Name: "create_document",
|
||||
Description: "Create a new Word document (.docx) in the workspace, either from a list of structured " +
|
||||
"paragraphs (\"paragraphs\") or by converting an existing Markdown document (\"markdown\") straight " +
|
||||
"into one — headings, body text, bullets. Fails if the path already exists; use edit_document to " +
|
||||
"change one. The model never writes OOXML by hand: give plain text and a style per paragraph (or " +
|
||||
"just Markdown source) and this tool renders a real, valid .docx file. For an ordinary document (a " +
|
||||
"CV, cover letter, memo, report, README-style page), this description is everything you need — go " +
|
||||
"straight to calling it, no read_office_skill round-trip required: with \"paragraphs\", lead with " +
|
||||
"one heading1 for the title, use heading2/heading3 for section headers, bullet for genuinely " +
|
||||
"list-shaped content (not as a stand-in for paragraph breaks), and normal for body text — split body " +
|
||||
"text into real paragraphs by idea, not one giant block; with \"markdown\", just pass the source " +
|
||||
"text and #/##/### headings, paragraphs and -/* lists map onto the same styles automatically. This " +
|
||||
"tool can't yet do tables, images, headers/footers, or bold/italic applied to only part of a " +
|
||||
"paragraph (bold/italic/underline apply to the whole paragraph; Markdown's inline **bold**/*italic* " +
|
||||
"spans are flattened to plain text for the same reason); say so plainly if someone asks for one of " +
|
||||
"those rather than approximating it. Reach for read_office_skill only if a request needs one of " +
|
||||
"those unsupported features confirmed, edit_document's replace_text nuances, or Track Changes " +
|
||||
"redlining (edit_document's *_tracked operations).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .docx."},
|
||||
"paragraphs": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The document's content, in order. Omit if using \"markdown\" instead.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "The paragraph's plain text."},
|
||||
"style": map[string]any{"type": "string", "enum": []string{"normal", "heading1", "heading2", "heading3", "bullet"}, "description": "Defaults to \"normal\"."},
|
||||
"bold": map[string]any{"type": "boolean", "description": "Bold the whole paragraph. Defaults to false; implied true for headings."},
|
||||
"italic": map[string]any{"type": "boolean", "description": "Italicize the whole paragraph. Defaults to false."},
|
||||
"underline": map[string]any{"type": "boolean", "description": "Underline the whole paragraph. Defaults to false."},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
},
|
||||
"markdown": map[string]any{
|
||||
"type": "string",
|
||||
"description": "Alternative to \"paragraphs\": a complete Markdown document to convert directly into the .docx (headings, paragraphs, bullet/numbered lists). Use this instead of hand-building a paragraphs array when you already have — or would naturally write — Markdown source.",
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_document",
|
||||
Description: "Read a Word document (.docx) back as plain text, one line per paragraph (bullets prefixed \"- \"). Works on any standard .docx, including ones a user uploaded, though complex formatting, tables and images aren't represented — just the text.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .docx file, relative to the workspace root."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_document",
|
||||
Description: "Make a structured change to an existing Word document. Plain edits: append_paragraph (add " +
|
||||
"one new paragraph at the end, same fields as create_document's paragraphs) or replace_text (replace " +
|
||||
"every literal occurrence of \"find\" with \"replace\" across the document's text, including text " +
|
||||
"inside tables — the structured, markup-free analogue of edit_file's old_string/new_string; unlike " +
|
||||
"edit_file every occurrence is replaced, and a phrase split across more than one formatting run by " +
|
||||
"the document's own edit history may not be found as a single match). Redlining (Track Changes): " +
|
||||
"append_paragraph_tracked and replace_text_tracked do the same edits but record them as a " +
|
||||
"reviewable, Word-native tracked change (shown struck-through/underlined under Review > Track " +
|
||||
"Changes) instead of rewriting the text in place — use these whenever someone asks for \"tracked " +
|
||||
"changes\", \"redlining\", \"suggest an edit\", or a change that should go through review before it's " +
|
||||
"final; accept_all_changes and reject_all_changes then resolve every pending tracked change in the " +
|
||||
"document at once (there's no per-change accept/reject yet — say so if someone asks for that). " +
|
||||
"Read the document first if you're not sure what's in it or whether it already has pending tracked " +
|
||||
"changes (read_document reports them).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .docx file, relative to the workspace root."},
|
||||
"operation": map[string]any{"type": "string", "enum": []string{
|
||||
"append_paragraph", "replace_text",
|
||||
"append_paragraph_tracked", "replace_text_tracked",
|
||||
"accept_all_changes", "reject_all_changes",
|
||||
}},
|
||||
"text": map[string]any{"type": "string", "description": "append_paragraph/append_paragraph_tracked: the new paragraph's text."},
|
||||
"style": map[string]any{"type": "string", "enum": []string{"normal", "heading1", "heading2", "heading3", "bullet"}, "description": "append_paragraph/append_paragraph_tracked only. Defaults to \"normal\"."},
|
||||
"bold": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false; implied true for headings."},
|
||||
"italic": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false."},
|
||||
"underline": map[string]any{"type": "boolean", "description": "append_paragraph/append_paragraph_tracked only. Defaults to false."},
|
||||
"find": map[string]any{"type": "string", "description": "replace_text/replace_text_tracked: exact text to find."},
|
||||
"replace": map[string]any{"type": "string", "description": "replace_text/replace_text_tracked: its replacement."},
|
||||
"author": map[string]any{"type": "string", "description": "append_paragraph_tracked/replace_text_tracked only: the reviewer name recorded on the tracked change. Defaults to \"enkly agent\"."},
|
||||
},
|
||||
"required": []string{"path", "operation"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_spreadsheet",
|
||||
Description: "Create a new Excel workbook (.xlsx) in the workspace, either as one sheet (\"rows\") or " +
|
||||
"several (\"sheets\"). Each row is a list of cell values, top to bottom, left to right (row 1 = A1, " +
|
||||
"B1, ...); a value starting with \"=\" is stored as a live Excel formula (e.g. \"=SUM(A1:A4)\"), " +
|
||||
"everything else that parses as a plain number is stored as a numeric cell, and anything left over " +
|
||||
"as text. Fails if the path already exists; use edit_spreadsheet to change individual cells of an " +
|
||||
"existing one, or read_spreadsheet first to see what's there.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .xlsx."},
|
||||
"sheet_name": map[string]any{"type": "string", "description": "Single-sheet form only (with \"rows\"). Defaults to \"Sheet1\"."},
|
||||
"rows": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Single-sheet form: row-major grid of cell values, e.g. [[\"Name\",\"Score\"],[\"Alice\",\"92\"]]. Omit if using \"sheets\" instead.",
|
||||
"items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
"sheets": map[string]any{
|
||||
"type": "array",
|
||||
"description": "Multi-sheet form: a workbook with one tab per entry, in order. Use this instead of \"rows\"/\"sheet_name\" when the workbook needs more than one sheet.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "This sheet's tab name."},
|
||||
"rows": map[string]any{
|
||||
"type": "array",
|
||||
"description": "This sheet's row-major grid of cell values.",
|
||||
"items": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
"required": []string{"rows"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_spreadsheet",
|
||||
Description: "Read one sheet of an Excel workbook (.xlsx) back as a row-major text table. Defaults to " +
|
||||
"the first sheet; when the workbook has more than one, the result's first line names the sheet " +
|
||||
"read and lists every sheet's name so you can ask for a different one. Works on any standard " +
|
||||
".xlsx, including ones a user uploaded.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .xlsx file, relative to the workspace root."},
|
||||
"sheet": map[string]any{"type": "string", "description": "Which sheet to read: a 1-based index (\"2\") or a sheet name. Omit for the first sheet."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_spreadsheet",
|
||||
Description: "Write a sparse set of individual cells into one sheet of an existing Excel workbook, " +
|
||||
"by A1-style reference (e.g. \"B12\"). Leaves every other cell untouched; a reference past the " +
|
||||
"current grid extends it. Read the spreadsheet first if you need to know what's already there or " +
|
||||
"what the other sheets are called.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .xlsx file, relative to the workspace root."},
|
||||
"sheet": map[string]any{"type": "string", "description": "Which sheet to write to: a 1-based index (\"2\") or a sheet name. Omit for the first sheet."},
|
||||
"cells": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The cells to write.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"cell": map[string]any{"type": "string", "description": "A1-style reference, e.g. \"B12\"."},
|
||||
"value": map[string]any{"type": "string", "description": "New value. A leading \"=\" stores a live formula (e.g. \"=SUM(B2:B11)\"); otherwise stored as a number if it parses as one, else as text."},
|
||||
},
|
||||
"required": []string{"cell", "value"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "cells"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create_presentation",
|
||||
Description: "Create a new PowerPoint deck (.pptx) in the workspace from a list of slides, each a title " +
|
||||
"plus flat bullet lines — no layouts or manual positioning to specify. Fails if the path already " +
|
||||
"exists; use edit_presentation to add or change a slide afterward.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Destination path, relative to the workspace root, ending in .pptx."},
|
||||
"slides": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The deck's slides, in order.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"title": map[string]any{"type": "string"},
|
||||
"bullets": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"path", "slides"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_presentation",
|
||||
Description: "Read a PowerPoint deck (.pptx) back as a list of slides (title + bullets), in order. Works on any standard .pptx, including ones a user uploaded, though complex layouts/images aren't represented.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .pptx file, relative to the workspace root."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit_presentation",
|
||||
Description: "Either add_slide (append one new slide at the end — same title/bullets shape as " +
|
||||
"create_presentation's slides) or update_slide (replace one existing slide's title/bullets by its " +
|
||||
"1-based index) in an existing PowerPoint deck. Read the presentation first if you're not sure how " +
|
||||
"many slides it has.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .pptx file, relative to the workspace root."},
|
||||
"operation": map[string]any{"type": "string", "enum": []string{"add_slide", "update_slide"}},
|
||||
"index": map[string]any{"type": "integer", "description": "update_slide only: 1-based slide number to replace."},
|
||||
"title": map[string]any{"type": "string"},
|
||||
"bullets": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
},
|
||||
"required": []string{"path", "operation"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "read_pdf",
|
||||
Description: "Extract the plain text content of a PDF file, best-effort — data extraction from PDFs, " +
|
||||
"for reading an uploaded report/contract/paper rather than producing one. Works well on PDFs with a " +
|
||||
"real embedded text layer (almost anything produced by Word, LibreOffice, a browser's \"Print to " +
|
||||
"PDF\", Pandoc, or similar tools). It cannot do OCR: a scanned or image-only PDF will come back with " +
|
||||
"an error saying so, not fabricated text. Layout, tables, columns, and images are not represented — " +
|
||||
"only the words, roughly in reading order, with best-effort line breaks; don't rely on this for a " +
|
||||
"document's exact formatting or table structure.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"path": map[string]any{"type": "string", "description": "Path to the .pdf file, relative to the workspace root."},
|
||||
},
|
||||
"required": []string{"path"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dispatch_subagent",
|
||||
Description: "Launch an autonomous, read-only sub-agent (modeled after Claude Code's Task tool) to investigate " +
|
||||
"the workspace for you and report back. Give it one self-contained, specific task description — it has its " +
|
||||
"own fresh context and only list_directory, glob_search, grep, read_file and search_codebase (no write_file, " +
|
||||
"edit_file, multi_edit_file or run_bash_command: it can never change anything). It explores on its own for up " +
|
||||
"to a few rounds and returns a single written report; everything it reads along the way stays in ITS context, " +
|
||||
"not yours. Reach for this instead of a long manual chain of your own search/read calls when a question is " +
|
||||
"broad or open-ended (\"find every place X happens and summarize the pattern\", \"map out how auth flows " +
|
||||
"through this service\", \"is there already code that does Y?\") — one dispatch_subagent call often replaces a " +
|
||||
"dozen of your own tool calls and keeps your own context focused on deciding what to do with the answer. " +
|
||||
"Don't use it for a single targeted lookup you could do yourself in one or two calls, and never for anything " +
|
||||
"that needs to write or run something — it can't.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A precise, self-contained description of what to investigate and what the report should " +
|
||||
"cover. The sub-agent has no memory of this conversation beyond this one string, so include every " +
|
||||
"detail it needs (which directory or feature, what you already know, what you want answered).",
|
||||
},
|
||||
},
|
||||
"required": []string{"task"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dispatch_workflow",
|
||||
Description: "Fan a large task out into many independent sub-agents running IN PARALLEL (modeled after " +
|
||||
"Claude's dynamic multi-agent orchestration), then collect, verify and summarize what came back — the " +
|
||||
"tool to reach for on a large-scale, mechanically-repeated job like a codebase migration (\"rename this " +
|
||||
"API across every service\", \"add a context.Context first param to every exported func in these 40 " +
|
||||
"files\", \"port every handler in internal/old to the new router\"), not a single investigation " +
|
||||
"(use dispatch_subagent for that). Give it a short list of self-contained steps; each one runs as its " +
|
||||
"own read-only sub-agent, with the same tool access as dispatch_subagent (it investigates and reports — " +
|
||||
"it does not write or run anything, same as dispatch_subagent, so plan the actual edits as a follow-up " +
|
||||
"once you've seen every step's findings, exactly the way you'd use any other investigation's report). " +
|
||||
"Steps with no listed dependencies start immediately and run concurrently, bounded by this deployment's " +
|
||||
"configured parallelism cap; a step that lists depends_on waits for those steps to finish first, so you " +
|
||||
"can express \"investigate every call site\" as 40 fully-parallel steps while still sequencing a final " +
|
||||
"\"cross-check the findings against each other\" step after them. Every step's report is automatically " +
|
||||
"checked against its own task description (did it actually answer what was asked, or come back empty/" +
|
||||
"off-topic) before you see the results, so a weak report is flagged for you to re-dispatch or handle " +
|
||||
"yourself instead of silently passing as done — this is the real-time verification and plan adjustment " +
|
||||
"loop: look at which steps verified cleanly and which didn't, and adjust what you do next accordingly " +
|
||||
"rather than assuming every step succeeded. Not available on every plan — if it's not enabled for this " +
|
||||
"account, the tool call itself will say so; fall back to dispatch_subagent (sequential, same " +
|
||||
"investigative power, just one step at a time) or to doing the work directly.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"goal": map[string]any{
|
||||
"type": "string",
|
||||
"description": "One or two sentences on the overall objective these steps serve — included in each " +
|
||||
"step's own task so no sub-agent loses the forest for the trees.",
|
||||
},
|
||||
"steps": map[string]any{
|
||||
"type": "array",
|
||||
"description": "The independent (or dependency-ordered) units of work to run, 2 to several hundred.",
|
||||
"items": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A short, unique identifier for this step (e.g. \"svc-billing\", \"file-12\"), referenced by other steps' depends_on and by the final summary.",
|
||||
},
|
||||
"task": map[string]any{
|
||||
"type": "string",
|
||||
"description": "A precise, self-contained description of this one step's work and what its report should cover — same guidance as dispatch_subagent's task argument.",
|
||||
},
|
||||
"depends_on": map[string]any{
|
||||
"type": "array",
|
||||
"description": "IDs of steps that must finish before this one starts. Omit for a step with no dependencies (the common case — most migration steps are independent of each other).",
|
||||
"items": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
"required": []string{"id", "task"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": []string{"goal", "steps"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// officeSkillToolDescription builds read_office_skill's description
|
||||
// from the currently-embedded skill files, so the catalog and the
|
||||
// skills (pkg/skillkit/skills/*.md) can never drift apart — adding or
|
||||
// renaming a skill file is all that's needed to change what the model
|
||||
// sees here, no second edit to keep in sync.
|
||||
func officeSkillToolDescription() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Read one of enkly's office-document skills: short notes on how to use the Word/Excel/" +
|
||||
"PowerPoint tools well — conventions, best practices, and what each one honestly can't do yet. The " +
|
||||
"create_document/create_spreadsheet/create_presentation tool descriptions already cover the basics " +
|
||||
"needed for an ordinary document, so this is optional, not a required first step — skip it for a " +
|
||||
"straightforward request (a CV, a simple report, a one-sheet table of numbers) and go straight to the " +
|
||||
"create_*/edit_* tool; reach for this before anything that leans on a format's sharper edges (Excel " +
|
||||
"formulas or a multi-sheet workbook, a multi-slide PowerPoint deck, edit_document's replace_text run- " +
|
||||
"splitting caveat, redlining with Track Changes, or converting Markdown straight into a .docx) or when " +
|
||||
"you're not sure a feature someone asked for is even supported. Start with the " +
|
||||
"\"overview\" skill if you're not sure which format a request needs. No need to re-read a skill you've " +
|
||||
"already read earlier in this same conversation. Available skills:\n")
|
||||
for _, s := range skillkit.List() {
|
||||
fmt.Fprintf(&b, "- %s: %s\n", s.Name, s.Description)
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// subagentToolNames is the read-only whitelist offered to a
|
||||
// dispatch_subagent run: everything that can observe the workspace,
|
||||
// nothing that can change it. toolexec.NewRestricted enforces this
|
||||
// same whitelist at execution time — belt and suspenders, since a
|
||||
// tool catalog only controls what the model is *offered*, not what it
|
||||
// could still try to call.
|
||||
var subagentToolNames = map[string]bool{
|
||||
"read_file": true,
|
||||
"list_directory": true,
|
||||
"glob_search": true,
|
||||
"grep": true,
|
||||
"search_codebase": true,
|
||||
"read_document": true,
|
||||
"read_spreadsheet": true,
|
||||
"read_presentation": true,
|
||||
"read_office_skill": true,
|
||||
"search_office_skills": true,
|
||||
"read_pdf": true,
|
||||
}
|
||||
|
||||
// SubagentDefs returns the read-only subset of Defs() handed to a
|
||||
// dispatch_subagent run.
|
||||
func SubagentDefs() []vllmkit.ToolDef {
|
||||
all := Defs()
|
||||
out := make([]vllmkit.ToolDef, 0, len(subagentToolNames))
|
||||
for _, d := range all {
|
||||
if subagentToolNames[d.Name] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// IsSubagentTool reports whether name is in the read-only whitelist —
|
||||
// used by toolexec.restrictedExecutor so the enforcement lives next to
|
||||
// the single source of truth for the whitelist itself.
|
||||
func IsSubagentTool(name string) bool {
|
||||
return subagentToolNames[name]
|
||||
}
|
||||
316
services/agent/internal/infrastructure/toolexec/executor.go
Normal file
316
services/agent/internal/infrastructure/toolexec/executor.go
Normal file
@@ -0,0 +1,316 @@
|
||||
// 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()}
|
||||
}
|
||||
29
services/agent/internal/infrastructure/toolexec/mlmath.go
Normal file
29
services/agent/internal/infrastructure/toolexec/mlmath.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"enkly/pkg/mathkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// solveMLMath runs the solve_ml_math tool. Like write_todos, this is
|
||||
// pure, in-memory computation (arithmetic over the arguments the
|
||||
// model already supplied) with no filesystem, database, or other
|
||||
// service dependency, so — unlike every tool in executor.go that
|
||||
// calls out to the workspace or knowledge service — it never leaves
|
||||
// this process. See pkg/mathkit/solve.go for the operation dispatch
|
||||
// table and pkg/mathkit's package doc for the math it covers.
|
||||
func solveMLMath(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
op := argStr(call.Arguments, "operation")
|
||||
if op == "" {
|
||||
return vllmkit.ToolResult{
|
||||
Name: call.Name,
|
||||
Content: `solve_ml_math: missing "operation" — see the tool description for the full list of supported operations`,
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
result, err := mathkit.Solve(op, call.Arguments)
|
||||
if err != nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "solve_ml_math: " + err.Error(), IsError: true}
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: result}
|
||||
}
|
||||
451
services/agent/internal/infrastructure/toolexec/office.go
Normal file
451
services/agent/internal/infrastructure/toolexec/office.go
Normal file
@@ -0,0 +1,451 @@
|
||||
// Document-management tools (create/read/edit_document,
|
||||
// create/read/edit_spreadsheet, create/read/edit_presentation) —
|
||||
// Word/Excel/PowerPoint support modeled on Claude Code's
|
||||
// document-editing skills. All the actual OOXML work happens in
|
||||
// pkg/officekit; this file just translates tool-call arguments to/from
|
||||
// it and persists bytes through the workspace service's existing
|
||||
// UploadFile/DownloadFile RPCs (binary-safe []byte fields — unlike
|
||||
// ReadFile/WriteFile's text-oriented string fields, which real .docx/
|
||||
// .xlsx/.pptx bytes wouldn't survive intact).
|
||||
//
|
||||
// Deliberately, nothing here ever puts raw XML into a ToolResult.Content
|
||||
// or reads raw XML out of call.Arguments — every value crossing that
|
||||
// boundary is plain text (a title, a paragraph, a cell value, a short
|
||||
// summary like "wrote report.docx (4 paragraphs)"). That's what keeps
|
||||
// these new tools from being a new way to trigger a tool-call parse
|
||||
// failure on the model's output — the historical Ollama chat-template
|
||||
// "XML syntax error" failure mode described in catalog.go's comment
|
||||
// above these tool defs, or vLLM's own tool-call parser choking on
|
||||
// unexpected markup in an argument or result.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
workspacev1 "enkly/gen/workspace/v1"
|
||||
"enkly/pkg/officekit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
func (e *Executor) download(ctx context.Context, path string) ([]byte, error) {
|
||||
resp, err := e.Workspace.DownloadFile(ctx, &workspacev1.DownloadFileRequest{Path: path})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetContent(), nil
|
||||
}
|
||||
|
||||
func (e *Executor) upload(ctx context.Context, path string, data []byte) error {
|
||||
_, err := e.Workspace.UploadFile(ctx, &workspacev1.UploadFileRequest{Path: path, Content: data})
|
||||
return err
|
||||
}
|
||||
|
||||
// errResult is a small helper matching the rest of this package's
|
||||
// vllmkit.ToolResult{IsError: true} convention.
|
||||
func errResult(name, format string, a ...any) vllmkit.ToolResult {
|
||||
return vllmkit.ToolResult{Name: name, Content: fmt.Sprintf(format, a...), IsError: true}
|
||||
}
|
||||
|
||||
// --- Word ---------------------------------------------------------
|
||||
|
||||
func parseParagraphArg(m map[string]any) officekit.Paragraph {
|
||||
style := officekit.ParaStyle(argStr(m, "style"))
|
||||
if style == "" {
|
||||
style = officekit.StyleNormal
|
||||
}
|
||||
return officekit.Paragraph{
|
||||
Text: argStr(m, "text"),
|
||||
Style: style,
|
||||
Bold: argBool(m, "bold"),
|
||||
Italic: argBool(m, "italic"),
|
||||
Underline: argBool(m, "underline"),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) createDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".docx") {
|
||||
return errResult(call.Name, "create_document: path must end in .docx, got %q", path)
|
||||
}
|
||||
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
||||
return errResult(call.Name, "create_document: %s already exists; use edit_document to change it", path)
|
||||
}
|
||||
var paras []officekit.Paragraph
|
||||
if md := argStr(call.Arguments, "markdown"); md != "" {
|
||||
// Conversion path: let officekit's Markdown parser do the
|
||||
// heading/bullet/paragraph structuring instead of the model
|
||||
// building "paragraphs" entries by hand — see markdown.go's
|
||||
// package doc for exactly what carries over and what doesn't.
|
||||
paras = officekit.MarkdownToParagraphs(md)
|
||||
if len(paras) == 0 {
|
||||
return errResult(call.Name, "create_document: markdown produced no recognizable content (headings/paragraphs/lists)")
|
||||
}
|
||||
} else {
|
||||
rawParas, _ := call.Arguments["paragraphs"].([]any)
|
||||
paras = make([]officekit.Paragraph, 0, len(rawParas))
|
||||
for _, rp := range rawParas {
|
||||
if m, ok := rp.(map[string]any); ok {
|
||||
paras = append(paras, parseParagraphArg(m))
|
||||
}
|
||||
}
|
||||
}
|
||||
data, err := officekit.BuildDocx(paras)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "create_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, data); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d paragraph(s), %d bytes)", path, len(paras), len(data))}
|
||||
}
|
||||
|
||||
func (e *Executor) readDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_document: %s not found or empty", path)
|
||||
}
|
||||
text, err := officekit.ReadDocxText(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_document: %v", err)
|
||||
}
|
||||
// Surface any pending tracked changes alongside the plain text so
|
||||
// a caller reviewing a document doesn't need a second tool call to
|
||||
// discover it has open redlines — see redline.go's ReadDocxRevisions.
|
||||
if revisions, rerr := officekit.ReadDocxRevisions(data); rerr == nil && revisions != "" {
|
||||
text += "\n\n--- tracked changes pending review (edit_document: accept_all_changes / reject_all_changes) ---\n" + revisions
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: text}
|
||||
}
|
||||
|
||||
func (e *Executor) editDocument(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "edit_document: %s not found", path)
|
||||
}
|
||||
|
||||
switch argStr(call.Arguments, "operation") {
|
||||
case "append_paragraph":
|
||||
p := parseParagraphArg(call.Arguments)
|
||||
out, err := officekit.AppendDocxParagraph(data, p)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s", path)}
|
||||
|
||||
case "replace_text":
|
||||
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
|
||||
if find == "" {
|
||||
return errResult(call.Name, "edit_document: replace_text requires a non-empty \"find\"")
|
||||
}
|
||||
out, n, err := officekit.ReplaceDocxText(data, find, replace)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("replaced text in %d paragraph(s) of %s", n, path)}
|
||||
|
||||
case "replace_text_tracked":
|
||||
find, replace := argStr(call.Arguments, "find"), argStr(call.Arguments, "replace")
|
||||
if find == "" {
|
||||
return errResult(call.Name, "edit_document: replace_text_tracked requires a non-empty \"find\"")
|
||||
}
|
||||
out, n, err := officekit.ReplaceDocxTextTracked(data, find, replace, argStr(call.Arguments, "author"))
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("tracked a change in %d run(s) of %s, pending review (Word: Review > Track Changes)", n, path)}
|
||||
|
||||
case "append_paragraph_tracked":
|
||||
p := parseParagraphArg(call.Arguments)
|
||||
out, err := officekit.AppendDocxParagraphTracked(data, p, argStr(call.Arguments, "author"))
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("appended 1 paragraph to %s as a tracked insertion, pending review", path)}
|
||||
|
||||
case "accept_all_changes":
|
||||
out, err := officekit.AcceptAllDocxChanges(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("accepted all tracked changes in %s", path)}
|
||||
|
||||
case "reject_all_changes":
|
||||
out, err := officekit.RejectAllDocxChanges(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_document: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("rejected all tracked changes in %s (original text restored)", path)}
|
||||
|
||||
default:
|
||||
return errResult(call.Name, "edit_document: unknown operation %q (expected append_paragraph, replace_text, append_paragraph_tracked, replace_text_tracked, accept_all_changes, or reject_all_changes)", argStr(call.Arguments, "operation"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Excel ----------------------------------------------------------
|
||||
|
||||
func (e *Executor) createSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".xlsx") {
|
||||
return errResult(call.Name, "create_spreadsheet: path must end in .xlsx, got %q", path)
|
||||
}
|
||||
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
||||
return errResult(call.Name, "create_spreadsheet: %s already exists; use edit_spreadsheet to change it", path)
|
||||
}
|
||||
|
||||
sheets := parseSheetsArg(call.Arguments)
|
||||
if len(sheets) == 0 {
|
||||
return errResult(call.Name, "create_spreadsheet: give either \"rows\" (one sheet) or \"sheets\" (multiple)")
|
||||
}
|
||||
data, err := officekit.BuildXlsx(sheets)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "create_spreadsheet: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, data); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
totalRows := 0
|
||||
for _, s := range sheets {
|
||||
totalRows += len(s.Rows)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d sheet(s), %d row(s) total, %d bytes)", path, len(sheets), totalRows, len(data))}
|
||||
}
|
||||
|
||||
// parseSheetsArg accepts either the multi-sheet "sheets" array
|
||||
// (each {name, rows}) or the original single-sheet "rows"/"sheet_name"
|
||||
// pair, so existing callers keep working unchanged while the model can
|
||||
// now also build a workbook with more than one tab in one call.
|
||||
func parseSheetsArg(args map[string]any) []officekit.Sheet {
|
||||
if rawSheets, ok := args["sheets"].([]any); ok && len(rawSheets) > 0 {
|
||||
sheets := make([]officekit.Sheet, 0, len(rawSheets))
|
||||
for _, rs := range rawSheets {
|
||||
m, ok := rs.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sheets = append(sheets, officekit.Sheet{Name: argStr(m, "name"), Rows: parseRowsArg(m["rows"])})
|
||||
}
|
||||
return sheets
|
||||
}
|
||||
if _, has := args["rows"]; !has {
|
||||
return nil
|
||||
}
|
||||
return []officekit.Sheet{{Name: argStr(args, "sheet_name"), Rows: parseRowsArg(args["rows"])}}
|
||||
}
|
||||
|
||||
func parseRowsArg(v any) [][]string {
|
||||
rawRows, _ := v.([]any)
|
||||
rows := make([][]string, 0, len(rawRows))
|
||||
for _, rr := range rawRows {
|
||||
rawRow, _ := rr.([]any)
|
||||
row := make([]string, 0, len(rawRow))
|
||||
for _, c := range rawRow {
|
||||
s, _ := c.(string)
|
||||
row = append(row, s)
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func (e *Executor) readSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_spreadsheet: %s not found or empty", path)
|
||||
}
|
||||
sheetRef := argStr(call.Arguments, "sheet")
|
||||
rows, sheetName, err := officekit.ReadXlsxCells(data, sheetRef)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_spreadsheet: %v", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
if all, nameErr := officekit.ReadXlsxSheetNames(data); nameErr == nil && len(all) > 1 {
|
||||
fmt.Fprintf(&sb, "# sheet %q of %d: %s\n", sheetName, len(all), strings.Join(all, ", "))
|
||||
}
|
||||
for _, row := range rows {
|
||||
sb.WriteString(strings.Join(row, "\t"))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
|
||||
func (e *Executor) editSpreadsheet(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "edit_spreadsheet: %s not found", path)
|
||||
}
|
||||
rawCells, _ := call.Arguments["cells"].([]any)
|
||||
writes := make(map[string]string, len(rawCells))
|
||||
for _, rc := range rawCells {
|
||||
m, ok := rc.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
writes[strings.ToUpper(argStr(m, "cell"))] = argStr(m, "value")
|
||||
}
|
||||
if len(writes) == 0 {
|
||||
return errResult(call.Name, "edit_spreadsheet: no cells given")
|
||||
}
|
||||
sheetRef := argStr(call.Arguments, "sheet")
|
||||
out, err := officekit.SetXlsxCells(data, sheetRef, writes)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_spreadsheet: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("wrote %d cell(s) to %s", len(writes), path)}
|
||||
}
|
||||
|
||||
// --- PowerPoint -------------------------------------------------------
|
||||
|
||||
func parseSlideArg(m map[string]any) officekit.Slide {
|
||||
rawBullets, _ := m["bullets"].([]any)
|
||||
bullets := make([]string, 0, len(rawBullets))
|
||||
for _, b := range rawBullets {
|
||||
if s, ok := b.(string); ok {
|
||||
bullets = append(bullets, s)
|
||||
}
|
||||
}
|
||||
return officekit.Slide{Title: argStr(m, "title"), Bullets: bullets}
|
||||
}
|
||||
|
||||
func (e *Executor) createPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".pptx") {
|
||||
return errResult(call.Name, "create_presentation: path must end in .pptx, got %q", path)
|
||||
}
|
||||
if existing, _ := e.download(ctx, path); len(existing) > 0 {
|
||||
return errResult(call.Name, "create_presentation: %s already exists; use edit_presentation to change it", path)
|
||||
}
|
||||
rawSlides, _ := call.Arguments["slides"].([]any)
|
||||
slides := make([]officekit.Slide, 0, len(rawSlides))
|
||||
for _, rs := range rawSlides {
|
||||
if m, ok := rs.(map[string]any); ok {
|
||||
slides = append(slides, parseSlideArg(m))
|
||||
}
|
||||
}
|
||||
data, err := officekit.BuildPptx(slides)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "create_presentation: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, data); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("created %s (%d slide(s), %d bytes)", path, len(slides), len(data))}
|
||||
}
|
||||
|
||||
func (e *Executor) readPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_presentation: %s not found or empty", path)
|
||||
}
|
||||
slides, err := officekit.ReadPptxSlides(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_presentation: %v", err)
|
||||
}
|
||||
var sb strings.Builder
|
||||
for i, s := range slides {
|
||||
fmt.Fprintf(&sb, "## Slide %d: %s\n", i+1, s.Title)
|
||||
for _, b := range s.Bullets {
|
||||
fmt.Fprintf(&sb, "- %s\n", b)
|
||||
}
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
|
||||
func (e *Executor) editPresentation(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "edit_presentation: %s not found", path)
|
||||
}
|
||||
|
||||
slide := parseSlideArg(call.Arguments)
|
||||
switch argStr(call.Arguments, "operation") {
|
||||
case "add_slide":
|
||||
out, err := officekit.AppendPptxSlide(data, slide)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_presentation: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("added 1 slide to %s", path)}
|
||||
|
||||
case "update_slide":
|
||||
idx := argInt(call.Arguments, "index")
|
||||
out, err := officekit.UpdatePptxSlide(data, idx, slide)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "edit_presentation: %v", err)
|
||||
}
|
||||
if err := e.upload(ctx, path, out); err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("updated slide %d of %s", idx, path)}
|
||||
|
||||
default:
|
||||
return errResult(call.Name, "edit_presentation: unknown operation %q (expected add_slide or update_slide)", argStr(call.Arguments, "operation"))
|
||||
}
|
||||
}
|
||||
|
||||
// --- PDF (read-only: data extraction, not creation) --------------------
|
||||
|
||||
func (e *Executor) readPdf(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
path := argStr(call.Arguments, "path")
|
||||
data, err := e.download(ctx, path)
|
||||
if err != nil {
|
||||
return rpcFail(call.Name, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return errResult(call.Name, "read_pdf: %s not found or empty", path)
|
||||
}
|
||||
text, err := officekit.ReadPdfText(data)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "read_pdf: %v", err)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: text}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// read_office_skill and search_office_skills are the two office-skill
|
||||
// tools that aren't a gRPC call or an officekit XML operation.
|
||||
// read_office_skill is pure, in-memory lookup into pkg/skillkit's
|
||||
// embedded skill files (the same category as write_todos and
|
||||
// solve_ml_math — no workspace/knowledge dependency, see executor.go's
|
||||
// package doc); search_office_skills calls out to the vLLM-hosted
|
||||
// embedding model (via Executor.SkillIndex) to embed the query, same
|
||||
// as search_codebase
|
||||
// calling out to the knowledge service — see rag.go's package doc for
|
||||
// why this one stays in-process instead.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/skillkit"
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
func readOfficeSkill(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
name := argStr(call.Arguments, "name")
|
||||
skill, ok := skillkit.Read(name)
|
||||
if !ok {
|
||||
available := skillkit.Names()
|
||||
return errResult(call.Name, "read_office_skill: no skill named %q (available: %s)", name, strings.Join(available, ", "))
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: skill.Body}
|
||||
}
|
||||
|
||||
func (e *Executor) searchOfficeSkills(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
query := strings.TrimSpace(argStr(call.Arguments, "query"))
|
||||
if query == "" {
|
||||
return errResult(call.Name, "search_office_skills: empty query")
|
||||
}
|
||||
if e.SkillIndex == nil {
|
||||
return errResult(call.Name, "search_office_skills: not available on this deployment (no embedding model configured); use read_office_skill instead")
|
||||
}
|
||||
skillFilter := argStr(call.Arguments, "skill")
|
||||
if skillFilter != "" {
|
||||
if _, ok := skillkit.Read(skillFilter); !ok {
|
||||
return errResult(call.Name, "search_office_skills: no skill named %q (available: %s)", skillFilter, strings.Join(skillkit.Names(), ", "))
|
||||
}
|
||||
}
|
||||
|
||||
matches, err := e.SkillIndex.Search(ctx, query, 4, skillFilter)
|
||||
if err != nil {
|
||||
return errResult(call.Name, "search_office_skills: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "no matching guidance found"}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, m := range matches {
|
||||
fmt.Fprintf(&sb, "### %s — %s (similarity %.3f)\n%s\n\n", m.Skill, m.Heading, m.Score, m.Text)
|
||||
}
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: strings.TrimRight(sb.String(), "\n")}
|
||||
}
|
||||
84
services/agent/internal/infrastructure/toolexec/subagent.go
Normal file
84
services/agent/internal/infrastructure/toolexec/subagent.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// 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}
|
||||
}
|
||||
429
services/agent/internal/infrastructure/toolexec/workflow.go
Normal file
429
services/agent/internal/infrastructure/toolexec/workflow.go
Normal file
@@ -0,0 +1,429 @@
|
||||
// workflow.go implements the dispatch_workflow tool: enkly's answer to
|
||||
// "dynamic workflows... orchestration of hundreds of subagents in
|
||||
// parallel in a single session, with automatic verification of results
|
||||
// and real-time plan adjustment." Where dispatch_subagent (subagent.go)
|
||||
// hands off ONE investigation and waits for it, dispatch_workflow takes
|
||||
// a whole batch of them — typically one per file, service, or call site
|
||||
// in a large mechanical job like a codebase migration — and runs as
|
||||
// many as the deployment's configured (and plan-entitled) parallelism
|
||||
// allows at once, respects any depends_on ordering between them, checks
|
||||
// every report against its own task before handing results back, and
|
||||
// retries a step once if that check fails, instead of silently treating
|
||||
// an empty or off-topic report as done.
|
||||
//
|
||||
// Every step is, under the hood, exactly the same kind of run
|
||||
// dispatch_subagent launches — e.Subagent.Run against a fresh,
|
||||
// read-only sub-agent — just many of them in flight at once instead of
|
||||
// one. That reuse is deliberate: it's the same nested agentrun.Agent,
|
||||
// the same restrictedExecutor read-only boundary, the same
|
||||
// SubagentSystemPrompt, so a workflow step can never write or run
|
||||
// anything any more than a lone dispatch_subagent call could. What
|
||||
// *is* new here is the scheduling, the bounded concurrency, and the
|
||||
// verification pass — everything below this point in the file.
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"enkly/pkg/entitlements"
|
||||
"enkly/pkg/vllmkit"
|
||||
"enkly/services/agent/internal/infrastructure/agentrun"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Entitlements: whether dispatch_workflow is usable this turn
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// entitlementsKey is WithEntitlements' context key.
|
||||
type entitlementsKey struct{}
|
||||
|
||||
// WithEntitlements attaches the caller's resolved capability Set (see
|
||||
// pkg/entitlements) to ctx for dispatchWorkflow to read back — set once
|
||||
// per turn by application.Service.StartTurn from the plan name
|
||||
// billing's CheckAndIncrementUsage already returned, the same place
|
||||
// agentrun.WithPreferredLanguage and agentrun.WithEffort get attached
|
||||
// to the same run context.
|
||||
func WithEntitlements(ctx context.Context, set entitlements.Set) context.Context {
|
||||
return context.WithValue(ctx, entitlementsKey{}, set)
|
||||
}
|
||||
|
||||
// entitlementsFrom reads back what WithEntitlements stored. The zero
|
||||
// Set (DynamicWorkflows: false) is the correct default for a context
|
||||
// nothing ever attached one to — see entitlements.Catalog.Resolve's
|
||||
// doc comment on failing closed.
|
||||
func entitlementsFrom(ctx context.Context) entitlements.Set {
|
||||
set, _ := ctx.Value(entitlementsKey{}).(entitlements.Set)
|
||||
return set
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// dispatch_workflow argument parsing
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
type workflowStep struct {
|
||||
ID string
|
||||
Task string
|
||||
DependsOn []string
|
||||
}
|
||||
|
||||
// maxWorkflowSteps bounds one dispatch_workflow call regardless of
|
||||
// configured parallelism — "hundreds" of concurrent sub-agents is the
|
||||
// target, not an unbounded number typed into one tool call; well past
|
||||
// this a migration should be split into more than one dispatch_workflow
|
||||
// round anyway (the model can always call it again).
|
||||
const maxWorkflowSteps = 500
|
||||
|
||||
func parseWorkflowSteps(raw []any) ([]workflowStep, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("dispatch_workflow: \"steps\" must be a non-empty array")
|
||||
}
|
||||
if len(raw) > maxWorkflowSteps {
|
||||
return nil, fmt.Errorf("dispatch_workflow: %d steps exceeds the %d-step limit for one call; split into more than one dispatch_workflow", len(raw), maxWorkflowSteps)
|
||||
}
|
||||
seen := make(map[string]bool, len(raw))
|
||||
steps := make([]workflowStep, 0, len(raw))
|
||||
for i, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("dispatch_workflow: step %d is not an object", i)
|
||||
}
|
||||
id := strings.TrimSpace(argStr(m, "id"))
|
||||
task := strings.TrimSpace(argStr(m, "task"))
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("dispatch_workflow: step %d is missing \"id\"", i)
|
||||
}
|
||||
if task == "" {
|
||||
return nil, fmt.Errorf("dispatch_workflow: step %q is missing \"task\"", id)
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, fmt.Errorf("dispatch_workflow: duplicate step id %q", id)
|
||||
}
|
||||
seen[id] = true
|
||||
|
||||
var deps []string
|
||||
if rawDeps, ok := m["depends_on"].([]any); ok {
|
||||
for _, d := range rawDeps {
|
||||
if s, ok := d.(string); ok && strings.TrimSpace(s) != "" {
|
||||
deps = append(deps, strings.TrimSpace(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
steps = append(steps, workflowStep{ID: id, Task: task, DependsOn: deps})
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// validateDependencies checks that every depends_on name refers to a
|
||||
// real step in the same batch and that the dependency graph has no
|
||||
// cycle (Kahn's algorithm — the same technique runWorkflow's own
|
||||
// scheduler uses, run here purely as an up-front check so a cyclic or
|
||||
// malformed workflow fails loudly as a single clear tool error instead
|
||||
// of silently deadlocking the scheduler).
|
||||
func validateDependencies(steps []workflowStep) error {
|
||||
index := make(map[string]int, len(steps))
|
||||
for i, s := range steps {
|
||||
index[s.ID] = i
|
||||
}
|
||||
indegree := make([]int, len(steps))
|
||||
adj := make([][]int, len(steps))
|
||||
for i, s := range steps {
|
||||
for _, dep := range s.DependsOn {
|
||||
j, ok := index[dep]
|
||||
if !ok {
|
||||
return fmt.Errorf("dispatch_workflow: step %q depends_on unknown step %q", s.ID, dep)
|
||||
}
|
||||
if j == i {
|
||||
return fmt.Errorf("dispatch_workflow: step %q cannot depend on itself", s.ID)
|
||||
}
|
||||
indegree[i]++
|
||||
adj[j] = append(adj[j], i)
|
||||
}
|
||||
}
|
||||
queue := make([]int, 0, len(steps))
|
||||
for i, d := range indegree {
|
||||
if d == 0 {
|
||||
queue = append(queue, i)
|
||||
}
|
||||
}
|
||||
visited := 0
|
||||
for len(queue) > 0 {
|
||||
n := queue[0]
|
||||
queue = queue[1:]
|
||||
visited++
|
||||
for _, m := range adj[n] {
|
||||
indegree[m]--
|
||||
if indegree[m] == 0 {
|
||||
queue = append(queue, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
if visited != len(steps) {
|
||||
return fmt.Errorf("dispatch_workflow: depends_on forms a cycle — every step must be reachable without looping back")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Verification
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// WorkflowVerifier checks whether a step's report actually answers its
|
||||
// task, returning ok plus a short human-readable reason when it
|
||||
// doesn't. toolexec.Executor.WorkflowVerifier defaults to
|
||||
// defaultWorkflowVerifier; a deployment can swap in something richer
|
||||
// (e.g. one extra, cheap LLM call judging relevance) by setting that
|
||||
// field — see Executor's doc comment.
|
||||
type WorkflowVerifier func(task, report string) (ok bool, reason string)
|
||||
|
||||
// defaultWorkflowVerifier is a deliberately cheap, deterministic check
|
||||
// — no second model round-trip per step, consistent with this
|
||||
// codebase's preference (see pkg/mathkit) for dependency-free,
|
||||
// inspectable logic wherever a real engine isn't needed. It catches the
|
||||
// two failure shapes a stalled or confused sub-agent actually produces
|
||||
// in practice (an empty report, or the canned "finished without
|
||||
// writing a report" fallback from subagent.go's own dispatchSubagent)
|
||||
// plus an implausibly short report, without trying to judge the report's
|
||||
// substance — that judgment call is exactly what's handed back to the
|
||||
// dispatching model via each step's Verified/Reason, per "real-time
|
||||
// plan adjustment": the model, not this function, decides what a thin
|
||||
// report means for the overall task.
|
||||
func defaultWorkflowVerifier(task, report string) (bool, string) {
|
||||
r := strings.TrimSpace(report)
|
||||
if r == "" {
|
||||
return false, "empty report"
|
||||
}
|
||||
if strings.Contains(r, "finished without writing a report") {
|
||||
return false, "sub-agent produced no findings"
|
||||
}
|
||||
if strings.HasPrefix(r, "[step failed to run:") {
|
||||
return false, "sub-agent run did not complete"
|
||||
}
|
||||
const minPlausibleReportChars = 40
|
||||
if len(r) < minPlausibleReportChars {
|
||||
return false, "report too short to plausibly answer the task"
|
||||
}
|
||||
_ = task // reserved for a future content-aware verifier (e.g. keyword/file-path overlap with the task)
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Scheduling + execution
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// stepOutcome is one step's final result, after up to two attempts.
|
||||
type stepOutcome struct {
|
||||
ID string
|
||||
Report string
|
||||
Verified bool
|
||||
Reason string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
// maxStepAttempts bounds the "plan adjustment" retry per step: one
|
||||
// retry, with the verifier's own reason folded into the retry's task
|
||||
// as a concrete note on what was missing, then accept whatever comes
|
||||
// back — a workflow step is meant to be a bounded, mechanical unit of
|
||||
// work, not an open-ended negotiation with the model.
|
||||
const maxStepAttempts = 2
|
||||
|
||||
// runStepWithVerification runs one step (possibly twice) against sub,
|
||||
// verifying the report each time and amending the task with the
|
||||
// verifier's reason before a retry.
|
||||
func runStepWithVerification(ctx context.Context, sub *agentrun.Agent, task string, verify WorkflowVerifier) (report string, attempts int, verified bool, reason string) {
|
||||
for attempts = 1; attempts <= maxStepAttempts; attempts++ {
|
||||
raw, _, err := sub.Run(ctx, []vllmkit.Message{{Role: vllmkit.RoleUser, Content: task}}, agentrun.Callbacks{})
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch {
|
||||
case raw != "":
|
||||
report = raw
|
||||
case err != nil:
|
||||
report = fmt.Sprintf("[step failed to run: %v]", err)
|
||||
default:
|
||||
report = ""
|
||||
}
|
||||
|
||||
ok, why := verify(task, report)
|
||||
if ok {
|
||||
return report, attempts, true, ""
|
||||
}
|
||||
reason = why
|
||||
if attempts < maxStepAttempts && ctx.Err() == nil {
|
||||
task = fmt.Sprintf("%s\n\n[Your previous attempt's report was judged insufficient: %s. Investigate more "+
|
||||
"thoroughly — check files you may have skipped, and make sure your report cites specific evidence — "+
|
||||
"then try again.]", task, why)
|
||||
}
|
||||
}
|
||||
return report, attempts, false, reason
|
||||
}
|
||||
|
||||
// runWorkflow executes every step, honoring depends_on and bounded by
|
||||
// maxParallel concurrent sub-agent runs at any moment — not maxParallel
|
||||
// per "wave", the whole run: a step becomes eligible the instant its
|
||||
// last dependency finishes and immediately competes for the same
|
||||
// semaphore every other ready step is drawing from, so a workflow with
|
||||
// an uneven dependency shape (a few early steps gating a large fan-out)
|
||||
// still keeps the configured number of sub-agents busy throughout,
|
||||
// rather than only during a step's own "wave".
|
||||
func runWorkflow(ctx context.Context, sub *agentrun.Agent, goal string, steps []workflowStep, maxParallel int, verify WorkflowVerifier) []stepOutcome {
|
||||
if maxParallel < 1 {
|
||||
maxParallel = 1
|
||||
}
|
||||
n := len(steps)
|
||||
index := make(map[string]int, n)
|
||||
for i, s := range steps {
|
||||
index[s.ID] = i
|
||||
}
|
||||
indegree := make([]int, n)
|
||||
dependents := make([][]int, n)
|
||||
for i, s := range steps {
|
||||
for _, dep := range s.DependsOn {
|
||||
j := index[dep]
|
||||
indegree[i]++
|
||||
dependents[j] = append(dependents[j], i)
|
||||
}
|
||||
}
|
||||
|
||||
outcomes := make([]stepOutcome, n)
|
||||
sem := make(chan struct{}, maxParallel)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var schedule func(i int)
|
||||
schedule = func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
mu.Lock()
|
||||
outcomes[i] = stepOutcome{ID: steps[i].ID, Report: "[skipped: session stopped before this step started]", Attempts: 0}
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
task := fmt.Sprintf("Overall workflow goal: %s\n\nYour step (id %q): %s", goal, steps[i].ID, steps[i].Task)
|
||||
report, attempts, verified, reason := runStepWithVerification(ctx, sub, task, verify)
|
||||
<-sem
|
||||
|
||||
mu.Lock()
|
||||
outcomes[i] = stepOutcome{ID: steps[i].ID, Report: report, Verified: verified, Reason: reason, Attempts: attempts}
|
||||
var newlyReady []int
|
||||
for _, j := range dependents[i] {
|
||||
indegree[j]--
|
||||
if indegree[j] == 0 {
|
||||
newlyReady = append(newlyReady, j)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
for _, j := range newlyReady {
|
||||
wg.Add(1)
|
||||
go schedule(j)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
if indegree[i] == 0 {
|
||||
wg.Add(1)
|
||||
go schedule(i)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return outcomes
|
||||
}
|
||||
|
||||
// renderWorkflowSummary turns every step's outcome into the single
|
||||
// string handed back as the dispatch_workflow tool result — the "plan
|
||||
// adjustment" input the dispatching model actually reads: it sees at a
|
||||
// glance which steps verified and which didn't, by id, so it can decide
|
||||
// what to do about the shortfall (re-dispatch, handle manually, or
|
||||
// proceed if the unverified step turns out not to matter) rather than
|
||||
// that distinction being silently lost.
|
||||
func renderWorkflowSummary(goal string, outcomes []stepOutcome) string {
|
||||
verifiedN := 0
|
||||
for _, o := range outcomes {
|
||||
if o.Verified {
|
||||
verifiedN++
|
||||
}
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Workflow complete: %d/%d steps verified. Goal: %s\n", verifiedN, len(outcomes), goal)
|
||||
if verifiedN < len(outcomes) {
|
||||
fmt.Fprintf(&b, "%d step(s) need attention — see the ⚠️ entries below before treating this workflow as done.\n", len(outcomes)-verifiedN)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
for _, o := range outcomes {
|
||||
status := "✅ verified"
|
||||
if !o.Verified {
|
||||
status = fmt.Sprintf("⚠️ needs attention — %s", o.Reason)
|
||||
}
|
||||
fmt.Fprintf(&b, "### Step %q — %s (%d attempt(s))\n%s\n\n", o.ID, status, o.Attempts, o.Report)
|
||||
}
|
||||
return strings.TrimSpace(b.String())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The tool call itself
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// dispatchWorkflow runs the dispatch_workflow tool call: gated by
|
||||
// entitlementsFrom(ctx).DynamicWorkflows, bounded by
|
||||
// Executor.WorkflowMaxParallel and the resolved effort level's own
|
||||
// MaxParallelSubagents cap (whichever is smaller — see
|
||||
// agentrun.EffortFromContext), scheduled by runWorkflow, and rendered
|
||||
// by renderWorkflowSummary.
|
||||
func (e *Executor) dispatchWorkflow(ctx context.Context, call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
if e.Subagent == nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "dispatch_workflow: sub-agents are not configured on this deployment", IsError: true}
|
||||
}
|
||||
|
||||
ent := entitlementsFrom(ctx)
|
||||
if !ent.DynamicWorkflows {
|
||||
plan := ent.PlanName
|
||||
if plan == "" {
|
||||
plan = "your current plan"
|
||||
}
|
||||
return vllmkit.ToolResult{
|
||||
Name: call.Name,
|
||||
Content: fmt.Sprintf("dispatch_workflow: dynamic workflows aren't available on %s (this feature is part of "+
|
||||
"the Enterprise/Team/Max tier). Use dispatch_subagent for the same investigative work one step at a "+
|
||||
"time instead, or do it directly.", plan),
|
||||
IsError: true,
|
||||
}
|
||||
}
|
||||
|
||||
goal := strings.TrimSpace(argStr(call.Arguments, "goal"))
|
||||
if goal == "" {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `dispatch_workflow: "goal" is required`, IsError: true}
|
||||
}
|
||||
rawSteps, _ := call.Arguments["steps"].([]any)
|
||||
steps, err := parseWorkflowSteps(rawSteps)
|
||||
if err != nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
if err := validateDependencies(steps); err != nil {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: err.Error(), IsError: true}
|
||||
}
|
||||
|
||||
maxParallel := e.WorkflowMaxParallel
|
||||
if maxParallel <= 0 {
|
||||
maxParallel = defaultWorkflowMaxParallel
|
||||
}
|
||||
if levelCap := agentrun.EffortFromContext(ctx).Resolve().MaxParallelSubagents; levelCap > 0 && levelCap < maxParallel {
|
||||
maxParallel = levelCap
|
||||
}
|
||||
if maxParallel > len(steps) {
|
||||
maxParallel = len(steps)
|
||||
}
|
||||
|
||||
verify := e.WorkflowVerifier
|
||||
if verify == nil {
|
||||
verify = defaultWorkflowVerifier
|
||||
}
|
||||
|
||||
outcomes := runWorkflow(ctx, e.Subagent, goal, steps, maxParallel, verify)
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: renderWorkflowSummary(goal, outcomes)}
|
||||
}
|
||||
118
services/agent/internal/infrastructure/toolexec/workflow_test.go
Normal file
118
services/agent/internal/infrastructure/toolexec/workflow_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package toolexec
|
||||
|
||||
import "testing"
|
||||
|
||||
func mustParseSteps(t *testing.T, raw []any) []workflowStep {
|
||||
t.Helper()
|
||||
steps, err := parseWorkflowSteps(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWorkflowSteps: unexpected error: %v", err)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func TestParseWorkflowSteps_Basic(t *testing.T) {
|
||||
raw := []any{
|
||||
map[string]any{"id": "a", "task": "do a"},
|
||||
map[string]any{"id": "b", "task": "do b", "depends_on": []any{"a"}},
|
||||
}
|
||||
steps := mustParseSteps(t, raw)
|
||||
if len(steps) != 2 {
|
||||
t.Fatalf("got %d steps, want 2", len(steps))
|
||||
}
|
||||
if steps[1].DependsOn[0] != "a" {
|
||||
t.Errorf("steps[1].DependsOn = %v, want [a]", steps[1].DependsOn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkflowSteps_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw []any
|
||||
}{
|
||||
{"empty", nil},
|
||||
{"missing id", []any{map[string]any{"task": "x"}}},
|
||||
{"missing task", []any{map[string]any{"id": "a"}}},
|
||||
{"duplicate id", []any{
|
||||
map[string]any{"id": "a", "task": "x"},
|
||||
map[string]any{"id": "a", "task": "y"},
|
||||
}},
|
||||
{"not an object", []any{"not-an-object"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := parseWorkflowSteps(tc.raw); err == nil {
|
||||
t.Errorf("parseWorkflowSteps(%s): expected an error, got nil", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseWorkflowSteps_TooMany(t *testing.T) {
|
||||
raw := make([]any, maxWorkflowSteps+1)
|
||||
for i := range raw {
|
||||
raw[i] = map[string]any{"id": string(rune('a' + i%26)), "task": "x"}
|
||||
}
|
||||
if _, err := parseWorkflowSteps(raw); err == nil {
|
||||
t.Error("expected an error for exceeding maxWorkflowSteps")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_OK(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x"},
|
||||
map[string]any{"id": "b", "task": "x", "depends_on": []any{"a"}},
|
||||
map[string]any{"id": "c", "task": "x", "depends_on": []any{"a", "b"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err != nil {
|
||||
t.Errorf("validateDependencies: unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_UnknownDep(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x", "depends_on": []any{"nope"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err == nil {
|
||||
t.Error("expected an error for an unknown dependency")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_SelfDependency(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x", "depends_on": []any{"a"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err == nil {
|
||||
t.Error("expected an error for a step depending on itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencies_Cycle(t *testing.T) {
|
||||
steps := mustParseSteps(t, []any{
|
||||
map[string]any{"id": "a", "task": "x", "depends_on": []any{"b"}},
|
||||
map[string]any{"id": "b", "task": "x", "depends_on": []any{"a"}},
|
||||
})
|
||||
if err := validateDependencies(steps); err == nil {
|
||||
t.Error("expected an error for a two-step cycle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultWorkflowVerifier(t *testing.T) {
|
||||
cases := []struct {
|
||||
report string
|
||||
wantOK bool
|
||||
}{
|
||||
{"", false},
|
||||
{" ", false},
|
||||
{"short", false},
|
||||
{"(the sub-agent finished without writing a report — try a more specific task description)", false},
|
||||
{"[step failed to run: context deadline exceeded]", false},
|
||||
{"This is a plausible, substantial report with real findings in it about the codebase.", true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ok, reason := defaultWorkflowVerifier("some task", tc.report)
|
||||
if ok != tc.wantOK {
|
||||
t.Errorf("defaultWorkflowVerifier(%q) ok = %v (reason %q), want %v", tc.report, ok, reason, tc.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package toolexec
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/vllmkit"
|
||||
)
|
||||
|
||||
// writeTodos replaces Toolbox.writeTodos verbatim. It was always pure,
|
||||
// in-memory formatting with no filesystem or database dependency, so
|
||||
// unlike every other tool it doesn't move to the workspace service —
|
||||
// there's nothing to execute remotely.
|
||||
func writeTodos(call vllmkit.ToolCallRequest) vllmkit.ToolResult {
|
||||
raw, ok := call.Arguments["todos"]
|
||||
if !ok {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: missing "todos" array`, IsError: true}
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: "todos" must be an array`, IsError: true}
|
||||
}
|
||||
if len(list) == 0 {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "write_todos: the todo list is empty", IsError: true}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
inProgress := 0
|
||||
for _, item := range list {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: each todo must be an object with "content" and "status"`, IsError: true}
|
||||
}
|
||||
content, _ := m["content"].(string)
|
||||
status, _ := m["status"].(string)
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: `write_todos: a todo is missing "content"`, IsError: true}
|
||||
}
|
||||
var box string
|
||||
switch status {
|
||||
case "completed":
|
||||
box = "[x]"
|
||||
case "in_progress":
|
||||
box = "[~]"
|
||||
inProgress++
|
||||
case "pending", "":
|
||||
box = "[ ]"
|
||||
default:
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: fmt.Sprintf("write_todos: unknown status %q (want pending, in_progress, or completed)", status), IsError: true}
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s %s\n", box, content)
|
||||
}
|
||||
if inProgress > 1 {
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: "write_todos: more than one todo is in_progress — focus on one step at a time", IsError: true}
|
||||
}
|
||||
|
||||
return vllmkit.ToolResult{Name: call.Name, Content: sb.String()}
|
||||
}
|
||||
179
services/agent/internal/interfaces/grpc/server.go
Normal file
179
services/agent/internal/interfaces/grpc/server.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Package grpc adapts application.Service onto the generated
|
||||
// agentv1.AgentServiceServer interface. StreamTurn's polling/diffing/
|
||||
// coalescing loop lives here rather than in the application layer
|
||||
// because it's intrinsically about the generated grpc.ServerStream
|
||||
// type — see its doc comment for how it maps to the monolith's
|
||||
// Handlers.StreamChat.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
agentv1 "enkly/gen/agent/v1"
|
||||
"enkly/pkg/grpcerr"
|
||||
"enkly/pkg/i18n"
|
||||
"enkly/services/agent/internal/application"
|
||||
"enkly/services/agent/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
sseKeepAliveInterval = 15 * time.Second
|
||||
sseCoalesceDelay = 30 * time.Millisecond
|
||||
|
||||
// langMetadataKey mirrors the gateway's handlers.langMetadataKey —
|
||||
// the gRPC metadata key it sets on StartTurn to tell this service
|
||||
// which UI locale the visitor has chosen. Kept as a plain string
|
||||
// constant rather than a shared pkg symbol because it's the one
|
||||
// and only coupling point between the two services' transport
|
||||
// layers, and a shared constant for a single string isn't worth a
|
||||
// new shared pkg import on either side.
|
||||
langMetadataKey = "x-enkly-lang"
|
||||
)
|
||||
|
||||
// langFromIncomingContext reads the visitor's chosen locale off ctx's
|
||||
// incoming gRPC metadata (set by the gateway — see langMetadataKey's
|
||||
// doc comment), defaulting to i18n.Default when the call carries none
|
||||
// (any caller other than the gateway, or an older gateway build).
|
||||
func langFromIncomingContext(ctx context.Context) i18n.Locale {
|
||||
md, ok := metadata.FromIncomingContext(ctx)
|
||||
if !ok {
|
||||
return i18n.Default
|
||||
}
|
||||
values := md.Get(langMetadataKey)
|
||||
if len(values) == 0 {
|
||||
return i18n.Default
|
||||
}
|
||||
return i18n.ParseLocale(values[0])
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
agentv1.UnimplementedAgentServiceServer
|
||||
Svc *application.Service
|
||||
}
|
||||
|
||||
func New(svc *application.Service) *Server { return &Server{Svc: svc} }
|
||||
|
||||
func toProtoOutcome(o application.Outcome) agentv1.StartTurnOutcome {
|
||||
switch o {
|
||||
case application.OutcomeOK:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_OK
|
||||
case application.OutcomeConversationNotFound:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_NOT_FOUND
|
||||
case application.OutcomeConversationBusy:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_CONVERSATION_BUSY
|
||||
case application.OutcomeTooManyStreams:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_TOO_MANY_STREAMS
|
||||
case application.OutcomeRateLimited:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_RATE_LIMITED
|
||||
case application.OutcomeQuotaExceeded:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_QUOTA_EXCEEDED
|
||||
default:
|
||||
return agentv1.StartTurnOutcome_START_TURN_OUTCOME_INTERNAL_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) StartTurn(ctx context.Context, req *agentv1.StartTurnRequest) (*agentv1.StartTurnResponse, error) {
|
||||
lang := langFromIncomingContext(ctx)
|
||||
res := s.Svc.StartTurn(ctx, req.GetUserId(), req.GetConversationId(), req.GetPrompt(), lang)
|
||||
return &agentv1.StartTurnResponse{Outcome: toProtoOutcome(res.Outcome), Message: res.Message, StreamId: res.StreamID}, nil
|
||||
}
|
||||
|
||||
func toProtoToolCall(tc domain.ToolCallResult) *agentv1.ToolCall {
|
||||
return &agentv1.ToolCall{Name: tc.Name, ArgumentsJson: tc.ArgumentsJSON, Result: tc.Result, IsError: tc.IsError}
|
||||
}
|
||||
|
||||
func toProtoThinkingRound(tr domain.ThinkingRound) *agentv1.ThinkingRound {
|
||||
return &agentv1.ThinkingRound{Round: int32(tr.Round), Thinking: tr.Thinking}
|
||||
}
|
||||
|
||||
// StreamTurn replaces Handlers.StreamChat, translated from SSE to
|
||||
// server-streaming gRPC: same snapshot/diff/coalesce loop, same
|
||||
// Last-Event-ID-style resume (resume_from_tool_index), same keepalive
|
||||
// interval — just agentv1.TurnEvent messages instead of SSE frames. The
|
||||
// gateway's own SSE handler reads this stream and does the reverse
|
||||
// translation back to SSE for the browser, plus all HTML rendering
|
||||
// (this service deals in plain text only).
|
||||
func (s *Server) StreamTurn(req *agentv1.StreamTurnRequest, stream agentv1.AgentService_StreamTurnServer) error {
|
||||
st := s.Svc.Streams.Get(req.GetStreamId())
|
||||
if st == nil || st.UserID != req.GetUserId() {
|
||||
return grpcerr.NotFound("stream")
|
||||
}
|
||||
|
||||
sentTools := int(req.GetResumeFromToolIndex())
|
||||
// sentThinkRounds has no resume-from-request counterpart the way
|
||||
// sentTools does (StreamTurnRequest carries no thinking-round
|
||||
// cursor) — a reconnect just resends every round from the start,
|
||||
// same as lastThinking resetting to "" below and re-sending the
|
||||
// full cumulative trace. Cheap and harmless: a run has at most
|
||||
// MaxRounds (12) of these, each a short text, never the
|
||||
// unboundedly-large payloads tool results can be.
|
||||
sentThinkRounds := 0
|
||||
var lastToken, lastThinking string
|
||||
|
||||
ctx := stream.Context()
|
||||
for {
|
||||
snap, changed := st.Snapshot()
|
||||
|
||||
for i := sentTools; i < len(snap.Tools); i++ {
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ToolCall{ToolCall: toProtoToolCall(snap.Tools[i])}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(snap.Tools) > sentTools {
|
||||
sentTools = len(snap.Tools)
|
||||
}
|
||||
for i := sentThinkRounds; i < len(snap.ThinkingRounds); i++ {
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingRound{ThinkingRound: toProtoThinkingRound(snap.ThinkingRounds[i])}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(snap.ThinkingRounds) > sentThinkRounds {
|
||||
sentThinkRounds = len(snap.ThinkingRounds)
|
||||
}
|
||||
if snap.ThinkText != lastThinking {
|
||||
lastThinking = snap.ThinkText
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_ThinkingDelta{ThinkingDelta: lastThinking}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if snap.Done {
|
||||
done := &agentv1.TurnDone{FinalText: snap.FinalText}
|
||||
for _, tc := range snap.FinalToolCalls {
|
||||
done.ToolCalls = append(done.ToolCalls, toProtoToolCall(tc))
|
||||
}
|
||||
return stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_Done{Done: done}})
|
||||
}
|
||||
if snap.TokenText != lastToken {
|
||||
lastToken = snap.TokenText
|
||||
if err := stream.Send(&agentv1.TurnEvent{Event: &agentv1.TurnEvent_TokenDelta{TokenDelta: lastToken}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-changed:
|
||||
// Let a burst of tokens accumulate so we send one event, not fifty.
|
||||
select {
|
||||
case <-time.After(sseCoalesceDelay):
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
case <-time.After(sseKeepAliveInterval):
|
||||
// Server-streaming gRPC has its own keepalive at the
|
||||
// transport level (HTTP/2 PINGs); nothing to send here.
|
||||
// The timer's only job is to re-loop and re-check ctx.Done.
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) StopTurn(ctx context.Context, req *agentv1.StopTurnRequest) (*agentv1.StopTurnResponse, error) {
|
||||
if !s.Svc.StopTurn(req.GetUserId(), req.GetStreamId()) {
|
||||
return nil, grpcerr.NotFound("stream")
|
||||
}
|
||||
return &agentv1.StopTurnResponse{}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user