package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"harness/internal/llm"
"harness/internal/metrics"
"harness/internal/skills"
"harness/internal/tools"
)
// DefaultMaxTurns caps one Run. An agent that loops without converging burns
// money quickly, so the cap is low and the message when it trips is explicit.
const DefaultMaxTurns = 30
// ErrMaxTurns is returned when the turn cap 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.
const defaultSystem = `You are a coding agent operating on a local repository through tools.
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.
When you change something, verify it by running the project's own tests rather
than asserting that it works.
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.
Be concise. When the task is done, say what you changed and why, briefly.`
// 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\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 /<name> 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\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 int
System string
// Effort maps to output_config.effort. Empty means the API default (high).
Effort string
// ContextWindow and CompactAt control when the conversation gets folded
// into a summary. Zero means the defaults.
ContextWindow int
CompactAt float64
// 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
// 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
// harness prompt. It carries the identity string an OAuth credential
// requires, and is empty for every other credential.
SystemPrefix string
}
// 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"
// EventTextDelta is emitted instead of EventText while streaming.
EventTextDelta EventKind = "text_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"
)
// 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
Input json.RawMessage
Result string
IsError bool
Usage llm.Usage
}
// 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)
// 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})
select {
case <-ch:
a.emit(Event{Kind: EventResumed, Turn: turn})
a.log.Log(metrics.Event{Type: "resumed", 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
}
// 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.MaxTurns <= 0 {
opts.MaxTurns = DefaultMaxTurns
}
if opts.System == "" {
opts.System = defaultSystem
}
opts.System += skillListing(opts.Skills)
opts.System += modelListing(opts.Models)
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 }
func (a *Agent) emit(ev Event) {
if a.Observe != nil {
a.Observe(ev)
}
}
// 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) {
a.state.Append(llm.Message{Role: llm.RoleUser, Content: []llm.ContentBlock{llm.TextBlock(prompt)}})
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) {
for turn := 1; 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})
// 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())
// While streaming the text has already gone out delta by delta.
if text := resp.Text(); text != "" && !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 {
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.
a.state.Append(llm.Message{Role: llm.RoleUser, Content: results})
case llm.StopPauseTurn:
// A server-side tool needs another round trip; resend as-is.
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 "", fmt.Errorf("%w: stopped after %d turns without finishing; narrow the task 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) {
if ev.Kind == llm.StreamText {
a.emit(Event{Kind: EventTextDelta, 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, Input: call.Input})
out, err := a.authorizedExecute(ctx, call)
if ctx.Err() != nil {
return nil, ctx.Err()
}
isErr := err != nil
payload := out
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}})
}
}
}
results = append(results, llm.ToolResultBlock(call.ID, payload, isErr))
a.emit(Event{Kind: EventToolResult, Turn: turn, Tool: call.Name, Result: payload, IsError: isErr})
}
return results, nil
}
// 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 a harness crash.
func (a *Agent) authorizedExecute(ctx context.Context, call llm.ContentBlock) (string, error) {
if a.Approve != nil {
if err := a.Approve.Approve(ctx, call.Name, call.Input); err != nil {
if ctx.Err() != nil {
return "", ctx.Err()
}
a.log.Log(metrics.Event{Type: "tool_denied", Tool: call.Name, Err: err.Error()})
return "", err
}
}
return a.tools.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 {
defs := make([]llm.ToolDef, 0, len(a.tools.List()))
for _, t := range a.tools.List() {
defs = append(defs, llm.ToolDef{
Name: t.Name(),
Description: t.Description(),
InputSchema: t.InputSchema(),
})
}
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())
}