Init Project
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s

This commit is contained in:
enkly
2026-10-30 18:06:55 +01:00
parent da8af794bd
commit 79ce8dc4c0
211 changed files with 44434 additions and 1 deletions

16
services/agent/Dockerfile Normal file
View 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"]

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

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

View 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
}
}

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

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

View 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")
}
}

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

View File

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

View 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]
}

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

View 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}
}

View 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}
}

View File

@@ -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")}
}

View 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}
}

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

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

View File

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

View 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
}