@steerable/agent-runtime 0.0.0-stage → 0.9.0
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 +201 -0
- package/README.md +140 -2
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/jsonrpc.d.ts +88 -0
- package/dist/jsonrpc.js +167 -0
- package/dist/methods.d.ts +16 -0
- package/dist/methods.js +58 -0
- package/dist/platform-runtime.d.ts +87 -0
- package/dist/platform-runtime.js +211 -0
- package/dist/runtime.d.ts +460 -0
- package/dist/runtime.js +446 -0
- package/dist/sidecar.d.ts +122 -0
- package/dist/sidecar.js +306 -0
- package/dist/transports.d.ts +68 -0
- package/dist/transports.js +57 -0
- package/package.json +57 -3
package/dist/runtime.js
ADDED
|
@@ -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 };
|