@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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Tool-result retention (W855) — bound what ONE step may keep inline, and make
3
+ * every omitted byte retrievable.
4
+
5
+ * B6 (W855) split the two halves of a result:
6
+ * - the session LOG stores the ORIGINAL value (`retainToolResult().logged`);
7
+ * - the MODEL/SSE face is the bounded head/tail window + locator notice
8
+ * (`retainToolResult().face`), rendered by the shared core primitives so a
9
+ * replay of the same log reproduces the live model context byte for byte.
10
+
11
+ * Contract discipline (mirrors dsh-output-retention / dsh-spill):
12
+ * - an omission means THE BUDGET kept something out. An upstream that
13
+ * returned an incomplete body keeps its own domain field and is never
14
+ * described as "truncated" here;
15
+ * - the notice names the omitted amount EXACTLY and always carries a
16
+ * retrieval instruction (the spill locator);
17
+ * - cutting never returns broken UTF-8: head/tail are cut on code-point
18
+ * boundaries;
19
+ * - a spill that fails is BEST-EFFORT: the inline text is kept and the tool
20
+ * call still succeeds (never turned into an error).
21
+
22
+ * The policy is session-scoped and lives in the Context under
23
+ * [RETENTION_SERVICE]; the agent loop reads it once per turn. The persistence
24
+ * half (where the bytes go) belongs to the host, NOT this L1 package.
25
+ */
26
+ import { type ToolOutput } from "@celestea/core";
27
+ /** Context service token: the retention policy of THIS session. */
28
+ export declare const RETENTION_SERVICE = "celestea.agent-loop.ToolResultRetention";
29
+ /**
30
+ * Tools whose results are NEVER rewritten by retention (W855 #8b).
31
+ *
32
+ * `read_file` is how the model retrieves a spilled locator; rewriting a read
33
+ * result into another "read_file <locator>" notice would invite a
34
+ * read -> spill -> read loop (`dsh-spill-policy` skips the same tool). The
35
+ * skipped result does NOT debit the step budget: those bytes are outside the
36
+ * retention budget by policy. W846: `read_file` now paginates with
37
+ * `offset`/`limit`, but each page is still bounded by the tool's 256 KiB
38
+ * budget, so the skip stays correct — a page stays inline up to that cap and
39
+ * the model advances with `nextOffset` instead of a retention locator.
40
+ */
41
+ export declare const RETENTION_SKIP_TOOLS: ReadonlySet<string>;
42
+ /** A persisted full-text tool result. */
43
+ export interface SpillRef {
44
+ /** Where the full text lives (a path the model can read back). */
45
+ locator: string;
46
+ /** Exact byte size of the persisted text. */
47
+ bytes: number;
48
+ /** A concrete instruction for getting the text back. */
49
+ retrievalHint: string;
50
+ }
51
+ export interface ToolResultRetention {
52
+ /** A single result whose model-visible text exceeds this is retained. */
53
+ singleResultBytes: number;
54
+ /** Cumulative model-visible bytes one step may keep inline. */
55
+ stepResultBytes: number;
56
+ /** Head window kept inline when a result is retained. */
57
+ previewHeadBytes: number;
58
+ /** Tail window kept inline when a result is retained. */
59
+ previewTailBytes: number;
60
+ /** Persist the full text; null = best-effort failure (keep it inline). */
61
+ spill(text: string, meta: {
62
+ callId: string;
63
+ }): Promise<SpillRef | null>;
64
+ }
65
+ /** Default single-result threshold: 64 KiB (~16k tokens). */
66
+ export declare const DEFAULT_SINGLE_RESULT_BYTES: number;
67
+ /** Default per-step cumulative threshold: 128 KiB (~32k tokens). */
68
+ export declare const DEFAULT_STEP_RESULT_BYTES: number;
69
+ /** Default inline head window: 4 KiB. */
70
+ export declare const DEFAULT_PREVIEW_HEAD_BYTES: number;
71
+ /** Default inline tail window: 1 KiB. */
72
+ export declare const DEFAULT_PREVIEW_TAIL_BYTES = 1024;
73
+ /** Mutable per-step budget cursor (one per dispatchToolCalls call). */
74
+ export interface StepRetention {
75
+ consumedBytes: number;
76
+ }
77
+ export declare function newStepRetention(): StepRetention;
78
+ export { cutPrefixCodePoints, cutSuffixCodePoints, retainHeadTail } from "@celestea/core";
79
+ export type { RetainedText } from "@celestea/core";
80
+ /** The text retention measures and persists: a string value stays RAW (so the
81
+ * spill file is readable), anything else is the model-visible JSON. */
82
+ export declare function retentionText(output: ToolOutput): string;
83
+ /** The two surfaces of one tool result: what the log keeps vs what the model sees. */
84
+ export interface ToolResultFaces {
85
+ /** The log row: the ORIGINAL value, plus the optional surface descriptor. */
86
+ logged: ToolOutput;
87
+ /** The model/SSE face: `value`/`render` replaced by the rendered surface. */
88
+ face: ToolOutput;
89
+ }
90
+ /** Apply a tool-authored surface (or none) to the face; the log keeps the original. */
91
+ export declare function faceToolOutput(output: ToolOutput): ToolResultFaces;
92
+ /**
93
+ * Apply retention to ONE tool output, returning BOTH surfaces.
94
+ *
95
+ * A result is retained when it exceeds the single-result threshold OR when it
96
+ * would push this step past the cumulative threshold. On ANY spill failure the
97
+ * ORIGINAL is kept inline (fail-soft).
98
+ */
99
+ export declare function retainToolResult(output: ToolOutput, policy: ToolResultRetention, step: StepRetention, toolName?: string | null): Promise<ToolResultFaces>;
100
+ /**
101
+ * Back-compat wrapper: the FACE only (what the model/SSE sees). Callers that
102
+ * must also persist the ORIGINAL use [retainToolResult].
103
+ */
104
+ export declare function retainToolOutput(output: ToolOutput, policy: ToolResultRetention, step: StepRetention, toolName?: string | null): Promise<ToolOutput>;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Tool-result retention (W855) — bound what ONE step may keep inline, and make
3
+ * every omitted byte retrievable.
4
+
5
+ * B6 (W855) split the two halves of a result:
6
+ * - the session LOG stores the ORIGINAL value (`retainToolResult().logged`);
7
+ * - the MODEL/SSE face is the bounded head/tail window + locator notice
8
+ * (`retainToolResult().face`), rendered by the shared core primitives so a
9
+ * replay of the same log reproduces the live model context byte for byte.
10
+
11
+ * Contract discipline (mirrors dsh-output-retention / dsh-spill):
12
+ * - an omission means THE BUDGET kept something out. An upstream that
13
+ * returned an incomplete body keeps its own domain field and is never
14
+ * described as "truncated" here;
15
+ * - the notice names the omitted amount EXACTLY and always carries a
16
+ * retrieval instruction (the spill locator);
17
+ * - cutting never returns broken UTF-8: head/tail are cut on code-point
18
+ * boundaries;
19
+ * - a spill that fails is BEST-EFFORT: the inline text is kept and the tool
20
+ * call still succeeds (never turned into an error).
21
+
22
+ * The policy is session-scoped and lives in the Context under
23
+ * [RETENTION_SERVICE]; the agent loop reads it once per turn. The persistence
24
+ * half (where the bytes go) belongs to the host, NOT this L1 package.
25
+ */
26
+ import { renderOmittedText, retainHeadTail, toolSurfaceText, toolSurfaceValue } from "@celestea/core";
27
+ /** Context service token: the retention policy of THIS session. */
28
+ export const RETENTION_SERVICE = "celestea.agent-loop.ToolResultRetention";
29
+ /**
30
+ * Tools whose results are NEVER rewritten by retention (W855 #8b).
31
+ *
32
+ * `read_file` is how the model retrieves a spilled locator; rewriting a read
33
+ * result into another "read_file <locator>" notice would invite a
34
+ * read -> spill -> read loop (`dsh-spill-policy` skips the same tool). The
35
+ * skipped result does NOT debit the step budget: those bytes are outside the
36
+ * retention budget by policy. W846: `read_file` now paginates with
37
+ * `offset`/`limit`, but each page is still bounded by the tool's 256 KiB
38
+ * budget, so the skip stays correct — a page stays inline up to that cap and
39
+ * the model advances with `nextOffset` instead of a retention locator.
40
+ */
41
+ export const RETENTION_SKIP_TOOLS = new Set(["read_file"]);
42
+ /** Default single-result threshold: 64 KiB (~16k tokens). */
43
+ export const DEFAULT_SINGLE_RESULT_BYTES = 64 * 1024;
44
+ /** Default per-step cumulative threshold: 128 KiB (~32k tokens). */
45
+ export const DEFAULT_STEP_RESULT_BYTES = 128 * 1024;
46
+ /** Default inline head window: 4 KiB. */
47
+ export const DEFAULT_PREVIEW_HEAD_BYTES = 4 * 1024;
48
+ /** Default inline tail window: 1 KiB. */
49
+ export const DEFAULT_PREVIEW_TAIL_BYTES = 1024;
50
+ export function newStepRetention() {
51
+ return { consumedBytes: 0 };
52
+ }
53
+ // W855 (B6): the pure cut/render primitives live in core so the projection can
54
+ // render the face without importing this L1 policy layer. Re-exported here so
55
+ // the package keeps its stable import path.
56
+ export { cutPrefixCodePoints, cutSuffixCodePoints, retainHeadTail } from "@celestea/core";
57
+ /** The text retention measures and persists: a string value stays RAW (so the
58
+ * spill file is readable), anything else is the model-visible JSON. */
59
+ export function retentionText(output) {
60
+ if (typeof output.error === "string" && output.error.length > 0)
61
+ return "Error: " + output.error;
62
+ return typeof output.value === "string" ? output.value : toolSurfaceText(output.value, undefined);
63
+ }
64
+ /** Apply a tool-authored surface (or none) to the face; the log keeps the original. */
65
+ export function faceToolOutput(output) {
66
+ if (output.surface === undefined)
67
+ return { logged: output, face: output };
68
+ const face = toolSurfaceValue(output.value, output.surface); // RAW (SSE/UI) face
69
+ return { logged: output, face: { ...output, value: face, render: typeof face === "string" ? face : output.render } };
70
+ }
71
+ /**
72
+ * Apply retention to ONE tool output, returning BOTH surfaces.
73
+ *
74
+ * A result is retained when it exceeds the single-result threshold OR when it
75
+ * would push this step past the cumulative threshold. On ANY spill failure the
76
+ * ORIGINAL is kept inline (fail-soft).
77
+ */
78
+ export async function retainToolResult(output, policy, step, toolName = null) {
79
+ const text = retentionText(output);
80
+ const bytes = Buffer.byteLength(text, "utf8");
81
+ // W855 #8b: a read tool's result IS the retrieval path, not a payload to
82
+ // spill; skipping it prevents read -> spill -> read (and does not debit the
83
+ // step budget — see RETENTION_SKIP_TOOLS).
84
+ if (toolName !== null && RETENTION_SKIP_TOOLS.has(toolName))
85
+ return faceToolOutput(output);
86
+ // W855 decision (architect, 2026-09-18): ONLY a successful STRING result is
87
+ // rewritten. An object result's value shape is part of the tool contract
88
+ // (consumers branch on typeof value === "object"), so this layer NEVER
89
+ // changes its type; shrinking a large object is the tool's own decision
90
+ // (deferred). Error results are messages, not payloads, and pass through too.
91
+ // Non-string results still count toward the step budget.
92
+ if (output.error !== null || typeof output.value !== "string") {
93
+ step.consumedBytes += bytes;
94
+ return faceToolOutput(output);
95
+ }
96
+ const overSingle = bytes > policy.singleResultBytes;
97
+ const overStep = step.consumedBytes + bytes > policy.stepResultBytes;
98
+ if (!overSingle && !overStep) {
99
+ step.consumedBytes += bytes;
100
+ return faceToolOutput(output);
101
+ }
102
+ let ref = null;
103
+ try {
104
+ ref = await policy.spill(text, { callId: output.call_id });
105
+ }
106
+ catch {
107
+ ref = null;
108
+ }
109
+ if (ref === null) {
110
+ // Best-effort: the tool call stays successful and keeps its full result.
111
+ step.consumedBytes += bytes;
112
+ return faceToolOutput(output);
113
+ }
114
+ const window = retainHeadTail(text, policy.previewHeadBytes, policy.previewTailBytes);
115
+ const surface = {
116
+ kind: "omitted",
117
+ omitted_bytes: window.omittedBytes,
118
+ total_bytes: window.totalBytes,
119
+ locator: ref.locator,
120
+ retrieval_hint: ref.retrievalHint,
121
+ head_bytes: policy.previewHeadBytes,
122
+ tail_bytes: policy.previewTailBytes,
123
+ };
124
+ const face = renderOmittedText(text, surface);
125
+ step.consumedBytes += Buffer.byteLength(face, "utf8");
126
+ return { logged: { ...output, surface }, face: { ...output, value: face, render: face, surface } };
127
+ }
128
+ /**
129
+ * Back-compat wrapper: the FACE only (what the model/SSE sees). Callers that
130
+ * must also persist the ORIGINAL use [retainToolResult].
131
+ */
132
+ export async function retainToolOutput(output, policy, step, toolName = null) {
133
+ return (await retainToolResult(output, policy, step, toolName)).face;
134
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Driver-seam resolution for one turn.
3
+ *
4
+ * The loop never imports an implementation: it resolves the three seams the
5
+ * legacy `run_turn` resolved from the shared `Context` — `Llm` (how to generate),
6
+ * `SessionLog` (where the single source of truth lives) and `ToolRegistry`
7
+ * (how to dispatch tool calls) — and fails loudly when the composition root
8
+ * forgot one. Same three services, same order, as `crates/agent-loop/src/loop.rs`.
9
+ */
10
+ import { type Context, type Llm, type SessionLog, type ToolCall, type ToolInput, type ToolOutput, type ToolRegistry } from "@celestea/core";
11
+ /** The three seams a turn resolves from the Context. */
12
+ export interface Seams {
13
+ llm: Llm;
14
+ session: SessionLog;
15
+ registry: ToolRegistry;
16
+ }
17
+ /** Resolve the driver seams; a missing service is a wiring bug, not a state. */
18
+ export declare function resolveSeams(ctx: Context): Seams;
19
+ export declare function toToolInput(call: ToolCall): ToolInput;
20
+ /**
21
+ * Dispatch one call, containing a seam violation: `ToolRegistry.dispatch`
22
+ * captures tool errors in `ToolOutput.error` and must not throw, so an escaping
23
+ * exception would otherwise leave the turn without a terminal state. The
24
+ * legacy loop could rely on `?`-free unwrapping; TS keeps the same totality
25
+ * explicitly.
26
+ */
27
+ export declare function dispatchCall(registry: ToolRegistry, input: ToolInput): Promise<ToolOutput>;
package/dist/seams.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Driver-seam resolution for one turn.
3
+ *
4
+ * The loop never imports an implementation: it resolves the three seams the
5
+ * legacy `run_turn` resolved from the shared `Context` — `Llm` (how to generate),
6
+ * `SessionLog` (where the single source of truth lives) and `ToolRegistry`
7
+ * (how to dispatch tool calls) — and fails loudly when the composition root
8
+ * forgot one. Same three services, same order, as `crates/agent-loop/src/loop.rs`.
9
+ */
10
+ import { AgentError, LLM_SERVICE, SESSION_LOG_SERVICE, TOOL_REGISTRY_SERVICE, } from "@celestea/core";
11
+ import { errorMessage } from "./cancel.js";
12
+ /** Resolve the driver seams; a missing service is a wiring bug, not a state. */
13
+ export function resolveSeams(ctx) {
14
+ const llm = ctx.get(LLM_SERVICE);
15
+ if (llm === undefined)
16
+ throw new AgentError("missing LlmService in context");
17
+ const session = ctx.get(SESSION_LOG_SERVICE);
18
+ if (session === undefined)
19
+ throw new AgentError("missing SessionLog service in context");
20
+ const registry = ctx.get(TOOL_REGISTRY_SERVICE);
21
+ if (registry === undefined)
22
+ throw new AgentError("missing ToolRegistryService in context");
23
+ return { llm, session, registry };
24
+ }
25
+ export function toToolInput(call) {
26
+ return { call_id: call.id, name: call.name, args: call.args };
27
+ }
28
+ /**
29
+ * Dispatch one call, containing a seam violation: `ToolRegistry.dispatch`
30
+ * captures tool errors in `ToolOutput.error` and must not throw, so an escaping
31
+ * exception would otherwise leave the turn without a terminal state. The
32
+ * legacy loop could rely on `?`-free unwrapping; TS keeps the same totality
33
+ * explicitly.
34
+ */
35
+ export async function dispatchCall(registry, input) {
36
+ try {
37
+ return await registry.dispatch(input);
38
+ }
39
+ catch (error) {
40
+ return { call_id: input.call_id, value: null, render: null, error: errorMessage(error), decision: null };
41
+ }
42
+ }
package/dist/sse.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * LoopEvent -> (SSE event name, payload) — the exact mapping of
3
+ * `celestea_studio/src/main.rs:667-713` (`loop_event_to_json`).
4
+ *
5
+ * Kept in this package because it is contract, not transport: the frozen
6
+ * `contracts/sse-events.json` table is checked against it, and the host
7
+ * (`apps/studio`) only publishes the resulting frames. Phase/error labels come
8
+ * from `core` (`outcomePhase` / `outcomeError`), so the agent loop never keeps
9
+ * a second copy of that vocabulary.
10
+ */
11
+ import { type LoopEvent, type SseEventName } from "@celestea/core";
12
+ export interface SseFrame {
13
+ event: SseEventName;
14
+ payload: Record<string, unknown>;
15
+ }
16
+ /** LoopEvent -> (SSE event name, payload) exactly as `loop_event_to_json`. */
17
+ export declare function loopEventToSse(ev: LoopEvent): SseFrame;
package/dist/sse.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * LoopEvent -> (SSE event name, payload) — the exact mapping of
3
+ * `celestea_studio/src/main.rs:667-713` (`loop_event_to_json`).
4
+ *
5
+ * Kept in this package because it is contract, not transport: the frozen
6
+ * `contracts/sse-events.json` table is checked against it, and the host
7
+ * (`apps/studio`) only publishes the resulting frames. Phase/error labels come
8
+ * from `core` (`outcomePhase` / `outcomeError`), so the agent loop never keeps
9
+ * a second copy of that vocabulary.
10
+ */
11
+ import { outcomeError, outcomePhase } from "@celestea/core";
12
+ /** LoopEvent -> (SSE event name, payload) exactly as `loop_event_to_json`. */
13
+ export function loopEventToSse(ev) {
14
+ switch (ev.kind) {
15
+ case "text":
16
+ return { event: "text", payload: { delta: ev.delta } };
17
+ case "thinking":
18
+ return { event: "thinking", payload: { delta: ev.delta } };
19
+ case "tool_call":
20
+ return { event: "tool", payload: { id: ev.id, name: ev.name, args: ev.args } };
21
+ case "tool_result":
22
+ // W855 (B6): `value` is the MODEL FACE, not the log's original — the loop
23
+ // builds the face from the retention surface before emitting. Replay derives
24
+ // the same face from the log (`session/src/replay.ts`).
25
+ return {
26
+ event: "tool_result",
27
+ payload: {
28
+ id: ev.callId,
29
+ ok: ev.ok,
30
+ value: ev.value,
31
+ render: ev.render,
32
+ error: ev.error,
33
+ decision: ev.decision,
34
+ },
35
+ };
36
+ case "turn_end":
37
+ return { event: "turn_end", payload: { outcome: outcomePhase(ev.outcome), error: outcomeError(ev.outcome) } };
38
+ case "done":
39
+ return { event: "done", payload: { text: ev.text, tool_calls: ev.tool_calls } };
40
+ }
41
+ }
package/dist/step.d.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Per-step bookkeeping types and the pure folds of one model step.
3
+ *
4
+ * Kept apart from the loop driver so the verdict vocabulary ("what did this
5
+ * step decide?") is readable on its own: a step can end the turn, ask for
6
+ * another step, be cancelled, or fail to start at all.
7
+ */
8
+ import { type LlmStream, type Message, type StreamEvent, type ToolCall, type TurnOutcome } from "@celestea/core";
9
+ /** Verdict of one model step, consumed by the step loop. */
10
+ export type StepResult =
11
+ /** The turn is over (completed, or a torn stream after a done frame). */
12
+ {
13
+ kind: "final";
14
+ outcome: TurnOutcome;
15
+ }
16
+ /** Tool calls were dispatched; step again. */
17
+ | {
18
+ kind: "continue";
19
+ } | {
20
+ kind: "cancelled";
21
+ };
22
+ /** What starting one model response produced. */
23
+ export type GenerateResult = {
24
+ kind: "ok";
25
+ stream: LlmStream;
26
+ } | {
27
+ kind: "cancelled";
28
+ } | {
29
+ kind: "failed";
30
+ outcome: TurnOutcome;
31
+ };
32
+ /** Everything the loop learned from one response stream. */
33
+ export interface StreamOutcome {
34
+ assistantText: string;
35
+ toolCalls: ToolCall[];
36
+ sawDone: boolean;
37
+ doneMessage: Message | null;
38
+ cancelled: boolean;
39
+ terminal: TurnOutcome | null;
40
+ }
41
+ export declare function emptyStreamOutcome(): StreamOutcome;
42
+ /** Fold the authoritative `done` message into the step accumulators. */
43
+ export declare function absorbDone(out: StreamOutcome, message: Message): void;
44
+ /** `failed` / `interrupted` are terminal: they decide the turn's state. */
45
+ export declare function terminalFromStreamEvent(ev: StreamEvent): TurnOutcome;
package/dist/step.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Per-step bookkeeping types and the pure folds of one model step.
3
+ *
4
+ * Kept apart from the loop driver so the verdict vocabulary ("what did this
5
+ * step decide?") is readable on its own: a step can end the turn, ask for
6
+ * another step, be cancelled, or fail to start at all.
7
+ */
8
+ import { messageTexts, messageToolCalls } from "@celestea/core";
9
+ export function emptyStreamOutcome() {
10
+ return { assistantText: "", toolCalls: [], sawDone: false, doneMessage: null, cancelled: false, terminal: null };
11
+ }
12
+ /** Fold the authoritative `done` message into the step accumulators. */
13
+ export function absorbDone(out, message) {
14
+ out.sawDone = true;
15
+ out.doneMessage = message;
16
+ out.assistantText += messageTexts(message).join("");
17
+ out.toolCalls.push(...messageToolCalls(message));
18
+ }
19
+ /** `failed` / `interrupted` are terminal: they decide the turn's state. */
20
+ export function terminalFromStreamEvent(ev) {
21
+ return ev.kind === "failed" ? { error: { kind: ev.kindOf, message: ev.message } } : "interrupted";
22
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * W252: thinking-delta aggregation.
3
+ *
4
+ * The session log is the replay source of truth, but reasoning streams arrive
5
+ * token by token: one jsonl row per delta would explode the log. Consecutive
6
+ * `thinking` deltas are therefore concatenated into a buffer and flushed only
7
+ * at visible boundaries (text / done / failed / stream end / cancellation), so
8
+ * one contiguous reasoning burst becomes exactly ONE persisted
9
+ * `thinking_delta` row. The live `thinking` event is still emitted per delta —
10
+ * only persistence aggregates.
11
+ */
12
+ import type { SessionLog } from "@celestea/core";
13
+ export declare class ThinkingBuffer {
14
+ private readonly session;
15
+ private buffered;
16
+ constructor(session: SessionLog);
17
+ /** Concatenate one delta into the current burst. */
18
+ push(delta: string): void;
19
+ /** Persist the current burst as one row; a no-op for an empty buffer. */
20
+ flush(): void;
21
+ /** True when deltas are waiting to be persisted (diagnostics / tests). */
22
+ get pending(): boolean;
23
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * W252: thinking-delta aggregation.
3
+ *
4
+ * The session log is the replay source of truth, but reasoning streams arrive
5
+ * token by token: one jsonl row per delta would explode the log. Consecutive
6
+ * `thinking` deltas are therefore concatenated into a buffer and flushed only
7
+ * at visible boundaries (text / done / failed / stream end / cancellation), so
8
+ * one contiguous reasoning burst becomes exactly ONE persisted
9
+ * `thinking_delta` row. The live `thinking` event is still emitted per delta —
10
+ * only persistence aggregates.
11
+ */
12
+ export class ThinkingBuffer {
13
+ session;
14
+ buffered = "";
15
+ constructor(session) {
16
+ this.session = session;
17
+ }
18
+ /** Concatenate one delta into the current burst. */
19
+ push(delta) {
20
+ this.buffered += delta;
21
+ }
22
+ /** Persist the current burst as one row; a no-op for an empty buffer. */
23
+ flush() {
24
+ if (this.buffered === "")
25
+ return;
26
+ const text = this.buffered;
27
+ this.buffered = "";
28
+ this.session.append({ type: "thinking_delta", text });
29
+ }
30
+ /** True when deltas are waiting to be persisted (diagnostics / tests). */
31
+ get pending() {
32
+ return this.buffered !== "";
33
+ }
34
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * UsageTracker — port of the `UsageTracker` of
3
+ * `crates/agent-loop/src/loop.rs` (W220).
4
+ *
5
+ * The loop records the `usage` stream event of every LLM response; the host
6
+ * reads `latest()` (most recent response) and `total()` (cumulative) to expose
7
+ * the statusline and to drive future trimming decisions. Both accessors return
8
+ * copies, matching the engine's `Copy` semantics — a caller can never mutate the
9
+ * tracker's state through the returned value.
10
+ */
11
+ import { type Usage } from "@celestea/core";
12
+ export declare class UsageTracker {
13
+ private totalUsage;
14
+ private latestUsage;
15
+ /** Record one LLM response: adds to the cumulative total, becomes latest. */
16
+ record(usage: Usage): void;
17
+ /** The usage of the most recent LLM response (zeroed when none yet). */
18
+ latest(): Usage;
19
+ /** Cumulative usage across every recorded response. */
20
+ total(): Usage;
21
+ }
22
+ /** Factory form (`createXxx` convention, ARCHITECTURE.md §6.1). */
23
+ export declare function createUsageTracker(): UsageTracker;
package/dist/usage.js ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * UsageTracker — port of the `UsageTracker` of
3
+ * `crates/agent-loop/src/loop.rs` (W220).
4
+ *
5
+ * The loop records the `usage` stream event of every LLM response; the host
6
+ * reads `latest()` (most recent response) and `total()` (cumulative) to expose
7
+ * the statusline and to drive future trimming decisions. Both accessors return
8
+ * copies, matching the engine's `Copy` semantics — a caller can never mutate the
9
+ * tracker's state through the returned value.
10
+ */
11
+ import { usageAdd, zeroUsage } from "@celestea/core";
12
+ export class UsageTracker {
13
+ totalUsage = zeroUsage();
14
+ latestUsage = zeroUsage();
15
+ /** Record one LLM response: adds to the cumulative total, becomes latest. */
16
+ record(usage) {
17
+ this.totalUsage = usageAdd(this.totalUsage, usage);
18
+ this.latestUsage = usage;
19
+ }
20
+ /** The usage of the most recent LLM response (zeroed when none yet). */
21
+ latest() {
22
+ return { ...this.latestUsage };
23
+ }
24
+ /** Cumulative usage across every recorded response. */
25
+ total() {
26
+ return { ...this.totalUsage };
27
+ }
28
+ }
29
+ /** Factory form (`createXxx` convention, ARCHITECTURE.md §6.1). */
30
+ export function createUsageTracker() {
31
+ return new UsageTracker();
32
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@celestea/agent-loop",
3
+ "version": "2.7.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@celestea/core": "2.7.1"
14
+ },
15
+ "license": "MIT",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit -p tsconfig.json",
26
+ "build": "tsc -p tsconfig.build.json"
27
+ }
28
+ }