@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mcd0LUO
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,182 @@
1
+ # @celestea/agent-loop
2
+
3
+ The `AgentLoop` implementation: turn/step driving, context-budget trimming,
4
+ cooperative cancellation and the five real terminal states. Depends only on
5
+ `@celestea/core`; every collaborator (`Llm`, `SessionLog`, `ToolRegistry`) is
6
+ resolved from the `Context` at turn start, so this package never imports a
7
+ provider, a storage backend or a tool implementation.
8
+
9
+ Ports (1:1 against the legacy engine unless listed under **Divergences**):
10
+
11
+ | TS module | Legacy source |
12
+ |---|---|
13
+ | `loop.ts` (`DefaultAgentLoop`, the step loop) | `crates/agent-loop/src/loop.rs` (`impl AgentLoop for DefaultAgentLoop`) |
14
+ | `step.ts` (per-step verdict types + folds) | inline in `loop.rs` |
15
+ | `seams.ts` (Context → Llm/SessionLog/ToolRegistry) | `loop.rs:204-213` |
16
+ | `cancel.ts` (checkpoints, synthetic result text) | `loop.rs:177-200` (`cancel_set` / `wait_cancel`, W267 constant) |
17
+ | `context-trim.ts` (`estimate_*`, `trim_context`) | `crates/agent-loop/src/context.rs` (W220) |
18
+ | `usage.ts` (`UsageTracker`) | `loop.rs:49-89` (W220) |
19
+ | `events.ts` (`LoopEvent` builders + `EventSink`) | `crates/agent-loop/src/events.rs` |
20
+ | `thinking.ts` (W252 thinking-burst aggregation) | `loop.rs:165-171` (`flush_thinking`) |
21
+ | `plugin.ts` (`AGENT_LOOP_SERVICE` registration) | `crates/runtime/src/compose.rs:208-220` |
22
+ | `sse.ts` (`LoopEvent` → SSE frame) | `celestea_studio/src/main.rs:667-713` (`loop_event_to_json`) |
23
+
24
+ ## Public API (via `index.ts` only)
25
+
26
+ - **Loop** — `DefaultAgentLoop` (implements core's `AgentLoop`), with
27
+ `runTurn(ctx, input)` for the seam and `runTurnOutcome(ctx, input)` for hosts
28
+ that want the terminal state back; `AgentLoopBindings` = `{ signal?, sink?,
29
+ usage? }`; read-only `agentConfig` / `usageTracker` accessors.
30
+ - **Plugin** — `agentLoopPlugin(config, bindings?, name?)` provides a loop under
31
+ core's `AGENT_LOOP_SERVICE` token; `createAgentLoop(config, bindings?)` builds
32
+ one without a Context. Mounting order is patch semantics: a later mount wins.
33
+ - **Cancellation** — `CANCELLED_BEFORE_EXECUTION`, `raceAbort(signal, work)`,
34
+ `isAborted(signal)`, `closeIterator(iter)`, `errorMessage(err)`, `RaceResult`.
35
+ - **Context budget** — `trimContext(messages, systemTokens, windowTokens,
36
+ threshold, keepRecent)` → `{ messages, outcome }`, `estimateTokens`,
37
+ `estimateMessageTokens`, `estimateMessagesTokens`, `trimmedMarkerMessage`,
38
+ `TrimOutcome`, `TrimResult`, `TRIMMED_MARKER_PREFIX`.
39
+ - **Usage** — `UsageTracker` (`record` / `latest` / `total`) and
40
+ `createUsageTracker()`.
41
+ - **Events** — `EventSink`, `toolCallEvent`, `toolResultEvent`, `doneEvent`,
42
+ `turnEndEvent`, `decisionLabel`.
43
+ - **SSE** — `loopEventToSse(event)` → `{ event, payload }` (`SseFrame`).
44
+
45
+ ## Turn semantics (the five terminal states)
46
+
47
+ One turn = `turn_start` + `user_message`, then up to `max_steps` model steps,
48
+ then **exactly one** `turn_end`, written to the log and emitted to the sink from
49
+ a single exit point. `max_steps === 0` means unlimited (W220). The terminal
50
+ state is one of:
51
+
52
+ | state | when |
53
+ |---|---|
54
+ | `completed` | the model answered without tool calls |
55
+ | `cancelled` | the `AbortSignal` fired at any checkpoint |
56
+ | `error{kind}` | `generate` failed before the stream, or `stream` failed mid-flight |
57
+ | `step_limit` | the step budget ran out without a final answer — **never** `completed` |
58
+ | `interrupted` | the stream ended without a terminal frame (torn/EOF) |
59
+
60
+ A partial answer is never flushed as a reply, and `runTurn` only rejects with
61
+ `AgentError` when the Context is missing a driver seam (`missing LlmService in
62
+ context` / `missing SessionLog service in context` / `missing ToolRegistryService
63
+ in context`) — terminal states always ride the log.
64
+
65
+ Log ordering contracts (same as the legacy engine):
66
+
67
+ - all `tool_call` rows of a step precede every `tool_result` row of that step;
68
+ - results are appended in the model's call order even when dispatched in
69
+ parallel batches of `max_parallel_tool_calls` (clamped to ≥ 1);
70
+ - thinking bursts are persisted **before** the reply / tool calls they precede;
71
+ - derived history comes from `SessionLog.deriveMessages()` only — the loop never
72
+ keeps a second copy of the conversation. Tool-call merging is `session`'s job.
73
+
74
+ ## Cancellation
75
+
76
+ Cooperative over an `AbortSignal`, re-checked at every await checkpoint:
77
+ before generating, while consuming the stream (each `next()`), and while a tool
78
+ batch is in flight. On a mid-dispatch cancel, every call of the step that has no
79
+ real result yet gets a synthesized `tool_result` with
80
+ `error = "cancelled before execution"` (W267), appended after the real results
81
+ and before `turn_end`, in model order — the log stays protocol-valid (no
82
+ dangling assistant `tool_calls`), and the sink receives the paired event.
83
+
84
+ `raceAbort` never rejects: a rejected `work` returns `{ outcome: "failed" }`, so
85
+ the loop keeps its state contract instead of leaking a seam exception. The
86
+ abandoned provider stream is closed via `closeIterator`.
87
+
88
+ ## Thinking aggregation (W252) and deferred Done
89
+
90
+ Streamed reasoning deltas are concatenated and flushed as **one**
91
+ `thinking_delta` row per contiguous burst (boundaries: text / done / failed /
92
+ interrupted / stream end / cancel); the live `thinking` event is still emitted
93
+ per delta. The `done` event is **deferred until the stream truly ends**, so
94
+ trailing reasoning that providers send after the finish frame still lands above
95
+ the reply on the wire.
96
+
97
+ ## Usage
98
+
99
+ Bind a `UsageTracker` and every `usage` stream event is recorded: `latest()` is
100
+ the most recent response, `total()` the cumulative sum (detached copies, `Copy`
101
+ semantics). Without a tracker, `usage` events are consumed and ignored.
102
+
103
+ ## Extension points
104
+
105
+ ```ts
106
+ import { Context, mountPlugins, SESSION_LOG_SERVICE, TOOL_REGISTRY_SERVICE, LLM_SERVICE } from "@celestea/core";
107
+ import { agentLoopPlugin, defaultAgentConfig, createUsageTracker } from "@celestea/agent-loop";
108
+
109
+ const usage = createUsageTracker();
110
+ const ctx = Context.root();
111
+ // …mount the llm / session / tools plugins first (they provide the seams)…
112
+ mountPlugins(ctx, [agentLoopPlugin(defaultAgentConfig({ max_steps: 32 }), { usage, signal: abort.signal, sink })]);
113
+ ```
114
+
115
+ - **Another provider / storage / tool set** — mount a different plugin; this
116
+ package needs no change (no `if (provider === …)` anywhere).
117
+ - **Another event transport** — pass your own `EventSink`; without one, events
118
+ are dropped (the host owns rendering).
119
+ - **Another cancellation source** — pass any `AbortSignal` (HTTP request, CLI
120
+ ctrl-c, a worker watchdog); one controller can abort exactly one turn.
121
+ - **Another trimming policy** — `trimContext` is a pure function; call it
122
+ directly or replace the call site in `buildRequest`.
123
+
124
+ ## Divergences from the legacy engine (deliberate, all documented in code)
125
+
126
+ 1. **Cancel signal**: `AbortSignal` owned by the caller instead of a
127
+ `tokio::sync::watch::Receiver` built by the loop.
128
+ 2. **No legacy stdout printer**: a missing sink drops events instead of printing
129
+ `text` / `[thinking] …` (product code must not write to stdout —
130
+ ARCHITECTURE.md §6.3).
131
+ 3. **`max_steps = 0` is unlimited** here (W220 semantics); the studio's
132
+ `MIN_STEPS = 4096` clamp belongs to the host, not the loop.
133
+ 4. **Trim marker text** is a single-spaced sentence; the legacy literal carried
134
+ the source indentation as runs of spaces. Contract-relevant part
135
+ (`[context-trimmed]`, counts, token estimate) is identical.
136
+ 5. **Torn stream after a `done` frame** ends the turn (with the stream's terminal
137
+ state) instead of dispatching that step's tool calls under a sticky error
138
+ outcome — a protocol-violating input that must not look successful.
139
+ 6. **Seam violation on dispatch**: a registry that throws is contained as a
140
+ `tool_result` error (the legacy loop would unwind), so the turn still reaches
141
+ a terminal state; a batch that somehow rejects is reported as `AgentError`.
142
+
143
+ ### P0 placeholder cleanup
144
+
145
+ The P0 skeleton of this package exported `outcomePhase` / `outcomeError`
146
+ (duplicates of `core`'s, now imported from there), plus `MIN_STEPS`,
147
+ `STATUS_TICK_MS` and `createCancelSignal` — host-side loop scaffolding. All of
148
+ them moved out in P1b: the five-state vocabulary lives in `core`, cancellation
149
+ is an `AbortSignal` owned by the caller, and the studio's step clamp belongs to
150
+ `apps/studio`. `loopEventToSse` kept its signature and now lives in `sse.ts`.
151
+
152
+ ## Tests
153
+
154
+ `pnpm vitest run packages/agent-loop` — 56 cases across 7 files, all against
155
+ fake `Llm` / `SessionLog` / `ToolRegistry` seam doubles (no network, no disk):
156
+ `context-trim.test.ts` (estimate + trim matrix), `usage.test.ts`,
157
+ `loop.test.ts` (stepping, five terminal states, TurnEnd uniqueness, deferred
158
+ Done, thinking aggregation, unique turn ids), `loop-tools.test.ts` (dispatch
159
+ order, batch concurrency, request building/trimming, usage), `cancel.test.ts`
160
+ (before the turn / mid-stream / mid-dispatch + W267 synthesis),
161
+ `events.test.ts` (builders + SSE mapping), `plugin.test.ts` (service token).
162
+
163
+ ## File layout
164
+
165
+ ```
166
+ src/index.ts public API (module map + re-exports)
167
+ src/loop.ts DefaultAgentLoop: turn bookkeeping + step loop
168
+ src/step.ts StepResult / GenerateResult / StreamOutcome + folds
169
+ src/seams.ts Context -> Llm / SessionLog / ToolRegistry, safe dispatch
170
+ src/cancel.ts AbortSignal checkpoints + W267 constant
171
+ src/context-trim.ts token estimate + trim_context
172
+ src/usage.ts UsageTracker
173
+ src/events.ts LoopEvent builders + EventSink
174
+ src/thinking.ts thinking-burst buffer (W252)
175
+ src/plugin.ts AGENT_LOOP_SERVICE registration
176
+ src/sse.ts LoopEvent -> SSE frame (contracts/sse-events.json)
177
+ src/fakes.test-util.ts seam doubles + turn harness for the tests
178
+ ```
179
+
180
+ Size policy: every file ≤ 400 lines (max 292), every function ≤ 80 lines, no
181
+ `ARCH_EXCEPTIONS` entry. `pnpm check` (typecheck + lint + lint:arch + test) is
182
+ the gate.
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Cooperative cancellation — the AbortSignal counterpart of the legacy loop's
3
+ * `watch::Receiver<bool>` checkpoints (`cancel_set` / `wait_cancel`).
4
+ *
5
+ * The legacy loop awaited every interruptible step inside a `tokio::select!`
6
+ * against a cancellation future; the TS port races the same checkpoints against
7
+ * an [AbortSignal]. The differences are deliberate and documented in README.md:
8
+ * - the signal is owned by the CALLER (host HTTP route / CLI), not built by
9
+ * the loop, so one controller can abort a whole turn and be reused by the
10
+ * next one;
11
+ * - `raceAbort` never rejects: a rejected `work` is reported as
12
+ * `{ outcome: "failed" }`, so the loop can keep the five-state contract
13
+ * instead of leaking a seam exception;
14
+ * - abandoning in-flight work must not raise an unhandled rejection, so the
15
+ * loser of every race gets a no-op catch attached.
16
+ */
17
+ /** W267: canonical error text of a synthesized ToolResult for a call that
18
+ * never ran because the turn was cancelled mid-dispatch. Shared by the session
19
+ * append and the emitted event so both sides carry the exact same string. */
20
+ export declare const CANCELLED_BEFORE_EXECUTION = "cancelled before execution";
21
+ /** Why an in-flight promise stopped being awaited. */
22
+ export type RaceResult<T> = {
23
+ outcome: "ok";
24
+ value: T;
25
+ } | {
26
+ outcome: "aborted";
27
+ } | {
28
+ outcome: "failed";
29
+ error: unknown;
30
+ };
31
+ /** True when cancellation was already signalled (a synchronous checkpoint). */
32
+ export declare function isAborted(signal: AbortSignal | undefined): boolean;
33
+ /**
34
+ * Await `work`, but give up as soon as `signal` aborts. Re-checks the current
35
+ * value first, so it is safe to call at every checkpoint of a turn.
36
+ */
37
+ export declare function raceAbort<T>(signal: AbortSignal | undefined, work: Promise<T>): Promise<RaceResult<T>>;
38
+ /**
39
+ * Best-effort close of an async iterator abandoned on cancellation: releases
40
+ * the provider stream (and its socket) instead of leaving it suspended. A
41
+ * close failure is irrelevant to the turn, which is already terminal.
42
+ */
43
+ export declare function closeIterator<T>(iter: AsyncIterator<T>): void;
44
+ /** The message of a thrown value, for seam errors that arrive as `unknown`. */
45
+ export declare function errorMessage(error: unknown): string;
package/dist/cancel.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Cooperative cancellation — the AbortSignal counterpart of the legacy loop's
3
+ * `watch::Receiver<bool>` checkpoints (`cancel_set` / `wait_cancel`).
4
+ *
5
+ * The legacy loop awaited every interruptible step inside a `tokio::select!`
6
+ * against a cancellation future; the TS port races the same checkpoints against
7
+ * an [AbortSignal]. The differences are deliberate and documented in README.md:
8
+ * - the signal is owned by the CALLER (host HTTP route / CLI), not built by
9
+ * the loop, so one controller can abort a whole turn and be reused by the
10
+ * next one;
11
+ * - `raceAbort` never rejects: a rejected `work` is reported as
12
+ * `{ outcome: "failed" }`, so the loop can keep the five-state contract
13
+ * instead of leaking a seam exception;
14
+ * - abandoning in-flight work must not raise an unhandled rejection, so the
15
+ * loser of every race gets a no-op catch attached.
16
+ */
17
+ /** W267: canonical error text of a synthesized ToolResult for a call that
18
+ * never ran because the turn was cancelled mid-dispatch. Shared by the session
19
+ * append and the emitted event so both sides carry the exact same string. */
20
+ export const CANCELLED_BEFORE_EXECUTION = "cancelled before execution";
21
+ function ignore() {
22
+ // Deliberate no-op: the loser of a race only needs its rejection consumed.
23
+ }
24
+ function settled(work) {
25
+ return work.then((value) => ({ outcome: "ok", value }), (error) => ({ outcome: "failed", error }));
26
+ }
27
+ /** True when cancellation was already signalled (a synchronous checkpoint). */
28
+ export function isAborted(signal) {
29
+ return signal !== undefined && signal.aborted;
30
+ }
31
+ /**
32
+ * Await `work`, but give up as soon as `signal` aborts. Re-checks the current
33
+ * value first, so it is safe to call at every checkpoint of a turn.
34
+ */
35
+ export function raceAbort(signal, work) {
36
+ if (signal === undefined)
37
+ return settled(work);
38
+ if (signal.aborted) {
39
+ void work.catch(ignore);
40
+ return Promise.resolve({ outcome: "aborted" });
41
+ }
42
+ return new Promise((resolve) => {
43
+ const onAbort = () => {
44
+ void work.catch(ignore);
45
+ resolve({ outcome: "aborted" });
46
+ };
47
+ signal.addEventListener("abort", onAbort, { once: true });
48
+ void work.then((value) => {
49
+ signal.removeEventListener("abort", onAbort);
50
+ resolve({ outcome: "ok", value });
51
+ }, (error) => {
52
+ signal.removeEventListener("abort", onAbort);
53
+ resolve({ outcome: "failed", error });
54
+ });
55
+ });
56
+ }
57
+ /**
58
+ * Best-effort close of an async iterator abandoned on cancellation: releases
59
+ * the provider stream (and its socket) instead of leaving it suspended. A
60
+ * close failure is irrelevant to the turn, which is already terminal.
61
+ */
62
+ export function closeIterator(iter) {
63
+ const close = iter.return;
64
+ if (close === undefined)
65
+ return;
66
+ void Promise.resolve(close.call(iter)).catch(ignore);
67
+ }
68
+ /** The message of a thrown value, for seam errors that arrive as `unknown`. */
69
+ export function errorMessage(error) {
70
+ return error instanceof Error ? error.message : String(error);
71
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Context-budget utilities for the agent loop — port of
3
+ * `crates/agent-loop/src/context.rs` (W220).
4
+ *
5
+ * Token estimation operates on the model-facing messages only and decides
6
+ * *when* to trim; it is deliberately approximate (UTF-8 bytes / 4) and never
7
+ * used to bill or to report usage. Real numbers come from the provider
8
+ * (`core.Usage`) and are handled by [UsageTracker].
9
+ *
10
+ * W762: the trim pass is O(n). It used to score EVERY candidate cut with
11
+ * `estimateMessagesTokens(rest.slice(candidate))` — an O(n) estimate plus an
12
+ * array allocation per candidate, i.e. O(n²) time and O(n) allocations for one
13
+ * pass, which the statusline paid on every tick once a session went over budget
14
+ * (measured: 5k messages ≈ 0.8 s, 10k-event session ≈ 1.0 s per tick). The pass
15
+ * now pre-computes suffix token sums once (`suffix[i] = Σ_{j>=i} tokens`) and
16
+ * scores each candidate in O(1); `removedTokens` is a suffix difference instead
17
+ * of a second full estimate. `pickCut` / `safeCutPositions` are unchanged, so
18
+ * the chosen cut is bit-for-bit the same as before.
19
+ *
20
+ * Contract notes (all mirrored by unit tests, same as the legacy module):
21
+ * - `contextWindowTokens === 0` disables trimming entirely;
22
+ * - over budget, the `contextKeepRecent` most-recent messages survive, plus
23
+ * every `system` message (always kept, always first);
24
+ * - removal is marked with ONE short system message so the model knows;
25
+ * - every cut lands on a system/user boundary, so an assistant tool-call
26
+ * group is never split and the history never starts with an orphan tool
27
+ * message.
28
+ */
29
+ import { type Message } from "@celestea/core";
30
+ /** W804: fixed overhead of one image block in the estimate. */
31
+ export declare const IMAGE_BASE_TOKENS = 85;
32
+ /** W804: coarse area divisor used to estimate an image block's token cost. */
33
+ export declare const IMAGE_PIXEL_DIVISOR = 750;
34
+ /** Marker text prefix (contract: consumers key off `[context-trimmed]`). */
35
+ export declare const TRIMMED_MARKER_PREFIX = "[context-trimmed]";
36
+ /** Estimate the token count of a text fragment (UTF-8 bytes / 4, rounded up). */
37
+ export declare function estimateTokens(text: string): number;
38
+ /** Estimate the token count of one message (content + structural overhead). */
39
+ export declare function estimateMessageTokens(msg: Message): number;
40
+ /**
41
+ * W804: coarse token estimate of one image block (fixed overhead + area term).
42
+ * It is deliberately approximate, exactly like [estimateTokens]: it decides WHEN
43
+ * to trim, never what the provider bills.
44
+ */
45
+ export declare function estimateImageTokens(width: number, height: number): number;
46
+ /**
47
+ * W889: the per-message estimator is INJECTABLE so a caller (test, diagnostic)
48
+ * can count how many times the trim pass estimates — the deterministic stand-in
49
+ * for the old wall-clock "linear-ish" guard. The default is the real estimator,
50
+ * so production behaviour is unchanged.
51
+ */
52
+ export type MessageEstimator = (msg: Message) => number;
53
+ /**
54
+ * Run `fn` with an injected estimator, restoring the previous one afterwards.
55
+ * Scoped (not a bare setter) so a counter can never leak into another test.
56
+ */
57
+ export declare function withMessageEstimator<T>(estimate: MessageEstimator, fn: () => T): T;
58
+ /** Estimate the total token count of a message list (estimator injectable). */
59
+ export declare function estimateMessagesTokens(messages: readonly Message[], estimate?: MessageEstimator): number;
60
+ /** The outcome of one trim pass over the derived history (`TrimOutcome`). */
61
+ export interface TrimOutcome {
62
+ /** How many messages were removed (0 = nothing trimmed). */
63
+ removedMessages: number;
64
+ /** Estimated tokens of the removed messages. */
65
+ removedTokens: number;
66
+ /** True when the history was actually trimmed this pass. */
67
+ trimmed: boolean;
68
+ }
69
+ /** The trimmed history plus what the pass did. */
70
+ export interface TrimResult {
71
+ messages: Message[];
72
+ outcome: TrimOutcome;
73
+ }
74
+ /**
75
+ * Mark the removal of earlier messages with one short system message, so the
76
+ * model knows older context was dropped instead of silently missing it.
77
+ */
78
+ export declare function trimmedMarkerMessage(removedMessages: number, removedTokens: number): Message;
79
+ /**
80
+ * Trim an over-budget message history to fit the context window (W220 v1).
81
+ *
82
+ * `systemTokens` is the estimated size of the outside system prompt
83
+ * (`ModelRequest.system`): it is never trimmed but counts into the budget.
84
+ */
85
+ export declare function trimContext(messages: readonly Message[], systemTokens: number, contextWindowTokens: number, threshold: number, keepRecent: number): TrimResult;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Context-budget utilities for the agent loop — port of
3
+ * `crates/agent-loop/src/context.rs` (W220).
4
+ *
5
+ * Token estimation operates on the model-facing messages only and decides
6
+ * *when* to trim; it is deliberately approximate (UTF-8 bytes / 4) and never
7
+ * used to bill or to report usage. Real numbers come from the provider
8
+ * (`core.Usage`) and are handled by [UsageTracker].
9
+ *
10
+ * W762: the trim pass is O(n). It used to score EVERY candidate cut with
11
+ * `estimateMessagesTokens(rest.slice(candidate))` — an O(n) estimate plus an
12
+ * array allocation per candidate, i.e. O(n²) time and O(n) allocations for one
13
+ * pass, which the statusline paid on every tick once a session went over budget
14
+ * (measured: 5k messages ≈ 0.8 s, 10k-event session ≈ 1.0 s per tick). The pass
15
+ * now pre-computes suffix token sums once (`suffix[i] = Σ_{j>=i} tokens`) and
16
+ * scores each candidate in O(1); `removedTokens` is a suffix difference instead
17
+ * of a second full estimate. `pickCut` / `safeCutPositions` are unchanged, so
18
+ * the chosen cut is bit-for-bit the same as before.
19
+ *
20
+ * Contract notes (all mirrored by unit tests, same as the legacy module):
21
+ * - `contextWindowTokens === 0` disables trimming entirely;
22
+ * - over budget, the `contextKeepRecent` most-recent messages survive, plus
23
+ * every `system` message (always kept, always first);
24
+ * - removal is marked with ONE short system message so the model knows;
25
+ * - every cut lands on a system/user boundary, so an assistant tool-call
26
+ * group is never split and the history never starts with an orphan tool
27
+ * message.
28
+ */
29
+ import { isImageContent, isTextContent, isToolCallContent, serdeJsonString, systemMessage, } from "@celestea/core";
30
+ /** Per-message structural overhead (role + framing) in the estimate. */
31
+ const MESSAGE_OVERHEAD_TOKENS = 4;
32
+ /** Per-tool-call structural overhead in the estimate. */
33
+ const TOOL_CALL_OVERHEAD_TOKENS = 10;
34
+ /** W804: fixed overhead of one image block in the estimate. */
35
+ export const IMAGE_BASE_TOKENS = 85;
36
+ /** W804: coarse area divisor used to estimate an image block's token cost. */
37
+ export const IMAGE_PIXEL_DIVISOR = 750;
38
+ /** Marker text prefix (contract: consumers key off `[context-trimmed]`). */
39
+ export const TRIMMED_MARKER_PREFIX = "[context-trimmed]";
40
+ /** Estimate the token count of a text fragment (UTF-8 bytes / 4, rounded up). */
41
+ export function estimateTokens(text) {
42
+ const bytes = Buffer.byteLength(text, "utf8");
43
+ return Math.ceil(bytes / 4);
44
+ }
45
+ /** Estimate the token count of one message (content + structural overhead). */
46
+ export function estimateMessageTokens(msg) {
47
+ let total = MESSAGE_OVERHEAD_TOKENS;
48
+ for (const content of msg.content) {
49
+ if (isTextContent(content))
50
+ total += estimateTokens(content.content);
51
+ else if (isToolCallContent(content)) {
52
+ const call = content.content;
53
+ total += TOOL_CALL_OVERHEAD_TOKENS + estimateTokens(call.name) + estimateTokens(serdeJsonString(call.args));
54
+ }
55
+ else if (isImageContent(content)) {
56
+ // W804 (R8): an image block MUST contribute tokens, otherwise the trim
57
+ // systematically under-counts and can send an over-window request. P0 has
58
+ // no decoder here, so the estimate is fixed overhead + a coarse area term.
59
+ total += estimateImageTokens(content.content.width, content.content.height);
60
+ }
61
+ }
62
+ if (msg.tool_call_id !== null)
63
+ total += estimateTokens(msg.tool_call_id);
64
+ return total;
65
+ }
66
+ /**
67
+ * W804: coarse token estimate of one image block (fixed overhead + area term).
68
+ * It is deliberately approximate, exactly like [estimateTokens]: it decides WHEN
69
+ * to trim, never what the provider bills.
70
+ */
71
+ export function estimateImageTokens(width, height) {
72
+ const w = Number.isFinite(width) && width > 0 ? width : 0;
73
+ const h = Number.isFinite(height) && height > 0 ? height : 0;
74
+ return IMAGE_BASE_TOKENS + Math.ceil((w * h) / IMAGE_PIXEL_DIVISOR);
75
+ }
76
+ /**
77
+ * W889: the estimator the trim pass uses. Injectable via [withMessageEstimator]
78
+ * so a caller can COUNT estimates (the deterministic complexity guard).
79
+ */
80
+ let activeEstimator = estimateMessageTokens;
81
+ /**
82
+ * Run `fn` with an injected estimator, restoring the previous one afterwards.
83
+ * Scoped (not a bare setter) so a counter can never leak into another test.
84
+ */
85
+ export function withMessageEstimator(estimate, fn) {
86
+ const previous = activeEstimator;
87
+ activeEstimator = estimate;
88
+ try {
89
+ return fn();
90
+ }
91
+ finally {
92
+ activeEstimator = previous;
93
+ }
94
+ }
95
+ /** Estimate the total token count of a message list (estimator injectable). */
96
+ export function estimateMessagesTokens(messages, estimate = estimateMessageTokens) {
97
+ let total = 0;
98
+ for (const msg of messages)
99
+ total += estimate(msg);
100
+ return total;
101
+ }
102
+ const NOT_TRIMMED = { removedMessages: 0, removedTokens: 0, trimmed: false };
103
+ /**
104
+ * Mark the removal of earlier messages with one short system message, so the
105
+ * model knows older context was dropped instead of silently missing it.
106
+ */
107
+ export function trimmedMarkerMessage(removedMessages, removedTokens) {
108
+ return systemMessage(`${TRIMMED_MARKER_PREFIX} Earlier conversation was trimmed to fit the context budget: ` +
109
+ `${removedMessages} message(s) ~${removedTokens} tokens removed. Continue from the recent ` +
110
+ "messages below; ask the user to restate any earlier detail you need.");
111
+ }
112
+ function isCutBoundary(role) {
113
+ return role === "system" || role === "user";
114
+ }
115
+ /** Indices of `rest` where a protocol-safe suffix may start. */
116
+ function safeCutPositions(rest) {
117
+ const cuts = [];
118
+ for (let i = 0; i < rest.length; i++) {
119
+ const role = rest[i]?.role;
120
+ if (role !== undefined && isCutBoundary(role))
121
+ cuts.push(i);
122
+ }
123
+ return cuts;
124
+ }
125
+ /**
126
+ * Pick the cut index: prefer keeping exactly `keepRecent` messages, trim
127
+ * further when that suffix is still over budget (keep the most that fits), and
128
+ * fall back to the last safe boundary when nothing fits.
129
+ */
130
+ function pickCut(cuts, keepRecent, fits) {
131
+ const withinKeep = cuts.find((c) => c >= keepRecent);
132
+ const fitsBudget = cuts.find((c) => fits(c));
133
+ if (withinKeep !== undefined && fitsBudget !== undefined)
134
+ return withinKeep >= fitsBudget ? withinKeep : fitsBudget;
135
+ if (withinKeep !== undefined)
136
+ return withinKeep;
137
+ if (fitsBudget !== undefined)
138
+ return fitsBudget;
139
+ return cuts[cuts.length - 1] ?? 0;
140
+ }
141
+ /**
142
+ * Trim an over-budget message history to fit the context window (W220 v1).
143
+ *
144
+ * `systemTokens` is the estimated size of the outside system prompt
145
+ * (`ModelRequest.system`): it is never trimmed but counts into the budget.
146
+ */
147
+ export function trimContext(messages, systemTokens, contextWindowTokens, threshold, keepRecent) {
148
+ if (contextWindowTokens === 0)
149
+ return { messages: [...messages], outcome: NOT_TRIMMED };
150
+ const budget = Math.max(1, Math.floor(contextWindowTokens * Math.min(Math.max(threshold, 0), 1)));
151
+ // Unchanged fast path: one O(n) estimate, no split, no suffix (the common case
152
+ // of a session inside its budget must not pay for the trim machinery).
153
+ if (systemTokens + estimateMessagesTokens(messages, activeEstimator) <= budget) {
154
+ return { messages: [...messages], outcome: NOT_TRIMMED };
155
+ }
156
+ const split = splitHistory(messages, activeEstimator);
157
+ const systems = split.systems;
158
+ const rest = split.rest;
159
+ if (rest.length === 0)
160
+ return { messages: [...systems], outcome: NOT_TRIMMED };
161
+ const cuts = safeCutPositions(rest);
162
+ // No safe boundary: refuse to risk breaking the tool-call protocol.
163
+ if (cuts.length === 0)
164
+ return { messages: [...systems, ...rest], outcome: NOT_TRIMMED };
165
+ // W762: one O(n) pass, then every `fits(candidate)` is an O(1) array read.
166
+ const suffix = suffixTokenSums(split.restTokens);
167
+ const keep = Math.max(1, keepRecent);
168
+ // W813 P2-trimContext: the predicate must score the request the cut ACTUALLY
169
+ // produces, not just its non-system suffix. Two parts always travel next to
170
+ // that suffix and both used to be dropped from the budget:
171
+ // - every history system message: kept and prepended unconditionally;
172
+ // - the one trim marker: inserted unconditionally whenever anything is cut.
173
+ // The marker carries this candidate's own removed message/token counts, so it
174
+ // is scored exactly (a few characters, still O(1)); the suffix stays a read.
175
+ const fixedTokens = systemTokens + estimateMessagesTokens(systems, activeEstimator);
176
+ const cut = pickCut(cuts, Math.max(0, rest.length - keep), (candidate) => {
177
+ const candidateRemovedTokens = (suffix[0] ?? 0) - (suffix[candidate] ?? 0);
178
+ const markerTokens = activeEstimator(trimmedMarkerMessage(candidate, candidateRemovedTokens));
179
+ return fixedTokens + markerTokens + (suffix[candidate] ?? 0) <= budget;
180
+ });
181
+ // `suffix[0] - suffix[cut]` IS `estimateMessagesTokens(rest.slice(0, cut))`:
182
+ // both are the sum of the same per-message estimates (W762).
183
+ const removedTokens = (suffix[0] ?? 0) - (suffix[cut] ?? 0);
184
+ const outcome = { removedMessages: cut, removedTokens, trimmed: cut > 0 };
185
+ if (!outcome.trimmed)
186
+ return { messages: [...systems, ...rest], outcome: NOT_TRIMMED };
187
+ return {
188
+ messages: [...systems, trimmedMarkerMessage(outcome.removedMessages, outcome.removedTokens), ...rest.slice(cut)],
189
+ outcome,
190
+ };
191
+ }
192
+ /** Split the history in ONE pass, estimating each non-system message once. */
193
+ function splitHistory(messages, estimate) {
194
+ const systems = [];
195
+ const rest = [];
196
+ const restTokens = [];
197
+ for (const msg of messages) {
198
+ if (msg.role === "system")
199
+ systems.push(msg);
200
+ else {
201
+ rest.push(msg);
202
+ restTokens.push(estimate(msg));
203
+ }
204
+ }
205
+ return { systems, rest, restTokens };
206
+ }
207
+ /**
208
+ * W762: suffix token sums — `suffix[i]` is the estimated size of `tokens[i..]`,
209
+ * with `suffix[tokens.length] === 0`. Token counts are non-negative, so the sum
210
+ * is non-increasing in `i`: "does this cut fit?" is monotone, and scoring a
211
+ * candidate costs one array read instead of a full re-estimate.
212
+ */
213
+ function suffixTokenSums(tokens) {
214
+ const suffix = new Array(tokens.length + 1).fill(0);
215
+ for (let i = tokens.length - 1; i >= 0; i -= 1) {
216
+ suffix[i] = (suffix[i + 1] ?? 0) + (tokens[i] ?? 0);
217
+ }
218
+ return suffix;
219
+ }
@@ -0,0 +1,28 @@
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 { type LoopEvent, type Message, type ToolCall, type ToolDecision, type ToolOutput, type TurnOutcome } from "@celestea/core";
18
+ /** A sink receives every LoopEvent of a turn, in log order. */
19
+ export type EventSink = (event: LoopEvent) => void;
20
+ /** `Some(ToolDecision::Allow)` -> `"allow"`: the flat label of the SSE payload. */
21
+ export declare function decisionLabel(decision: ToolDecision | null): "allow" | "deny" | "ask" | null;
22
+ export declare function toolCallEvent(call: ToolCall): LoopEvent;
23
+ /** The full ToolOutput rides the event: value, authored render, error, verdict. */
24
+ export declare function toolResultEvent(output: ToolOutput): LoopEvent;
25
+ /** The authoritative assistant reply of one model step (not terminal). */
26
+ export declare function doneEvent(message: Message): LoopEvent;
27
+ /** The single terminal verdict of the turn. */
28
+ export declare function turnEndEvent(outcome: TurnOutcome): LoopEvent;