@steerable/agent-runtime 0.0.0-stage → 0.9.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,446 @@
1
+ /**
2
+ * `AgentRuntime` — the CoreLoop-level API of the framework for TypeScript
3
+ * products. Every method maps 1:1 onto the sidecar JSON-RPC surface
4
+ * (`docs/spec/sidecar.md`); the runtime drives the same Rust CoreLoop the
5
+ * server uses (via the Python sidecar facade), so there is no second
6
+ * implementation to drift.
7
+ *
8
+ * Streaming is exposed as an async-iterable of `SSEEvent`-shaped frames so
9
+ * callers can `for await` a turn, and as a `done` promise carrying the
10
+ * terminal status. `cancel` / `steer` target the in-flight stream by id.
11
+ */
12
+ import { SidecarProcess } from './sidecar.js';
13
+ /**
14
+ * Sink for one in-flight stream. Events are single-consumer: they buffer
15
+ * until the first `events` iterable attaches, then flow live. `whenDone`
16
+ * settles on the terminal status regardless of whether anyone consumed the
17
+ * events, and a consumer that attaches after termination drains the buffer
18
+ * and completes immediately.
19
+ */
20
+ class StreamSink {
21
+ whenDone;
22
+ buffer = [];
23
+ listener = null;
24
+ settle;
25
+ terminal = null;
26
+ failure = null;
27
+ /**
28
+ * Set by `chatStream` once it has captured this sink from the map. The
29
+ * map entry may be deleted on termination only after the claim — a done
30
+ * frame that coalesces with the response into one read must not orphan
31
+ * the events before the caller gets them.
32
+ */
33
+ claimed = false;
34
+ get isTerminal() {
35
+ return this.terminal !== null;
36
+ }
37
+ constructor() {
38
+ this.whenDone = new Promise((resolve, reject) => {
39
+ this.settle = { resolve, reject };
40
+ });
41
+ // A stream nobody awaits must not crash the host on transport failure.
42
+ this.whenDone.catch(() => undefined);
43
+ }
44
+ push(event) {
45
+ if (this.terminal)
46
+ return;
47
+ if (this.listener)
48
+ this.listener.push(event);
49
+ else
50
+ this.buffer.push(event);
51
+ }
52
+ finish(status, cancelled) {
53
+ if (this.terminal)
54
+ return;
55
+ this.terminal = { status, cancelled };
56
+ this.listener?.finish();
57
+ this.settle.resolve(this.terminal);
58
+ }
59
+ fail(err) {
60
+ if (this.terminal)
61
+ return;
62
+ this.terminal = { status: 'error', cancelled: false };
63
+ this.failure = err;
64
+ this.listener?.fail(err);
65
+ this.settle.reject(err);
66
+ }
67
+ /** Single-consumer attach: replay the buffer, then flow live. */
68
+ attach(listener) {
69
+ if (this.listener) {
70
+ throw new Error('stream events are single-consumer and already attached');
71
+ }
72
+ const buffered = this.buffer;
73
+ this.buffer = [];
74
+ if (this.failure) {
75
+ listener.fail(this.failure);
76
+ return;
77
+ }
78
+ this.listener = listener;
79
+ for (const event of buffered)
80
+ listener.push(event);
81
+ if (this.terminal)
82
+ listener.finish();
83
+ }
84
+ detach(listener) {
85
+ if (this.listener === listener)
86
+ this.listener = null;
87
+ }
88
+ }
89
+ export class AgentRuntime {
90
+ process;
91
+ toolHandlers = new Map();
92
+ spawnHandler = null;
93
+ streams = new Map();
94
+ constructor(options = {}) {
95
+ this.process = new SidecarProcess({
96
+ ...options,
97
+ onRequest: (method, params) => this.handleReverse(method, params),
98
+ onNotification: (method, params) => {
99
+ this.routeStreamNotification(method, params);
100
+ options.onNotification?.(method, params);
101
+ },
102
+ });
103
+ for (const [name, handler] of Object.entries(options.tools ?? {})) {
104
+ this.toolHandlers.set(name, handler);
105
+ }
106
+ }
107
+ /** Spawn the sidecar and wait for readiness. */
108
+ start() {
109
+ return this.process.start();
110
+ }
111
+ /** Graceful drain and exit; in-flight streams finish or fail first. */
112
+ async close() {
113
+ await this.process.close();
114
+ for (const [, s] of this.streams) {
115
+ s.fail(new Error('runtime closed mid-stream'));
116
+ }
117
+ this.streams.clear();
118
+ }
119
+ /** Register a host tool the CoreLoop may call back during turns. */
120
+ registerTool(name, handler) {
121
+ this.toolHandlers.set(name, handler);
122
+ }
123
+ /**
124
+ * Register the host confined-spawn capability. Without a handler,
125
+ * `host.process.spawn` reverse calls are rejected and the sidecar fails
126
+ * closed (the command never runs unsandboxed on no-backend platforms).
127
+ */
128
+ onProcessSpawn(handler) {
129
+ this.spawnHandler = handler;
130
+ }
131
+ // ---- system ----------------------------------------------------------
132
+ ping() {
133
+ return this.process.request('system.ping');
134
+ }
135
+ // ---- sessions --------------------------------------------------------
136
+ createSession(input) {
137
+ return this.process.request('agent.session.create', input);
138
+ }
139
+ resumeSession(sessionId) {
140
+ return this.process.request('agent.session.resume', { sessionId });
141
+ }
142
+ listSessions(filter = {}) {
143
+ return this.process.request('agent.session.list', filter);
144
+ }
145
+ /** Fork a record into a new branch without running a turn. */
146
+ forkSession(params) {
147
+ return this.process.request('agent.session.fork', params);
148
+ }
149
+ /** Branch-family view for a lineage. */
150
+ sessionBranches(lineage) {
151
+ return this.process.request('agent.session.branches', { lineage });
152
+ }
153
+ /**
154
+ * Projected post-boundary message span of a history record — what the
155
+ * model would see on resume. Fails loud on unknown records.
156
+ */
157
+ sessionMessages(recordId) {
158
+ return this.process.request('agent.session.messages', { recordId });
159
+ }
160
+ // ---- chat ------------------------------------------------------------
161
+ /**
162
+ * Start one CoreLoop turn. Resolves with a handle as soon as the sidecar
163
+ * assigns a stream id; consume `handle.events` for the turn and await
164
+ * `handle.done` for the terminal status.
165
+ */
166
+ async chatStream(params) {
167
+ // The sidecar's CoreLoop path is opt-in per request; this runtime's
168
+ // whole API (cooperative cancel, steer, orchestration, bounded
169
+ // fragments) only exists there, so default it on. A caller embedding
170
+ // against the legacy direct-stream path can still pass
171
+ // `useCoreLoop: false` explicitly.
172
+ const { streamId } = await this.process.request('agent.chat.stream', { useCoreLoop: true, ...params });
173
+ // Chunks can already have arrived (the sidecar starts streaming before
174
+ // the response reaches us) — routeStreamNotification registers the sink
175
+ // eagerly in that case; otherwise create it now. Claiming keeps the map
176
+ // entry alive past termination until this lookup has happened.
177
+ let sink = this.streams.get(streamId);
178
+ if (!sink) {
179
+ sink = new StreamSink();
180
+ this.streams.set(streamId, sink);
181
+ }
182
+ sink.claimed = true;
183
+ if (sink.isTerminal)
184
+ this.streams.delete(streamId);
185
+ return { streamId, events: streamIterable(sink), done: sink.whenDone };
186
+ }
187
+ /** Cooperative cancel: the loop winds down at the next safe point. */
188
+ async cancelChat(streamId) {
189
+ await this.process.request('agent.chat.cancel', { streamId });
190
+ }
191
+ /** Mid-turn steer; resolves true when the running turn accepted it. */
192
+ async steerChat(streamId, content) {
193
+ const res = await this.process.request('agent.chat.steer', { streamId, content });
194
+ return res?.accepted === true;
195
+ }
196
+ /** Fork the record of a running turn. */
197
+ forkChat(streamId, label) {
198
+ return this.process.request('agent.chat.fork', { streamId, label });
199
+ }
200
+ // ---- tools / skills / workspace --------------------------------------
201
+ listTools() {
202
+ return this.process.request('tool.list');
203
+ }
204
+ invokeTool(call) {
205
+ return this.process.request('tool.invoke', call);
206
+ }
207
+ listSkills(roots) {
208
+ return this.process.request('skills.list', { roots });
209
+ }
210
+ applyEdits(params) {
211
+ return this.process.request('workspace.apply_edits', params);
212
+ }
213
+ // ---- trace / config ----------------------------------------------------
214
+ fetchTrace(traceId) {
215
+ return this.process.request('trace.fetch', { traceId });
216
+ }
217
+ exportTrace(traceId) {
218
+ return this.process.request('trace.export', { traceId });
219
+ }
220
+ /** POST turn-outcome counters to a collector's `/v1/metrics`. */
221
+ exportMetrics(params) {
222
+ return this.process.request('metrics.export', params);
223
+ }
224
+ getConfig() {
225
+ return this.process.request('config.get');
226
+ }
227
+ async setConfig(patch) {
228
+ await this.process.request('config.set', patch);
229
+ }
230
+ /**
231
+ * The framework-owned OpenAI-compat flag vocabulary (`describe_compat_flags`
232
+ * on the Python side). Host settings UIs render their compat section from
233
+ * these descriptors instead of hardcoding flag names, so a new flag needs
234
+ * no host-side constant to stay in sync.
235
+ */
236
+ describeCompatFlags() {
237
+ return this.process.request('compat.describe');
238
+ }
239
+ /**
240
+ * Per-exec enforcement this host can actually reach (`sandbox.describe`),
241
+ * given the egress arguments a turn will send. Hosts that set
242
+ * `execSandbox.requireFull` from a platform guess refuse every shell call
243
+ * on backends that cannot pin per-host (bwrap, Landlock, Windows). Probe
244
+ * once and require full only when the reply is `full`.
245
+ */
246
+ describeSandbox(params) {
247
+ return this.process.request('sandbox.describe', params);
248
+ }
249
+ /**
250
+ * The framework-owned harness vocabulary (`harness.describe`, W1.2.2):
251
+ * every dimension's available implementations with their assumption
252
+ * contracts, plus the active default selection. Host harness pickers
253
+ * render from this payload; a new strategy needs no host-side constant.
254
+ */
255
+ describeHarness() {
256
+ return this.process.request('harness.describe');
257
+ }
258
+ /**
259
+ * The gateway's live model catalog (`models.list`): the ids the
260
+ * configured `STEERABLE_BASE_URL` actually accepts, joined with
261
+ * models.dev capabilities — reasoning levels included — by the same
262
+ * resolution the request path's clamp uses, so a picker and the wire
263
+ * never disagree about a model's knob. Host model pickers render from
264
+ * this payload and badge the `stale`/`offline` states.
265
+ */
266
+ listModels(params) {
267
+ return this.process.request('models.list', params);
268
+ }
269
+ // ---- plugins / presets / session tree --------------------------------
270
+ /** Every plugin the sidecar registry knows, with lifecycle state. */
271
+ listPlugins() {
272
+ return this.process.request('plugin.list');
273
+ }
274
+ /** Enable a registered plugin; rejects invalid_params on unknown names. */
275
+ enablePlugin(name) {
276
+ return this.process.request('plugin.enable', { name });
277
+ }
278
+ /** Disable a plugin without unloading its code. */
279
+ disablePlugin(name) {
280
+ return this.process.request('plugin.disable', { name });
281
+ }
282
+ /** Hot-reload a reloadable plugin from its source. */
283
+ reloadPlugin(name) {
284
+ return this.process.request('plugin.reload', { name });
285
+ }
286
+ /**
287
+ * Descriptors for every tool an enabled plugin provides. Hosts that own
288
+ * the model-visible tool list advertise these and call them with
289
+ * `tool.invoke`; disabled plugins contribute none.
290
+ */
291
+ describePluginTools() {
292
+ return this.process.request('plugin.tools.describe');
293
+ }
294
+ /** The framework-owned provider-preset table for host settings UIs. */
295
+ describePresets() {
296
+ return this.process.request('presets.describe');
297
+ }
298
+ /**
299
+ * The bundled serving-provider catalog (`catalog.describe`): default
300
+ * URLs, sidecar wire kinds, and chat-capable model ids. Host vendor
301
+ * pickers render from this payload so a catalog refresh needs no
302
+ * host-side constant.
303
+ */
304
+ describeCatalog() {
305
+ return this.process.request('catalog.describe');
306
+ }
307
+ /** Which preset a (baseUrl, model) pair would auto-match; null when none. */
308
+ resolvePreset(input) {
309
+ return this.process.request('presets.resolve', input);
310
+ }
311
+ /** The full branch family containing a record (pi-style tree view). */
312
+ sessionTree(recordId) {
313
+ return this.process.request('agent.session.tree', { recordId });
314
+ }
315
+ /**
316
+ * Ask a running turn to compact its transcript at the next pre_step
317
+ * boundary (CC `/compact` parity). Soft-fails false when the stream
318
+ * already completed, mirroring steerChat.
319
+ */
320
+ async compactChat(streamId) {
321
+ const res = await this.process.request('agent.chat.compact', { streamId });
322
+ return res?.ok === true;
323
+ }
324
+ // ---- internals ---------------------------------------------------------
325
+ handleReverse(method, params) {
326
+ if (method === 'tool.invoke') {
327
+ const call = params;
328
+ const handler = call && this.toolHandlers.get(call.name);
329
+ if (!call || !handler) {
330
+ return Promise.reject(new Error(`tool.invoke: no host handler for ${call?.name ?? '<missing>'}`));
331
+ }
332
+ return handler((call.arguments ?? {}));
333
+ }
334
+ if (method === 'host.process.spawn') {
335
+ if (!this.spawnHandler) {
336
+ return Promise.reject(new Error('host.process.spawn: capability not implemented by this host'));
337
+ }
338
+ return this.spawnHandler(params);
339
+ }
340
+ return Promise.reject(new Error(`unsupported reverse method ${method}`));
341
+ }
342
+ routeStreamNotification(method, params) {
343
+ const p = (params ?? {});
344
+ if (!p.streamId)
345
+ return;
346
+ if (method === 'stream.chunk' && !this.streams.has(p.streamId)) {
347
+ // Chunks can arrive before chatStream's response resolves (the sidecar
348
+ // starts streaming immediately) — register the sink eagerly.
349
+ this.streams.set(p.streamId, new StreamSink());
350
+ }
351
+ const state = this.streams.get(p.streamId);
352
+ if (!state)
353
+ return;
354
+ switch (method) {
355
+ case 'stream.chunk': {
356
+ const c = params;
357
+ if (c.delta) {
358
+ state.push({ type: 'content', content: c.delta });
359
+ }
360
+ if (c.toolCall) {
361
+ state.push({ type: 'tool_call', payload: c.toolCall });
362
+ }
363
+ if (c.toolResult) {
364
+ state.push({ type: 'tool_result', payload: c.toolResult });
365
+ }
366
+ if (c.usage) {
367
+ state.push({ type: 'usage', payload: c.usage });
368
+ }
369
+ return;
370
+ }
371
+ case 'stream.done': {
372
+ const d = params;
373
+ state.push({ type: 'done' });
374
+ const status = (d.status ??
375
+ (d.cancelled ? 'cancelled' : d.ok ? 'completed' : 'error'));
376
+ state.finish(status, d.cancelled === true || status === 'cancelled');
377
+ if (state.claimed)
378
+ this.streams.delete(p.streamId);
379
+ return;
380
+ }
381
+ case 'stream.error': {
382
+ const e = params;
383
+ state.push({ type: 'error', message: e.message });
384
+ state.finish('error', false);
385
+ if (state.claimed)
386
+ this.streams.delete(p.streamId);
387
+ return;
388
+ }
389
+ case 'agent.child': {
390
+ state.push({
391
+ type: 'orchestration',
392
+ payload: params,
393
+ });
394
+ return;
395
+ }
396
+ default:
397
+ return;
398
+ }
399
+ }
400
+ }
401
+ function streamIterable(sink) {
402
+ return {
403
+ [Symbol.asyncIterator]() {
404
+ const queue = [];
405
+ let done = false;
406
+ let error = null;
407
+ let wake = null;
408
+ const listener = {
409
+ push(event) {
410
+ queue.push(event);
411
+ wake?.();
412
+ },
413
+ finish() {
414
+ done = true;
415
+ wake?.();
416
+ },
417
+ fail(err) {
418
+ error = err;
419
+ wake?.();
420
+ },
421
+ };
422
+ sink.attach(listener);
423
+ return {
424
+ async next() {
425
+ for (;;) {
426
+ const event = queue.shift();
427
+ if (event !== undefined)
428
+ return { value: event, done: false };
429
+ if (error)
430
+ throw error;
431
+ if (done)
432
+ return { value: undefined, done: true };
433
+ await new Promise((resolve) => {
434
+ wake = resolve;
435
+ });
436
+ wake = null;
437
+ }
438
+ },
439
+ async return() {
440
+ sink.detach(listener);
441
+ return { value: undefined, done: true };
442
+ },
443
+ };
444
+ },
445
+ };
446
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `SidecarProcess` — owns the embedded Python sidecar lifecycle so callers
3
+ * never write subprocess management:
4
+ *
5
+ * spawn → wait `lifecycle.ready` → `system.ping` → serve requests
6
+ * unexpected exit → bounded auto-restart with backoff
7
+ * close() → `system.shutdown` → wait → SIGTERM → SIGKILL (escalating)
8
+ *
9
+ * Requests made while the process is down reject fast with
10
+ * `SidecarNotReadyError`; the caller decides whether to retry after the
11
+ * next `ready` event. This mirrors the fail-loud posture of the rest of
12
+ * the framework — a wedged sidecar surfaces as an error, not a hang.
13
+ */
14
+ import { EventEmitter } from 'node:events';
15
+ import { JsonRpcTransportClosedError } from './jsonrpc.js';
16
+ /**
17
+ * Interpreter for the sidecar process.
18
+ *
19
+ * Resolution order: explicit `STEERABLE_SIDECAR_PYTHON`, the platform package
20
+ * npm installed for this host, the framework checkout's `.venv` (so an example
21
+ * or test inside the repo finds the editable install), then
22
+ * `STEERABLE_PYTHON`, then `python3`.
23
+ *
24
+ * A global install has no framework checkout; a checkout has no platform
25
+ * package. Both paths end at a working sidecar without extra setup.
26
+ *
27
+ * @returns Path to the interpreter, or `python3` when nothing else resolves.
28
+ */
29
+ export declare function resolveRuntimePython(): string;
30
+ export declare class SidecarNotReadyError extends Error {
31
+ constructor(message?: string);
32
+ }
33
+ export declare class SidecarStartError extends Error {
34
+ constructor(message: string);
35
+ }
36
+ export interface SidecarRestartPolicy {
37
+ /** Max consecutive restarts before the process is left dead. Default 3. */
38
+ maxRestarts?: number;
39
+ /** Base backoff in ms; attempt n waits `base * 2^(n-1)`. Default 250. */
40
+ backoffMs?: number;
41
+ }
42
+ export interface SidecarProcessOptions {
43
+ /**
44
+ * Python interpreter used to launch the sidecar. Default: env
45
+ * `STEERABLE_SIDECAR_PYTHON`, then the `@steerable/runtime-<target>` package
46
+ * npm installed for this host, then env `STEERABLE_PYTHON`, then `python3`.
47
+ * The interpreter must be able to import `steerable_sidecar`.
48
+ */
49
+ python?: string;
50
+ /**
51
+ * Entry module; defaults to `steerable_sidecar`. Pass `null` to invoke the
52
+ * interpreter directly on `args` (no `-m <module>` prefix) — used by tests
53
+ * that drive a fake sidecar under `node`.
54
+ */
55
+ entryModule?: string | null;
56
+ /** Extra CLI flags appended after `-m <entryModule>`. */
57
+ args?: string[];
58
+ /** Working directory for the sidecar process. */
59
+ cwd?: string;
60
+ /** Extra environment merged over `process.env`. */
61
+ env?: NodeJS.ProcessEnv;
62
+ /** Restart policy for unexpected exits. Pass `false` to disable. */
63
+ restart?: SidecarRestartPolicy | false;
64
+ /** Ms to wait for `lifecycle.ready` before failing the start. Default 15s. */
65
+ readyTimeoutMs?: number;
66
+ /** Ms to wait for graceful exit before SIGTERM. Default 5s. */
67
+ shutdownGraceMs?: number;
68
+ /** Reverse-channel handler (sidecar → host, e.g. `tool.invoke`). */
69
+ onRequest?: (method: string, params: unknown) => Promise<unknown>;
70
+ /** Sidecar-originated notifications (stream.chunk, agent.child, …). */
71
+ onNotification?: (method: string, params: unknown) => void;
72
+ /** stderr lines from the sidecar land here (default: swallowed). */
73
+ onStderr?: (line: string) => void;
74
+ }
75
+ export interface SidecarReadyInfo {
76
+ version?: string;
77
+ protocolVersion?: string;
78
+ pid?: number;
79
+ listenInfo?: unknown;
80
+ }
81
+ export declare class SidecarProcess extends EventEmitter {
82
+ private readonly options;
83
+ private child;
84
+ private peer;
85
+ private readyInfo;
86
+ /** True between close() and actual exit — an expected exit, no restart. */
87
+ private closing;
88
+ /** True once a boot fully succeeded; only booted processes earn restarts. */
89
+ private booted;
90
+ private restarts;
91
+ private startPromise;
92
+ /** Internal deferred resolved when the current boot sees lifecycle.ready. */
93
+ private readyDeferred;
94
+ constructor(options?: SidecarProcessOptions);
95
+ /** True once `lifecycle.ready` + `system.ping` have completed. */
96
+ get isReady(): boolean;
97
+ /** Params of the `lifecycle.ready` notification, once received. */
98
+ get ready(): SidecarReadyInfo | null;
99
+ /** PID of the live child, if any. */
100
+ get pid(): number | undefined;
101
+ /**
102
+ * Spawn the sidecar and wait for readiness. A manual start resets the
103
+ * consecutive-restart budget; the auto-restart path deliberately does not
104
+ * (a crash-looping sidecar must exhaust `maxRestarts`, not loop forever).
105
+ */
106
+ start(): Promise<void>;
107
+ private boot;
108
+ /**
109
+ * Graceful stop: `system.shutdown`, wait for exit, escalate to SIGTERM
110
+ * then SIGKILL. Resolves when the process is gone (or was never up).
111
+ */
112
+ close(): Promise<void>;
113
+ /** JSON-RPC request against the live sidecar; rejects fast when down. */
114
+ request<TResult = unknown>(method: string, params?: unknown, options?: {
115
+ timeoutMs?: number | null;
116
+ }): Promise<TResult>;
117
+ private spawnOnce;
118
+ private handleNotification;
119
+ private handleExit;
120
+ private teardownPeer;
121
+ }
122
+ export { JsonRpcTransportClosedError };