package agent import ( "context" "encoding/json" "errors" "fmt" "sort" "strings" "sync" "github.com/johannSo/codey/internal/hooks" "github.com/johannSo/codey/internal/llm" "github.com/johannSo/codey/internal/metrics" "github.com/johannSo/codey/internal/skills" "github.com/johannSo/codey/internal/tools" ) // NoTurnLimit is what a non-positive MaxTurns means: the loop runs until the // model stops asking for tools, the context window fills, or the caller // cancels. That is the default for a session someone is sitting in front of, // because a turn count is a bad proxy for the thing it was guarding against. // A task is not finished at turn thirty and unfinished at thirty-one; what // actually bounds a run is the window, the bill, and a human who can press esc. // // An unattended run has none of those, which is what -max-turns is for: set it // in -p mode and in CI, where nobody is watching and a model stuck repeating // one failing tool call would otherwise repeat it until the money ran out. const NoTurnLimit = 0 // ErrMaxTurns is returned when a turn cap that was asked for is hit. var ErrMaxTurns = errors.New("turn limit reached") // defaultSystem is deliberately static: it is the first thing in the cached // prompt prefix, so a timestamp or session id here would cost a cache hit on // every single request. It also holds no digits, for the same reason — see // TestRequestShape. // // Its shape follows the prompts Claude Code and Codex ship with: short headed // sections on how to work, what not to touch, what to confirm, and how to // report. Rules for tools this harness does not have — patch grammars, plan // trackers — are left out on purpose. const defaultSystem = `You are a coding agent running in Codey, a terminal harness, on a local repository. You are expected to be precise, careful, and useful. The user is on the same machine and can open any file you mention. # Harness Text you write outside of tool calls is shown to the user as Markdown in a terminal. Tools run under a permission mode the user chose: some calls run automatically, others stop and ask. A denied call means the user declined it — adjust your approach, do not retry it verbatim. If the repository has a CLAUDE.md or AGENTS.md, read it once at the start of the task and follow it: it outranks these defaults on anything about this codebase. # How you work Never guess at file paths. If you do not know where something lives, find it with glob or grep first — one search costs less than a run of wrong guesses, and a wrong guess tells you nothing about where the file actually is. Once you know the path, read the specific file or region you need rather than dumping whole directories. Prefer the dedicated file and search tools over shell equivalents when one fits; bash is for running things. Independent tool calls go in one response, in parallel. When a call depends on the result of another, wait for that result first. Do not re-read a file after editing it: edit_file returns a diff and fails loudly when it cannot apply. A command that does not return on its own — a dev server, a watcher, a tail — is started with background: true and read with process_output; stop it with process_kill. Never leave one in the foreground waiting for a timeout, and never reach for pkill: a pattern you pass it can match the shell running your own command. The file tools reach only the working root. Anything you need to read or write with them — a verification script, a screenshot — goes under .codey/scratch inside the project, which is ignored by git; bash itself can go anywhere, so a throwaway dependency install for a browser driver can live in a temp directory. Carry a task through. Unless the user asked for a plan, asked a question about the code, or is clearly thinking out loud, they want the change made — make it, verify it, and report, rather than describing what you would do. Do not stop at analysis or a partial fix. If a tool returns an error, read it: the error text usually says exactly what to do differently. Do not repeat a failing call unchanged. Use git log and git blame when you need to know why code is the way it is. Never claim the environment lacks something without having looked. "There is no browser here" is a finding from which, ls and npx, not an assumption; the same goes for compilers, databases and test runners. Check, then say what you found. # Economy Every turn sends the whole conversation again, so the cost of a task is mostly its turn count. Do as much as possible per response. Writing a new project means writing several files in one response, not one file per turn: a feature's component, its styles and its test go out together. Reading several screenshots means several read_file calls in one response. A check that can be one bash command — build, then test, then lint — is one command. Keep a plan with todo_write when a task has several distinct steps, when the order matters, or when the user asked for more than one thing — one item in_progress, marked done as you go. Skip it for anything you can finish in a couple of actions. When you are unsure whether there is room for what is left, context_status tells you how full the window is and how many turns you have spent. Write the files you want directly. Do not run a scaffolding generator and then delete and replace what it produced: that is the same work twice. Match the number of files to the size of the project; a small app does not need a stylesheet per component. Do not verify the same thing twice — a flow that passed once in the browser is not run again after a change elsewhere; only the piece that changed is looked at again. Keep tool output short: tail what you need, do not cat what you just wrote. # Scope Do what was asked, at the scope it was asked, and no more. In an existing codebase work with surgical precision: match its style, naming and comment density, and do not rename, move or reformat things the task did not require. Fix problems at the root rather than papering over symptoms. Scope cuts the other way on a new project. When the directory is empty or the user is starting something from scratch, the quality of what you deliver is the deliverable: be ambitious about it. Make it robust at the edges — browser APIs that may be missing in some contexts, empty states, bad input, storage that is unavailable — and make it look and feel finished. The rules below about not adding things guard existing code against drift; they are not a reason to ship a thin version of something new. Do not add features, abstractions, configuration, or error handling beyond what the task needs. Three similar lines beat a premature helper; a bug fix does not need surrounding cleanup. Do not validate what cannot happen — trust internal code and the language's guarantees, and check only at system boundaries. Do not leave backwards-compatibility shims, renamed unused variables, or commented-out code for things you removed: if it is unused, delete it. Default to writing no comments. A comment states a constraint the code cannot show; it never explains what the next line does or why your change is correct. Do not add license or copyright headers. Prefer editing existing files to creating new ones, and do not create planning or analysis documents unless the user asked for one. Do not fix unrelated bugs or failing tests you come across. Mention them in your report instead. Do not commit, create branches, or amend commits unless the user asked you to. # Executing actions with care Weigh the reversibility and blast radius of every action. Local, reversible steps — editing files, running tests, reading anything — are yours to take freely. Anything hard to undo or visible beyond this machine you confirm first: deleting files or branches, force pushes, git reset --hard, git checkout -- or git restore on paths you did not touch, dropping data, killing processes you did not start, modifying CI, pushing code, opening or commenting on pull requests, sending messages, uploading content to third-party services. One approval covers the scope it was given, not every later occurrence. You may be in a dirty worktree. Changes you did not make are the user's work in progress: never revert them, and if they are in files you are editing, read them and work with them. If you notice unexpected changes appearing while you work, stop and ask before continuing. Run git status before any command that could discard uncommitted work, and stash or commit what you find first. Do not bypass safety checks such as commit hooks to get past an obstacle; find the cause. When in doubt, a reversible step — move it aside, rename it, stash it — beats deletion. # Verifying When you change something, verify it by running the project's own tests rather than asserting that it works. Start with the tests closest to what you changed, then widen as confidence grows. Where the codebase has tests and an obvious place for a new one, add it; do not introduce a test suite or a formatter to a project that has none. Run the project's formatter if it has one. Do not fix unrelated failures you meet on the way — report them. Verify the way the user will use it, not only the way that is convenient to test. Test at the level the requirement lives: "survives a reload" is a test that renders, reloads and looks again, not a test of the reducer underneath. Passing unit tests on the logic say nothing about whether the thing works when clicked. Anything with a user interface gets one browser pass before you call it done — one, at the end, on the final build. Find a browser (which chromium chromium-browser google-chrome firefox, the playwright cache under the home directory, npx playwright); if there is one, serve the build and run a single script that drives the real flow — the actions the user will take, in order, against real storage — and saves three screenshots: desktop light, desktop dark, one narrow mobile width. Have the script also report what a picture cannot show, such as horizontal overflow at each width. Read the three screenshots in one response, fix what you see, and re-shoot only the view that changed. Only when there is genuinely no browser do you say so, in the first sentence of your report, and say what you verified instead. On a new project the order is: build and test in place, then the clean-room check — remove the build output and installed dependencies, install from scratch, build and test once more — then the one browser pass on that clean build. Not a browser pass before and another after. # Asking Use the ask_user tool when a decision is genuinely the user's: scope, risk appetite, which of several defensible designs they want. Do not ask about things you can settle by reading the code or by an obvious default. Bundle the questions you have into one call rather than interrupting repeatedly, and ask at the point the decision is needed, before building one of the answers. When an exploratory question is asked of you — what could we do about this, how should we approach that — answer in a few sentences with a recommendation and its main trade-off, and do not implement until the user agrees. # Delegating Subagents cost time and context: each one re-explores, and you then read its report. Delegate work that is genuinely independent, large enough to justify a fresh context window, or that would otherwise flood your own with search results. Do a small bounded sub-task — a few reads, one search, a short edit — yourself. Do not fan several agents out over one modest job, and do not redo or re-verify what you delegated while it runs. A well-briefed single agent beats several loosely-briefed ones: the brief is all it will ever know. # Reporting The user reads your text, not your thinking and rarely your tool output. Say in a sentence what you are about to do before your first tool call, and give a short update when you find something that changes the picture or change direction. Everything the user needs from a turn must be in your final message, after the last tool call. Lead with the outcome. If a test failed, a step was skipped, or something came back different from what you expected, that goes in the first sentence, even when the rest succeeded. Report what you observed, not what a step should have produced: done, fixed and verified are claims that rest on tool output you saw this session. If you did not check, say so. Do not describe partial work as finished, and do not quietly work around a failure so that it looks resolved. Match the size of the answer to the task. A simple question gets a direct answer in plain prose; a small change gets a few sentences; a large one a short walkthrough of what changed and why, grouped by file or concern. Reference code as path:line so the user can jump to it. Do not paste back files you just wrote, and do not tell the user to save or copy anything — they have the files. Suggest natural next steps briefly when there are any; skip it when there are not. No emojis unless asked. Avoid apologies and self-commentary: correct a mistake in a sentence and move on.` // ClaudeCodeIdentity is what an OAuth-authenticated request must present as // its first system block. It is a fixed string, so it costs no cache hit: the // prefix is still constant for the session. const ClaudeCodeIdentity = "You are Claude Code, Anthropic's official CLI for Claude." // skillListing is appended to the system prompt when skills were discovered. // It is constant for the session, so the cached prefix still matches request to // request: what breaks caching is content that varies per request, not content // that varies per session. func skillListing(found []skills.Skill) string { if len(found) == 0 { return "" } var b strings.Builder b.WriteString("\n\n# Skills\n\nSkills are instruction sets for specific kinds of work. Invoke one with\nthe skill tool when its description matches what you are doing — before\nstarting, not after. A user message beginning with / is a request to\ninvoke that skill; everything after the name is its arguments.\n\nAvailable skills:") for _, s := range found { fmt.Fprintf(&b, "\n %s: %s", s.Name, s.Description) } return b.String() } // modelListing tells the agent which models it may put a subagent on. Like the // skill listing it is constant for the session, so the cached prefix still // matches request to request. // // The newest is named as such because that is the rule: reach for it unless // the user asked for something else or it has been failing. An agent guessing // at model ids is how a subagent dies before its first turn on an id this // endpoint never served. func modelListing(models []llm.Model) string { if len(models) == 0 { return "" } // Newest first. The API answers in that order already; sorting is for the // endpoints that do not. sorted := append([]llm.Model(nil), models...) sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) }) var b strings.Builder b.WriteString("\n\n# Models\n\nModels this endpoint serves, newest first:") for i, m := range sorted { fmt.Fprintf(&b, "\n %s", m.ID) if i == 0 { b.WriteString(" (the newest)") } } b.WriteString("\n\nWhen you put a subagent on a model, use the newest one. Choose differently\n" + "only when the user asked for a particular model, or when the newest has already\n" + "failed for you this session — a subagent on a model this endpoint will not serve\n" + "dies before its first turn and reports nothing back. Naming no model at all runs\n" + "it on whatever this session is currently using, which is never a wrong answer.") return b.String() } // Options configures an Agent. type Options struct { // MaxTurns caps one Run. Zero or less is NoTurnLimit: the loop runs until // the model is done, the window is full, or the context is cancelled. MaxTurns int System string // SystemAppend is added to the end of the system prompt, whichever one // that is — `--append-system-prompt`, for a script that wants to add a // house rule without replacing the instructions on how to work. It is // constant for the session, so it costs no cache hit. SystemAppend string // Effort maps to output_config.effort. Empty means the API default (high). Effort string // ContextWindow, CompactAt and CompactFloor control when the conversation // gets folded into a summary: CompactAt is the ordinary threshold, // CompactFloor the fraction past which compaction stops being optional. // Zero means the defaults. ContextWindow int CompactAt float64 CompactFloor float64 // NoAutoCompact turns the automatic fold off entirely. The window then // becomes a hard stop at CompactFloor rather than a trigger, and /compact // is the only thing that makes room. NoAutoCompact bool // NoCache disables prompt cache breakpoints, for measuring what they buy. NoCache bool // Stream uses the SSE endpoint so output appears as it is produced. Stream bool // Instructions is the project's standing instructions — the CLAUDE.md // files, already formatted as a section of the system prompt — appended // after Codey's own. Read once at startup by whoever builds the agent, so // it is constant for the session like everything else in the prefix. // Empty leaves the prompt as it was: the model is still told to read a // CLAUDE.md or AGENTS.md for itself. Instructions string // Skills are the instruction sets discovered at startup. Their names and // descriptions are appended to the system prompt; the bodies are loaded on // demand by the skill tool. Skills []skills.Skill // Models are what this endpoint serves, for the agent to choose between // when it delegates work to a subagent. Empty means the listing is left // out — an endpoint that does not answer GET /v1/models is not one to // make claims about. // // It goes only to the agent that can spawn: a subagent has no such choice // to make, and the paragraph would be dead weight in its prompt. Models []llm.Model // SystemPrefix is prepended as a separate system block ahead of the // Codey's own system prompt. It carries the identity string an OAuth credential // requires, and is empty for every other credential. SystemPrefix string // ServerTools are tool definitions the API executes itself — hosted web // search, so far. Nothing in the local registry runs them: the model's // call comes back as server_tool_use, the API's answer as a result block // in the same response, and the loop's only job is to show both and send // them back unchanged. They are appended after the local tools and so sit // inside the cached prompt prefix, which means they must be constant for // the session — changing one mid-run costs a cache miss on every request // after it. ServerTools []llm.ToolDef } // EventKind identifies what happened, for an observer to render. type EventKind string const ( EventTurnStart EventKind = "turn_start" EventText EventKind = "text" EventToolStart EventKind = "tool_start" EventToolResult EventKind = "tool_result" EventUsage EventKind = "usage" EventCompact EventKind = "compact" // EventNotice is the loop telling the user something about the run itself // rather than about the task — so far, only that the window is full and // compaction can do nothing more about it. EventNotice EventKind = "notice" // EventTextDelta is emitted instead of EventText while streaming. EventTextDelta EventKind = "text_delta" // EventThinking is the reasoning the model showed before answering, and // EventThinkingDelta is the same thing arriving piece by piece while // streaming. Only models asked for extended thinking produce either. // // Redacted thinking is not reported at all: it is encrypted bytes that go // back to the API untouched, with nothing in them to show. EventThinking EventKind = "thinking" EventThinkingDelta EventKind = "thinking_delta" // EventToolDelta is output a tool has produced but not finished producing: // the lines of a test run as they appear, rather than all of them at the // end. Text carries the new bytes, not everything so far, and Tool and ID // say which call they belong to. // // Only tools that opt into it produce these, and only while someone is // watching — see [tools.WithProgress]. A front end that ignores this kind // still shows the whole output when the call finishes. EventToolDelta EventKind = "tool_delta" // EventPaused is emitted when the loop stops at a turn boundary, and // EventResumed when it is let go again. EventPaused EventKind = "paused" EventResumed EventKind = "resumed" // EventMessage is a message joining the history: each complete response // from the model, with Response set, and each batch of tool results that // answers one, with Message set. It is for a front end that reports the // conversation itself rather than drawing it — `-p --output-format // stream-json` prints one line per message — and everything else can // ignore it, because the same content has already arrived as the events // above. EventMessage EventKind = "message" ) // Event is what the loop reports to an observer. It carries plain data only — // no terminal concerns — so the eval runner can consume the same stream. type Event struct { Kind EventKind Turn int Text string Tool string // ID pairs a tool_start with the tool_result that answers it: the model's // own tool_use id, echoed on both. Dispatch is sequential, so a front end // can get away with pairing by arrival order — but a protocol that keys // tool calls by id, as ACP does, needs the id rather than a promise about // the order things arrive in. ID string Input json.RawMessage Result string IsError bool Usage llm.Usage // Response and Message are EventMessage's: the model's whole response, // or the user message of tool results the loop sent back. Exactly one is // set. Response *llm.Response Message *llm.Message } // Approver decides whether a tool call may proceed. It is an interface here so // the agent package stays free of any particular policy or interface. type Approver interface { Approve(ctx context.Context, tool string, input json.RawMessage) error } // Agent runs the turn loop. type Agent struct { client *llm.Client tools *tools.Registry state *State log *metrics.Logger opts Options // Observe is called for every event. It may be nil. Observe func(Event) // Hooks publishes the lifecycle to anything outside the process that // wants it — a multiplexer showing whether this pane is working or stuck, // a notifier, a script. It is deliberately not Observe: that one carries // text deltas and tool payloads for whoever is drawing the screen, and // there is only ever one of it. A nil *Hooks drops everything, which is // what a session nobody is watching from outside costs. Hooks *hooks.Hooks // Approve gates mutating tool calls. Nil means everything is allowed, // which is only appropriate against a throwaway tree or in a sandbox. Approve Approver // lastInputTokens is the size of the previous request as the API measured // it, cached and uncached parts together. lastInputTokens int // mu guards the settings a front end can change while the loop is // running: the effort level, and the pause gate below. mu sync.Mutex // resume is non-nil exactly while the agent is paused, and is closed to // release it. A channel rather than a flag because the loop has to be able // to wait on it alongside the context. resume chan struct{} } // Pause asks the loop to stop at the next turn boundary. It is safe to call // from another goroutine and from inside an Observe callback, and calling it // twice is the same as calling it once. func (a *Agent) Pause() { a.mu.Lock() defer a.mu.Unlock() if a.resume == nil { a.resume = make(chan struct{}) } } // Resume releases a paused loop. Resuming an agent that is not paused does // nothing. func (a *Agent) Resume() { a.mu.Lock() ch := a.resume a.resume = nil a.mu.Unlock() if ch != nil { close(ch) } } // Paused reports whether the loop is being held. func (a *Agent) Paused() bool { a.mu.Lock() defer a.mu.Unlock() return a.resume != nil } // hold blocks while the agent is paused. It is called at the top of each turn // and nowhere else: between turns the conversation is consistent, where // mid-turn there is a tool_use block whose tool_result has not been produced // yet — and a request missing one is an API error, not a pause. func (a *Agent) hold(ctx context.Context, turn int) error { a.mu.Lock() ch := a.resume a.mu.Unlock() if ch == nil { return ctx.Err() } a.emit(Event{Kind: EventPaused, Turn: turn}) a.log.Log(metrics.Event{Type: "paused", Turn: turn}) a.fire(ctx, hooks.Paused, hooks.Payload{Turn: turn}) select { case <-ch: a.emit(Event{Kind: EventResumed, Turn: turn}) a.log.Log(metrics.Event{Type: "resumed", Turn: turn}) a.fire(ctx, hooks.Resumed, hooks.Payload{Turn: turn}) return nil case <-ctx.Done(): // The agent is going away while held. Nothing resumed it, so the flag // stays set; the run is over either way. return ctx.Err() } } // SetEffort changes the effort level the turns that follow will ask for. An // empty level means the API's own default. func (a *Agent) SetEffort(level string) { a.mu.Lock() defer a.mu.Unlock() a.opts.Effort = level } // Effort reports the level currently in force. func (a *Agent) Effort() string { a.mu.Lock() defer a.mu.Unlock() return a.opts.Effort } // SetTools swaps the tool set the turns that follow will use. It exists so // /worktree can move the work into another directory: each tool captures the // root it was built with, so pointing the agent elsewhere means handing it // tools built against the new root rather than editing a field. // // In practice the swap lands between turns. It is guarded anyway, because the // loop reads a.tools while it runs and a front end is under no obligation to // wait for a quiet moment. func (a *Agent) SetTools(reg *tools.Registry) { if reg == nil { return } a.mu.Lock() defer a.mu.Unlock() a.tools = reg } // registry reads the current tool set. Every use goes through here rather than // touching a.tools directly, because SetTools can replace it from another // goroutine partway through a turn. func (a *Agent) registry() *tools.Registry { a.mu.Lock() defer a.mu.Unlock() return a.tools } // New wires an agent. state and log may be freshly created by the caller so the // eval runner can keep one per run. func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Logger, opts Options) *Agent { if opts.System == "" { opts.System = defaultSystem } opts.System += opts.Instructions opts.System += skillListing(opts.Skills) opts.System += modelListing(opts.Models) if opts.SystemAppend != "" { opts.System += "\n\n" + opts.SystemAppend } return &Agent{client: client, tools: reg, state: state, log: log, opts: opts} } // State exposes the conversation for callers that need to inspect or persist it. func (a *Agent) State() *State { return a.state } // ContextReport is what the context_status tool reports: how full the window // is, how many turns it took and what that cost. The model cannot see any of // it otherwise, and all three change how it should work — a session near the // fold should finish and report rather than start something new. // // lastInputTokens is read without the mutex because a tool only runs from // dispatch, which is on the turn loop's own goroutine — the same one that // writes it. func (a *Agent) ContextReport() tools.ContextReport { window, at, _ := a.thresholds() usage, turns := a.state.Usage() cost, known := usage.Cost(a.client.Model()) return tools.ContextReport{ UsedTokens: a.lastInputTokens, WindowTokens: window, CompactAt: at, Turns: turns, CostUSD: cost, CostKnown: known, } } func (a *Agent) emit(ev Event) { if a.Observe != nil { a.Observe(ev) } } // fire publishes a lifecycle event. It is nil-safe twice over — a nil Hooks // and a Hooks with nothing registered both return immediately — so the loop // calls it unconditionally rather than testing first. func (a *Agent) fire(ctx context.Context, ev hooks.Event, p hooks.Payload) { p.Event = ev a.Hooks.Fire(ctx, p) } // Run sends one user message and drives the tool loop until the model stops // asking for tools. It returns the final assistant text. func (a *Agent) Run(ctx context.Context, prompt string) (string, error) { return a.RunBlocks(ctx, []llm.ContentBlock{llm.TextBlock(prompt)}) } // RunBlocks is Run for a message that is not only text — a prompt with images // pasted into it arrives as several blocks, and the order matters, because // "what is wrong here?" after a screenshot is about that screenshot. func (a *Agent) RunBlocks(ctx context.Context, content []llm.ContentBlock) (string, error) { if len(content) == 0 { return "", errors.New("nothing to send: the message is empty") } // Checked before the prompt joins the history, so a turn refused for want // of room leaves the conversation exactly as it was: compacting and asking // again then sends the prompt once, not twice. if err := a.contextFull(a.lastInputTokens); err != nil { return "", err } a.state.Append(llm.Message{Role: llm.RoleUser, Content: content}) return a.drive(ctx) } // Continue drives the loop on the conversation as it already stands, for a // message the front end put there itself — a subagent's report, which is not // something the user typed and should not be glued onto what they type next. func (a *Agent) Continue(ctx context.Context) (string, error) { if a.state.Len() == 0 { return "", errors.New("nothing to continue: the conversation is empty") } return a.drive(ctx) } func (a *Agent) drive(ctx context.Context) (string, error) { // last is the most recent thing the model said. It is kept so that a run // which ends against a cap still hands back what it had got to: the work is // in the history either way, and returning "" would make a long run look // like a failed one to anything reading the return value — a subagent's // report, most of all. var last string // A run is the unit an onlooker cares about: from here until the model // stops asking for tools, this agent is busy. The end is deferred so that // every way out — the turn cap, a refusal, an interrupt — still says so. // An agent left marked busy because the run ended down an error path is // exactly the wrong state to leave in someone's sidebar. a.fire(ctx, hooks.RunStart, hooks.Payload{}) defer func() { a.fire(ctx, hooks.RunEnd, hooks.Payload{}) }() for turn := 1; a.opts.MaxTurns <= NoTurnLimit || turn <= a.opts.MaxTurns; turn++ { // A pause is honoured before the turn is announced, let alone spent: // a held agent has not started this turn, and anything counting turns // would otherwise show one that never happened. if err := a.hold(ctx, turn); err != nil { return "", err } a.emit(Event{Kind: EventTurnStart, Turn: turn}) a.fire(ctx, hooks.TurnStart, hooks.Payload{Turn: turn}) // Compaction is checked before building the request, using the token // count the API reported last turn — free and exact, where counting // locally would be neither. if _, err := a.maybeCompact(ctx, a.lastInputTokens); err != nil { return "", err } resp, err := a.send(ctx, turn) if err != nil { return "", err } a.state.AddUsage(resp.Usage) a.lastInputTokens = resp.Usage.InputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.CacheCreationInputTokens a.log.Log(metrics.Event{ Type: "turn", Turn: turn, StopReason: resp.StopReason, InputTokens: resp.Usage.InputTokens, OutputTokens: resp.Usage.OutputTokens, CacheReadTokens: resp.Usage.CacheReadInputTokens, CacheWriteTokens: resp.Usage.CacheCreationInputTokens, }) a.emit(Event{Kind: EventUsage, Turn: turn, Usage: resp.Usage}) // The assistant message goes into history verbatim, thinking blocks and // all: their signatures must come back unchanged on the next request. a.state.Append(resp.Message()) a.emit(Event{Kind: EventMessage, Turn: turn, Response: resp}) // Hosted tool calls are shown here, once the response is complete, // on both the streaming and the plain path. They never reach dispatch // — the API ran them — so without this the user would watch the model // go quiet for a search and see nothing of what it asked or found. a.emitServerTools(turn, resp) // While streaming both of these have already gone out delta by delta. // Thinking goes first because that is the order it was produced in: // the model reasons and then answers, and a front end that showed them // the other way round would be describing a conversation that did not // happen. if !a.opts.Stream { if thinking := resp.Thinking(); thinking != "" { a.emit(Event{Kind: EventThinking, Turn: turn, Text: thinking}) } } if text := resp.Text(); text != "" { last = text if !a.opts.Stream { a.emit(Event{Kind: EventText, Turn: turn, Text: text}) } } switch resp.StopReason { case llm.StopToolUse: results, err := a.dispatch(ctx, turn, resp.ToolUses()) if err != nil { // The turn was stopped part-way through its tool calls — esc, // ctrl-c, a killed subagent. The assistant message asking for // them is already in the history, so leaving now would strand // a tool_use nothing ever answers, and the API refuses every // request that follows over it: the next prompt, or a resume // weeks later. Close the calls out before going. a.closeInterruptedTurn(results) return "", err } // Every tool_result for this assistant turn goes back in ONE user // message. Splitting them across messages is an API error, and even // where it is not it teaches the model to stop calling tools in // parallel. answer := llm.Message{Role: llm.RoleUser, Content: results} a.state.Append(answer) a.emit(Event{Kind: EventMessage, Turn: turn, Message: &answer}) case llm.StopPauseTurn: // A server-side tool needs another round trip; resend as-is. Hosted // web search is what produces this: the API has stopped part-way // through searching and wants the conversation back, the // server_tool_use and web_search_tool_result blocks it just // produced included, with nothing added. In particular no // tool_result — the API supplied the result itself, and a local // one for a hosted id is an error. continue case llm.StopRefusal: return "", refusalError(resp) case llm.StopMaxTokens: return resp.Text(), fmt.Errorf("response hit max_tokens (%d) and was cut off", a.client.MaxTokens) default: return resp.Text(), nil } } return last, fmt.Errorf("%w: stopped after the %d turns asked for without finishing; "+ "the conversation is intact — say what to do next, or raise -max-turns", ErrMaxTurns, a.opts.MaxTurns) } // send performs one model call, streaming when asked to. func (a *Agent) send(ctx context.Context, turn int) (*llm.Response, error) { req := a.request() if !a.opts.Stream { return a.client.CreateMessage(ctx, req) } return a.client.StreamMessage(ctx, req, func(ev llm.StreamEvent) { switch ev.Kind { case llm.StreamText: a.emit(Event{Kind: EventTextDelta, Turn: turn, Text: ev.Text}) case llm.StreamThinking: a.emit(Event{Kind: EventThinkingDelta, Turn: turn, Text: ev.Text}) } }) } // dispatch runs each tool call in order and builds one tool_result per // tool_use id. A tool that fails still produces a result — a missing one is an // API error, and the model can usually recover from the error text. func (a *Agent) dispatch(ctx context.Context, turn int, calls []llm.ContentBlock) ([]llm.ContentBlock, error) { results := make([]llm.ContentBlock, 0, len(calls)) for _, call := range calls { a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: call.Name, ID: call.ID, Input: call.Input}) a.fire(ctx, hooks.ToolStart, hooks.Payload{Turn: turn, Tool: call.Name}) out, err := a.authorizedExecute(a.watching(ctx, turn, call), call) if ctx.Err() != nil { // The results collected so far travel out with the error: the // calls that did finish keep what they returned, and the caller // closes out the ones that did not. return results, ctx.Err() } isErr := err != nil payload := out.Text if isErr { payload = err.Error() } // A file that was just changed makes every earlier read of it wrong, // not merely stale. Blank those results rather than leave the model // reasoning from contents that no longer exist. if !isErr && (call.Name == "edit_file" || call.Name == "write_file") { if p := editedPath(call.Input); p != "" { if n := a.state.SupersedeReads(p); n > 0 { a.log.Log(metrics.Event{Type: "supersede_reads", Tool: call.Name, Extra: map[string]any{"path": p, "results_blanked": n}}) } } } // The model gets the blocks when there are any — a picture — and the // text otherwise. The transcript and the hooks only ever see text. if !isErr && len(out.Blocks) > 0 { results = append(results, llm.ToolResultBlocks(call.ID, out.Blocks)) } else { results = append(results, llm.ToolResultBlock(call.ID, payload, isErr)) } a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, ID: call.ID, Result: payload, IsError: isErr}) // The result itself is not published: a hook is a bystander, and what // a tool read out of this repository is not a bystander's business. // Whether it failed is, because that is what a watcher acts on. done := hooks.Payload{Turn: turn, Tool: call.Name} if isErr { done.Err = firstLine(payload) } a.fire(ctx, hooks.ToolResult, done) } return results, nil } // emitServerTools reports the hosted tool calls in a response as the same // tool_start/tool_result pairs a local call produces, so the TUI and the line // renderer — which are generic over tool names — show a web search with no // changes of their own. Only events are produced here: no registry call and no // tool_result block, because the API ran the tool and put its result in the // response already. // // Results are matched to their call by tool_use_id rather than by position, // because the API is free to interleave text between the two. func (a *Agent) emitServerTools(turn int, resp *llm.Response) { names := make(map[string]string) for _, b := range resp.Content { switch b.Type { case llm.BlockServerToolUse: names[b.ID] = b.Name a.emit(Event{Kind: EventToolStart, Turn: turn, Tool: b.Name, ID: b.ID, Input: b.Input}) case llm.BlockWebSearchToolResult: name, ok := names[b.ToolUseID] if !ok { // The call this answers was in an earlier response — a // pause_turn split them. The block type says what it was. name = llm.WebSearchToolName } summary, isErr := summarizeWebSearchResult(b) a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: name, ID: b.ToolUseID, Result: summary, IsError: isErr}) } } } // summarizeWebSearchResult turns a web_search_tool_result into the one line // per hit a person can scan — "Title — url" — and reports whether the block // is an error. The full payload is encrypted page content the API keeps for // the model's citations; it is meaningless to a reader and large, so none of // it is shown. It reads the raw block because the struct models content as a // string and this block's content is an array or an error object. func summarizeWebSearchResult(b llm.ContentBlock) (string, bool) { var block struct { Content json.RawMessage `json:"content"` } if json.Unmarshal(b.Raw(), &block) != nil || len(block.Content) == 0 { return "web search returned an unrecognised result", true } var failure struct { Type string `json:"type"` ErrorCode string `json:"error_code"` } if json.Unmarshal(block.Content, &failure) == nil && failure.ErrorCode != "" { return "web search failed: " + failure.ErrorCode, true } var results []struct { Title string `json:"title"` URL string `json:"url"` } if json.Unmarshal(block.Content, &results) != nil { return "web search returned an unrecognised result", true } if len(results) == 0 { return "(no results)", false } lines := make([]string, 0, len(results)) for _, r := range results { switch { case r.Title != "" && r.URL != "": lines = append(lines, r.Title+" — "+r.URL) case r.URL != "": lines = append(lines, r.URL) default: lines = append(lines, r.Title) } } return strings.Join(lines, "\n"), false } // watching attaches a progress sink to the context a tool runs under, so a // long command's output reaches the front end while it is still running rather // than in one lump at the end. // // The sink is installed only when somebody is observing: a tool that finds // nothing on the context does no extra copying, which keeps the eval runner // and the tests on exactly the path they had before. The mutex is not // decoration — a command's stdout and stderr are copied by separate // goroutines, and an observer written for the loop's own goroutine has no // reason to expect two calls at once. func (a *Agent) watching(ctx context.Context, turn int, call llm.ContentBlock) context.Context { if a.Observe == nil { return ctx } var mu sync.Mutex return tools.WithProgress(ctx, func(chunk string) { mu.Lock() defer mu.Unlock() a.emit(Event{Kind: EventToolDelta, Turn: turn, Tool: call.Name, ID: call.ID, Text: chunk}) }) } // authorizedExecute runs a tool once the approval policy allows it. A denial // comes back as an ordinary tool error: the model should read it and change // course, not see Codey crash. func (a *Agent) authorizedExecute(ctx context.Context, call llm.ContentBlock) (tools.Output, error) { if a.Approve != nil { if err := a.Approve.Approve(ctx, call.Name, call.Input); err != nil { if ctx.Err() != nil { return tools.Output{}, ctx.Err() } a.log.Log(metrics.Event{Type: "tool_denied", Tool: call.Name, Err: err.Error()}) return tools.Output{}, err } } return a.registry().Execute(ctx, call.Name, call.Input) } // request builds the body. Order matters for prompt caching: system, then // tools, then the growing conversation. func (a *Agent) request() llm.Request { reg := a.registry() defs := make([]llm.ToolDef, 0, len(reg.List())) for _, t := range reg.List() { defs = append(defs, llm.ToolDef{ Name: t.Name(), Description: t.Description(), InputSchema: t.InputSchema(), }) } // Hosted tools go after the local ones. The registry list is sorted so the // cached prefix is byte-stable; a constant tail appended to a stable list // is still stable, where interleaving would depend on the sort. defs = append(defs, a.opts.ServerTools...) messages := a.state.History() if !a.opts.NoCache { // Two breakpoints. The first caches the tools and system prompt, which // never change. The second rolls forward over the conversation each // turn, so every turn writes a small delta and reads a large prefix. if n := len(messages); n > 0 { if blocks := messages[n-1].Content; len(blocks) > 0 { messages[n-1].Content = append([]llm.ContentBlock(nil), blocks...) messages[n-1].Content[len(blocks)-1].CacheControl = llm.Ephemeral() } } } req := llm.Request{ System: a.systemBlocks(), Messages: messages, Tools: defs, Thinking: a.thinking(), } // Effort is a per-model capability, not a universal parameter: a model // that does not take this level rejects the whole request with a 400 // rather than ignoring the field. Dropping it here is what lets a cheap // subagent run under a session set to a level its model never had. if effort := a.Effort(); effort != "" && llm.SupportsEffort(a.modelID(), effort) { req.OutputConfig = &llm.OutputConfig{Effort: effort} } return req } // thinking is the thinking parameter for the model this agent is running on, // or nil — which the field omits — when that model does not take one. It is a // method rather than a constant because a subagent runs the same loop against a // cloned client on its own model: the explore preset pins claude-haiku-4-5, // which 400s on adaptive thinking, so asking the client which model it is // pointed at is the only way to get this right for both. func (a *Agent) thinking() *llm.Thinking { if !llm.SupportsAdaptiveThinking(a.modelID()) { return nil } return llm.Adaptive() } // modelID is the model this agent's requests ask for. The tests that check the // shape of a request build an agent with no client at all, so this answers for // them rather than making every per-model decision guard its own nil. func (a *Agent) modelID() string { if a.client == nil { return "" } return a.client.Model() } // systemBlocks builds the system prompt. The cache breakpoint goes on the last // block so everything ahead of it — tools, identity, prompt — is cached // together; on the first block it would cache the identity string and re-read // the rest every turn. func (a *Agent) systemBlocks() []llm.SystemBlock { var blocks []llm.SystemBlock if a.opts.SystemPrefix != "" { blocks = append(blocks, llm.SystemBlock{Type: "text", Text: a.opts.SystemPrefix}) } blocks = append(blocks, llm.SystemBlock{Type: "text", Text: a.opts.System}) if !a.opts.NoCache { blocks[len(blocks)-1].CacheControl = llm.Ephemeral() } return blocks } // editedPath pulls the path out of an edit_file or write_file call. func editedPath(input json.RawMessage) string { var args struct { Path string `json:"path"` } if json.Unmarshal(input, &args) != nil { return "" } return args.Path } func refusalError(resp *llm.Response) error { var b strings.Builder b.WriteString("the model declined this request") if resp.StopDetails != nil { if resp.StopDetails.Category != "" { fmt.Fprintf(&b, " (%s)", resp.StopDetails.Category) } if resp.StopDetails.Explanation != "" { fmt.Fprintf(&b, ": %s", resp.StopDetails.Explanation) } } return errors.New(b.String()) }