925 lines
45 KiB
Go
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)
|
|
}
|