@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,306 @@
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 { spawn } from 'node:child_process';
15
+ import { EventEmitter } from 'node:events';
16
+ import { existsSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { resolvePlatformRuntime } from './platform-runtime.js';
20
+ import { JsonRpcPeer, JsonRpcTransportClosedError, } from './jsonrpc.js';
21
+ /**
22
+ * Interpreter for the sidecar process.
23
+ *
24
+ * Resolution order: explicit `STEERABLE_SIDECAR_PYTHON`, the platform package
25
+ * npm installed for this host, the framework checkout's `.venv` (so an example
26
+ * or test inside the repo finds the editable install), then
27
+ * `STEERABLE_PYTHON`, then `python3`.
28
+ *
29
+ * A global install has no framework checkout; a checkout has no platform
30
+ * package. Both paths end at a working sidecar without extra setup.
31
+ *
32
+ * @returns Path to the interpreter, or `python3` when nothing else resolves.
33
+ */
34
+ export function resolveRuntimePython() {
35
+ const explicit = process.env.STEERABLE_SIDECAR_PYTHON?.trim();
36
+ if (explicit)
37
+ return explicit;
38
+ const platform = resolvePlatformRuntime();
39
+ if (platform.kind === 'ok')
40
+ return platform.runtime.sidecarPython;
41
+ const venv = frameworkVenvPython();
42
+ if (venv)
43
+ return venv;
44
+ return process.env.STEERABLE_PYTHON?.trim() || 'python3';
45
+ }
46
+ /**
47
+ * Walks up from this module to a framework checkout and returns its `.venv`
48
+ * interpreter when present.
49
+ *
50
+ * The package runs from two planes — source (`ts/src/`) and build
51
+ * (`ts/dist/`) — so both depths are probed and the first hit wins. Absent in a
52
+ * packed install, where the platform package above already supplied one.
53
+ */
54
+ function frameworkVenvPython() {
55
+ const here = dirname(fileURLToPath(import.meta.url));
56
+ for (const up of [join('..', '..', '..'), join('..', '..', '..', '..')]) {
57
+ const venv = join(here, up, '.venv');
58
+ for (const candidate of [join(venv, 'bin', 'python3'), join(venv, 'Scripts', 'python.exe')]) {
59
+ if (existsSync(candidate))
60
+ return candidate;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ export class SidecarNotReadyError extends Error {
66
+ constructor(message = 'sidecar is not running') {
67
+ super(message);
68
+ this.name = 'SidecarNotReadyError';
69
+ }
70
+ }
71
+ export class SidecarStartError extends Error {
72
+ constructor(message) {
73
+ super(message);
74
+ this.name = 'SidecarStartError';
75
+ }
76
+ }
77
+ const SIGKILL_GRACE_MS = 1_000;
78
+ export class SidecarProcess extends EventEmitter {
79
+ options;
80
+ child = null;
81
+ peer = null;
82
+ readyInfo = null;
83
+ /** True between close() and actual exit — an expected exit, no restart. */
84
+ closing = false;
85
+ /** True once a boot fully succeeded; only booted processes earn restarts. */
86
+ booted = false;
87
+ restarts = 0;
88
+ startPromise = null;
89
+ /** Internal deferred resolved when the current boot sees lifecycle.ready. */
90
+ readyDeferred = null;
91
+ constructor(options = {}) {
92
+ super();
93
+ this.options = options;
94
+ }
95
+ /** True once `lifecycle.ready` + `system.ping` have completed. */
96
+ get isReady() {
97
+ return this.peer !== null;
98
+ }
99
+ /** Params of the `lifecycle.ready` notification, once received. */
100
+ get ready() {
101
+ return this.readyInfo;
102
+ }
103
+ /** PID of the live child, if any. */
104
+ get pid() {
105
+ return this.child?.pid;
106
+ }
107
+ /**
108
+ * Spawn the sidecar and wait for readiness. A manual start resets the
109
+ * consecutive-restart budget; the auto-restart path deliberately does not
110
+ * (a crash-looping sidecar must exhaust `maxRestarts`, not loop forever).
111
+ */
112
+ start() {
113
+ this.restarts = 0;
114
+ return this.boot();
115
+ }
116
+ boot() {
117
+ if (this.startPromise)
118
+ return this.startPromise;
119
+ this.startPromise = this.spawnOnce().finally(() => {
120
+ this.startPromise = null;
121
+ });
122
+ return this.startPromise;
123
+ }
124
+ /**
125
+ * Graceful stop: `system.shutdown`, wait for exit, escalate to SIGTERM
126
+ * then SIGKILL. Resolves when the process is gone (or was never up).
127
+ */
128
+ async close() {
129
+ this.closing = true;
130
+ // A restart boot may be in flight — wait for it so we don't leave a
131
+ // live child behind after close() returns.
132
+ if (this.startPromise) {
133
+ await this.startPromise.catch(() => undefined);
134
+ }
135
+ const child = this.child;
136
+ if (!child)
137
+ return;
138
+ const exited = new Promise((resolve) => {
139
+ child.once('exit', () => resolve());
140
+ });
141
+ try {
142
+ if (this.peer) {
143
+ await this.peer
144
+ .request('system.shutdown', undefined, { timeoutMs: 5_000 })
145
+ .catch(() => undefined);
146
+ }
147
+ }
148
+ finally {
149
+ const graceMs = this.options.shutdownGraceMs ?? 5_000;
150
+ const grace = await raceTimeout(exited, graceMs);
151
+ if (!grace) {
152
+ child.kill('SIGTERM');
153
+ const terminated = await raceTimeout(exited, SIGKILL_GRACE_MS);
154
+ if (!terminated)
155
+ child.kill('SIGKILL');
156
+ }
157
+ await exited.catch(() => undefined);
158
+ this.teardownPeer('sidecar closed');
159
+ this.child = null;
160
+ }
161
+ }
162
+ /** JSON-RPC request against the live sidecar; rejects fast when down. */
163
+ request(method, params, options) {
164
+ const peer = this.peer;
165
+ if (!peer)
166
+ return Promise.reject(new SidecarNotReadyError());
167
+ return peer.request(method, params, options);
168
+ }
169
+ async spawnOnce() {
170
+ const python = this.options.python ?? resolveRuntimePython();
171
+ const entry = this.options.entryModule === null
172
+ ? null
173
+ : (this.options.entryModule ?? 'steerable_sidecar');
174
+ const args = [
175
+ ...(entry === null ? [] : ['-m', entry]),
176
+ ...(this.options.args ?? []),
177
+ ];
178
+ const child = spawn(python, args, {
179
+ cwd: this.options.cwd,
180
+ env: { ...process.env, ...this.options.env },
181
+ stdio: ['pipe', 'pipe', 'pipe'],
182
+ });
183
+ this.child = child;
184
+ child.stderr?.setEncoding('utf8');
185
+ let stderrBuf = '';
186
+ child.stderr?.on('data', (chunk) => {
187
+ stderrBuf += chunk;
188
+ let nl;
189
+ while ((nl = stderrBuf.indexOf('\n')) !== -1) {
190
+ const line = stderrBuf.slice(0, nl);
191
+ stderrBuf = stderrBuf.slice(nl + 1);
192
+ if (line.trim().length > 0)
193
+ this.options.onStderr?.(line);
194
+ }
195
+ });
196
+ const peer = new JsonRpcPeer({
197
+ write: (line) => {
198
+ child.stdin?.write(line);
199
+ },
200
+ onNotification: (method, params) => this.handleNotification(method, params),
201
+ onRequest: this.options.onRequest,
202
+ });
203
+ child.stdout?.setEncoding('utf8');
204
+ child.stdout?.on('data', (chunk) => peer.feed(chunk));
205
+ child.on('exit', (code, signal) => this.handleExit(code, signal));
206
+ child.on('error', (err) => {
207
+ this.teardownPeer(`sidecar spawn error: ${err.message}`);
208
+ });
209
+ const readyTimeoutMs = this.options.readyTimeoutMs ?? 15_000;
210
+ const readySignal = new Promise((resolve, reject) => {
211
+ this.readyDeferred = { resolve, reject };
212
+ });
213
+ const timer = setTimeout(() => {
214
+ this.readyDeferred?.reject(new SidecarStartError(`sidecar did not emit lifecycle.ready within ${readyTimeoutMs}ms`));
215
+ }, readyTimeoutMs);
216
+ child.once('exit', (code) => {
217
+ this.readyDeferred?.reject(new SidecarStartError(`sidecar exited during boot (code ${String(code)})`));
218
+ });
219
+ // The peer only becomes the serving peer after ready + ping, so a
220
+ // half-booted process never answers requests. A failed boot kills the
221
+ // child — callers must not leak a process they never got a handle to.
222
+ try {
223
+ await readySignal;
224
+ }
225
+ catch (err) {
226
+ child.kill('SIGKILL');
227
+ throw err;
228
+ }
229
+ finally {
230
+ clearTimeout(timer);
231
+ this.readyDeferred = null;
232
+ }
233
+ try {
234
+ await peer.request('system.ping', undefined, { timeoutMs: 5_000 });
235
+ }
236
+ catch (err) {
237
+ child.kill('SIGKILL');
238
+ throw new SidecarStartError(`sidecar failed post-ready ping: ${err instanceof Error ? err.message : String(err)}`);
239
+ }
240
+ this.peer = peer;
241
+ this.booted = true;
242
+ // 'ready' means fully serving: listeners may issue requests immediately.
243
+ this.emit('ready', this.readyInfo);
244
+ }
245
+ handleNotification(method, params) {
246
+ if (method === 'lifecycle.ready') {
247
+ this.readyInfo = (params ?? {});
248
+ this.readyDeferred?.resolve();
249
+ return;
250
+ }
251
+ if (method === 'lifecycle.shutdown') {
252
+ this.emit('shutdown', params);
253
+ return;
254
+ }
255
+ this.options.onNotification?.(method, params);
256
+ }
257
+ handleExit(code, signal) {
258
+ const wasClosing = this.closing;
259
+ const wasBooted = this.booted;
260
+ this.booted = false;
261
+ this.teardownPeer(`sidecar exited (code ${String(code)}, signal ${String(signal)})`);
262
+ this.child = null;
263
+ this.readyInfo = null;
264
+ this.emit('exit', code, signal);
265
+ // Only a process that fully booted earns a restart — a boot failure is
266
+ // almost always deterministic (bad interpreter, missing package), and
267
+ // retrying it just delays the caller's error.
268
+ if (wasClosing || !wasBooted)
269
+ return;
270
+ const policy = this.options.restart;
271
+ if (policy === false)
272
+ return;
273
+ const maxRestarts = policy?.maxRestarts ?? 3;
274
+ const backoffMs = policy?.backoffMs ?? 250;
275
+ if (this.restarts >= maxRestarts) {
276
+ this.emit('dead', code, signal);
277
+ return;
278
+ }
279
+ this.restarts += 1;
280
+ const delay = backoffMs * 2 ** (this.restarts - 1);
281
+ this.emit('restart', this.restarts, delay);
282
+ setTimeout(() => {
283
+ if (this.closing)
284
+ return;
285
+ this.boot().catch((err) => {
286
+ this.emit('restart-failed', err instanceof Error ? err : new Error(String(err)));
287
+ });
288
+ }, delay);
289
+ }
290
+ teardownPeer(reason) {
291
+ if (!this.peer)
292
+ return;
293
+ this.peer.close(reason);
294
+ this.peer = null;
295
+ }
296
+ }
297
+ function raceTimeout(p, ms) {
298
+ return new Promise((resolve) => {
299
+ const timer = setTimeout(() => resolve(false), ms);
300
+ p.then(() => {
301
+ clearTimeout(timer);
302
+ resolve(true);
303
+ });
304
+ });
305
+ }
306
+ export { JsonRpcTransportClosedError };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Adapters that let `@steerable/agent-ui` hooks consume an `AgentRuntime`
3
+ * directly — `useChatStream` gets a `ChatStreamTransport`, `useAgentSession`
4
+ * gets an `AgentSessionTransport`.
5
+ *
6
+ * The interfaces below are declared locally and kept structurally identical
7
+ * to the ones in `@steerable/agent-ui/hooks`; the runtime package must not
8
+ * depend on the UI package (layering: UI sits above runtime). The
9
+ * compile-time gate `test/ui-transport.types.ts` asserts assignability both
10
+ * ways so the two declarations cannot drift apart.
11
+ */
12
+ import type { AgentSession, SSEEvent } from '@steerable/agent-protocol';
13
+ import type { AgentRuntime, ChatStreamParams } from './runtime.js';
14
+ /** Mirrors `@steerable/agent-ui` `ChatStreamSendInput`. */
15
+ export interface ChatStreamSendInput {
16
+ content: string;
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+ /** Mirrors `@steerable/agent-ui` `ChatStreamTransport`. */
20
+ export interface ChatStreamTransport {
21
+ stream: (input: ChatStreamSendInput, onEvent: (event: SSEEvent) => void) => Promise<void | (() => void)>;
22
+ steer?: (content: string) => Promise<boolean>;
23
+ }
24
+ /** Mirrors `@steerable/agent-ui` `AgentSessionTransport`. */
25
+ export interface AgentSessionTransport {
26
+ create: (input: {
27
+ chatId: string;
28
+ userId: string;
29
+ projectId?: string | null;
30
+ scenario?: string;
31
+ stageData?: Record<string, unknown> | null;
32
+ }) => Promise<AgentSession>;
33
+ resume: (sessionId: string) => Promise<AgentSession>;
34
+ list: (filter: {
35
+ userId?: string;
36
+ chatId?: string;
37
+ activeOnly?: boolean;
38
+ }) => Promise<AgentSession[]>;
39
+ }
40
+ export interface ChatStreamTransportOptions {
41
+ /**
42
+ * Static portion of every `agent.chat.stream` request — must pin at least
43
+ * the provider and model; any other sidecar field is passed through.
44
+ * (Written as Pick+Record instead of Omit: Omit collapses the index
45
+ * signature on ChatStreamParams and would drop the required fields.)
46
+ */
47
+ params: Pick<ChatStreamParams, 'provider' | 'model'> & Record<string, unknown>;
48
+ /**
49
+ * Resolve the session a turn belongs to. Called once per `stream` call;
50
+ * typically `() => currentSessionId` from your session hook. The value is
51
+ * forwarded as the request's `sessionId` passthrough field — session
52
+ * binding itself is the host's job (`agent.session.create`/`resume`).
53
+ */
54
+ sessionId: () => string;
55
+ }
56
+ /**
57
+ * Build the `ChatStreamTransport` for `useChatStream`. Per the hook's
58
+ * contract, `stream` resolves when the turn terminates; mid-turn stop is
59
+ * `runtime.cancelChat(streamId)` (cooperative — the loop winds down at the
60
+ * next safe point and the terminal `done` still arrives), and `steer`
61
+ * targets the currently active stream.
62
+ */
63
+ export declare function createChatStreamTransport(runtime: AgentRuntime, options: ChatStreamTransportOptions): ChatStreamTransport;
64
+ /**
65
+ * Build the `AgentSessionTransport` for `useAgentSession` — session
66
+ * lifecycle straight onto the sidecar's storage adapter.
67
+ */
68
+ export declare function createSessionTransport(runtime: AgentRuntime): AgentSessionTransport;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Adapters that let `@steerable/agent-ui` hooks consume an `AgentRuntime`
3
+ * directly — `useChatStream` gets a `ChatStreamTransport`, `useAgentSession`
4
+ * gets an `AgentSessionTransport`.
5
+ *
6
+ * The interfaces below are declared locally and kept structurally identical
7
+ * to the ones in `@steerable/agent-ui/hooks`; the runtime package must not
8
+ * depend on the UI package (layering: UI sits above runtime). The
9
+ * compile-time gate `test/ui-transport.types.ts` asserts assignability both
10
+ * ways so the two declarations cannot drift apart.
11
+ */
12
+ /**
13
+ * Build the `ChatStreamTransport` for `useChatStream`. Per the hook's
14
+ * contract, `stream` resolves when the turn terminates; mid-turn stop is
15
+ * `runtime.cancelChat(streamId)` (cooperative — the loop winds down at the
16
+ * next safe point and the terminal `done` still arrives), and `steer`
17
+ * targets the currently active stream.
18
+ */
19
+ export function createChatStreamTransport(runtime, options) {
20
+ let activeStreamId = null;
21
+ return {
22
+ stream: async (input, onEvent) => {
23
+ const handle = await runtime.chatStream({
24
+ ...options.params,
25
+ messages: [{ role: 'user', content: input.content }],
26
+ sessionId: options.sessionId(),
27
+ metadata: input.metadata,
28
+ });
29
+ activeStreamId = handle.streamId;
30
+ try {
31
+ for await (const event of handle.events) {
32
+ onEvent(event);
33
+ }
34
+ }
35
+ finally {
36
+ if (activeStreamId === handle.streamId)
37
+ activeStreamId = null;
38
+ }
39
+ },
40
+ steer: async (content) => {
41
+ if (!activeStreamId)
42
+ return false;
43
+ return runtime.steerChat(activeStreamId, content);
44
+ },
45
+ };
46
+ }
47
+ /**
48
+ * Build the `AgentSessionTransport` for `useAgentSession` — session
49
+ * lifecycle straight onto the sidecar's storage adapter.
50
+ */
51
+ export function createSessionTransport(runtime) {
52
+ return {
53
+ create: (input) => runtime.createSession(input),
54
+ resume: (sessionId) => runtime.resumeSession(sessionId),
55
+ list: (filter) => runtime.listSessions(filter),
56
+ };
57
+ }
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@steerable/agent-runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.9.1",
4
+ "description": "Steerable framework — official TypeScript production runtime. Owns the embedded Python sidecar lifecycle (spawn / ready handshake / health / bounded auto-restart / graceful drain) and exposes the CoreLoop-level API over the sidecar JSON-RPC surface. There is no TS CoreLoop; this package drives the same Rust CoreLoop the server uses (via the Python sidecar facade), so cross-language conformance holds by construction.",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://steerableframework.com/",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/pathlyapp/steerable.git",
10
+ "directory": "packages/agent-runtime/ts"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/pathlyapp/steerable/issues"
14
+ },
15
+ "keywords": [
16
+ "steerable",
17
+ "agent",
18
+ "runtime",
19
+ "coreloop",
20
+ "sidecar",
21
+ "typescript"
22
+ ],
23
+ "type": "module",
24
+ "main": "dist/index.js",
25
+ "types": "dist/index.d.ts",
26
+ "files": [
27
+ "dist",
28
+ "README.md"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "engines": {
35
+ "node": ">=22.19.0"
36
+ },
37
+ "dependencies": {
38
+ "@steerable/agent-protocol": "0.9.1"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^22.10.5",
42
+ "typescript": "^5.8.3",
43
+ "vitest": "^3.2.4",
44
+ "@steerable/agent-ui": "0.9.1"
45
+ },
46
+ "optionalDependencies": {
47
+ "@steerable/runtime-darwin-arm64": "0.9.1",
48
+ "@steerable/runtime-darwin-x64": "0.9.1",
49
+ "@steerable/runtime-linux-arm64": "0.9.1",
50
+ "@steerable/runtime-linux-x64": "0.9.1",
51
+ "@steerable/runtime-win32-x64": "0.9.1"
52
+ },
53
+ "scripts": {
54
+ "gen": "echo agent-runtime-ts",
55
+ "build": "tsc -p tsconfig.json",
56
+ "test": "vitest run",
57
+ "lint": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json --noEmit",
58
+ "check:drift": "echo ok"
59
+ }
6
60
  }