// This file implements pi's two-layer agent loop (US-006, FR-1). It strings // together streaming assistant responses, batch tool execution, and the loop's // six hooks with control flow kept faithful to pi's runLoop: // // - Inner loop: one turn = stream an assistant response → execute its tool // calls → feed the results back, repeating until an assistant message has no // tool calls (a natural turn end). // - Outer loop: after the inner loop settles, pull getFollowUpMessages; if any // are returned they become the next pending input and the inner loop runs // again, otherwise the run ends. // // Per-turn hooks after each turn_end: getSteeringMessages (pulled after tool // execution and injected before the next turn), prepareNextTurn (may swap // context / model / thinkingLevel), shouldStopAfterTurn (true ⇒ agent_end + // exit). Two stop reasons are handled specially: length (the response was // truncated by the token cap) fails every tool call so the model resends // (failToolCallsFromTruncatedMessage); error / aborted end the run immediately. // // agentLoop starts a fresh run from a prompt already appended to the context. package runtime import ( "context" "fmt" "os" "time" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/agenttool" "github.com/smallnest/pigo/internal/compaction" "github.com/smallnest/pigo/internal/provider" ) // nowMillis returns the current Unix time in milliseconds, the timestamp unit // used for CompactionMessage checkpoints. func nowMillis() int64 { return time.Now().UnixMilli() } // TurnUpdate is the optional result of PrepareNextTurn: any non-nil field // replaces the corresponding piece of loop state before the next turn. It lets // a caller swap the trimmed context, system prompt, tool set, model, or // thinking level between turns (FR-6). type TurnUpdate struct { Messages *agentcore.MessageList SystemPrompt *string Tools *[]agentcore.AgentTool Model *string ThinkingLevel *agentcore.ThinkingLevel } // StopDecision is the result of the OnStop seam. Block=true prevents the run // from ending; Guidance, when non-empty, is appended as a user-role message to // steer the forced continuation (the Stop / SubagentStop hook's reason). The // zero value (Block=false) lets the run end. type StopDecision struct { Block bool Guidance string } // RunConfig is the full configuration for a loop run: the per-turn streaming // config (embedded LoopConfig), the batch tool-execution config, and the four // loop-level hooks. Every hook is optional (nil = default behavior). type RunConfig struct { LoopConfig // Batch holds the tool registry and the prepare/before/after hooks used to // execute each assistant message's tool calls. Batch agenttool.BatchConfig // GetFollowUpMessages is consulted after the inner loop settles (an assistant // message with no tool calls). Returning messages continues the outer loop // with them as the next input; returning none ends the run (FR-9). GetFollowUpMessages func(ctx context.Context, agentCtx *agentcore.AgentContext) []agentcore.AgentMessage // GetSteeringMessages is pulled after each turn's tool execution and injected // before the next turn (pi per-turn semantics, FR-8). GetSteeringMessages func(ctx context.Context) []agentcore.AgentMessage // PrepareNextTurn runs after each turn_end and may swap context / model / // thinkingLevel for the next turn (FR-6). PrepareNextTurn func(ctx context.Context, agentCtx *agentcore.AgentContext) *TurnUpdate // ShouldStopAfterTurn runs after each turn_end; true ends the run with an // agent_end event (FR-7). ShouldStopAfterTurn func(ctx context.Context, agentCtx *agentcore.AgentContext) bool // OnStop, when set, is consulted right before the run would end naturally (no // tool calls and no follow-up messages). Returning a decision with Block=true // keeps the loop running: any Guidance is appended as a user-role message to // steer the continued run (Stop / SubagentStop hooks, US-008/009, FR-10). The // seam carries no loop-protection itself — the caller's decorator owns the // consecutive-block counter and the FR-12 force-stop limit, so an ill-behaved // hook cannot loop forever. nil or a non-blocking decision lets the run end. OnStop func(ctx context.Context, agentCtx *agentcore.AgentContext) *StopDecision // Reminders holds the per-turn system-reminder providers (US-002, FR-1/FR-2). // When non-empty, ephemeral messages are injected into each // turn's LLM request through the existing TransformContext seam, so they never // enter the persisted history. nil / empty = no injection. Reminders *ReminderRegistry // EventBuffer is the buffer size of the emitted EventStream. 0 gives fully // synchronous back-pressure (matching pi's awaited emit). EventBuffer int // SessionID, when set, is carried in the run's agent_start event so a // stream-json consumer sees the backing session id in the first event and can // resume the run later (mirrors pi/Claude Code). It is also the session key // under which auto-compaction checkpoints are persisted (see MemoryRoot). SessionID string // MemoryRoot, when non-empty (together with SessionID), enables checkpoint // persistence for the "infinite context" feature (#480/#481): after a // successful auto-compaction the collapsed prefix's summary is written as a // checkpoint under /sessions//checkpoint.md so a later // run can reload it instead of replaying the whole transcript. It is left "" // when persistent memory is disabled (memory.enabled=false), which fully // disables checkpoint writing. A checkpoint write failure is non-fatal. MemoryRoot string } // LoopEventStream is the stream returned by the loop entry points: it carries // AgentEvents and yields the messages newly produced during the run. type LoopEventStream = agentcore.EventStream[agentcore.AgentEvent, []agentcore.AgentMessage] // agentLoop starts a fresh run. The caller has already appended the initiating // user message(s) to agentCtx.Messages. It returns immediately with an // EventStream; a producer goroutine drives the loop and closes the stream when // the run ends. func agentLoop(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig) *LoopEventStream { stream := agentcore.NewEventStream[agentcore.AgentEvent, []agentcore.AgentMessage](cfg.EventBuffer) go runLoop(ctx, agentCtx, cfg, stream) return stream } // StartRun is the exported entry point for a fresh run, used by out-of-package // drivers (the interactive REPL, US-022). It is a thin wrapper over agentLoop so // the loop internals stay unexported while callers outside the package can // still launch a run and consume its event stream. func StartRun(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig) *LoopEventStream { return agentLoop(ctx, agentCtx, cfg) } // runLoop is the producer: it drives the two-layer loop, emitting events onto // stream and setting the stream result to the messages produced during the run. func runLoop(ctx context.Context, agentCtx *agentcore.AgentContext, cfg RunConfig, stream *LoopEventStream) { // Wire per-turn system-reminder injection (US-002) onto the TransformContext // seam. Reminders are appended to the request-shaped copy only, so they stay // ephemeral: never written back to agentCtx.Messages, never persisted, never // swept into a compaction summary. if !cfg.Reminders.Empty() { cfg.TransformContext = cfg.Reminders.wrapTransform(cfg.TransformContext) } startIdx := len(agentCtx.Messages) // tel accumulates structured telemetry (turn count, per-tool durations, // truncation count, compaction count, latest context-utilization ratio) from // the events emitted below, surfaced as a TelemetryEvent at run end. tel := newTelemetry() // newMessages returns the messages appended since the run began. newMessages := func() []agentcore.AgentMessage { if len(agentCtx.Messages) <= startIdx { return nil } out := make([]agentcore.AgentMessage, len(agentCtx.Messages)-startIdx) copy(out, agentCtx.Messages[startIdx:]) return out } emit := func(ev agentcore.AgentEvent) error { tel.observe(ev) return stream.Emit(ctx, ev) } // emitFrom wraps the raw stream.Emit callback handed to streamAssistantResponse // and ExecuteToolCalls so telemetry observes those events (message_* and // tool_execution_*) too, without changing their signatures. emitFrom := func(c context.Context, ev agentcore.AgentEvent) error { tel.observe(ev) return stream.Emit(c, ev) } // finish emits the telemetry summary then agent_end (unless suppressed by a // prior emit error), records the run result, and closes the stream exactly // once. Telemetry is emitted first so a consumer sees the run's structured // metrics immediately before the terminal event. finish := func() { _ = emit(tel.summary()) msgs := newMessages() _ = emit(agentcore.AgentEndEvent{Messages: msgs}) stream.SetResult(msgs) stream.Close() } if err := emit(agentcore.AgentStartEvent{SessionID: cfg.SessionID}); err != nil { finish() return } for { // outer loop: pending / follow-up messages for { // inner loop: turns until no tool calls if err := emit(agentcore.TurnStartEvent{}); err != nil { finish() return } assistant, err := streamAssistantResponse(ctx, agentCtx, cfg.LoopConfig, emitFrom) if err != nil { // emit was cancelled mid-stream; end the run. finish() return } switch assistant.StopReason { case agentcore.StopReasonLength: // Truncated by the token cap: fail every tool call so the model // resends, then continue feeding back. toolResults := failToolCallsFromTruncatedMessage(agentCtx, assistant) if err := emit(agentcore.TurnEndEvent{Message: assistant, ToolResults: toolResults}); err != nil { finish() return } if afterTurn(ctx, agentCtx, &cfg, true, emit, tel) { finish() return } continue case agentcore.StopReasonError, agentcore.StopReasonAborted: // Terminal failure: emit the turn end and stop. _ = emit(agentcore.TurnEndEvent{Message: assistant}) finish() return } calls := toAgentToolCalls(assistant.ToolCalls()) if len(calls) == 0 { // Natural turn end: no tools to run. if err := emit(agentcore.TurnEndEvent{Message: assistant}); err != nil { finish() return } if afterTurn(ctx, agentCtx, &cfg, false, emit, tel) { finish() return } break // exit inner loop → consult follow-up messages } // Inject the run-level emitter into the context so tools (notably the // generic task tool) can retrieve it via ProgressEmitterFromContext and // surface a dispatched sub-agent's progress up this parent event stream. // emitFrom feeds the parent stream and is run-scoped, so a child's // SubAgentProgressEvent lands on the right run's stream. toolCtx := agentcore.WithProgressEmitter(ctx, emitFrom) toolResults, allTerminate := agenttool.ExecuteToolCalls(toolCtx, cfg.Batch, calls, emitFrom) for _, tr := range toolResults { agentCtx.Messages = append(agentCtx.Messages, tr) } if err := emit(agentcore.TurnEndEvent{Message: assistant, ToolResults: toolResults}); err != nil { finish() return } if allTerminate { // Every tool asked to terminate the run. finish() return } if afterTurn(ctx, agentCtx, &cfg, true, emit, tel) { finish() return } // Feed the tool results back into the next turn. } // Inner loop settled: consult follow-up messages. if cfg.GetFollowUpMessages != nil { if follow := cfg.GetFollowUpMessages(ctx, agentCtx); len(follow) > 0 { agentCtx.Messages = append(agentCtx.Messages, follow...) continue // outer loop with the follow-ups as new input } } // Stop hook: the run is about to end naturally. A hook may block the end // and force a continuation, feeding its guidance back as the next input // (US-008/009, FR-10). The consecutive-block counter and force-stop limit // (FR-12) live in the decorator behind OnStop, so this seam stays simple. if cfg.OnStop != nil { if dec := cfg.OnStop(ctx, agentCtx); dec != nil && dec.Block { if dec.Guidance != "" { agentCtx.Messages = append(agentCtx.Messages, agentcore.UserMessage{ RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent(dec.Guidance)}, }) } continue // outer loop: keep the run alive } } break } finish() } // afterTurn runs the per-turn hooks after a turn_end. When hadToolExecution is // true it first pulls getSteeringMessages and injects them before the next turn // (pi per-turn semantics). It then applies prepareNextTurn, runs auto-compaction // when the context has outgrown its window, and finally consults // shouldStopAfterTurn, returning true when the run should end. func afterTurn(ctx context.Context, agentCtx *agentcore.AgentContext, cfg *RunConfig, hadToolExecution bool, emit func(agentcore.AgentEvent) error, tel *telemetry) (stop bool) { if hadToolExecution && cfg.GetSteeringMessages != nil { if steer := cfg.GetSteeringMessages(ctx); len(steer) > 0 { agentCtx.Messages = append(agentCtx.Messages, steer...) } } if cfg.PrepareNextTurn != nil { if upd := cfg.PrepareNextTurn(ctx, agentCtx); upd != nil { applyTurnUpdate(agentCtx, cfg, upd) } } maybeAutoCompact(ctx, agentCtx, cfg, emit, tel) // Record the latest context-utilization ratio once the turn has settled (after // any compaction), so the telemetry summary reports the current used/window // figure. This runs even when auto-compaction is disabled so utilization is // still observable whenever the context window is known. if tel != nil && cfg.ContextWindow > 0 { tokens := compaction.EstimateContextTokens(agentCtx.Messages).Tokens tel.recordContext(tokens, cfg.ContextWindow) } if cfg.ShouldStopAfterTurn != nil { return cfg.ShouldStopAfterTurn(ctx, agentCtx) } return false } // maybeAutoCompact checks whether the context has outgrown its usable window and, // if so, compacts it in place and emits a CompactionEvent. Compaction is a no-op // when disabled, when the context window is unknown (<= 0), or when usage is // under threshold. A compaction failure is non-fatal: the original context is // preserved and a CompactionEvent carrying ErrorMessage is emitted so the failure // is observable without aborting the run (US-004). func maybeAutoCompact(ctx context.Context, agentCtx *agentcore.AgentContext, cfg *RunConfig, emit func(agentcore.AgentEvent) error, tel *telemetry) { if !cfg.Compaction.Enabled || cfg.ContextWindow <= 0 { return } before := compaction.EstimateContextTokens(agentCtx.Messages).Tokens // Record pre-compaction utilization so the ratio reflects the peak that // triggered (or nearly triggered) compaction even when the summary is read // mid-run. afterTurn overwrites it with the post-settle figure. if tel != nil { tel.recordContext(before, cfg.ContextWindow) } if !compaction.ShouldCompact(before, cfg.ContextWindow, cfg.Compaction) { return } // Signal the start so a front-end can show an in-progress indicator while the // summarization request (an LLM call that blocks the loop) is in flight. _ = emit(agentcore.CompactionStartEvent{Reason: "threshold", TokensBefore: before}) res, err := runCompaction(ctx, agentCtx.Messages, cfg) kept := len(agentCtx.Messages) if err != nil { _ = emit(agentcore.CompactionEvent{ Reason: "threshold", TokensBefore: before, TokensAfter: before, KeptCount: kept, ErrorMessage: err.Error(), }) return } if res == nil { // Nothing to summarize (cut point left no prefix); leave context as-is. return } // Persist a checkpoint of the collapsed prefix before rewriting the context so // a later run can reload it (infinite context, #480/#481). It reuses the // summary compaction just produced — no extra LLM call — and is best-effort: // a write failure is logged and the run continues on the compacted context. writeCompactionCheckpoint(ctx, agentCtx.Messages, res, cfg) now := nowMillis() rebuilt := res.RebuildContext(agentCtx.Messages, now) summarized := len(agentCtx.Messages) - (len(rebuilt) - 1) agentCtx.Messages = rebuilt after := compaction.EstimateContextTokens(rebuilt).Tokens _ = emit(agentcore.CompactionEvent{ Reason: "threshold", TokensBefore: before, TokensAfter: after, SummarizedCount: summarized, KeptCount: len(rebuilt) - 1, }) } // runCompaction invokes compaction.Compact with the loop's summarization config, // falling back to the primary Stream/Model when the summary-specific fields are // unset. Compact derives the cut point from settings.KeepRecentTokens. func runCompaction(ctx context.Context, msgs agentcore.MessageList, cfg *RunConfig) (*compaction.CompactionResult, error) { stream := cfg.SummaryStream if stream == nil { stream = cfg.Stream } model := cfg.SummaryModel if model.ID == "" { model = provider.Model{Provider: cfg.Provider, ID: cfg.Model, ContextWindow: cfg.ContextWindow} } // Resolve the API key the same way the primary turn does (dynamic key wins, // static APIKey is the fallback) so the summarization stream authenticates // against auth-requiring providers instead of failing with "missing API key". key := cfg.APIKey if cfg.GetAPIKey != nil { if dyn := cfg.GetAPIKey(ctx, cfg.Provider); dyn != "" { key = dyn } } scfg := provider.StreamConfig{APIKey: key, ThinkingLevel: cfg.ThinkingLevel} return compaction.Compact(ctx, stream, model, msgs, cfg.Compaction, -1, nil, "", scfg) } // writeCompactionCheckpoint persists the just-produced compaction summary as a // session checkpoint so a later run can reload the collapsed prefix instead of // replaying it (#480/#481). It is a no-op unless checkpoint persistence is wired // (MemoryRoot and SessionID both set) — which is how memory.enabled=false keeps // the whole subsystem inert. It reuses res.Summary (no extra summarization call) // via BuildCheckpoint, tagging the checkpoint with the compaction cut point as // its watermark. All failures are non-fatal: they are logged to stderr and the // run continues on the compacted context (WriteCheckpoint's log-and-continue // contract). func writeCompactionCheckpoint(ctx context.Context, msgs agentcore.MessageList, res *compaction.CompactionResult, cfg *RunConfig) { if cfg.MemoryRoot == "" || cfg.SessionID == "" || res == nil { return } watermark := res.FirstKeptIndex if watermark < 0 { watermark = 0 } if watermark > len(msgs) { watermark = len(msgs) } // summarize returns the summary the compaction already computed, so // BuildCheckpoint records an honest CoveredMessages count without a second // LLM round-trip. summarize := func(context.Context, []agentcore.Message) (string, error) { return res.Summary, nil } cp, err := BuildCheckpoint(ctx, msgs[:watermark], watermark, time.Now(), summarize) if err != nil { fmt.Fprintf(os.Stderr, "pigo: checkpoint: build for session %s: %v\n", cfg.SessionID, err) return } if err := WriteCheckpoint(cfg.SessionID, cfg.MemoryRoot, cp); err != nil { fmt.Fprintf(os.Stderr, "pigo: checkpoint: write for session %s: %v\n", cfg.SessionID, err) } } // applyTurnUpdate applies a non-nil TurnUpdate to the mutable loop state: any // set field replaces the current context / config value for the next turn. func applyTurnUpdate(agentCtx *agentcore.AgentContext, cfg *RunConfig, upd *TurnUpdate) { if upd.Messages != nil { agentCtx.Messages = *upd.Messages } if upd.SystemPrompt != nil { agentCtx.SystemPrompt = *upd.SystemPrompt } if upd.Tools != nil { agentCtx.Tools = *upd.Tools } if upd.Model != nil { cfg.Model = *upd.Model } if upd.ThinkingLevel != nil { cfg.ThinkingLevel = *upd.ThinkingLevel } } // failToolCallsFromTruncatedMessage produces an error tool-result message for // every tool call in a truncated (stopReason=length) assistant message, telling // the model the response was cut off and to resend. The results are appended to // the context and returned. Mirrors pi's failToolCallsFromTruncatedMessage. func failToolCallsFromTruncatedMessage(agentCtx *agentcore.AgentContext, assistant agentcore.AssistantMessage) []agentcore.ToolResultMessage { calls := assistant.ToolCalls() if len(calls) == 0 { return nil } results := make([]agentcore.ToolResultMessage, 0, len(calls)) for _, c := range calls { results = append(results, agentcore.ToolResultMessage{ RoleField: agentcore.RoleToolResult, ToolCallID: c.ID, ToolName: c.Name, Content: agentcore.ContentList{agentcore.NewTextContent( "The previous response was truncated because it hit the output token limit, " + "so this tool call was not executed. Please send a shorter response and retry.")}, IsError: true, }) } for _, r := range results { agentCtx.Messages = append(agentCtx.Messages, r) } return results } // toAgentToolCalls converts the assistant message's ToolCallContent blocks into // the loop-level AgentToolCall view executeToolCalls consumes. func toAgentToolCalls(blocks []agentcore.ToolCallContent) []agentcore.AgentToolCall { if len(blocks) == 0 { return nil } calls := make([]agentcore.AgentToolCall, len(blocks)) for i, b := range blocks { calls[i] = agentcore.AgentToolCall{ID: b.ID, Name: b.Name, Arguments: b.Arguments} } return calls }