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

925 lines
45 KiB
Go

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