import * as smoltalk from "smoltalk"; import type { Interrupt } from "./interrupts.js"; import type { MessageThread } from "./state/messageThread.js"; import type { StateStack } from "./state/stateStack.js"; import { type RoundWarningSettings } from "./roundWarning.js"; import type { Expiry } from "./state/messageMetadata.js"; /** * INTERNAL — consumed by prompt.ts only. The public way to inject a message * into a running conversation is `MessageThread.queueMessage` (surfaced as * `agency.thread.current().queueMessage(...)`); this module is the delivery * side. It owns the turn boundary: the safe point between "all tool results * are in" and "the next request goes out", where pending messages from * every source get pushed with the step-and-snapshot discipline handled * once, here, instead of hand-woven per source in prompt.ts. * * Four sources ("producers") exist, each keeping its own storage and merge * rule because those genuinely differ: * - reply attachments: harvested per tool onto runnerState, merged * N-into-1 user message; * - guard feedback: the approver's `approve({message})` text, queued * branch-locally on the stack (an approval in a fork branch must follow * THAT branch's next request), joined into one labeled user message; * - queued messages: `queueMessage` entries on the active thread, * delivered FIFO, roles and labels preserved, never collapsed; * - the round warning: one user message when few tool rounds are left, * recorded on runnerState so each warning goes out once, and expiring * with the llm() call. * Adding a producer is a runtime-maintainer decision; features inject by * calling queueMessage, not by registering here. */ export type TurnMessage = { message: smoltalk.Message; label: string | null; /** When the message is removed; absent for one that stays. */ expires?: Expiry | null; }; /** What the round-warning producer needs at a round boundary. */ export type RoundWarningContext = { settings: RoundWarningSettings; /** The call's tool-round cap. */ limit: number; /** The round just run, counted from 0. */ round: number; /** The llm() call's key; the warning expires with the call. */ callKey: string; }; export type BoundaryContext = { /** The PromptRunner's step method, bound. Injected so this module is * unit-testable with a recording fake. The body's return type matters: * pr.step treats a returned Interrupt[] as "pause here" and stamps a * checkpoint — that is HOW a guard trip suspends the run. */ step: (key: string, body: () => Promise) => Promise; /** runPrompt's guardGate closure (raiseGuardTripsUntilClear). Its * Interrupt[] return is the pause signal and MUST flow through to * step() unaltered — never wrap this in a void-returning adapter. */ guardGate: () => Promise; /** The live conversation of this llm() call. */ messages: MessageThread; /** Per-llm()-call serialized state; home of replyAttachments. */ runnerState: Record; /** The branch's stack; home of the guard-feedback queue. */ stateStack: StateStack; /** Set only at a round boundary; null wherever no warning can be due. */ roundWarning: RoundWarningContext | null; /** Serializes the given thread into the frame shadow * (self.messagesJSON). Takes the thread as a parameter so the sync * target is explicit rather than a closure over a mutable local. */ snapshot: (thread: MessageThread) => void; }; export type TurnMessageProducer = { /** Step-key fragment, e.g. "attachReplies". */ name: string; /** Destructive read: return what is pending and clear it at the source. * Runs INSIDE the step, so a replayed-and-skipped step never loses * work. Empty array = nothing this time. */ take: (bctx: BoundaryContext) => TurnMessage[]; }; export declare const attachmentsProducer: TurnMessageProducer; export declare const guardFeedbackProducer: TurnMessageProducer; export declare const queuedMessagesProducer: TurnMessageProducer; export declare const roundWarningProducer: TurnMessageProducer; /** Deliver one producer's pending messages inside an idempotent step. The * step always opens (uniform emission; cross-version checkpoint * compatibility is not promised); an empty take means the step no-ops — * no push, no snapshot, matching the shipped empty paths. */ export declare function drainProducer(p: TurnMessageProducer, stepKey: string, bctx: BoundaryContext): Promise; /** The two-beat figure that appears at four kinds of site in the loop: * raise any pending guard trip, then deliver the approver's feedback. * Keys are passed verbatim — the four sites do not share a naming scheme * and the names are frozen. */ export declare function runGateAndFeedback(gateKey: string, feedbackKey: string, bctx: BoundaryContext): Promise; /** The gate-and-feedback figure between a round's first batch and its * handoff, so a tripped guard stops the round before the handoff body * starts, and the approver's feedback reaches the body. At this point * the handoff's tool call has no result yet. If the gate ends the round, * a rejected trip say, `onStopped` answers that call, because a thread * must not keep a tool call with no result. A pause does not end the * round: the handoff runs after the resume. */ export declare function runHandoffGate(round: number, bctx: BoundaryContext, onStopped: (reason: string) => void): Promise; /** The full round boundary, in the one canonical order: tool artifacts * first, then queued messages, then the round warning, then the guard * machinery gets the last word before the next request. */ export declare function runRoundBoundary(round: number, bctx: BoundaryContext): Promise; /** The call-entry boundary: deliver messages queued before this llm() * call (so a no-tool call still delivers, and queued content precedes * the new prompt — it reviews past work, same positioning as initial * guard feedback), then the gate-and-feedback figure. No attachments * phase: none can exist before the first request. */ export declare function runInitialBoundary(bctx: BoundaryContext): Promise;