Help us shape the future of Go! Take the 2026 Go Developer Survey

turn

package
v0.4.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 25 Imported by: 0

Documentation

Overview

Builds the streaming provider.Request from the active model selection and the session's text conversation history.

Bounded, classification-driven auto-retry of transient provider-round failures: the per-turn retry loop around a single provider round, the full-jitter exponential backoff and Retry-After honoring, and the injectable clock/sleep seams that keep backoff, budget, and cancellation testable without wall-clock sleeps. Handoff retries stop before dispatch begins, so completed tool dispatch is never replayed.

Development-only suggest_followup trace: the turn loop's seams call appendSuggestFollowupDebug to record how far an intercepted-followup attempt got, gated by Deps.SuggestFollowupDebug. It complements the TUI-side ghost stages emitted from internal/tui so one session log shows the whole suggest_followup path.

Recognizes a tool call a small/open model emits as assistant text (a JSON object, optionally inside one code fence) instead of as a structured tool call, and turns it into the synthetic streamed tool-call delta the agentic loop already assembles and dispatches (docs/ibm-model-issues.md).

Auto-title helper: asks an injected provider client for a short session title from the opening exchange, building its own narrow streaming request rather than reusing the full turn request builder.

Agentic tool-call iteration for one assistant turn: assemble the streamed tool-call deltas the stream loop hands off, persist the assistant tool-call message, schedule and dispatch the calls through the scheduler and an injected dispatcher seam, persist the tool results, and re-issue the turn until the model returns a final no-tool-call answer.

Package turn runs a single cancellable streamed provider turn: it turns a submitted prompt into a streamed model reply, emits the turn events, records the assistant message, and exposes a cancel handle. Its collaborators (provider client, delta sink, flush hooks, tool-call hook) are injected so the package stays free of internal/tui and internal/bootstrap (no import cycle); bootstrap wires the production bindings.

Index

Constants

This section is empty.

Variables

View Source
var ErrNoModelSelected = errors.New("turn: no model selected; refuse the turn")

ErrNoModelSelected is returned by Run when no model is selected: the turn is refused before any provider request or turn event is issued.

Functions

func BuildCompactionSummaryRequest

func BuildCompactionSummaryRequest(sel model.Selection, records []sessions.Record) provider.Request

func BuildRequest

func BuildRequest(active *model.ActiveState, store *sessions.Store, systemPrompt string, tools []provider.ToolDefinition) (req provider.Request, sel model.Selection, source model.Source, ok bool)

BuildRequest builds the streaming provider.Request for the active model selection and the session's text conversation history. systemPrompt is the assembled system message — the active mode's base prompt, the environment block, and the loaded AGENTS.md instructions: when non-empty it is prepended as a single synthetic "system" message before the conversation history so each request leads with it. An empty systemPrompt adds no message — defensive only, since production assembly always yields the non-empty base + environment sections. ok is false when no model is selected (ActiveState.Current reports not-selected); the caller must then refuse the turn without issuing a provider request, and req/sel/source are zero. Effort is deliberately left unset here: the caller resolves the supported-effort filter via model.EmitTurnStart and sets the filtered value on the returned request.

tools is the turn's already-assembled, mode-filtered advertised tool set: the caller snapshots it once per user turn and passes the same set on every provider round (initial and tool-call continuation) so the advertised set is stable across the turn while a /mode switch only affects the next turn. It is copied onto the request as is; an empty set leaves provider.Request.Tools empty so ChatCompletion omits the `tools`/`tool_choice` fields.

func GenerateTitle

func GenerateTitle(ctx context.Context, client provider.Client, sel model.Selection, firstUser, firstAssistant string, supportedEffort func(providerID, modelID string) []string) string

GenerateTitle asks client for a short title summarizing the opening exchange (firstUser, plus firstAssistant when non-empty) for the active model sel. It builds a narrow streaming request, since some providers refuse non-streaming ones — no tools, no system prompt or history beyond the title instruction — requesting low effort only when the model supports it. It returns "" on a request or stream error, a cancelled context, a missing stream, or a blank candidate, and never a partial title. Title generation is metadata, so it emits no conversation records and writes nothing.

A nil supportedEffort lookup exposes only no-reasoning.

func ToolActivityLines

func ToolActivityLines(calls []AssembledToolCall) []string

Types

type AssembledToolCall

type AssembledToolCall struct {
	ID        string
	Name      string
	Arguments string
}

AssembledToolCall is one complete tool call reconstructed from the streamed per-chunk tool-call fragments the stream loop collects in ToolCallHandoff.ToolCallDeltas. ID identifies the call for tool_call_id correlation; Name is the function name; Arguments is the raw JSON-encoded arguments string exactly as the model produced it.

type Deps

type Deps struct {
	Active           *model.ActiveState
	Store            *sessions.Store
	Client           provider.Client
	Sink             Sink
	Flush            FlushHook
	FlushUnsupported FlushHook
	OnToolCalls      ToolCallHook

	TurnID string

	// A nil SupportedEffort lookup exposes only no-reasoning.
	SupportedEffort func(providerID, modelID string) []string

	// SuggestFollowup routes an intercepted suggest_followup tool call to the
	// TUI: label is the short imperative shown as Tab-to-accept ghost text and prompt is
	// the text prefilled when the user accepts. The agentic loop fires it instead of
	// dispatching or persisting that call, so the suggestion is purely ephemeral UI state
	// and never reaches the session record or transcript. Optional and nil-safe: a
	// standalone Runner or a test that does not wire it simply drops the suggestion (the
	// call is still omitted from the records).
	SuggestFollowup func(label, prompt string)

	// OnToolActivity surfaces a tool-call round's about-to-run calls as live, inline
	// transcript lines, one formatted line per call in assembled order (see
	// ToolActivityLines). The agentic loop fires it once per tool-call round, after the
	// calls are assembled and before they are scheduled/dispatched, so the user sees what
	// is about to run — and a denied call still shows its line. It is derived purely from
	// the already-assembled calls, so it costs no extra provider tokens. Optional and
	// nil-safe: a standalone Runner or a test that does not wire it simply emits nothing.
	OnToolActivity func(lines []string)
	// SealPartial commits a buffered pre-tool reply that OnToolActivity left unsealed. The loop
	// calls it after dispatch unless the time limit cancelled the round, which rolls it back.
	SealPartial func()

	OnEditPatch func(patch string)

	// Progress reports the cumulative retained assistant-content rune count as the
	// reply streams — an approximate in-flight progress signal, not exact tokens
	// (stream usage only arrives in the final chunk). It remains a retained-rune
	// callback for tests and any other consumer, but production no longer feeds it to
	// the token indicator: that indicator is wired to exact per-round Usage only,
	// because retained runes routinely exceed the round's exact output tokens and feeding
	// them in would make the ↓ figure climb then drop (see Usage / UsageAcc). May be nil.
	// It is called in arrival order on the single stream goroutine, like Sink. In an
	// agentic turn ToolLoop wraps this so the reported count is cumulative across provider
	// rounds (prior rounds' retained chars plus the current round's), keeping the live
	// signal from shrinking when a fresh per-round Runner resets its own count to zero.
	Progress func(retainedChars int)
	// Usage reports the exact provider token usage as cumulative input/output totals,
	// feeding the in-flight indicator's exact figure. It fires once per
	// successful round under a ToolLoop and once total for a bare Runner: under a
	// ToolLoop (UsageAcc != nil) the loop owns emission and fires it after every round
	// with the running turn total, so the ↑ input figure reaches each completed round's
	// real prompt cost immediately rather than only at turn end; a bare Runner
	// (UsageAcc == nil) fires it once on the natural non-tool completion that records
	// model_response. May be nil; it is not called when no usage chunk arrived in any
	// round (an interrupted or usage-missing turn). The count it reports is the summed
	// turn total (see UsageAcc), so it stays consistent with /status.
	Usage func(inputTokens, outputTokens int64)

	// UsageAcc accumulates the exact stream usage of every provider round in one
	// agentic turn so the final model_response and the Usage indicator report the
	// summed turn total rather than only the last round. ToolLoop owns one
	// per turn and threads the same pointer through every round's Deps; a standalone
	// Runner (no tool-call iteration) leaves it nil and records only its own round.
	UsageAcc *model.UsageAccumulator

	// SubmitStart is the wall-clock instant the user's input for this turn was
	// accepted at the pre-turn boundary (sessionturn.Persister), threaded through
	// bootstrap so the final provider round can record one end-to-end turn_latency
	// event (user submit → final answer) for the whole agentic turn. The same
	// value is carried onto every round's Deps, but only the final no-tool answer
	// records it, so a multi-round turn yields exactly one turn_latency. The zero
	// value means "not wired" (a standalone Runner or a test that does not exercise
	// turn latency), in which case no turn_latency is recorded.
	SubmitStart time.Time

	// SystemPrompt is the assembled system message — the active mode's base prompt,
	// the environment block, and the loaded AGENTS.md instructions. The bootstrap turn
	// wiring assembles it per dispatch and passes a stable snapshot here, so every
	// provider round in one turn uses the same prompt while a later /mode switch is
	// reflected on the next turn. When non-empty it leads each provider request as a
	// single synthetic system message before the conversation history; empty adds no
	// message.
	SystemPrompt string

	// Tools is the turn's mode-filtered advertised tool set, assembled once by
	// the bootstrap turn wiring per dispatch from the snapshotted active mode and the
	// realized registry plus the product-authored `bash` definition. It is threaded
	// onto every provider round's request through BuildRequest, so the tool-call
	// continuation rounds advertise the same set as the initial round and a /mode switch
	// only affects the next user turn. Empty (the default) advertises no tools, so the
	// request omits the `tools`/`tool_choice` fields.
	Tools []provider.ToolDefinition

	// SuggestFollowupDebug gates the development-only suggest_followup trace: when
	// true the turn loop appends ordered suggest_followup_debug events (advertised /
	// no-call / tool-call-seen / intercepted) to Store so a developer can see how far a
	// suggestion got. Off by default (false), so a normal turn records none of them. It
	// is append-only and best-effort — a missing diagnostic never affects the turn.
	SuggestFollowupDebug bool
	// Mode is the active interaction-mode key (plan/build/auto_edits) for this turn,
	// stamped onto the suggest_followup debug events alongside the provider/model from Active. It is
	// captured by bootstrap at dispatch (the turn package has no mode concept) and is only
	// read when SuggestFollowupDebug is on; empty otherwise.
	Mode string

	// Notice reports a provider/turn request failure as user-facing text (the rendered
	// failure line — for an HTTP error the friendly message, optionally with
	// the remediation hint line). It is called only
	// from the provider-failure paths of Run. The production binding commits it
	// as an in-place transcript line tied to the turn (App.CommitProviderError,
	// marshaled through the tview queue so it is safe from the background turn
	// goroutine) rather than a bottom-pinned notice, so a stale provider error can no
	// longer re-render below a later turn. Session-write failures take the
	// OnFailureWrite path instead and stay on the transient notice surface.
	Notice     func(text string)
	ToolNotice func(text string)
	// RenderNotice renders a provider-failure notice: line 1 is the caller-supplied
	// message (for an HTTP error, the friendly line from FriendlyHTTPMessage;
	// for transport/body failures, the synthetic surface), with the remediation hint
	// appended as line 2 when the (phase, reason, httpStatus) tuple matches the hint
	// table. The production binding is tui.RenderProviderRequestFailedNotice; injecting
	// it keeps this package free of internal/tui while still using the TUI's hint logic.
	RenderNotice func(verbatim, phase, reason string, httpStatus int) string
	// RenderRetryNotice renders the terminal provider-failure notice after auto-retry
	// gives up, enriching the normal notice with the number of attempts that were made
	// while preserving the provider-specific hint behavior. The production binding is
	// tui.RenderProviderRequestFailedAfterAttemptsNotice. Nil defaults to the normal
	// renderer with a generic "failed after N attempts" prefix on line 1.
	RenderRetryNotice func(verbatim, phase, reason string, httpStatus, attempts int) string
	// Interrupt seals the in-progress transcript reply as interrupted. It is
	// called on a real mid-stream transport drop, and on a user interrupt only
	// when the reply was still a held text-tool-call candidate that never reached the
	// Sink — so the ESC/CTRL-C path's own seal found no open stream to close. On an
	// ordinary user interrupt whose content already streamed, that path seals the reply
	// itself and this hook is not called.
	Interrupt func()
	// Finalize commits a naturally completed reply into the transcript so the
	// next turn starts a fresh buffer. It is called only on a successful non-tool
	// stream, after the assistant message and model_response are recorded.
	Finalize func()
	// FinalizeErr reports a failure to deliver what Finalize committed.
	FinalizeErr func() error
	// OnFailureWrite routes a real atomic-write failure from the
	// provider_request_failed flush through the per-turn write-failure
	// protocol (sessionfailures.HandleWriteFailure). It may be nil.
	OnFailureWrite events.WriteFailureHandler

	// Retry is the auto-retry policy for the turn's provider rounds. The
	// zero value (Enabled false) is the no-retry behavior: every provider
	// failure surfaces once, exactly as before. New applies the per-turn budget
	// default when an enabled policy leaves it unset. Production threads it from the
	// provider.retry settings block via bootstrap.
	Retry RetryPolicy
	// RetryBackoff reports that the next retry attempt has been scheduled and the
	// retry loop is entering backoff. nextAttempt is 1-based and names the provider
	// attempt that will run after the wait; totalAttempts is Retry.MaxAttempts. The
	// TUI uses it for the transient "Reconnecting..." indicator. Optional and nil-safe.
	RetryBackoff func(nextAttempt, totalAttempts int)
	// RetryResume reports that backoff completed and the provider round is about to be
	// re-issued, so the TUI can return the activity indicator to its normal working
	// state. Optional and nil-safe.
	RetryResume func()
	// RetryRecovered reports that a provider round completed successfully after one or
	// more scheduled retries. retries is the number of retries that were actually
	// issued, not the total attempt count. Optional and nil-safe.
	RetryRecovered func(retries int)
	// RollbackPartial removes the un-sealed in-progress reply line before a
	// discard-and-restart retry of a partial provider round, so the
	// regenerated answer does not stack on the discarded partial. The backend calls
	// it; the UI owns the visible transcript rollback against this seam. Optional and
	// nil-safe: New defaults it to a no-op, so a standalone Runner or a turn whose
	// stream never partially drops is unaffected.
	RollbackPartial func()

	IgnoredProviders      *IgnoredProviders
	ProviderIgnoredNotice func(text string)

	// OnProviderStreamIdle reports that the active provider round's stream has gone
	// quiet — no chunk for streamIdleThreshold. It fires at most once per provider round,
	// on the turn goroutine, after the durable provider_stream_idle event has been
	// flushed. Optional and nil-safe: New defaults it to a no-op.
	OnProviderStreamIdle func(StreamIdleInfo)
	// OnProviderStreamActivity reports that a chunk arrived after an idle crossing. It
	// fires at most once per round and only after OnProviderStreamIdle. Optional and
	// nil-safe: New defaults it to a no-op.
	OnProviderStreamActivity func()

	OnUserMessageAdded func(text string, parts []sessions.ContentPart)
	// contains filtered or unexported fields
}

Deps gathers the injected collaborators for one provider turn. Active, Store, and Client are required; the remaining hooks default to no-ops when nil (RenderNotice to an identity renderer), so a test opts in only to the behavior it asserts. Sink, Flush, FlushUnsupported, and OnToolCalls are the runtime hooks; Notice, RenderNotice, RenderRetryNotice, Interrupt, Finalize, OnFailureWrite, and the retry UX hooks are failure/status hooks that keep the package free of internal/tui and internal/bootstrap.

type DispatchFailure

type DispatchFailure struct {
	Err       error
	Reason    string
	Tool      string
	SessionID string
}

DispatchFailure classifies a tool-call failure that happened after scheduling began. Error() must stay safe text only — every failure surface prints it — while Unwrap keeps the original error matchable by errors.Is.

func (*DispatchFailure) Error

func (e *DispatchFailure) Error() string

func (*DispatchFailure) Unwrap

func (e *DispatchFailure) Unwrap() error

type FinalFlushError

type FinalFlushError struct{ Err error }

FinalFlushError wraps a flush failure after the provider stream already completed and model_response was appended. Callers can distinguish it from provider/stream errors when deciding whether the visible stream should be sealed.

func (*FinalFlushError) Error

func (e *FinalFlushError) Error() string

func (*FinalFlushError) Unwrap

func (e *FinalFlushError) Unwrap() error

type FlushHook

type FlushHook func() error

FlushHook makes turn records durable at boundaries. The production binding flushes the real session store and routes atomic-write failures through the per-turn write-failure protocol.

type IgnoredProviders

type IgnoredProviders struct {
	// contains filtered or unexported fields
}

func (*IgnoredProviders) Add

func (s *IgnoredProviders) Add(slug string) bool

func (*IgnoredProviders) List

func (s *IgnoredProviders) List() []string

type MoveTarget

type MoveTarget struct {
	Source      string
	Destination string
}

type PendingUserMessage

type PendingUserMessage struct {
	Text  string
	Parts []sessions.ContentPart
}

type PlannedToolCall

type PlannedToolCall struct {
	Call AssembledToolCall

	Deps    []string
	Targets []string

	// ContinueOnError withholds this unit's failure from the scheduler, so concurrent
	// siblings drain instead of being cancelled. The loop still reports the error before
	// persisting anything.
	ContinueOnError bool

	// PreRun runs before Run and skips it on error. It is separate from Run because a
	// wrapping dispatcher may replace Run, and a planner's guard must survive that.
	PreRun func(ctx context.Context) error

	Run func(ctx context.Context) (ToolResult, error)
}

type RetryPolicy

type RetryPolicy struct {
	Enabled     bool
	MaxAttempts int
	BaseDelay   time.Duration
	MaxDelay    time.Duration
	Budget      time.Duration
}

RetryPolicy is the resolved auto-retry configuration for one turn. The zero value is disabled, so a Runner built without a policy behaves exactly as a no-retry runner. Production wiring fills it from the provider.retry settings block (config.Cache.ProviderRetry); MaxAttempts is the total number of attempts including the first.

type RoundReport

type RoundReport struct {
	Outcome         string
	FailurePhase    string
	FailedTool      string
	FailureReason   string
	DispatchStarted bool
}

type Runner

type Runner struct {
	// contains filtered or unexported fields
}

Runner runs one streamed provider turn and exposes the cancel handle the landed tui.StreamCanceller needs. A Runner is single-use: create one per turn.

func New

func New(deps Deps) *Runner

New builds a Runner from deps, defaulting the optional hooks to no-ops.

func (*Runner) Cancel

func (r *Runner) Cancel() (retainedChars int, inFlight bool)

Cancel cancels the in-flight provider request and reports the characters retained for the response so far plus whether a stream was actually in flight, matching the landed tui.StreamCanceller shape (the production wiring adapts it). It is safe to call from another goroutine and is idempotent: once the loop has observed cancellation or completion it returns inFlight=false and cancels nothing.

func (*Runner) InFlight

func (r *Runner) InFlight() bool

func (*Runner) Run

func (r *Runner) Run(ctx context.Context) error

Run executes the plain send→reply turn synchronously: build the request, emit the start-of-turn events once (filtering unsupported effort), and then drive one or more provider-round attempts under the retry policy (runWithRetry → runAttempt). A transient provider-round failure with attempts and budget remaining is auto-retried with bounded jittered backoff; a deterministic, user-actionable failure surfaces in one pass with no retry. The per-attempt failure routing and the natural-completion/tool-call paths are documented on runAttempt. It returns ErrNoModelSelected when no model is selected (no provider call), the flush-hook error when the post-unsupported flush fails (no provider call), and the provider/stream error (or nil) of the final attempt. No failure path emits model_response, and tool-call turns hand off to OnToolCalls.

type Sink

type Sink func(delta string)

Sink receives each assistant content delta as it streams in, in arrival order. The production binding pipes it to the transcript via App.AppendStreamDelta.

type StreamIdleInfo

type StreamIdleInfo struct {
	Provider       string
	ModelID        string
	RoundElapsedMs int64
	IdleMs         int64
	PartialChars   int64
}

StreamIdleInfo is the live idle-crossing payload delivered to OnProviderStreamIdle. It mirrors the durable provider_stream_idle event but is handed over in-process so a consumer can react without reading events back.

type ToolCallHandoff

type ToolCallHandoff struct {
	AssistantText    string
	ToolCallDeltas   [][]openai.ChatCompletionChunkChoiceDeltaToolCall
	Report           *RoundReport
	UpstreamProvider string
}

ToolCallHandoff carries the completed-turn data a tool-call hook needs to take over agentic iteration. AssistantText is the streamed assistant content so far; ToolCallDeltas are the raw per-chunk tool_call fragments collected in arrival order. Assembling and executing them is the tool-call hook's job.

type ToolCallHook

type ToolCallHook func(ctx context.Context, h ToolCallHandoff) error

ToolCallHook intercepts a completed turn whose response requested tool calls so the agentic loop can iterate without rewriting the stream loop. The default is a no-op.

type ToolDispatcher

type ToolDispatcher interface {
	// Plan classifies each call on its own: prepared project-confined structured reads may
	// overlap within a contiguous run, and every other call is a serial barrier that keeps
	// model-call order across the runs on either side of it.
	Plan(calls []AssembledToolCall, maxConcurrency int) ToolPlan
	// Dispatch executes one authorized-or-denied call and returns its tool-result
	// content. A denied call returns the deny envelope as the content with a nil
	// error and ToolResult.Denied set; the loop persists that envelope and its
	// denied_by decides what happens next — a user or unclassifiable denial halts
	// the turn, a recoverable one (permissions.IsRecoverableDeniedBy) re-issues (see ToolResult).
	// A non-nil error is an internal failure that aborts the loop without partial
	// continuation.
	Dispatch(ctx context.Context, call AssembledToolCall) (ToolResult, error)
}

ToolDispatcher is the injected, turn-level seam the agentic loop runs each assembled tool call through. Production wiring binds it to the real registry/runner and the production authorizer behind DispatchShell/DispatchTool; turn tests inject a fake, so the loop is exercised without bootstrap wiring.

type ToolLoop

type ToolLoop struct {

	// Expire fires the time limit immediately while Run has a deadline set; only tests call it.
	Expire func()
	// contains filtered or unexported fields
}

ToolLoop drives agentic tool-call iteration for one assistant turn. It is the production replacement for a bare Runner.Run when tool dispatch is wired: Run issues the streamed turn, and on a tool-call response the OnToolCalls hook (onToolCalls below) assembles the calls, persists the assistant tool-call message, schedules and dispatches the calls, persists the tool results, and signals continuation so Run re-issues the turn. The base Runner loop is unchanged — the loop re-enters it through the hook rather than rewriting it.

func NewToolLoop

func NewToolLoop(deps Deps, dispatcher ToolDispatcher, maxConcurrency int) *ToolLoop

NewToolLoop builds an agentic loop over the per-turn deps, the injected dispatcher seam, and the scheduler concurrency limit (production threads Cache.AgentMaxConcurrency() here). It wires its own OnToolCalls hook onto deps so every turn it issues hands tool-call responses back to the loop.

func (*ToolLoop) AddUserMessage

func (l *ToolLoop) AddUserMessage(text string, parts []sessions.ContentPart) bool

func (*ToolLoop) Cancel

func (l *ToolLoop) Cancel() (retainedChars int, inFlight bool)

Cancel cancels the currently active provider stream, when one exists, and reports the retained assistant text for the TUI's stream_interrupted event. Tool dispatch cancellation is owned by the parent context passed to Run.

func (*ToolLoop) Denied

func (l *ToolLoop) Denied() bool

Denied reports whether the turn stopped on a terminal permission refusal — a user denial or an unclassifiable deny envelope — rather than a completed final answer. It is valid after Run returns nil: a terminal denial-stop appends the assistant tool-call record and deny envelope but produces no final assistant answer, so callers gate first-turn completion work (e.g. title generation) on this being false. A recoverable policy denial reports false: that turn re-issued the model and ran on to a real final answer.

func (*ToolLoop) InFlight

func (l *ToolLoop) InFlight() bool

func (*ToolLoop) Run

func (l *ToolLoop) Run(ctx context.Context) error

Run drives the turn to a final answer. It issues a fresh single-use Runner per provider round (Runner is single-use, and BuildRequest re-reads the store each round so the appended assistant/tool messages feed the next request). A tool-call round handled by onToolCalls sets continued and re-issues; a final no-tool-call round records model_response and finalizes the stream without setting continued, ending the loop. A round whose dispatched results include a terminal permission denial (a user denial or an unclassifiable envelope) leaves continued false, so that refusal halts the attempt without a follow-up provider round; a round denied only by mode policy continues so the model sees the refusal and can adapt. A round carrying ToolResult.Terminal ends the turn after its results are recorded. Any provider/stream/flush error, or a dispatch or scheduler failure surfaced by onToolCalls, ends the loop and is returned.

func (*ToolLoop) SetDeadline

func (l *ToolLoop) SetDeadline(deadline time.Time)

func (*ToolLoop) TimeLimitReached

func (l *ToolLoop) TimeLimitReached() bool

func (*ToolLoop) Unsent

func (l *ToolLoop) Unsent() []PendingUserMessage

type ToolPlan

type ToolPlan struct {
	Units []PlannedToolCall
}

type ToolResult

type ToolResult struct {
	Content  string
	Denied   bool
	Terminal bool
	WrapUp   bool
	Notice   string

	ModelContextImage string
	DisplayPatch      string

	// WriteTarget is the path a dispatcher resolved and authorized for an edit or create that
	// then returned exit status 0, and is empty otherwise. It is evidence of a successful
	// write, not a record of every filesystem change the call made.
	WriteTarget  string
	DeleteTarget string
	MoveTarget   MoveTarget
}

ToolResult is the outcome of dispatching one tool call. Content is the tool-message text fed back to the model: captured command output on success, or the synthetic deny envelope when the call was refused. A denial is an in-band result the loop always records; whether it then halts or continues depends on the envelope's denied_by kind (see Denied). An internal dispatch failure is instead reported as a non-nil error from Dispatch, which aborts the loop. The deny envelope and an executed result are recorded identically, exactly as the model should see them; only whether the loop re-issues afterward differs.

Denied marks a permission refusal. When set, the loop persists Content through permissions.DeliverDeny (the deny delivery seam) keyed to the call's tool_call_id, instead of the ordinary tool-result record, so DeliverDeny is the single production delivery path for the deny envelope. The loop then decodes Content's denied_by: a user-denied prompt (denied_by:"user") or an unclassifiable envelope halts the turn so a refusal cannot be fed back as an invitation to retry, try an alternate method, or claim a result without the user; a denial permissions.IsRecoverableDeniedBy accepts is instead re-issued, so the model sees the refusal and can pick a permitted alternative. Content carries the envelope either way, so the model-facing result is byte-identical; the marker selects both the append path and, via the decoded kind, the halt-vs-continue decision, because Content alone cannot distinguish a deny envelope from ordinary tool output.

Terminal marks an ordinary result that ends the turn; it is not a permission denial.

WrapUp marks a recoverable refusal after which the loop allows exactly one more provider round, advertising no tools, for the model's final answer.

Notice is display-only text the loop shows the user for a result that still continues the turn, emitted independently of Terminal. A dispatcher must set it already bounded and redacted, because it reaches the user verbatim.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL