@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 +21 -0
- package/README.md +182 -0
- package/dist/cancel.d.ts +45 -0
- package/dist/cancel.js +71 -0
- package/dist/context-trim.d.ts +85 -0
- package/dist/context-trim.js +219 -0
- package/dist/events.d.ts +28 -0
- package/dist/events.js +48 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +31 -0
- package/dist/loop.d.ts +131 -0
- package/dist/loop.js +364 -0
- package/dist/plugin.d.ts +21 -0
- package/dist/plugin.js +25 -0
- package/dist/retention.d.ts +104 -0
- package/dist/retention.js +134 -0
- package/dist/seams.d.ts +27 -0
- package/dist/seams.js +42 -0
- package/dist/sse.d.ts +17 -0
- package/dist/sse.js +41 -0
- package/dist/step.d.ts +45 -0
- package/dist/step.js +22 -0
- package/dist/thinking.d.ts +23 -0
- package/dist/thinking.js +34 -0
- package/dist/usage.d.ts +23 -0
- package/dist/usage.js +32 -0
- package/package.json +28 -0
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.
|
package/dist/cancel.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -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;
|