@celestea/runtime 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 +106 -0
- package/dist/agent-config.d.ts +18 -0
- package/dist/agent-config.js +31 -0
- package/dist/autowake.d.ts +141 -0
- package/dist/autowake.js +262 -0
- package/dist/compact/index.d.ts +13 -0
- package/dist/compact/index.js +13 -0
- package/dist/compact/plan.d.ts +51 -0
- package/dist/compact/plan.js +98 -0
- package/dist/compact/rewrite.d.ts +23 -0
- package/dist/compact/rewrite.js +79 -0
- package/dist/compact/run.d.ts +44 -0
- package/dist/compact/run.js +59 -0
- package/dist/compact/summarize.d.ts +30 -0
- package/dist/compact/summarize.js +70 -0
- package/dist/compact/transcript.d.ts +35 -0
- package/dist/compact/transcript.js +88 -0
- package/dist/compose.d.ts +117 -0
- package/dist/compose.js +191 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +34 -0
- package/dist/frames.d.ts +46 -0
- package/dist/frames.js +62 -0
- package/dist/gen.d.ts +86 -0
- package/dist/gen.js +129 -0
- package/dist/host/engine-session.d.ts +117 -0
- package/dist/host/engine-session.js +109 -0
- package/dist/host/index.d.ts +39 -0
- package/dist/host/index.js +39 -0
- package/dist/host/provider-target.d.ts +113 -0
- package/dist/host/provider-target.js +116 -0
- package/dist/inbox-checkpoint.d.ts +18 -0
- package/dist/inbox-checkpoint.js +37 -0
- package/dist/inbox.d.ts +94 -0
- package/dist/inbox.js +139 -0
- package/dist/index.d.ts +71 -0
- package/dist/index.js +71 -0
- package/dist/ledger-io.d.ts +27 -0
- package/dist/ledger-io.js +74 -0
- package/dist/ledger-llm.d.ts +48 -0
- package/dist/ledger-llm.js +115 -0
- package/dist/ledger-query.d.ts +91 -0
- package/dist/ledger-query.js +153 -0
- package/dist/ledger.d.ts +271 -0
- package/dist/ledger.js +444 -0
- package/dist/pricing.d.ts +100 -0
- package/dist/pricing.js +167 -0
- package/dist/profile.d.ts +26 -0
- package/dist/profile.js +39 -0
- package/dist/recovery.d.ts +56 -0
- package/dist/recovery.js +91 -0
- package/dist/retention.d.ts +49 -0
- package/dist/retention.js +119 -0
- package/dist/runtime.d.ts +197 -0
- package/dist/runtime.js +347 -0
- package/dist/sanitize.d.ts +35 -0
- package/dist/sanitize.js +36 -0
- package/dist/session-binding.d.ts +36 -0
- package/dist/session-binding.js +33 -0
- package/dist/session-registry.d.ts +238 -0
- package/dist/session-registry.js +388 -0
- package/dist/status.d.ts +279 -0
- package/dist/status.js +411 -0
- package/dist/tokens.d.ts +25 -0
- package/dist/tokens.js +25 -0
- package/dist/turn-runner.d.ts +169 -0
- package/dist/turn-runner.js +242 -0
- package/dist/usage.d.ts +64 -0
- package/dist/usage.js +88 -0
- package/dist/watchdog-mount.d.ts +79 -0
- package/dist/watchdog-mount.js +120 -0
- package/dist/worker-wiring.d.ts +74 -0
- package/dist/worker-wiring.js +107 -0
- package/package.json +31 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composed runtime engine: one generation's services, wired and ready.
|
|
3
|
+
*
|
|
4
|
+
* A [Runtime] is deliberately thin — it owns the handles and the lifecycle and
|
|
5
|
+
* delegates turn driving to [TurnRunner]:
|
|
6
|
+
*
|
|
7
|
+
* - **turn driving** `runTurn(input, {signal, sink})`, single concurrency slot;
|
|
8
|
+
* - **statusline** `statusline()` reads the live tracker + usage accounting;
|
|
9
|
+
* - **rebinding** `rebind(binding)` re-opens the SAME session directory;
|
|
10
|
+
* - **shutdown** idempotent, re-entrant teardown (drivers, host process
|
|
11
|
+
* hooks, mailbox, registries): calling it twice is a
|
|
12
|
+
* no-op, and a concurrent caller awaits the same promise;
|
|
13
|
+
* - **release** explicit strong-reference drop for hot swaps (W248).
|
|
14
|
+
*
|
|
15
|
+
* `shutdown` also marks the session's checkpoint sidecar as cleanly closed
|
|
16
|
+
* (E §1.3 P0 ⑤), so the next boot can tell a graceful exit from a crash.
|
|
17
|
+
*
|
|
18
|
+
* [release] nulls every handle, which — together with the WeakRef the worker
|
|
19
|
+
* tools hold on the registry — breaks the
|
|
20
|
+
* `Runtime -> ctx -> ToolRegistry -> worker tool -> registry` cycle, so a
|
|
21
|
+
* swapped-out generation can actually be collected.
|
|
22
|
+
*/
|
|
23
|
+
import type { AgentConfig, Context, LlmRegistry, ModelRequest, SessionLog, Statusline, ToolRegistry, TurnOutcome } from "@celestea/core";
|
|
24
|
+
import type { Watchdog, WorkerRegistry } from "@celestea/workers";
|
|
25
|
+
import type { InjectionLane } from "@celestea/core";
|
|
26
|
+
import type { InboxPushOptions, InjectedMessage, SessionInbox } from "./inbox.js";
|
|
27
|
+
import { type SessionBinding } from "./session-binding.js";
|
|
28
|
+
import { type AssembledContext, type StatusTracker, type StatusView } from "./status.js";
|
|
29
|
+
import type { FrameSink, TurnOptions, TurnRunner } from "./turn-runner.js";
|
|
30
|
+
import type { TurnFrame } from "./frames.js";
|
|
31
|
+
import type { UsageAccounting } from "./usage.js";
|
|
32
|
+
import type { Profile } from "./profile.js";
|
|
33
|
+
import type { WorkerHost } from "./worker-wiring.js";
|
|
34
|
+
export type ShutdownHook = () => void | Promise<void>;
|
|
35
|
+
/** Everything compose hands over; the constructor never does IO of its own. */
|
|
36
|
+
export interface RuntimeParts {
|
|
37
|
+
ctx: Context;
|
|
38
|
+
profile: Profile;
|
|
39
|
+
agentConfig: AgentConfig;
|
|
40
|
+
/** Mutable holder: a rebind swaps the log every reader observes. */
|
|
41
|
+
sessionRef: {
|
|
42
|
+
log: SessionLog;
|
|
43
|
+
};
|
|
44
|
+
binding: SessionBinding | null;
|
|
45
|
+
status: StatusTracker;
|
|
46
|
+
usage: UsageAccounting;
|
|
47
|
+
/** Per-session mid-turn injection queue (W513). */
|
|
48
|
+
inbox: SessionInbox;
|
|
49
|
+
runner: TurnRunner;
|
|
50
|
+
/** Worker wiring + the W740 watchdog mounted over it (null when off). */
|
|
51
|
+
workerHost: WorkerHost | null;
|
|
52
|
+
llm: LlmRegistry | null;
|
|
53
|
+
tools: ToolRegistry | null;
|
|
54
|
+
agentLoop: unknown | null;
|
|
55
|
+
/** Names of the mounted plugins, in mount order (order is semantics). */
|
|
56
|
+
plugins: readonly string[];
|
|
57
|
+
shutdownHooks: readonly ShutdownHook[];
|
|
58
|
+
}
|
|
59
|
+
export declare class Runtime {
|
|
60
|
+
private parts;
|
|
61
|
+
private binding;
|
|
62
|
+
private shutdownPromise;
|
|
63
|
+
private released;
|
|
64
|
+
/**
|
|
65
|
+
* W755 (Fix B): the context-usage projection state for THIS session (one
|
|
66
|
+
* generation = one session). Deliberately owned here rather than in
|
|
67
|
+
* `RuntimeParts` (compose passes no such thing) and never a module singleton:
|
|
68
|
+
* two live sessions must not share a prompt anchor. [rebind] re-opens the SAME
|
|
69
|
+
* session, so the anchor survives it exactly like the usage tracker's does.
|
|
70
|
+
*/
|
|
71
|
+
private readonly pressure;
|
|
72
|
+
/**
|
|
73
|
+
* W762: the last assembly handed out, with the log state it was built from.
|
|
74
|
+
* One slot, per generation (never a module singleton, never a timer).
|
|
75
|
+
*/
|
|
76
|
+
private snapshotCache;
|
|
77
|
+
constructor(parts: RuntimeParts);
|
|
78
|
+
private get p();
|
|
79
|
+
get ctx(): Context;
|
|
80
|
+
get profile(): Profile;
|
|
81
|
+
get agentConfig(): AgentConfig;
|
|
82
|
+
/** The active conversation log (a rebind swaps this handle). */
|
|
83
|
+
get session(): SessionLog;
|
|
84
|
+
get sessionBinding(): SessionBinding | null;
|
|
85
|
+
get status(): StatusTracker;
|
|
86
|
+
/** The session's injection queue (drained by the turn at step boundaries). */
|
|
87
|
+
get inbox(): SessionInbox;
|
|
88
|
+
get usage(): UsageAccounting;
|
|
89
|
+
get llm(): LlmRegistry | null;
|
|
90
|
+
get tools(): ToolRegistry | null;
|
|
91
|
+
get workers(): WorkerRegistry | null;
|
|
92
|
+
/**
|
|
93
|
+
* W740: the liveness watchdog mounted over this generation's worker registry
|
|
94
|
+
* (null when the watchdog is off). Adjudication itself belongs to the watchdog
|
|
95
|
+
* plugin — this is only the host's handle on it (`tick()` by hand, or read
|
|
96
|
+
* `running`), never a second liveness rule.
|
|
97
|
+
*/
|
|
98
|
+
get watchdog(): Watchdog | null;
|
|
99
|
+
get hostSessionId(): string | null;
|
|
100
|
+
get isBusy(): boolean;
|
|
101
|
+
get isReleased(): boolean;
|
|
102
|
+
/** Mounted plugin names, in mount order. */
|
|
103
|
+
get pluginNames(): readonly string[];
|
|
104
|
+
/** Drive one turn; a second concurrent call is a [TurnBusyError] (409). */
|
|
105
|
+
runTurn(input: string | null, opts?: TurnOptions): Promise<TurnOutcome>;
|
|
106
|
+
/** Cancel the in-flight turn (cooperative); false when nothing was running. */
|
|
107
|
+
cancelTurn(): boolean;
|
|
108
|
+
/** Snapshot for the statusline reader (SSE status payloads / GET /api/status). */
|
|
109
|
+
statusView(): StatusView;
|
|
110
|
+
/** The frozen `/api/status` payload for this generation. */
|
|
111
|
+
statusline(): Statusline;
|
|
112
|
+
/**
|
|
113
|
+
* W725: this generation's model-visible context, exactly as the loop would
|
|
114
|
+
* build it for the NEXT step (system + trimmed history + tool schemas), or
|
|
115
|
+
* null when the mounted loop has no snapshot capability (a test double).
|
|
116
|
+
*
|
|
117
|
+
* The assembly is the agent loop's, never this layer's: runtime only forwards
|
|
118
|
+
* the Context, so the read-only snapshot cannot drift from the real request.
|
|
119
|
+
*
|
|
120
|
+
* W762: the result is memoized on the LOG STATE it was derived from, because
|
|
121
|
+
* the statusline reads it on every 2s tick and the context viewer on every
|
|
122
|
+
* refresh, while a session log only changes when the engine appends to it.
|
|
123
|
+
* The key is `(log identity, event count, last event reference)`, all three
|
|
124
|
+
* cheap to obtain, and it is COMPLETE within one generation: the profile /
|
|
125
|
+
* trim config is fixed at compose (a config change swaps the generation, not
|
|
126
|
+
* this object) and the tool surface is mounted at compose too, so
|
|
127
|
+
* `registry.schemas()` cannot drift under the cache. A rebind swaps the log,
|
|
128
|
+
* and the identity term catches it even when the new log has the same length.
|
|
129
|
+
*
|
|
130
|
+
* Consumers are read-only by construction (`contextViewOf` maps messages into
|
|
131
|
+
* fresh view rows; `estimatedContextTokens` only reads), so the cached object
|
|
132
|
+
* is shared rather than copied. A MISS costs one extra `events()` copy (the
|
|
133
|
+
* key) on top of the assembly: ~0.35ms at 50k events against an 18ms
|
|
134
|
+
* assembly. A HIT costs only that copy — ~2 orders of magnitude less.
|
|
135
|
+
*/
|
|
136
|
+
contextSnapshot(): ModelRequest | null;
|
|
137
|
+
/**
|
|
138
|
+
* W766: the memoized assembly TOGETHER with its token estimate — what the
|
|
139
|
+
* statusline's `context_usage` fallback needs.
|
|
140
|
+
*
|
|
141
|
+
* W755 made the tick read the loop's own assembly; W762 memoized the assembly
|
|
142
|
+
* but left the ESTIMATE to be recomputed on every read, which dominated the
|
|
143
|
+
* tick (7.4ms of a 7.4ms tick at 50k events: an O(bytes) walk of the messages
|
|
144
|
+
* that could not have changed, because the request it walked was the very
|
|
145
|
+
* object the cache had just handed back). The estimate now rides in the same
|
|
146
|
+
* entry, so it is derived once per log state and dropped with the request.
|
|
147
|
+
*
|
|
148
|
+
* Lazy on purpose: the usage-frame path and the context viewer never read the
|
|
149
|
+
* estimate, so a MISS must not pay for it (same reason `tokens`/`assembled`
|
|
150
|
+
* start null rather than being filled by the assembly that built the request).
|
|
151
|
+
*/
|
|
152
|
+
assembledContext(): AssembledContext | null;
|
|
153
|
+
/**
|
|
154
|
+
* The memoized snapshot entry for the log state right now, building (and
|
|
155
|
+
* caching) the assembly on a miss. This is the ONE cache the runtime keeps:
|
|
156
|
+
* the request, its estimate and its display wrapper all hang off it, so they
|
|
157
|
+
* can never disagree about which log state they describe.
|
|
158
|
+
*/
|
|
159
|
+
private snapshotEntry;
|
|
160
|
+
/** Pending host receipts (worker -> host) that the next turn will inject. */
|
|
161
|
+
pendingReceipts(): number;
|
|
162
|
+
/**
|
|
163
|
+
* Deliver a message into THIS session's inbox on `lane` (W513/W515 §1):
|
|
164
|
+
* `next-turn` = drained at the next turn start (`placement: "queued"`),
|
|
165
|
+
* `next-step` = drained at the next step boundary of the RUNNING turn
|
|
166
|
+
* (`placement: "steering"`). The lane is the caller's decision — the host
|
|
167
|
+
* knows whether a turn is in flight, the runtime does not guess.
|
|
168
|
+
*/
|
|
169
|
+
inject(text: string, lane?: InjectionLane, opts?: InboxPushOptions): InjectedMessage;
|
|
170
|
+
/** Messages queued on one lane, or on both (diagnostics / tests). */
|
|
171
|
+
pendingInjections(lane?: InjectionLane): number;
|
|
172
|
+
/**
|
|
173
|
+
* Rebind this generation to the SAME session (same directory, same id) by
|
|
174
|
+
* re-opening its log. Only between turns: mid-turn rebinding would let one
|
|
175
|
+
* turn write into two logs.
|
|
176
|
+
*/
|
|
177
|
+
rebind(binding: SessionBinding): SessionLog;
|
|
178
|
+
/**
|
|
179
|
+
* Idempotent + re-entrant shutdown: stop drivers, run the host teardown hooks
|
|
180
|
+
* (process kills), purge mailboxes, clear registries. Repeating it is a no-op
|
|
181
|
+
* returning the first promise, so concurrent callers cannot double-run a hook.
|
|
182
|
+
*/
|
|
183
|
+
shutdown(): Promise<void>;
|
|
184
|
+
private doShutdown;
|
|
185
|
+
/**
|
|
186
|
+
* Explicit strong-reference drop (the sync half of a hot swap): stop the
|
|
187
|
+
* runner, abort drivers, release the registry and null every handle. Call it
|
|
188
|
+
* after [shutdown] when the generation is discarded for good.
|
|
189
|
+
*/
|
|
190
|
+
release(): void;
|
|
191
|
+
private assertLive;
|
|
192
|
+
}
|
|
193
|
+
/** Host-facing convenience: a sink that only collects frames (tests / CLI). */
|
|
194
|
+
export declare function collectingSink(): {
|
|
195
|
+
frames: TurnFrame[];
|
|
196
|
+
sink: FrameSink;
|
|
197
|
+
};
|
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composed runtime engine: one generation's services, wired and ready.
|
|
3
|
+
*
|
|
4
|
+
* A [Runtime] is deliberately thin — it owns the handles and the lifecycle and
|
|
5
|
+
* delegates turn driving to [TurnRunner]:
|
|
6
|
+
*
|
|
7
|
+
* - **turn driving** `runTurn(input, {signal, sink})`, single concurrency slot;
|
|
8
|
+
* - **statusline** `statusline()` reads the live tracker + usage accounting;
|
|
9
|
+
* - **rebinding** `rebind(binding)` re-opens the SAME session directory;
|
|
10
|
+
* - **shutdown** idempotent, re-entrant teardown (drivers, host process
|
|
11
|
+
* hooks, mailbox, registries): calling it twice is a
|
|
12
|
+
* no-op, and a concurrent caller awaits the same promise;
|
|
13
|
+
* - **release** explicit strong-reference drop for hot swaps (W248).
|
|
14
|
+
*
|
|
15
|
+
* `shutdown` also marks the session's checkpoint sidecar as cleanly closed
|
|
16
|
+
* (E §1.3 P0 ⑤), so the next boot can tell a graceful exit from a crash.
|
|
17
|
+
*
|
|
18
|
+
* [release] nulls every handle, which — together with the WeakRef the worker
|
|
19
|
+
* tools hold on the registry — breaks the
|
|
20
|
+
* `Runtime -> ctx -> ToolRegistry -> worker tool -> registry` cycle, so a
|
|
21
|
+
* swapped-out generation can actually be collected.
|
|
22
|
+
*/
|
|
23
|
+
import { EVENT_BUS_SERVICE, contextSnapshotOf, createEventBus } from "@celestea/core";
|
|
24
|
+
import { markCleanShutdown } from "@celestea/session";
|
|
25
|
+
import { closeLog } from "./host/engine-session.js";
|
|
26
|
+
import { RuntimeReleasedError, TurnBusyError } from "./errors.js";
|
|
27
|
+
import { bindSession } from "./session-binding.js";
|
|
28
|
+
import { ContextPressure, estimatedContextTokens, statuslineOf, } from "./status.js";
|
|
29
|
+
export class Runtime {
|
|
30
|
+
parts;
|
|
31
|
+
binding;
|
|
32
|
+
shutdownPromise = null;
|
|
33
|
+
released = false;
|
|
34
|
+
/**
|
|
35
|
+
* W755 (Fix B): the context-usage projection state for THIS session (one
|
|
36
|
+
* generation = one session). Deliberately owned here rather than in
|
|
37
|
+
* `RuntimeParts` (compose passes no such thing) and never a module singleton:
|
|
38
|
+
* two live sessions must not share a prompt anchor. [rebind] re-opens the SAME
|
|
39
|
+
* session, so the anchor survives it exactly like the usage tracker's does.
|
|
40
|
+
*/
|
|
41
|
+
pressure = new ContextPressure();
|
|
42
|
+
/**
|
|
43
|
+
* W762: the last assembly handed out, with the log state it was built from.
|
|
44
|
+
* One slot, per generation (never a module singleton, never a timer).
|
|
45
|
+
*/
|
|
46
|
+
snapshotCache = null;
|
|
47
|
+
constructor(parts) {
|
|
48
|
+
this.parts = parts;
|
|
49
|
+
this.binding = parts.binding;
|
|
50
|
+
if (!parts.ctx.has(EVENT_BUS_SERVICE))
|
|
51
|
+
parts.ctx.provide(EVENT_BUS_SERVICE, createEventBus());
|
|
52
|
+
}
|
|
53
|
+
get p() {
|
|
54
|
+
const parts = this.parts;
|
|
55
|
+
if (parts === null)
|
|
56
|
+
throw new RuntimeReleasedError();
|
|
57
|
+
return parts;
|
|
58
|
+
}
|
|
59
|
+
// --- handles -----------------------------------------------------------
|
|
60
|
+
get ctx() {
|
|
61
|
+
return this.p.ctx;
|
|
62
|
+
}
|
|
63
|
+
get profile() {
|
|
64
|
+
return this.p.profile;
|
|
65
|
+
}
|
|
66
|
+
get agentConfig() {
|
|
67
|
+
return this.p.agentConfig;
|
|
68
|
+
}
|
|
69
|
+
/** The active conversation log (a rebind swaps this handle). */
|
|
70
|
+
get session() {
|
|
71
|
+
return this.p.sessionRef.log;
|
|
72
|
+
}
|
|
73
|
+
get sessionBinding() {
|
|
74
|
+
return this.binding;
|
|
75
|
+
}
|
|
76
|
+
get status() {
|
|
77
|
+
return this.p.status;
|
|
78
|
+
}
|
|
79
|
+
/** The session's injection queue (drained by the turn at step boundaries). */
|
|
80
|
+
get inbox() {
|
|
81
|
+
return this.p.inbox;
|
|
82
|
+
}
|
|
83
|
+
get usage() {
|
|
84
|
+
return this.p.usage;
|
|
85
|
+
}
|
|
86
|
+
get llm() {
|
|
87
|
+
return this.p.llm;
|
|
88
|
+
}
|
|
89
|
+
get tools() {
|
|
90
|
+
return this.p.tools;
|
|
91
|
+
}
|
|
92
|
+
get workers() {
|
|
93
|
+
return this.p.workerHost?.registry ?? null;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* W740: the liveness watchdog mounted over this generation's worker registry
|
|
97
|
+
* (null when the watchdog is off). Adjudication itself belongs to the watchdog
|
|
98
|
+
* plugin — this is only the host's handle on it (`tick()` by hand, or read
|
|
99
|
+
* `running`), never a second liveness rule.
|
|
100
|
+
*/
|
|
101
|
+
get watchdog() {
|
|
102
|
+
return this.p.workerHost?.watchdog ?? null;
|
|
103
|
+
}
|
|
104
|
+
get hostSessionId() {
|
|
105
|
+
return this.p.workerHost?.hostSessionId ?? null;
|
|
106
|
+
}
|
|
107
|
+
get isBusy() {
|
|
108
|
+
return this.parts !== null && this.parts.runner.isBusy;
|
|
109
|
+
}
|
|
110
|
+
get isReleased() {
|
|
111
|
+
return this.released;
|
|
112
|
+
}
|
|
113
|
+
/** Mounted plugin names, in mount order. */
|
|
114
|
+
get pluginNames() {
|
|
115
|
+
return this.p.plugins;
|
|
116
|
+
}
|
|
117
|
+
// --- driving turns -----------------------------------------------------
|
|
118
|
+
/** Drive one turn; a second concurrent call is a [TurnBusyError] (409). */
|
|
119
|
+
async runTurn(input, opts = {}) {
|
|
120
|
+
this.assertLive();
|
|
121
|
+
return this.p.runner.runTurn(input, opts);
|
|
122
|
+
}
|
|
123
|
+
/** Cancel the in-flight turn (cooperative); false when nothing was running. */
|
|
124
|
+
cancelTurn() {
|
|
125
|
+
return this.parts?.runner.cancel() ?? false;
|
|
126
|
+
}
|
|
127
|
+
/** Snapshot for the statusline reader (SSE status payloads / GET /api/status). */
|
|
128
|
+
statusView() {
|
|
129
|
+
const p = this.p;
|
|
130
|
+
return {
|
|
131
|
+
model: p.profile.model,
|
|
132
|
+
reasoning_effort: p.profile.reasoning_effort,
|
|
133
|
+
status: p.status,
|
|
134
|
+
usage: p.usage,
|
|
135
|
+
context_window: p.profile.context_window_tokens,
|
|
136
|
+
events: () => this.p.sessionRef.log.events(),
|
|
137
|
+
// W755 (Fix A): the SAME assembly `/api/sessions/{id}/context` serves, so
|
|
138
|
+
// the fallback estimate can never drift from the real next request. The
|
|
139
|
+
// statusline is polled (and pushed on every SSE tick), so a failing read
|
|
140
|
+
// degrades to "no snapshot" instead of failing the endpoint.
|
|
141
|
+
assembled: () => {
|
|
142
|
+
try {
|
|
143
|
+
return this.assembledContext();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
pressure: this.pressure,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** The frozen `/api/status` payload for this generation. */
|
|
153
|
+
statusline() {
|
|
154
|
+
return statuslineOf(this.statusView());
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* W725: this generation's model-visible context, exactly as the loop would
|
|
158
|
+
* build it for the NEXT step (system + trimmed history + tool schemas), or
|
|
159
|
+
* null when the mounted loop has no snapshot capability (a test double).
|
|
160
|
+
*
|
|
161
|
+
* The assembly is the agent loop's, never this layer's: runtime only forwards
|
|
162
|
+
* the Context, so the read-only snapshot cannot drift from the real request.
|
|
163
|
+
*
|
|
164
|
+
* W762: the result is memoized on the LOG STATE it was derived from, because
|
|
165
|
+
* the statusline reads it on every 2s tick and the context viewer on every
|
|
166
|
+
* refresh, while a session log only changes when the engine appends to it.
|
|
167
|
+
* The key is `(log identity, event count, last event reference)`, all three
|
|
168
|
+
* cheap to obtain, and it is COMPLETE within one generation: the profile /
|
|
169
|
+
* trim config is fixed at compose (a config change swaps the generation, not
|
|
170
|
+
* this object) and the tool surface is mounted at compose too, so
|
|
171
|
+
* `registry.schemas()` cannot drift under the cache. A rebind swaps the log,
|
|
172
|
+
* and the identity term catches it even when the new log has the same length.
|
|
173
|
+
*
|
|
174
|
+
* Consumers are read-only by construction (`contextViewOf` maps messages into
|
|
175
|
+
* fresh view rows; `estimatedContextTokens` only reads), so the cached object
|
|
176
|
+
* is shared rather than copied. A MISS costs one extra `events()` copy (the
|
|
177
|
+
* key) on top of the assembly: ~0.35ms at 50k events against an 18ms
|
|
178
|
+
* assembly. A HIT costs only that copy — ~2 orders of magnitude less.
|
|
179
|
+
*/
|
|
180
|
+
contextSnapshot() {
|
|
181
|
+
return this.snapshotEntry().request;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* W766: the memoized assembly TOGETHER with its token estimate — what the
|
|
185
|
+
* statusline's `context_usage` fallback needs.
|
|
186
|
+
*
|
|
187
|
+
* W755 made the tick read the loop's own assembly; W762 memoized the assembly
|
|
188
|
+
* but left the ESTIMATE to be recomputed on every read, which dominated the
|
|
189
|
+
* tick (7.4ms of a 7.4ms tick at 50k events: an O(bytes) walk of the messages
|
|
190
|
+
* that could not have changed, because the request it walked was the very
|
|
191
|
+
* object the cache had just handed back). The estimate now rides in the same
|
|
192
|
+
* entry, so it is derived once per log state and dropped with the request.
|
|
193
|
+
*
|
|
194
|
+
* Lazy on purpose: the usage-frame path and the context viewer never read the
|
|
195
|
+
* estimate, so a MISS must not pay for it (same reason `tokens`/`assembled`
|
|
196
|
+
* start null rather than being filled by the assembly that built the request).
|
|
197
|
+
*/
|
|
198
|
+
assembledContext() {
|
|
199
|
+
const entry = this.snapshotEntry();
|
|
200
|
+
const request = entry.request;
|
|
201
|
+
if (request === null)
|
|
202
|
+
return null;
|
|
203
|
+
entry.assembled ??= { request, tokens: estimatedContextTokens(request) };
|
|
204
|
+
return entry.assembled;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The memoized snapshot entry for the log state right now, building (and
|
|
208
|
+
* caching) the assembly on a miss. This is the ONE cache the runtime keeps:
|
|
209
|
+
* the request, its estimate and its display wrapper all hang off it, so they
|
|
210
|
+
* can never disagree about which log state they describe.
|
|
211
|
+
*/
|
|
212
|
+
snapshotEntry() {
|
|
213
|
+
const log = this.p.sessionRef.log;
|
|
214
|
+
const last = lastEventOf(log);
|
|
215
|
+
const cached = this.snapshotCache;
|
|
216
|
+
if (cached !== null && cached.log === log && cached.events === last.count && cached.last === last.event) {
|
|
217
|
+
return cached;
|
|
218
|
+
}
|
|
219
|
+
const request = contextSnapshotOf(this.p.agentLoop, this.p.ctx);
|
|
220
|
+
const entry = { log, events: last.count, last: last.event, request, assembled: null };
|
|
221
|
+
this.snapshotCache = entry;
|
|
222
|
+
return entry;
|
|
223
|
+
}
|
|
224
|
+
/** Pending host receipts (worker -> host) that the next turn will inject. */
|
|
225
|
+
pendingReceipts() {
|
|
226
|
+
const host = this.parts?.workerHost ?? null;
|
|
227
|
+
return host === null ? 0 : host.registry.mailbox.pending(host.hostSessionId);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Deliver a message into THIS session's inbox on `lane` (W513/W515 §1):
|
|
231
|
+
* `next-turn` = drained at the next turn start (`placement: "queued"`),
|
|
232
|
+
* `next-step` = drained at the next step boundary of the RUNNING turn
|
|
233
|
+
* (`placement: "steering"`). The lane is the caller's decision — the host
|
|
234
|
+
* knows whether a turn is in flight, the runtime does not guess.
|
|
235
|
+
*/
|
|
236
|
+
inject(text, lane = "next-turn", opts = {}) {
|
|
237
|
+
this.assertLive();
|
|
238
|
+
return this.p.inbox.push(text, lane, opts);
|
|
239
|
+
}
|
|
240
|
+
/** Messages queued on one lane, or on both (diagnostics / tests). */
|
|
241
|
+
pendingInjections(lane) {
|
|
242
|
+
return this.parts?.inbox.pending(lane) ?? 0;
|
|
243
|
+
}
|
|
244
|
+
// --- lifecycle ---------------------------------------------------------
|
|
245
|
+
/**
|
|
246
|
+
* Rebind this generation to the SAME session (same directory, same id) by
|
|
247
|
+
* re-opening its log. Only between turns: mid-turn rebinding would let one
|
|
248
|
+
* turn write into two logs.
|
|
249
|
+
*/
|
|
250
|
+
rebind(binding) {
|
|
251
|
+
this.assertLive();
|
|
252
|
+
if (this.p.runner.isBusy)
|
|
253
|
+
throw new TurnBusyError("rebind");
|
|
254
|
+
const previous = this.p.sessionRef.log;
|
|
255
|
+
const log = bindSession(this.p.ctx, binding);
|
|
256
|
+
this.p.sessionRef.log = log;
|
|
257
|
+
// P1-4 (W836): the swapped-out log descriptor is closed HERE, not left for
|
|
258
|
+
// a shutdown that may be generations away; a long-lived studio otherwise
|
|
259
|
+
// leaks one fd per rebind.
|
|
260
|
+
closeLog(previous);
|
|
261
|
+
this.binding = binding;
|
|
262
|
+
// W762: the cached assembly belonged to the log that was just swapped out.
|
|
263
|
+
this.snapshotCache = null;
|
|
264
|
+
return log;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Idempotent + re-entrant shutdown: stop drivers, run the host teardown hooks
|
|
268
|
+
* (process kills), purge mailboxes, clear registries. Repeating it is a no-op
|
|
269
|
+
* returning the first promise, so concurrent callers cannot double-run a hook.
|
|
270
|
+
*/
|
|
271
|
+
shutdown() {
|
|
272
|
+
if (this.shutdownPromise === null)
|
|
273
|
+
this.shutdownPromise = this.doShutdown();
|
|
274
|
+
return this.shutdownPromise;
|
|
275
|
+
}
|
|
276
|
+
async doShutdown() {
|
|
277
|
+
const parts = this.parts;
|
|
278
|
+
parts?.runner.stop();
|
|
279
|
+
// P1-5 (W836): stop() only aborts cooperatively. Defer the clean-shutdown
|
|
280
|
+
// claim until the in-flight turn has written its own terminal row.
|
|
281
|
+
await parts?.runner.join();
|
|
282
|
+
// E §1.3 P0 ⑤: a graceful teardown is the ONLY thing that may claim
|
|
283
|
+
// `clean_shutdown: true` in the session's checkpoint sidecar — that flag is
|
|
284
|
+
// what tells the next boot "do not repair", so a crash (no shutdown at all)
|
|
285
|
+
// keeps it false. A log without a checkpoint (tests, embedded use) is a no-op.
|
|
286
|
+
markCleanShutdown(parts?.sessionRef.log);
|
|
287
|
+
// P1-4 (W836): and a graceful teardown is where the log descriptor dies.
|
|
288
|
+
closeLog(parts?.sessionRef.log);
|
|
289
|
+
const host = parts?.workerHost ?? null;
|
|
290
|
+
if (host !== null) {
|
|
291
|
+
host.registry.abortAllNow();
|
|
292
|
+
await host.registry.joinDrivers();
|
|
293
|
+
}
|
|
294
|
+
for (const hook of parts?.shutdownHooks ?? [])
|
|
295
|
+
await runHook(hook);
|
|
296
|
+
if (host !== null) {
|
|
297
|
+
host.registry.mailbox.purgeAll();
|
|
298
|
+
host.registry.sessions.clear();
|
|
299
|
+
}
|
|
300
|
+
this.released = true;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Explicit strong-reference drop (the sync half of a hot swap): stop the
|
|
304
|
+
* runner, abort drivers, release the registry and null every handle. Call it
|
|
305
|
+
* after [shutdown] when the generation is discarded for good.
|
|
306
|
+
*/
|
|
307
|
+
release() {
|
|
308
|
+
const parts = this.parts;
|
|
309
|
+
if (parts === null)
|
|
310
|
+
return;
|
|
311
|
+
parts.runner.stop();
|
|
312
|
+
// P1-4 (W836): release is the generation's last breath even when shutdown
|
|
313
|
+
// was skipped (GenerationHub / registry teardown). Close is idempotent, so
|
|
314
|
+
// the host's own closeLog before release cannot fail here.
|
|
315
|
+
closeLog(parts.sessionRef.log);
|
|
316
|
+
if (parts.workerHost !== null) {
|
|
317
|
+
parts.workerHost.registry.abortAllNow();
|
|
318
|
+
parts.workerHost.registry.release();
|
|
319
|
+
}
|
|
320
|
+
this.parts = null;
|
|
321
|
+
this.snapshotCache = null;
|
|
322
|
+
this.released = true;
|
|
323
|
+
}
|
|
324
|
+
assertLive() {
|
|
325
|
+
if (this.released || this.parts === null)
|
|
326
|
+
throw new RuntimeReleasedError();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async function runHook(hook) {
|
|
330
|
+
try {
|
|
331
|
+
await hook();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
// A failing teardown hook must not stop the remaining ones: shutdown is the
|
|
335
|
+
// last thing a generation does, and it has to reach the end.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/** `(count, last event)` of a log — the cache key half that changes on append. */
|
|
339
|
+
function lastEventOf(log) {
|
|
340
|
+
const events = log.events();
|
|
341
|
+
return { count: events.length, event: events[events.length - 1] };
|
|
342
|
+
}
|
|
343
|
+
/** Host-facing convenience: a sink that only collects frames (tests / CLI). */
|
|
344
|
+
export function collectingSink() {
|
|
345
|
+
const frames = [];
|
|
346
|
+
return { frames, sink: (frame) => frames.push(frame) };
|
|
347
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation-level config sanitization.
|
|
3
|
+
*
|
|
4
|
+
* `GET/POST /api/config` hands the current generation's configuration to the
|
|
5
|
+
* client, so the projection is a whitelist: only the twelve frozen profile keys
|
|
6
|
+
* (minus the two key-bearing ones, which become existence flags) plus the
|
|
7
|
+
* derived loop budget. The result is then run through core's redactor before it
|
|
8
|
+
* is ever serialized, so a credential that sneaks into `base_url` cannot leave
|
|
9
|
+
* the process (ARCHITECTURE.md §6.3).
|
|
10
|
+
*/
|
|
11
|
+
import type { Profile } from "./profile.js";
|
|
12
|
+
/** Client-facing projection of a [Profile]: no secret value, ever. */
|
|
13
|
+
export interface SanitizedConfig {
|
|
14
|
+
model: string;
|
|
15
|
+
base_url: string;
|
|
16
|
+
request_format: Profile["request_format"];
|
|
17
|
+
reasoning_effort: string | null;
|
|
18
|
+
max_steps: number;
|
|
19
|
+
max_parallel_tool_calls: number;
|
|
20
|
+
max_output_tokens: number | null;
|
|
21
|
+
context_window_tokens: number;
|
|
22
|
+
temperature: number | null;
|
|
23
|
+
system_prompt: string;
|
|
24
|
+
/** Env var NAME the key is read from — a name is not a secret. */
|
|
25
|
+
api_key_env: string;
|
|
26
|
+
has_api_key_file: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** Whitelist projection of a profile (`api_key_file` becomes a boolean). */
|
|
29
|
+
export declare function sanitizeProfile(profile: Profile): SanitizedConfig;
|
|
30
|
+
/**
|
|
31
|
+
* The sanitized config as stable, redaction-clean JSON text. Redaction runs on
|
|
32
|
+
* every serialization (not once at compose), so the export can never carry a
|
|
33
|
+
* secret even if a future profile key smuggles one in.
|
|
34
|
+
*/
|
|
35
|
+
export declare function sanitizeConfigJson(config: SanitizedConfig): string;
|
package/dist/sanitize.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generation-level config sanitization.
|
|
3
|
+
*
|
|
4
|
+
* `GET/POST /api/config` hands the current generation's configuration to the
|
|
5
|
+
* client, so the projection is a whitelist: only the twelve frozen profile keys
|
|
6
|
+
* (minus the two key-bearing ones, which become existence flags) plus the
|
|
7
|
+
* derived loop budget. The result is then run through core's redactor before it
|
|
8
|
+
* is ever serialized, so a credential that sneaks into `base_url` cannot leave
|
|
9
|
+
* the process (ARCHITECTURE.md §6.3).
|
|
10
|
+
*/
|
|
11
|
+
import { createRedactor, stableStringify } from "@celestea/core";
|
|
12
|
+
/** Whitelist projection of a profile (`api_key_file` becomes a boolean). */
|
|
13
|
+
export function sanitizeProfile(profile) {
|
|
14
|
+
return {
|
|
15
|
+
model: profile.model,
|
|
16
|
+
base_url: profile.base_url,
|
|
17
|
+
request_format: profile.request_format,
|
|
18
|
+
reasoning_effort: profile.reasoning_effort,
|
|
19
|
+
max_steps: profile.max_steps,
|
|
20
|
+
max_parallel_tool_calls: profile.max_parallel_tool_calls,
|
|
21
|
+
max_output_tokens: profile.max_output_tokens,
|
|
22
|
+
context_window_tokens: profile.context_window_tokens,
|
|
23
|
+
temperature: profile.temperature,
|
|
24
|
+
system_prompt: profile.system_prompt,
|
|
25
|
+
api_key_env: profile.api_key_env,
|
|
26
|
+
has_api_key_file: profile.api_key_file !== null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The sanitized config as stable, redaction-clean JSON text. Redaction runs on
|
|
31
|
+
* every serialization (not once at compose), so the export can never carry a
|
|
32
|
+
* secret even if a future profile key smuggles one in.
|
|
33
|
+
*/
|
|
34
|
+
export function sanitizeConfigJson(config) {
|
|
35
|
+
return createRedactor([]).redact(stableStringify(config));
|
|
36
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session binding — how a generation is tied to ONE conversation.
|
|
3
|
+
*
|
|
4
|
+
* A binding is (session id + directory + a way to open the log). Keeping the
|
|
5
|
+
* opener as a callback is what lets the runtime stay out of the persistence
|
|
6
|
+
* business: `packages/session` owns JSONL replay/repair, the host passes
|
|
7
|
+
* `() => PersistentSessionLog.open(dir, id)`, and the runtime only knows the
|
|
8
|
+
* `SessionLog` seam.
|
|
9
|
+
*
|
|
10
|
+
* Rebinding re-opens the SAME directory/id (`binding.open()` again) and
|
|
11
|
+
* re-provides the result under `SESSION_LOG_SERVICE`; a later `provide` of the
|
|
12
|
+
* same token replaces the earlier one, so every consumer that resolves the
|
|
13
|
+
* service lazily (the agent loop does, per turn) sees the new log while an
|
|
14
|
+
* in-flight reader keeps the object it already holds.
|
|
15
|
+
*/
|
|
16
|
+
import { type Context, type SessionLog } from "@celestea/core";
|
|
17
|
+
export interface SessionBinding {
|
|
18
|
+
/** Conversation id (`<workspace>/<session>` or the host id). */
|
|
19
|
+
readonly sessionId: string;
|
|
20
|
+
/** Session directory; the same value is reused by every rebind. */
|
|
21
|
+
readonly dir: string | null;
|
|
22
|
+
/** Open (or re-open) the log for this binding. Called at compose and on rebind. */
|
|
23
|
+
open(): SessionLog;
|
|
24
|
+
}
|
|
25
|
+
export interface SessionBindingSpec {
|
|
26
|
+
sessionId: string;
|
|
27
|
+
dir?: string | null;
|
|
28
|
+
open: () => SessionLog;
|
|
29
|
+
}
|
|
30
|
+
/** Ergonomic constructor for a binding (a plain object literal also works). */
|
|
31
|
+
export declare function createSessionBinding(spec: SessionBindingSpec): SessionBinding;
|
|
32
|
+
/**
|
|
33
|
+
* Open the binding and provide it into the context (last `provide` wins). The
|
|
34
|
+
* opened log is returned so the caller can keep a direct handle.
|
|
35
|
+
*/
|
|
36
|
+
export declare function bindSession(ctx: Context, binding: SessionBinding): SessionLog;
|