package agent import ( "context" "encoding/json" "errors" "fmt" "strings" "harness/internal/llm" "harness/internal/metrics" "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.` // 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 } // 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" ) // 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 } // 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) } // 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 } 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)}}) for turn := 1; turn <= a.opts.MaxTurns; turn++ { a.emit(Event{Kind: EventTurnStart, Turn: turn}) resp, err := a.client.CreateMessage(ctx, a.request()) if err != nil { return "", err } a.state.AddUsage(resp.Usage) 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()) if text := resp.Text(); text != "" { 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) } // 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.tools.Execute(ctx, call.Name, call.Input) if ctx.Err() != nil { return nil, ctx.Err() } isErr := err != nil payload := out if isErr { payload = err.Error() } 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 } // 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(), }) } req := llm.Request{ System: []llm.SystemBlock{{Type: "text", Text: a.opts.System}}, Messages: a.state.History(), Tools: defs, Thinking: llm.Adaptive(), } if a.opts.Effort != "" { req.OutputConfig = &llm.OutputConfig{Effort: a.opts.Effort} } return req } 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()) }