loop

package
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Jun 7, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

Package loop also provides conversation compaction helpers.

Package loop drives one bee turn: build prompt, stream provider, dispatch tools, persist to rollout, recurse until the model stops.

Index

Constants

View Source
const MaxIterations = config.DefaultMaxIterations

MaxIterations is the default safety cap: if the model keeps emitting tool_use past this many turns, abort. Per-run override via Config.MaxIterations (0 = unlimited).

View Source
const PreserveTail = 4

PreserveTail is the number of trailing messages kept verbatim during compaction.

Variables

View Source
var ErrEmptyCompletion = errors.New("loop: model returned empty output repeatedly")

ErrEmptyCompletion indicates the provider returned whitespace-only output — no text, no reasoning, no tool_use — N turns in a row. A nudge couldn't shake it loose, so the model (or its inference template) is producing dead turns; bail and let the user switch model instead of spinning the iter budget. Commonly a thinking-suppression switch the chat template mishandles.

View Source
var ErrEscalate = errors.New("loop: model escalated to user")

ErrEscalate is the typed sentinel for the `escalate` tool. callers match via errors.Is to detect "the model chose to stop and ask the user".

View Source
var ErrFormatStrike = errors.New("loop: format strike — model wedged on malformed envelope")

ErrFormatStrike indicates the model emitted text that LOOKED like a tool call (XML or JSON envelope) N times in a row without the parser recognizing any of them. signals the model is wedged on a malformed envelope shape that no amount of nudging will fix — bail and let the user / wrapper switch model or prompt.

View Source
var ErrMaxIterations = errors.New("loop: hit max iterations")

ErrMaxIterations indicates the loop hit its tool-use round cap without the model signalling done. Not a wedge — the model was making progress, it just ran out of budget — so the watchdog can resume it with a plain "continue".

View Source
var ErrPerToolFailureCap = errors.New("loop: tool failed beyond per-tool cap")

ErrPerToolFailureCap indicates a single tool name has errored K times in a row regardless of args. signals the model is wedged on a specific tool.

View Source
var ErrRepeatStream = errors.New("loop: stream stuck repeating itself")

ErrRepeatStream indicates the model's stream was cut for degenerate repetition (the same phrase looped) N turns in a row — it's wedged in a token loop that nudging won't fix. bail and let the user / wrapper switch model.

View Source
var ErrTruncatedStream = errors.New("loop: stream dropped mid-output repeatedly")

ErrTruncatedStream indicates the provider stream dropped mid-output (a transient network error AFTER content already streamed) N turns in a row. The first such drops are recovered — bee keeps the partial turn and nudges the model to continue — but a persistent drop means the connection is dead, so bail rather than reconnect forever.

View Source
var ErrTwoStrike = errors.New("loop: tool call failed twice in a row")

ErrTwoStrike indicates the same tool call (name + args) errored twice in a row. caller should stop looping and surface the cause to the user.

Functions

func BuildHandoff added in v0.1.1

func BuildHandoff(ctx context.Context, p llm.Provider, model string, msgs []types.Message, partial, stall string) (string, error)

BuildHandoff renders the prompt a bigger model receives when taking over a stuck small-model run: framing + the original goal (verbatim) + a terse summary of the confused middle (summarized with p/model) + the stall signal + the last PreserveTail turns + any interrupted partial output. stall and partial may be empty. The middle summary uses the same streamed-summary path as Compact, so it should run on the small/fast model BEFORE switching.

func IsWedge added in v0.1.1

func IsWedge(err error) bool

IsWedge reports the "this turn got stuck, a reframed retry is sane" family. This is the exact set cmd/bee/run_goal.go inlines as isWedgedTurn — extracted so the goal loop and the watchdog share one definition. Deliberately excludes ErrTruncatedStream, context.DeadlineExceeded, and ErrMaxIterations: those are handled as their own resume reasons, not as wedges.

func RoleThinking added in v0.1.1

func RoleThinking(r Role) llm.Thinking

RoleThinking is the reasoning budget baked into each role, applied when the user hasn't pinned an explicit Thinking override. worker stays cheap, scout digs, queen maxes out for the hive.

func ShouldAutoCompact

func ShouldAutoCompact(sys string, msgs []types.Message, budget int, threshold float64) bool

ShouldAutoCompact returns true if assembled prompt + history exceeds budget * threshold. budget<=0 disables. Estimate-only variant — kept for callers that don't have a recent provider usage report. Prefer ShouldAutoCompactWithUsage when an EventDone usage is available.

func ShouldAutoCompactWithUsage

func ShouldAutoCompactWithUsage(sys string, msgs []types.Message, actualInputTokens, budget int, threshold float64) bool

ShouldAutoCompactWithUsage trips when input-token usage crosses budget*threshold. When actualInputTokens > 0 (real value from provider's last EventDone usage) we use it directly — most accurate signal we have and works for any provider that reports usage. Falls back to a heuristic estimate over sys + every content block (text, thinking, tool_use input, tool_result content) when no live count is available.

budget<=0 or threshold<=0 disables.

func ShouldResume added in v0.1.1

func ShouldResume(err error, res RunResult) (bool, string)

ShouldResume is the thin (bool, continuation) wrapper over ClassifyResume.

func StripSchemaDescriptionsForProfile

func StripSchemaDescriptionsForProfile(spec map[string]any, profile string) map[string]any

StripSchemaDescriptionsForProfile removes all "description" keys deep in a tool's parameter schema when the profile is "tiny". Wire-format param descriptions aren't part of the token budget the user sees, but they DO count against the provider's context window: typically ~600 tokens for the full toolset. Tiny models don't need them, the one-line tool summary in the system prompt manifest is enough.

Types

type CompactStats

type CompactStats struct {
	BeforeMsgs   int
	AfterMsgs    int
	BeforeTokens int
	AfterTokens  int
	Duration     time.Duration
}

CompactStats reports what a Compact call achieved. Duration spans the LLM summarization plus token accounting. Token figures are estimates derived from estimateMessageTokens so they line up with the auto-compact trigger heuristic.

func Compact

func Compact(ctx context.Context, p llm.Provider, model string, msgs []types.Message) ([]types.Message, CompactStats, error)

Compact summarizes msgs[:-PreserveTail] using provider into a single user message containing "[compacted history]\n<summary>" and returns the new slice plus stats describing the size delta and elapsed time. Returns the input unchanged if it has PreserveTail or fewer messages.

type EmptyCompletionError added in v0.1.1

type EmptyCompletionError struct {
	Streak int
}

EmptyCompletionError wraps an empty-completion bail with the streak length.

func (*EmptyCompletionError) Error added in v0.1.1

func (e *EmptyCompletionError) Error() string

func (*EmptyCompletionError) Is added in v0.1.1

func (e *EmptyCompletionError) Is(target error) bool

func (*EmptyCompletionError) Unwrap added in v0.1.1

func (e *EmptyCompletionError) Unwrap() error

type Engine

type Engine struct {
	Provider llm.Provider
	Tools    *tools.Registry
	Skills   *skills.Registry
	Memory   KnowledgeStore
	Sessions *session.Rollout
	// Waggle, when non-nil, observes read-only tool calls so repeated routes can
	// be crystallized into reusable exec-skills (procedure memory). nil disables.
	Waggle *waggle.Manager
	// Replay, when non-nil, follows previously crystallized routes: after a tool
	// batch it matches the recent read-only calls against stored waggle prefixes
	// and, on a confident match, runs the route's remaining read-only steps off
	// the model's path, folding their output into the triggering tool result so
	// the model skips the round-trips. Zero prompt cost (matching is in Go). nil
	// disables.
	Replay *waggle.Replayer
	Cfg    config.Config
	Cwd    string
	Stdout io.Writer
	// SteerCh, when non-nil, is drained at the top of each iteration to
	// inject mid-turn user steering between LLM rounds.
	SteerCh chan string
	// StreamCh, when non-nil, receives every text delta produced by the
	// provider in lieu of writing them to Stdout. The TUI uses this to
	// route deltas through bubbletea so the alt-screen isn't corrupted.
	// Sends are non-blocking — a slow consumer drops deltas rather than
	// stalling the model stream.
	StreamCh chan string
	// ThinkCh, when non-nil, receives every chain-of-thought delta as it
	// arrives. Separate from StreamCh so the TUI can render reasoning
	// live in a dimmed/italic block above the answer instead of waiting
	// for the whole thinking buffer to flush at end-of-stream. Sends are
	// non-blocking — slow consumer drops deltas.
	ThinkCh chan string
	// LiveMsgCh, when non-nil, receives every assistant + tool message as
	// it's persisted, so a UI can render tool_use / tool_result cards in
	// real time instead of only after Run returns. User messages are NOT
	// sent (the caller's UI already shows an optimistic copy). Sends are
	// non-blocking — a stalled consumer doesn't stall the loop.
	LiveMsgCh chan types.Message
	// WarnCh, when non-nil, receives transient operational notices: stream
	// retries, watchdog stalls, etc. The TUI fades them as a small chrome
	// line so the user knows something happened without the turn aborting.
	// Sends are non-blocking — a slow consumer drops notices.
	WarnCh chan string
	// JSONEmitter, when non-nil, receives one NDJSON event per significant
	// happening (text delta, tool use, tool result, done, error) and
	// suppresses the human-readable text-delta write to Stdout.
	JSONEmitter *jsonmode.Emitter
	// Costs, when non-nil, accumulates per-turn usage/dollar events. The
	// TUI reads from the same tracker to drive the top-bar total and the
	// /cost monitor pane.
	Costs *cost.Tracker
	// InitialMessages, when non-nil, seeds the in-memory message list at
	// the start of each Run so the model receives prior turns as context.
	// The TUI refreshes this per submit; `bee back <id>` sets it from disk.
	// Messages here are NOT re-appended to the rollout — they're already on
	// disk or never were (caller's responsibility).
	InitialMessages []types.Message
	// Rebuild, when non-nil, is invoked by the TUI after a mid-session
	// provider/model switch (`/model` or the picker). The closure owns
	// re-creating Provider + Memory from the current Cfg so the next turn
	// talks to the new backend instead of the original one cached at Engine
	// construction. nil = no live switching (headless, hive workers).
	Rebuild func(*Engine) error

	// OnceAllowTools force-allows plan-only tools (e.g. ask_user) for the next
	// Run, regardless of the active role. A prompt skill sets it from its
	// frontmatter `tools` list so /plan can ask the user even from worker.
	// Only plan-only tools are honoured — it can't re-enable write/bash that a
	// read-only turn legitimately strips. Cleared at the top of each Run.
	OnceAllowTools []string

	// SkipPostureClassifier disables the worker read-only/act classifier for
	// this engine, so a worker turn always gets the full tool surface. Set for
	// the scripted-provider test harness (the extra side Stream call would
	// desync scripted response counts) and for callers that want byte-for-byte
	// "always act" worker behavior.
	SkipPostureClassifier bool
	// contains filtered or unexported fields
}

Engine wires every component bee needs to run one or many turns.

func (*Engine) Compact

func (e *Engine) Compact(ctx context.Context) ([]types.Message, CompactStats, error)

Compact summarizes the session's older messages and returns the compacted slice plus stats. Caller is responsible for replacing the in-memory message list (e.g. TUI scrollback / InitialMessages) so the next turn sees the shorter history. The raw on-disk log stays append-only, but a checkpoint marker is appended so resume (session.ReadResume) reconstructs this shortened view instead of replaying the full history.

func (*Engine) Handoff added in v0.1.1

func (e *Engine) Handoff(ctx context.Context, msgs []types.Message, partial, stall string) (string, error)

Handoff builds a rescue brief from the supplied in-memory transcript using the fast/compaction model and the CURRENT provider. Call this BEFORE switching to the big model so the cheap, already-warm provider does the distillation — never the slow big model on the full confused history.

func (*Engine) PrepareResume

func (e *Engine) PrepareResume(ctx context.Context) (CompactStats, bool, error)

PrepareResume compacts a resumed session's seeded history when it already exceeds the compaction threshold, BEFORE the first turn runs. Without this, the first post-resume turn ships the whole history to the model in one shot — on a slow local model that is many minutes of prompt processing that reads as a hang. Returns the stats and whether compaction actually ran; on success e.InitialMessages is replaced with the shortened history.

func (*Engine) Run

func (e *Engine) Run(ctx context.Context, userMsg string) (RunResult, error)

Run executes the agent loop until the model emits a stop without tool use, or MaxIterations is hit. The user message is appended to the session. Thin wrapper around RunWithContent for the text-only call path.

func (*Engine) RunWithContent

func (e *Engine) RunWithContent(ctx context.Context, content []types.ContentBlock) (RunResult, error)

RunWithContent is Run with a pre-built content slice. Used by the TUI when staging multimodal input (e.g. images via Ctrl+I) so the user message can carry text + image blocks together.

func (*Engine) RunWithContentDisplay

func (e *Engine) RunWithContentDisplay(ctx context.Context, content []types.ContentBlock, display string) (RunResult, error)

RunWithContentDisplay is RunWithContent with a render-only display label for the user turn. When display is non-empty the stored user message shows it in the TUI instead of Content (slash skills: typed command shown, expanded body sent). Empty display behaves exactly like RunWithContent.

type EscalateError

type EscalateError struct {
	Reason     string
	NextAction string
	Options    []string
}

EscalateError wraps the escalate tool's payload so callers (TUI, headless run) can surface the model's reason + suggested-next-action in the exit message instead of just a generic sentinel.

func (*EscalateError) Error

func (e *EscalateError) Error() string

func (*EscalateError) Is

func (e *EscalateError) Is(target error) bool

func (*EscalateError) Unwrap

func (e *EscalateError) Unwrap() error

type FormatStrikeError

type FormatStrikeError struct {
	Streak int
}

FormatStrikeError wraps a format-strike bail with the streak length so callers can render "model emitted malformed envelopes 3x — switch model".

func (*FormatStrikeError) Error

func (e *FormatStrikeError) Error() string

func (*FormatStrikeError) Is

func (e *FormatStrikeError) Is(target error) bool

func (*FormatStrikeError) Unwrap

func (e *FormatStrikeError) Unwrap() error

type KnowledgeStore

type KnowledgeStore interface {
	Query(ctx context.Context, query string, recentTools []string) ([]knowledge.Record, error)
}

KnowledgeStore abstracts knowledge selection so the engine doesn't pull in the full knowledge package (and tests can stub it).

type MaxIterationsError added in v0.1.1

type MaxIterationsError struct {
	Limit int
}

MaxIterationsError carries the cap so callers can surface it and so errors.Is(err, ErrMaxIterations) matches. Error() keeps the original guidance text (type 'continue' / /iterations) callers and tests rely on.

func (*MaxIterationsError) Error added in v0.1.1

func (e *MaxIterationsError) Error() string

func (*MaxIterationsError) Is added in v0.1.1

func (e *MaxIterationsError) Is(target error) bool

func (*MaxIterationsError) Unwrap added in v0.1.1

func (e *MaxIterationsError) Unwrap() error

type Observation

type Observation struct {
	Sig                         ToolCallSignature
	RepeatCount                 int  // how many times this exact Sig has fired this Run
	ConsecutiveSameToolFailures int  // streak of failures on the same tool name (any args)
	ConsecutiveSameSigFailures  int  // streak of failures on the same (tool,args) sig
	IsTwoStrike                 bool // same Sig failed twice in a row → nudge (no longer bails)
}

Observation summarizes what the tracker learned about a freshly observed (ToolUse, isError) pair. Caller decides how to react.

type PerToolFailureError

type PerToolFailureError struct {
	Use    types.ToolUse
	Tool   string
	Streak int
	Class  string
}

PerToolFailureError wraps a per-tool-cap bail with the offending tool name and the streak length so callers can surface "bash failed 8x in a row".

func (*PerToolFailureError) Error

func (e *PerToolFailureError) Error() string

func (*PerToolFailureError) Is

func (e *PerToolFailureError) Is(target error) bool

func (*PerToolFailureError) Unwrap

func (e *PerToolFailureError) Unwrap() error

type RecapResult

type RecapResult struct {
	Text    string
	Skipped bool
	Err     error
}

RecapResult carries the parsed recap line plus any error/skip cause so callers can render a visible diagnostic when generation fails or the model skipped. Text is empty when Err is set or Skipped is true.

func GenerateRecap

func GenerateRecap(ctx context.Context, p llm.Provider, model string, msgs []types.Message) RecapResult

GenerateRecap calls the same provider/model with a short summarising prompt and returns the assistant's recap line. Empty Text + nil Err + Skipped=true means the model emitted the "skip" sentinel or the turn had no assistant text. Stream enabled for parity with classifier — adapters without streaming still aggregate to a single text response.

type RepeatStreamError

type RepeatStreamError struct {
	Streak int
}

RepeatStreamError wraps a repetition-loop bail with the streak length so callers can render "model looped its output 3x — switch model".

func (*RepeatStreamError) Error

func (e *RepeatStreamError) Error() string

func (*RepeatStreamError) Is

func (e *RepeatStreamError) Is(target error) bool

func (*RepeatStreamError) Unwrap

func (e *RepeatStreamError) Unwrap() error

type ResumeDecision added in v0.1.1

type ResumeDecision struct {
	Resume       bool
	Continuation string // synthetic user message to re-trigger with
	Reason       string // short label for the warn line / stderr
}

ResumeDecision is the policy output. Stateless — the caller owns the counter and the cap.

func ClassifyResume added in v0.1.1

func ClassifyResume(err error, res RunResult) ResumeDecision

ClassifyResume maps a finished Run (err + result) to a resume decision. The non-resumable cases are checked first so a clean finish, a user abort, or a deliberate escalate always wins over any resumable signal.

Resumable: a hang surfaced as context.DeadlineExceeded, a persistently dropped stream, a model wedge (after the loop's own in-turn nudges gave up), and hitting the iteration cap. Non-resumable: clean finish, user cancel, escalate, hard budget cap, and any unrecognised/fatal error (auth, provider down, config) — those should fail fast, not spin the resume budget.

type Role added in v0.1.1

type Role string

Role gates engine behavior per Run. one axis the user cycles with shift+tab; each role bundles tool surface + reasoning budget + orchestration.

RoleWorker: full tool surface; a per-turn classifier picks read-only vs act
            so small models don't reflex into shell on a greeting. default.
RoleScout:  read-only research + web; proposes a plan, never mutates.
RoleQueen:  spawns a hive (decompose → workers → critic → synthesize).

yolo (auto-approve dangerous shell) is a separate toggle, not a role.

const (
	RoleWorker Role = "worker"
	RoleScout  Role = "scout"
	RoleQueen  Role = "queen"
)

func ParseRole added in v0.1.1

func ParseRole(s string) Role

ParseRole normalises a string into a Role, tolerating the legacy mode names (plan/auto/edit/yolo/mastermind) so an un-migrated config still resolves sanely. Unknown → RoleWorker.

type RunResult

type RunResult struct {
	Messages  []types.Message
	FinalText string
}

RunResult is the aggregate produced by one Run call.

type ToolCallSignature

type ToolCallSignature struct {
	Name     string
	ArgsHash string
}

ToolCallSignature identifies a tool call by name + canonical-args hash. Two calls with semantically-equal args (key order, whitespace in JSON stringification) collide on the same Sig.

type TruncatedStreamError

type TruncatedStreamError struct {
	Streak int
}

TruncatedStreamError wraps a mid-stream-drop bail with the streak length.

func (*TruncatedStreamError) Error

func (e *TruncatedStreamError) Error() string

func (*TruncatedStreamError) Is

func (e *TruncatedStreamError) Is(target error) bool

func (*TruncatedStreamError) Unwrap

func (e *TruncatedStreamError) Unwrap() error

type TwoStrikeError

type TwoStrikeError struct {
	Use   types.ToolUse
	Class string // tool-error class tag (toolErrNotFound, toolErrTimeout, etc.)
}

TwoStrikeError wraps the offending ToolUse so callers (TUI, headless `bee run`) can surface tool name + args in the exit message.

func (*TwoStrikeError) Error

func (e *TwoStrikeError) Error() string

func (*TwoStrikeError) Is

func (e *TwoStrikeError) Is(target error) bool

Is lets errors.Is(err, ErrTwoStrike) match wrapped variants.

func (*TwoStrikeError) Unwrap

func (e *TwoStrikeError) Unwrap() error

Unwrap surfaces the sentinel for errors.Is chains.

Jump to

Keyboard shortcuts

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