@celestea/agent-loop 2.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/events.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Turn-level event plumbing — port of `crates/agent-loop/src/events.rs`.
3
+ *
4
+ * A running turn delivers every [LoopEvent] it produces to an injected
5
+ * [EventSink], in log order: the LLM stream deltas (text / thinking / done)
6
+ * plus the tool lifecycle (tool_call + the FULL ToolOutput of every result) so
7
+ * a rich UI can draw tool cards without scraping the session log.
8
+ *
9
+ * P0-A: every started turn emits exactly ONE terminal
10
+ * `{ kind: "turn_end" }` carrying the real terminal state — consumers map it
11
+ * onto their "done"/status envelope, so cancelled / error / step-limit /
12
+ * interrupted turns can never be mistaken for completed ones.
13
+ *
14
+ * The builders below are the only place where core values become events, so
15
+ * the log and the event stream can never drift apart.
16
+ */
17
+ import { messageTexts, messageToolCalls, } from "@celestea/core";
18
+ /** `Some(ToolDecision::Allow)` -> `"allow"`: the flat label of the SSE payload. */
19
+ export function decisionLabel(decision) {
20
+ return decision === null ? null : decision.kind;
21
+ }
22
+ export function toolCallEvent(call) {
23
+ return { kind: "tool_call", id: call.id, name: call.name, args: call.args };
24
+ }
25
+ /** The full ToolOutput rides the event: value, authored render, error, verdict. */
26
+ export function toolResultEvent(output) {
27
+ return {
28
+ kind: "tool_result",
29
+ callId: output.call_id,
30
+ ok: output.error === null,
31
+ value: output.value,
32
+ render: output.render,
33
+ error: output.error,
34
+ decision: decisionLabel(output.decision),
35
+ };
36
+ }
37
+ /** The authoritative assistant reply of one model step (not terminal). */
38
+ export function doneEvent(message) {
39
+ return {
40
+ kind: "done",
41
+ text: messageTexts(message).join(""),
42
+ tool_calls: messageToolCalls(message).map((call) => ({ id: call.id, name: call.name, args: call.args })),
43
+ };
44
+ }
45
+ /** The single terminal verdict of the turn. */
46
+ export function turnEndEvent(outcome) {
47
+ return { kind: "turn_end", outcome };
48
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@celestea/agent-loop` — the AgentLoop implementation: turn/step driving,
3
+ * context-budget trimming, cooperative cancellation and the five terminal
4
+ * states, as a plugin over the `core` seams.
5
+ *
6
+ * Dependency direction: agent-loop -> core only. The loop resolves `Llm`,
7
+ * `SessionLog` and `ToolRegistry` from the Context at turn start, so this
8
+ * package never imports a provider, a storage backend or a tool implementation.
9
+ *
10
+ * Module map (legacy -> TS):
11
+ * loop.ts DefaultAgentLoop, the step loop (loop.rs)
12
+ * step.ts per-step verdict types + folds (loop.rs)
13
+ * seams.ts Context -> Llm/SessionLog/ToolRegistry (loop.rs:204-213)
14
+ * cancel.ts AbortSignal checkpoints, synth. result text (loop.rs:181-200)
15
+ * context-trim.ts token estimate + trim_context (context.rs)
16
+ * usage.ts UsageTracker (loop.rs:49-89)
17
+ * events.ts LoopEvent builders + EventSink (events.rs)
18
+ * thinking.ts W252 thinking-burst aggregation (loop.rs:165-171)
19
+ * plugin.ts AGENT_LOOP_SERVICE registration (runtime/src/compose.rs)
20
+ * sse.ts LoopEvent -> SSE frame mapping (studio/src/main.rs:667)
21
+ *
22
+ * Public API = this file. Everything else is an internal module.
23
+ */
24
+ export * from "./loop.js";
25
+ export * from "./cancel.js";
26
+ export * from "./context-trim.js";
27
+ export * from "./usage.js";
28
+ export * from "./events.js";
29
+ export * from "./retention.js";
30
+ export * from "./plugin.js";
31
+ export * from "./sse.js";
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@celestea/agent-loop` — the AgentLoop implementation: turn/step driving,
3
+ * context-budget trimming, cooperative cancellation and the five terminal
4
+ * states, as a plugin over the `core` seams.
5
+ *
6
+ * Dependency direction: agent-loop -> core only. The loop resolves `Llm`,
7
+ * `SessionLog` and `ToolRegistry` from the Context at turn start, so this
8
+ * package never imports a provider, a storage backend or a tool implementation.
9
+ *
10
+ * Module map (legacy -> TS):
11
+ * loop.ts DefaultAgentLoop, the step loop (loop.rs)
12
+ * step.ts per-step verdict types + folds (loop.rs)
13
+ * seams.ts Context -> Llm/SessionLog/ToolRegistry (loop.rs:204-213)
14
+ * cancel.ts AbortSignal checkpoints, synth. result text (loop.rs:181-200)
15
+ * context-trim.ts token estimate + trim_context (context.rs)
16
+ * usage.ts UsageTracker (loop.rs:49-89)
17
+ * events.ts LoopEvent builders + EventSink (events.rs)
18
+ * thinking.ts W252 thinking-burst aggregation (loop.rs:165-171)
19
+ * plugin.ts AGENT_LOOP_SERVICE registration (runtime/src/compose.rs)
20
+ * sse.ts LoopEvent -> SSE frame mapping (studio/src/main.rs:667)
21
+ *
22
+ * Public API = this file. Everything else is an internal module.
23
+ */
24
+ export * from "./loop.js";
25
+ export * from "./cancel.js";
26
+ export * from "./context-trim.js";
27
+ export * from "./usage.js";
28
+ export * from "./events.js";
29
+ export * from "./retention.js";
30
+ export * from "./plugin.js";
31
+ export * from "./sse.js";
package/dist/loop.d.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * DefaultAgentLoop — port of `crates/agent-loop/src/loop.rs`.
3
+ *
4
+ * One turn = one user message + N model steps. Per step the loop:
5
+ * 1. derives the model-visible history from the session log (the log is the
6
+ * only source of truth) and trims it to the context budget;
7
+ * 2. asks the `Llm` seam for a stream and consumes text / thinking / usage
8
+ * deltas, aggregating reasoning bursts into one persisted row;
9
+ * 3. appends the authoritative assistant reply, or dispatches the step's
10
+ * tool calls through the `ToolRegistry` seam (all `tool_call` rows first,
11
+ * then one `tool_result` per call, in model order);
12
+ * 4. appends whatever arrived while the turn was RUNNING — a user
13
+ * interjection or a worker receipt — to the log at the step boundary,
14
+ * right before the next model call (W513), so the running turn receives it
15
+ * without being interrupted and without a second turn being started;
16
+ * 5. repeats until the model answers without tool calls, the step budget is
17
+ * exhausted, the turn is cancelled, or the stream fails.
18
+ *
19
+ * Every started turn ends with EXACTLY ONE `turn_end` — in the log and on the
20
+ * event stream, written from a single exit point — carrying one of the five
21
+ * real terminal states: completed / cancelled / error / step_limit /
22
+ * interrupted. A torn stream, an exhausted budget or a cancellation is never
23
+ * reported as `completed`.
24
+ *
25
+ * Cancellation is cooperative over an [AbortSignal] and re-checked at every
26
+ * await checkpoint (before generating, while streaming, between tool batches).
27
+ * When a tool batch is abandoned, every unanswered call of the step gets a
28
+ * synthesized cancelled `tool_result`, so the log stays protocol-valid.
29
+ */
30
+ import { type AgentConfig, type AgentLoop, type Context, type InjectionSource, type ModelRequest, type ImageRef, type TurnOutcome } from "@celestea/core";
31
+ import { type EventSink } from "./events.js";
32
+ import { UsageTracker } from "./usage.js";
33
+ /** Optional collaborators of one loop instance (`with_bindings`). */
34
+ export interface AgentLoopBindings {
35
+ /** Cooperative cancellation; absent = the turn can never be cancelled. */
36
+ signal?: AbortSignal;
37
+ /** Turn-event sink; absent = events are dropped (the host owns rendering). */
38
+ sink?: EventSink;
39
+ /** Shared usage accounting; absent = provider usage is not recorded. */
40
+ usage?: UsageTracker;
41
+ /** Mid-turn injection source (W513); absent = the turn takes no interjections. */
42
+ injections?: InjectionSource;
43
+ }
44
+ /** Bound of the "do not close while a steering message waits" extension. */
45
+ export declare const MAX_STEER_EXTENSIONS = 8;
46
+ export declare class DefaultAgentLoop implements AgentLoop {
47
+ private readonly config;
48
+ private readonly signal;
49
+ private readonly sink;
50
+ private readonly usage;
51
+ private readonly injections;
52
+ /** W855: resolved from the Context once per turn (null = retention off). */
53
+ private retention;
54
+ constructor(config: AgentConfig, bindings?: AgentLoopBindings);
55
+ /** The config this loop drives turns with. */
56
+ get agentConfig(): AgentConfig;
57
+ /** The usage tracker bound at construction, when one was provided. */
58
+ get usageTracker(): UsageTracker | undefined;
59
+ /** AgentLoop seam: drive one turn; rejects only on a broken Context wiring. */
60
+ runTurn(ctx: Context, userInput: string | null, attachments?: readonly ImageRef[]): Promise<void>;
61
+ /**
62
+ * W725: the EXACT request the next step would build for `ctx` — the same
63
+ * `buildRequest` the turn uses (system prompt + post-trim derived history +
64
+ * `registry.schemas()`), so a read-only context snapshot can never drift from
65
+ * what the model is actually sent.
66
+ *
67
+ * Read-only by construction: it never appends to the log, never dispatches a
68
+ * tool and never touches the step budget or the usage tracker.
69
+ */
70
+ contextSnapshot(ctx: Context): ModelRequest;
71
+ /** Same turn, handing the terminal state back to the caller (hosts / tests). */
72
+ runTurnOutcome(ctx: Context, userInput: string | null, attachments?: readonly ImageRef[]): Promise<TurnOutcome>;
73
+ /**
74
+ * The step loop: budget -> cancel checkpoint -> one model step.
75
+ *
76
+ * W515 §1 invariant: a turn must NOT reach its terminal state while the
77
+ * `next-step` lane still holds something — the message is drained and answered
78
+ * inside THIS turn (that is what makes "closing -> inject" real). The
79
+ * extension is bounded, and a cancelled turn still stops immediately.
80
+ */
81
+ private driveSteps;
82
+ /** Steering messages still waiting (the close guard of W515 §1). */
83
+ private pendingSteering;
84
+ /**
85
+ * Derive the history from the log and trim it to the context budget. A
86
+ * `context_window_tokens` of 0 disables trimming (back-compat).
87
+ */
88
+ private buildRequest;
89
+ /** Start one model response; interruptible, never throws on provider failure. */
90
+ private generate;
91
+ /** One step: inject what arrived mid-turn, generate, consume, decide. */
92
+ private runStep;
93
+ /**
94
+ * Consume the response stream. A cancel drops the partial turn (no
95
+ * incomplete AssistantMessage is flushed); a failed / torn stream records the
96
+ * matching terminal state instead of pretending success.
97
+ */
98
+ private consumeStream;
99
+ /** Turn the consumed stream into the step verdict. */
100
+ private finishStep;
101
+ /**
102
+ * Append every `tool_call` of the step first, then dispatch in batches of
103
+ * `max_parallel_tool_calls` (clamped to >= 1) and append one `tool_result`
104
+ * per call in model order. Returns true when a cancellation abandoned the
105
+ * batches.
106
+ */
107
+ private dispatchToolCalls;
108
+ /** Dispatch one batch concurrently; results keep the model's call order. */
109
+ private dispatchBatch;
110
+ private recordToolResult;
111
+ /**
112
+ * W267: a cancel mid-dispatch drops the in-flight batch and every later
113
+ * batch, which would leave the assistant `tool_calls` dangling — an
114
+ * OpenAI-compatible upstream rejects that history with 400. One synthesized
115
+ * cancelled result per unanswered call keeps the LOG protocol-valid: in the
116
+ * model's call order, after every real result and before TurnEnd.
117
+ */
118
+ private synthesizeCancelledResults;
119
+ /**
120
+ * W513 step boundary: append every message that arrived while the turn was
121
+ * running as a `user_message` row, in arrival order, before the model call
122
+ * that follows. Returns how many rows were appended.
123
+ *
124
+ * The log is the only source of truth, so the injected text is part of the
125
+ * derived history of THIS turn and of every later step of it — and it is
126
+ * written by the same append path as the turn's own input.
127
+ */
128
+ private injectPending;
129
+ /** Route one turn event to the sink; without a sink the host renders nothing. */
130
+ private emit;
131
+ }
package/dist/loop.js ADDED
@@ -0,0 +1,364 @@
1
+ /**
2
+ * DefaultAgentLoop — port of `crates/agent-loop/src/loop.rs`.
3
+ *
4
+ * One turn = one user message + N model steps. Per step the loop:
5
+ * 1. derives the model-visible history from the session log (the log is the
6
+ * only source of truth) and trims it to the context budget;
7
+ * 2. asks the `Llm` seam for a stream and consumes text / thinking / usage
8
+ * deltas, aggregating reasoning bursts into one persisted row;
9
+ * 3. appends the authoritative assistant reply, or dispatches the step's
10
+ * tool calls through the `ToolRegistry` seam (all `tool_call` rows first,
11
+ * then one `tool_result` per call, in model order);
12
+ * 4. appends whatever arrived while the turn was RUNNING — a user
13
+ * interjection or a worker receipt — to the log at the step boundary,
14
+ * right before the next model call (W513), so the running turn receives it
15
+ * without being interrupted and without a second turn being started;
16
+ * 5. repeats until the model answers without tool calls, the step budget is
17
+ * exhausted, the turn is cancelled, or the stream fails.
18
+ *
19
+ * Every started turn ends with EXACTLY ONE `turn_end` — in the log and on the
20
+ * event stream, written from a single exit point — carrying one of the five
21
+ * real terminal states: completed / cancelled / error / step_limit /
22
+ * interrupted. A torn stream, an exhausted budget or a cancellation is never
23
+ * reported as `completed`.
24
+ *
25
+ * Cancellation is cooperative over an [AbortSignal] and re-checked at every
26
+ * await checkpoint (before generating, while streaming, between tool batches).
27
+ * When a tool batch is abandoned, every unanswered call of the step gets a
28
+ * synthesized cancelled `tool_result`, so the log stays protocol-valid.
29
+ */
30
+ import { AgentError, formatInjection, } from "@celestea/core";
31
+ import { CANCELLED_BEFORE_EXECUTION, closeIterator, errorMessage, isAborted, raceAbort } from "./cancel.js";
32
+ import { estimateTokens, trimContext } from "./context-trim.js";
33
+ import { doneEvent, toolCallEvent, toolResultEvent, turnEndEvent } from "./events.js";
34
+ import { dispatchCall, resolveSeams, toToolInput } from "./seams.js";
35
+ import { absorbDone, emptyStreamOutcome, terminalFromStreamEvent } from "./step.js";
36
+ import { ThinkingBuffer } from "./thinking.js";
37
+ import { UsageTracker } from "./usage.js";
38
+ import { RETENTION_SERVICE, faceToolOutput, newStepRetention, retainToolResult, } from "./retention.js";
39
+ /** Bound of the "do not close while a steering message waits" extension. */
40
+ export const MAX_STEER_EXTENSIONS = 8;
41
+ export class DefaultAgentLoop {
42
+ config;
43
+ signal;
44
+ sink;
45
+ usage;
46
+ injections;
47
+ /** W855: resolved from the Context once per turn (null = retention off). */
48
+ retention = null;
49
+ constructor(config, bindings = {}) {
50
+ this.config = config;
51
+ this.signal = bindings.signal;
52
+ this.sink = bindings.sink;
53
+ this.usage = bindings.usage;
54
+ this.injections = bindings.injections;
55
+ }
56
+ /** The config this loop drives turns with. */
57
+ get agentConfig() {
58
+ return this.config;
59
+ }
60
+ /** The usage tracker bound at construction, when one was provided. */
61
+ get usageTracker() {
62
+ return this.usage;
63
+ }
64
+ /** AgentLoop seam: drive one turn; rejects only on a broken Context wiring. */
65
+ async runTurn(ctx, userInput, attachments) {
66
+ await this.runTurnOutcome(ctx, userInput, attachments);
67
+ }
68
+ /**
69
+ * W725: the EXACT request the next step would build for `ctx` — the same
70
+ * `buildRequest` the turn uses (system prompt + post-trim derived history +
71
+ * `registry.schemas()`), so a read-only context snapshot can never drift from
72
+ * what the model is actually sent.
73
+ *
74
+ * Read-only by construction: it never appends to the log, never dispatches a
75
+ * tool and never touches the step budget or the usage tracker.
76
+ */
77
+ contextSnapshot(ctx) {
78
+ return this.buildRequest(resolveSeams(ctx));
79
+ }
80
+ /** Same turn, handing the terminal state back to the caller (hosts / tests). */
81
+ async runTurnOutcome(ctx, userInput, attachments) {
82
+ const seams = resolveSeams(ctx);
83
+ // W855: the host provides the session-scoped retention policy; absent = off.
84
+ this.retention = ctx.get(RETENTION_SERVICE) ?? null;
85
+ // The LOG owns the monotonic turn id counter, so ids stay unique across
86
+ // loop instances and process restarts.
87
+ const turnId = seams.session.nextTurnId();
88
+ seams.session.append({ type: "turn_start", id: turnId });
89
+ // W804: the turn's own input carries this turn's attachments. The condition
90
+ // keeps the no-attachment row byte-identical to the pre-W804 shape.
91
+ // W855 (C8): a `null` input with no attachments writes NO row — the turn's
92
+ // user content is whatever the drain already injected (see the AgentLoop
93
+ // seam doc). `null` + attachments still writes the `""`-text image row.
94
+ if (attachments !== undefined && attachments.length > 0) {
95
+ seams.session.append({ type: "user_message", text: userInput ?? "", attachments: [...attachments] });
96
+ }
97
+ else if (userInput !== null) {
98
+ seams.session.append({ type: "user_message", text: userInput });
99
+ }
100
+ let outcome = "interrupted";
101
+ let failure;
102
+ let failed = false;
103
+ try {
104
+ outcome = await this.driveSteps(seams);
105
+ }
106
+ catch (error) {
107
+ // W813 P2-runTurnOutcome: a seam throw while deriving the request / driving
108
+ // a step used to escape BEFORE the single TurnEnd write, leaving turn_start
109
+ // dangling (the watchdog then keeps the worker RUNNING forever). Capture it
110
+ // as this turn's terminal state, write the pair below, then rethrow so the
111
+ // broken seam is still visible to the caller.
112
+ failed = true;
113
+ failure = error;
114
+ outcome = { error: { kind: "generate", message: errorMessage(error) } };
115
+ }
116
+ // P0-A: exactly one TurnEnd per turn, log and event stream written as a pair
117
+ // from this single exit point — reached on the throw path too.
118
+ seams.session.append({ type: "turn_end", id: turnId, outcome });
119
+ this.emit(turnEndEvent(outcome));
120
+ if (failed)
121
+ throw failure;
122
+ return outcome;
123
+ }
124
+ /**
125
+ * The step loop: budget -> cancel checkpoint -> one model step.
126
+ *
127
+ * W515 §1 invariant: a turn must NOT reach its terminal state while the
128
+ * `next-step` lane still holds something — the message is drained and answered
129
+ * inside THIS turn (that is what makes "closing -> inject" real). The
130
+ * extension is bounded, and a cancelled turn still stops immediately.
131
+ */
132
+ async driveSteps(seams) {
133
+ let stepsDone = 0;
134
+ let extensions = 0;
135
+ for (;;) {
136
+ // max_steps === 0 means unlimited steps (W220); a nonzero cap stops the
137
+ // loop without a final answer, which is a step_limit, never completed.
138
+ if (this.config.max_steps > 0 && stepsDone >= this.config.max_steps)
139
+ return "step_limit";
140
+ stepsDone += 1;
141
+ if (isAborted(this.signal))
142
+ return "cancelled";
143
+ const step = await this.runStep(seams);
144
+ if (step.kind === "continue")
145
+ continue;
146
+ if (step.kind === "final" && extensions < MAX_STEER_EXTENSIONS && this.pendingSteering() > 0) {
147
+ extensions += 1;
148
+ continue;
149
+ }
150
+ return step.kind === "cancelled" ? "cancelled" : step.outcome;
151
+ }
152
+ }
153
+ /** Steering messages still waiting (the close guard of W515 §1). */
154
+ pendingSteering() {
155
+ return this.injections?.pending?.() ?? 0;
156
+ }
157
+ /**
158
+ * Derive the history from the log and trim it to the context budget. A
159
+ * `context_window_tokens` of 0 disables trimming (back-compat).
160
+ */
161
+ buildRequest(seams) {
162
+ const trimmed = trimContext(seams.session.deriveMessages(), estimateTokens(this.config.system_prompt), this.config.context_window_tokens, this.config.context_trim_threshold, this.config.context_keep_recent);
163
+ return {
164
+ model: this.config.model,
165
+ system: this.config.system_prompt,
166
+ messages: trimmed.messages,
167
+ tools: seams.registry.schemas(),
168
+ max_tokens: null,
169
+ temperature: null,
170
+ };
171
+ }
172
+ /** Start one model response; interruptible, never throws on provider failure. */
173
+ async generate(seams, request) {
174
+ const raced = await raceAbort(this.signal, seams.llm.generate(request));
175
+ if (raced.outcome === "aborted")
176
+ return { kind: "cancelled" };
177
+ if (raced.outcome === "failed") {
178
+ // Generation failure is a terminal error state with a TurnEnd (R1),
179
+ // never a silent return.
180
+ return { kind: "failed", outcome: { error: { kind: "generate", message: errorMessage(raced.error) } } };
181
+ }
182
+ return { kind: "ok", stream: raced.value };
183
+ }
184
+ /** One step: inject what arrived mid-turn, generate, consume, decide. */
185
+ async runStep(seams) {
186
+ this.injectPending(seams);
187
+ const started = await this.generate(seams, this.buildRequest(seams));
188
+ if (started.kind === "cancelled")
189
+ return { kind: "cancelled" };
190
+ if (started.kind === "failed")
191
+ return { kind: "final", outcome: started.outcome };
192
+ const thinking = new ThinkingBuffer(seams.session);
193
+ const stream = await this.consumeStream(started.stream, thinking);
194
+ // Stream-end flush: trailing reasoning (providers stream it AFTER the
195
+ // finish frame), a thinking-only stream and a mid-stream cancel all persist
196
+ // here, ahead of the appends below.
197
+ thinking.flush();
198
+ // The Done event is deferred to this point, so any late thinking still
199
+ // lands before the reply on the wire.
200
+ if (stream.doneMessage !== null)
201
+ this.emit(doneEvent(stream.doneMessage));
202
+ return this.finishStep(seams, stream);
203
+ }
204
+ /**
205
+ * Consume the response stream. A cancel drops the partial turn (no
206
+ * incomplete AssistantMessage is flushed); a failed / torn stream records the
207
+ * matching terminal state instead of pretending success.
208
+ */
209
+ async consumeStream(stream, thinking) {
210
+ const out = emptyStreamOutcome();
211
+ const iter = stream[Symbol.asyncIterator]();
212
+ for (;;) {
213
+ const next = await raceAbort(this.signal, iter.next());
214
+ if (next.outcome === "aborted") {
215
+ closeIterator(iter);
216
+ out.cancelled = true;
217
+ break;
218
+ }
219
+ if (next.outcome === "failed") {
220
+ out.terminal = { error: { kind: "stream", message: errorMessage(next.error) } };
221
+ break;
222
+ }
223
+ if (next.value.done === true)
224
+ break;
225
+ const event = next.value.value;
226
+ if (event.kind === "text") {
227
+ thinking.flush();
228
+ this.emit({ kind: "text", delta: event.text });
229
+ }
230
+ else if (event.kind === "thinking") {
231
+ thinking.push(event.text);
232
+ this.emit({ kind: "thinking", delta: event.text });
233
+ }
234
+ else if (event.kind === "usage") {
235
+ this.usage?.record(event.usage);
236
+ }
237
+ else if (event.kind === "done") {
238
+ thinking.flush();
239
+ absorbDone(out, event.message);
240
+ }
241
+ else {
242
+ thinking.flush();
243
+ out.terminal = terminalFromStreamEvent(event);
244
+ break;
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ /** Turn the consumed stream into the step verdict. */
250
+ async finishStep(seams, stream) {
251
+ if (stream.cancelled)
252
+ return { kind: "cancelled" };
253
+ if (!stream.sawDone) {
254
+ // Stream ended without a terminal frame: a real terminal state, and no
255
+ // empty AssistantMessage is flushed.
256
+ return { kind: "final", outcome: stream.terminal ?? "interrupted" };
257
+ }
258
+ if (stream.toolCalls.length === 0) {
259
+ seams.session.append({ type: "assistant_message", text: stream.assistantText });
260
+ return { kind: "final", outcome: stream.terminal ?? "completed" };
261
+ }
262
+ // Deliberate divergence from the legacy loop (README §Divergences): a torn
263
+ // stream after a done frame ends the turn instead of dispatching tools
264
+ // under a sticky error outcome.
265
+ if (stream.terminal !== null)
266
+ return { kind: "final", outcome: stream.terminal };
267
+ const cancelled = await this.dispatchToolCalls(seams, stream.toolCalls);
268
+ return cancelled ? { kind: "cancelled" } : { kind: "continue" };
269
+ }
270
+ /**
271
+ * Append every `tool_call` of the step first, then dispatch in batches of
272
+ * `max_parallel_tool_calls` (clamped to >= 1) and append one `tool_result`
273
+ * per call in model order. Returns true when a cancellation abandoned the
274
+ * batches.
275
+ */
276
+ async dispatchToolCalls(seams, calls) {
277
+ for (const call of calls) {
278
+ seams.session.append({ type: "tool_call", id: call.id, name: call.name, args: call.args });
279
+ this.emit(toolCallEvent(call));
280
+ }
281
+ const answered = new Set();
282
+ // W855 #8b: retention is skipped for read tools, so the post-execute path
283
+ // must know which tool produced each output (the model's call order).
284
+ const names = new Map(calls.map((call) => [call.id, call.name]));
285
+ const limit = Math.max(1, this.config.max_parallel_tool_calls);
286
+ // W855: ONE cumulative budget per step, debited in model order.
287
+ const step = newStepRetention();
288
+ let cancelled = false;
289
+ for (let start = 0; start < calls.length; start += limit) {
290
+ const batch = calls.slice(start, start + limit);
291
+ const raced = await raceAbort(this.signal, this.dispatchBatch(seams.registry, batch));
292
+ // Unreachable: dispatchCall is total, so a batch never rejects.
293
+ if (raced.outcome === "failed")
294
+ throw new AgentError(`tool dispatch failed: ${errorMessage(raced.error)}`);
295
+ if (raced.outcome === "aborted") {
296
+ cancelled = true;
297
+ break;
298
+ }
299
+ for (const output of raced.value)
300
+ await this.recordToolResult(seams, output, answered, step, names.get(output.call_id) ?? null);
301
+ }
302
+ if (cancelled)
303
+ this.synthesizeCancelledResults(seams, calls, answered);
304
+ return cancelled;
305
+ }
306
+ /** Dispatch one batch concurrently; results keep the model's call order. */
307
+ dispatchBatch(registry, batch) {
308
+ return Promise.all(batch.map((call) => dispatchCall(registry, toToolInput(call))));
309
+ }
310
+ async recordToolResult(seams, output, answered, step, toolName) {
311
+ // W855 (B6): the LOG keeps the ORIGINAL value + a `surface` descriptor; the
312
+ // MODEL/SSE face is rendered from it (retention's bounded window, or a
313
+ // tool-authored truncation note). A failed spill keeps the original inline.
314
+ // W855 #8b: `toolName` lets the policy exempt read tools (no read loop).
315
+ const faces = this.retention === null ? faceToolOutput(output) : await retainToolResult(output, this.retention, step, toolName);
316
+ this.emit(toolResultEvent(faces.face));
317
+ answered.add(faces.logged.call_id);
318
+ seams.session.append({
319
+ type: "tool_result",
320
+ id: faces.logged.call_id,
321
+ value: faces.logged.value,
322
+ error: faces.logged.error,
323
+ ...(faces.logged.surface === undefined ? {} : { surface: faces.logged.surface }),
324
+ });
325
+ }
326
+ /**
327
+ * W267: a cancel mid-dispatch drops the in-flight batch and every later
328
+ * batch, which would leave the assistant `tool_calls` dangling — an
329
+ * OpenAI-compatible upstream rejects that history with 400. One synthesized
330
+ * cancelled result per unanswered call keeps the LOG protocol-valid: in the
331
+ * model's call order, after every real result and before TurnEnd.
332
+ */
333
+ synthesizeCancelledResults(seams, calls, answered) {
334
+ for (const call of calls) {
335
+ if (answered.has(call.id))
336
+ continue;
337
+ const error = CANCELLED_BEFORE_EXECUTION;
338
+ seams.session.append({ type: "tool_result", id: call.id, value: null, error });
339
+ this.emit(toolResultEvent({ call_id: call.id, value: null, render: null, error, decision: null }));
340
+ }
341
+ }
342
+ /**
343
+ * W513 step boundary: append every message that arrived while the turn was
344
+ * running as a `user_message` row, in arrival order, before the model call
345
+ * that follows. Returns how many rows were appended.
346
+ *
347
+ * The log is the only source of truth, so the injected text is part of the
348
+ * derived history of THIS turn and of every later step of it — and it is
349
+ * written by the same append path as the turn's own input.
350
+ */
351
+ injectPending(seams) {
352
+ const pending = this.injections?.drain() ?? [];
353
+ for (const injection of pending) {
354
+ // W888: a mid-turn interjection (worker relay / inbox) is an injected row,
355
+ // not the human's next typed message.
356
+ seams.session.append({ type: "user_message", text: formatInjection(injection), origin: "steering" });
357
+ }
358
+ return pending.length;
359
+ }
360
+ /** Route one turn event to the sink; without a sink the host renders nothing. */
361
+ emit(event) {
362
+ this.sink?.(event);
363
+ }
364
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The agent loop as a PLUGIN (rule 3: everything is a plugin).
3
+ *
4
+ * The composition root mounts this plugin to provide a [DefaultAgentLoop] under
5
+ * the well-known `AGENT_LOOP_SERVICE` token, exactly like `celestea-runtime`
6
+ * provided `AgentLoopService(DefaultAgentLoop::…)`. The loop itself
7
+ * still resolves `Llm` / `SessionLog` / `ToolRegistry` from the Context at turn
8
+ * start, so a plugin never `new`s another package's implementation.
9
+ *
10
+ * Mount order matters at compose time: the three driver seams must be provided
11
+ * BEFORE a turn runs (not before mounting — the loop resolves lazily).
12
+ */
13
+ import { type AgentConfig, type Plugin } from "@celestea/core";
14
+ import { DefaultAgentLoop, type AgentLoopBindings } from "./loop.js";
15
+ /** Build a loop without touching a Context (hosts that drive turns directly). */
16
+ export declare function createAgentLoop(config: AgentConfig, bindings?: AgentLoopBindings): DefaultAgentLoop;
17
+ /**
18
+ * Provide a [DefaultAgentLoop] into the Context. A later mount of the same
19
+ * token wins, so a test can swap in a scripted loop over the real one.
20
+ */
21
+ export declare function agentLoopPlugin(config: AgentConfig, bindings?: AgentLoopBindings, name?: string): Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The agent loop as a PLUGIN (rule 3: everything is a plugin).
3
+ *
4
+ * The composition root mounts this plugin to provide a [DefaultAgentLoop] under
5
+ * the well-known `AGENT_LOOP_SERVICE` token, exactly like `celestea-runtime`
6
+ * provided `AgentLoopService(DefaultAgentLoop::…)`. The loop itself
7
+ * still resolves `Llm` / `SessionLog` / `ToolRegistry` from the Context at turn
8
+ * start, so a plugin never `new`s another package's implementation.
9
+ *
10
+ * Mount order matters at compose time: the three driver seams must be provided
11
+ * BEFORE a turn runs (not before mounting — the loop resolves lazily).
12
+ */
13
+ import { AGENT_LOOP_SERVICE, definePlugin } from "@celestea/core";
14
+ import { DefaultAgentLoop } from "./loop.js";
15
+ /** Build a loop without touching a Context (hosts that drive turns directly). */
16
+ export function createAgentLoop(config, bindings = {}) {
17
+ return new DefaultAgentLoop(config, bindings);
18
+ }
19
+ /**
20
+ * Provide a [DefaultAgentLoop] into the Context. A later mount of the same
21
+ * token wins, so a test can swap in a scripted loop over the real one.
22
+ */
23
+ export function agentLoopPlugin(config, bindings = {}, name = "celestea.agent-loop.DefaultAgentLoop") {
24
+ return definePlugin(name, (ctx) => ctx.provide(AGENT_LOOP_SERVICE, new DefaultAgentLoop(config, bindings)));
25
+ }