@ai-sdk-letta/server 0.1.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.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"serve.js","sourceRoot":"","sources":["../src/serve.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@ai-sdk-letta/server",
3
+ "version": "0.1.0",
4
+ "description": "Local HTTP runtime for ai-sdk-letta agents: durable threads and runs, NDJSON events, a token API and a loopback browser app.",
5
+ "license": "Apache-2.0",
6
+ "author": "Leonard Schneider",
7
+ "type": "module",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/leonardschneider/ai-sdk-letta.git",
11
+ "directory": "packages/server"
12
+ },
13
+ "homepage": "https://github.com/leonardschneider/ai-sdk-letta/tree/main/packages/server#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/leonardschneider/ai-sdk-letta/issues"
16
+ },
17
+ "keywords": [
18
+ "ai",
19
+ "ai-sdk",
20
+ "letta",
21
+ "agent",
22
+ "server",
23
+ "ndjson",
24
+ "express"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22.19.0"
28
+ },
29
+ "sideEffects": false,
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "ai-sdk-letta-source": "./src/index.ts",
35
+ "types": "./dist/index.d.ts",
36
+ "import": "./dist/index.js",
37
+ "default": "./dist/index.js"
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "src",
44
+ "CHANGELOG.md",
45
+ "NOTICE"
46
+ ],
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
52
+ "typecheck": "tsc --noEmit -p .",
53
+ "test": "node --conditions=ai-sdk-letta-source --import tsx --test test/*.test.ts",
54
+ "prepack": "node ../../scripts/check-dist.mjs"
55
+ },
56
+ "dependencies": {
57
+ "@types/express": "^5.0.6",
58
+ "ai-sdk-letta": "^0.1.0",
59
+ "express": "^5.2.1"
60
+ },
61
+ "peerDependencies": {
62
+ "ai": "^7.0.118"
63
+ },
64
+ "devDependencies": {
65
+ "@letta-ai/letta-agent-sdk": "0.8.22",
66
+ "ai": "7.0.118"
67
+ }
68
+ }
package/src/http.ts ADDED
@@ -0,0 +1,108 @@
1
+ import express from 'express';
2
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
3
+ import { IMAGE_LIMITS, IMAGE_MEDIA_TYPES } from 'ai-sdk-letta';
4
+ import type { RuntimeEvent, ThreadRuntime } from './runtime.js';
5
+ import { RuntimeFault } from './runtime.js';
6
+
7
+ /**
8
+ * Server-to-server API: bearer token (256-bit hex) plus an owner header, on
9
+ * 127.0.0.1 only. Browser origins and cross-site requests are never accepted.
10
+ */
11
+ export function tokenApiApp(runtime: ThreadRuntime, token: string, owner: string, port: number, shutdown?: () => Promise<void>) {
12
+ if (!/^[a-f0-9]{64}$/.test(token)) throw new Error('A private 256-bit token is required');
13
+ const app = express(); app.disable('x-powered-by');
14
+ app.use((req, res, next) => {
15
+ res.set({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
16
+ const supplied = req.headers.authorization?.replace(/^Bearer /, '');
17
+ if (req.headers.host !== `127.0.0.1:${port || req.socket.localPort}` || req.headers.origin || req.headers['sec-fetch-site'] === 'cross-site' || !supplied || !/^[a-f0-9]{64}$/.test(supplied) || !timingSafeEqual(Buffer.from(supplied), Buffer.from(token))) return res.status(401).json({ error: 'unauthorized' });
18
+ if (req.headers['x-runtime-owner'] !== owner) return res.status(403).json({ error: 'forbidden' });
19
+ next();
20
+ });
21
+ return runtimeRoutes(app, runtime, owner, shutdown);
22
+ }
23
+
24
+ /** JSON body limit for every route except `POST /v1/runs`. */
25
+ export const BODY_LIMIT_BYTES = 24 * 1024;
26
+ /**
27
+ * JSON body limit for `POST /v1/runs` only: base64 of the largest allowed
28
+ * image payload plus room for text and JSON. Still bounded; image limits are
29
+ * enforced again on the decoded bytes.
30
+ */
31
+ export const RUN_BODY_LIMIT_BYTES = Math.ceil(IMAGE_LIMITS.maxTotalBytes / 3) * 4 + 64 * 1024;
32
+
33
+ /** Shared routes; the caller must install its transport-specific authentication first. */
34
+ export function runtimeRoutes(app: express.Express, runtime: ThreadRuntime, owner: string, shutdown?: () => Promise<void>) {
35
+ const small = express.json({ limit: BODY_LIMIT_BYTES });
36
+ const runs = express.json({ limit: RUN_BODY_LIMIT_BYTES });
37
+ app.use((req, res, next) => (req.method === 'POST' && req.path === '/v1/runs' ? runs : small)(req, res, next));
38
+ app.get('/v1/capabilities', (_req, res) => res.json({ version: 1, stateful: true, tools: 'observed-only', interactions: ['approval', 'question'], history: true, replay: true, concurrency: 1, edits: false,
39
+ images: { mediaTypes: [...IMAGE_MEDIA_TYPES], maxImageBytes: IMAGE_LIMITS.maxImageBytes, maxImages: IMAGE_LIMITS.maxImages, maxTotalBytes: IMAGE_LIMITS.maxTotalBytes } }));
40
+ app.get('/v1/threads', (_req, res) => res.json(runtime.list(owner)));
41
+ app.post('/v1/threads', async (req, res) => res.status(201).json(await runtime.create(owner, req.body?.id, req.body?.title)));
42
+ app.patch('/v1/threads/:id', (req, res) => res.json(runtime.updateMetadata(owner, req.params.id, req.body)));
43
+ app.get('/v1/threads/:id/history', async (req, res) => res.json(await runtime.history(owner, req.params.id)));
44
+ app.get('/v1/threads/:id/view', async (req, res) => res.json(await runtime.view(owner, req.params.id)));
45
+ app.post('/v1/runs', async (req, res) => res.status(202).json(await runtime.start(owner, req.body)));
46
+ app.post('/v1/runs/:id/answer', (req, res) => { runtime.answer(owner, req.params.id, req.body); res.json({ accepted: true }); });
47
+ app.post('/v1/runs/:id/cancel', (req, res) => { runtime.cancel(owner, req.params.id); res.json({ accepted: true }); });
48
+ app.get('/v1/runs/:id/events', (req, res) => {
49
+ const snapshot = runtime.events(owner, req.params.id, Number(req.query.after ?? 0));
50
+ res.set({ 'Content-Type': 'application/x-ndjson', 'X-Accel-Buffering': 'no' }); res.flushHeaders();
51
+ const write = (event: RuntimeEvent) => { if (!res.destroyed) res.write(`${JSON.stringify(event)}\n`); if (event.type === 'completed' || event.type === 'failed') res.end(); };
52
+ for (const event of snapshot.events) write(event);
53
+ if (snapshot.status !== 'running') { res.end(); return; }
54
+ const unsubscribe = runtime.subscribe(req.params.id, write);
55
+ const heartbeat = setInterval(() => { if (!res.destroyed) res.write('\n'); }, 15_000);
56
+ res.on('close', () => { clearInterval(heartbeat); unsubscribe(); });
57
+ });
58
+ if (shutdown) app.post('/v1/shutdown', async (_req, res) => { await shutdown(); res.json({ stopped: true }); });
59
+ app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
60
+ app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
61
+ if (res.headersSent) return res.end();
62
+ // Body parser rejections: fixed codes, never parser messages.
63
+ if ((error as { type?: unknown } | undefined)?.type === 'entity.too.large') return res.status(413).json({ error: 'payload_too_large' });
64
+ res.status(error instanceof RuntimeFault ? error.status : error instanceof SyntaxError ? 400 : 503).json({ error: error instanceof RuntimeFault ? error.code : 'runtime_unavailable' });
65
+ });
66
+ return app;
67
+ }
68
+
69
+ /** What the browser may know about the agent. */
70
+ export interface GuiAgentInfo {
71
+ id: string;
72
+ name: string;
73
+ /** Tools whose calls always ask for approval (used for display only). */
74
+ approvalTools?: readonly string[];
75
+ }
76
+
77
+ /**
78
+ * Loopback-only, same-origin browser transport. No bearer token enters the
79
+ * browser: a random HttpOnly SameSite=Strict session cookie authenticates
80
+ * reads, and every mutation also needs the exact Origin and an in-memory CSRF
81
+ * token. Requests with a foreign Host, Origin or cross-site fetch metadata are
82
+ * rejected. Static assets are served with a strict CSP.
83
+ */
84
+ export function guiApp(runtime: ThreadRuntime, owner: string, port: number, assets: string, agent: GuiAgentInfo) {
85
+ const app = express();
86
+ const session = randomBytes(32).toString('hex');
87
+ const csrf = randomBytes(32).toString('hex');
88
+ app.disable('x-powered-by');
89
+ app.use((req, res, next) => {
90
+ const host = `127.0.0.1:${port || req.socket.localPort}`;
91
+ res.set({ 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
92
+ 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" });
93
+ if (req.headers.host !== host || (req.headers.origin && req.headers.origin !== `http://${host}`) || (req.headers['sec-fetch-site'] && !['same-origin', 'none'].includes(String(req.headers['sec-fetch-site'])))) return res.status(403).json({ error: 'invalid_origin' });
94
+ next();
95
+ });
96
+ app.get('/api/session', (_req, res) => {
97
+ res.cookie('ai_sdk_letta_session', session, { httpOnly: true, sameSite: 'strict', path: '/' });
98
+ res.json({ csrf, agent: { id: agent.id, name: agent.name, approvalTools: [...(agent.approvalTools ?? [])] } });
99
+ });
100
+ app.use('/api', (req, res, next) => {
101
+ const cookie = req.headers.cookie?.split(';').map(s => s.trim()).find(s => s.startsWith('ai_sdk_letta_session='))?.slice('ai_sdk_letta_session='.length);
102
+ if (!cookie || !/^[a-f0-9]{64}$/.test(cookie) || !timingSafeEqual(Buffer.from(cookie), Buffer.from(session))) return res.status(401).json({ error: 'session_required' });
103
+ if (!['GET', 'HEAD'].includes(req.method) && (req.headers.origin !== `http://127.0.0.1:${port || req.socket.localPort}` || req.headers['x-csrf-token'] !== csrf)) return res.status(403).json({ error: 'csrf_required' });
104
+ next();
105
+ }, runtimeRoutes(express(), runtime, owner));
106
+ app.use(express.static(assets, { index: 'index.html', dotfiles: 'deny' }));
107
+ return app;
108
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Local HTTP runtime for ai-sdk-letta agents: a durable thread/run service,
3
+ * a token-authenticated server-to-server API, and a loopback browser app.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ export { ThreadRuntime, RuntimeFault, displayRun, toolFailureReason, type RuntimeEvent, type Run, type RunImage, type RunInput, type RuntimeHost, type RuntimeSession } from './runtime.js';
8
+ export { guiApp, tokenApiApp, runtimeRoutes, BODY_LIMIT_BYTES, RUN_BODY_LIMIT_BYTES, type GuiAgentInfo } from './http.js';
9
+ export { startGuiServer, startApiServer, closeOnSignals, DEFAULT_PORT, type ServeOptions, type RunningServer } from './serve.js';
package/src/runtime.ts ADDED
@@ -0,0 +1,341 @@
1
+ import { readFileSync, writeFileSync, renameSync, existsSync, openSync, closeSync, fsyncSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { UIMessage, UserContent } from 'ai';
4
+ import { IMAGE_PLACEHOLDER, ImageInputError, MAX_INPUT_CHARACTERS, validateImages, validateResponse, type DecodedImage, type InteractionRequest, type InteractionResponse, type LettaAgent } from 'ai-sdk-letta';
5
+
6
+ /** One NDJSON event of a run. The application, never the HTTP consumer, owns tools. */
7
+ export type RuntimeEvent = { sequence: number; type: string; data: Record<string, unknown> };
8
+ // createdAt/lastActivityAt are optional: threads recorded before they existed stay valid.
9
+ type Thread = { id: string; owner: string; conversationId?: string; agentId?: string; title: string; archived: boolean; state: 'creating' | 'ready'; createdAt?: string; lastActivityAt?: string };
10
+ /** Metadata of an image sent with a run. The bytes live only in Letta history, never in runtime state. */
11
+ export type RunImage = { mediaType: string; bytes: number; sha256: string };
12
+ /** A single user turn and the events observed while it ran. */
13
+ export type Run = { id: string; threadId: string; input: string; images?: RunImage[]; parentRunId: string | null; status: 'running' | 'completed' | 'failed' | 'cancelled' | 'interrupted'; events: RuntimeEvent[]; startedAt?: string };
14
+ /** Input of `POST /v1/runs`. `images` carry base64 data (no data: prefix) and are validated against `IMAGE_LIMITS`. */
15
+ export type RunInput = { id: string; threadId: string; text: string; parentRunId: string | null; images?: { mediaType: string; data: string }[] };
16
+ type State = { version: 1; threads: Thread[]; runs: Run[] };
17
+ /** An opened agent conversation as seen by the runtime. */
18
+ export interface RuntimeSession {
19
+ agentId: string;
20
+ conversationId: string;
21
+ history: UIMessage[];
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ agent: Pick<LettaAgent<any>, 'stream' | 'interactions' | 'transcript'>;
24
+ }
25
+ /** Opens and closes the single agent session the runtime drives. */
26
+ export interface RuntimeHost {
27
+ open(options: { conversationId: string } | { newTitle: string }): Promise<RuntimeSession>;
28
+ close(): Promise<void>;
29
+ }
30
+ /** A client-visible failure with a fixed code and HTTP status. */
31
+ export class RuntimeFault extends Error {
32
+ constructor(readonly code: string, readonly status = 409) { super(code); }
33
+ }
34
+ const uuid = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
35
+ /** Only app-defined, fixed failure codes (e.g. user_denied) are surfaced; never free-form error text. */
36
+ const failureReasons = new Set(['user_denied', 'approval_cancelled', 'tool_denied', 'tool_cancelled', 'tool_timeout', 'tool_failed', 'invalid_arguments', 'interaction_unavailable', 'permission_denied', 'duplicate_or_limit', 'tool_output_limit']);
37
+ export function toolFailureReason(error: unknown): { reason?: string } {
38
+ let value = error;
39
+ if (typeof value === 'string') { try { value = JSON.parse(value); } catch { return {}; } }
40
+ const reason = value && typeof value === 'object' && !Array.isArray(value) ? (value as { error?: unknown }).error : undefined;
41
+ return typeof reason === 'string' && failureReasons.has(reason) ? { reason } : {};
42
+ }
43
+
44
+ /** Transport observations for reconnects/failed-turn inspection only, never agent input. */
45
+ export function displayRun(run: Run): UIMessage[] {
46
+ const parts: UIMessage['parts'] = [];
47
+ for (const event of run.events) {
48
+ const data = event.data;
49
+ if (event.type === 'text') {
50
+ const last = parts.at(-1);
51
+ if (last?.type === 'text') last.text += String(data.text);
52
+ else parts.push({ type: 'text', text: String(data.text) });
53
+ } else if (event.type === 'tool_started') parts.push({ type: 'dynamic-tool', toolName: String(data.name), toolCallId: String(data.toolCallId), state: 'input-available', input: data.input });
54
+ else if (event.type === 'tool_completed' || event.type === 'tool_failed') {
55
+ const index = parts.findIndex(p => p.type === 'dynamic-tool' && p.toolCallId === data.toolCallId);
56
+ const part = parts[index];
57
+ if (part?.type === 'dynamic-tool' && part.state === 'input-available') parts[index] = event.type === 'tool_completed'
58
+ ? { ...part, state: 'output-available', output: data.output }
59
+ : { ...part, state: 'output-error', errorText: typeof data.reason === 'string' ? JSON.stringify({ error: data.reason }) : String(data.code) };
60
+ }
61
+ }
62
+ if (run.status !== 'completed' && run.status !== 'running') for (let i = 0; i < parts.length; i++) {
63
+ const part = parts[i];
64
+ if (part.type === 'dynamic-tool' && part.state === 'input-available') parts[i] = { ...part, state: 'output-error', errorText: `Turn ${run.status}; execution not confirmed.` };
65
+ }
66
+ const metadata = run.startedAt ? { metadata: { createdAt: run.startedAt } } : {};
67
+ // Runtime state never stores image bytes; failed/reconnecting runs show a placeholder.
68
+ const user: UIMessage['parts'] = [...(run.input.trim() ? [{ type: 'text' as const, text: run.input }] : []), ...(run.images ?? []).map(() => ({ type: 'text' as const, text: IMAGE_PLACEHOLDER }))];
69
+ return [{ id: `${run.id}-user`, role: 'user', parts: user, ...metadata }, { id: `${run.id}-assistant`, role: 'assistant', parts }];
70
+ }
71
+
72
+ const sameImages = (a: RunImage[] = [], b: RunImage[] = []) => a.length === b.length && a.every((image, index) => image.sha256 === b[index]!.sha256);
73
+
74
+ /**
75
+ * Single-owner, single-turn thread service over one agent.
76
+ *
77
+ * Runs are recorded durably before delivery; a run that was in flight when the
78
+ * process stopped is marked `interrupted` and blocks further turns on that
79
+ * thread (uncertain delivery is never replayed). Human answers are validated
80
+ * and accepted exactly once.
81
+ */
82
+ export class ThreadRuntime {
83
+ private state: State;
84
+ private locked = false;
85
+ private current?: RuntimeSession;
86
+ private active?: { run: Run; control: AbortController };
87
+ private pending?: { runId: string; request: InteractionRequest; resolve(value: InteractionResponse): void };
88
+ private listeners = new Map<string, Set<(event: RuntimeEvent) => void>>();
89
+ constructor(private readonly host: RuntimeHost, private readonly filename: string, private readonly owner: string, private readonly deadlineMs = 180_000, private readonly humanWaitMs = 4 * 60_000) {
90
+ this.state = existsSync(filename) ? JSON.parse(readFileSync(filename, 'utf8')) as State : { version: 1, threads: [], runs: [] };
91
+ if (this.state.version !== 1 || !Array.isArray(this.state.threads) || !Array.isArray(this.state.runs)) throw new Error('Invalid runtime state');
92
+ for (const thread of this.state.threads) thread.archived ??= false;
93
+ for (const run of this.state.runs) if (run.status === 'running') {
94
+ run.status = 'interrupted';
95
+ run.events.push({ sequence: run.events.length + 1, type: 'failed', data: { code: 'delivery_uncertain' } });
96
+ }
97
+ this.save();
98
+ }
99
+ private save() {
100
+ // A leftover temporary file is an uncertain write, not permission to overwrite it.
101
+ const fd = openSync(`${this.filename}.tmp`, 'wx', 0o600);
102
+ try { writeFileSync(fd, JSON.stringify(this.state)); fsyncSync(fd); } finally { closeSync(fd); }
103
+ renameSync(`${this.filename}.tmp`, this.filename);
104
+ const directory = openSync(dirname(this.filename), 'r');
105
+ try { fsyncSync(directory); } finally { closeSync(directory); }
106
+ }
107
+ private authorize(owner: string) { if (owner !== this.owner) throw new RuntimeFault('forbidden', 403); }
108
+ private thread(owner: string, id: string) {
109
+ this.authorize(owner);
110
+ const thread = this.state.threads.find(t => t.id === id && t.owner === owner);
111
+ if (!thread) throw new RuntimeFault('not_found', 404);
112
+ if (thread.state !== 'ready') throw new RuntimeFault('creation_uncertain');
113
+ return thread;
114
+ }
115
+ private run(owner: string, id: string) {
116
+ this.authorize(owner);
117
+ const run = this.state.runs.find(r => r.id === id);
118
+ if (!run) throw new RuntimeFault('not_found', 404);
119
+ this.thread(owner, run.threadId);
120
+ return run;
121
+ }
122
+ private async exclusive<T>(fn: () => Promise<T>) {
123
+ if (this.locked || this.active) throw new RuntimeFault('runtime_busy');
124
+ this.locked = true;
125
+ try { return await fn(); } finally { this.locked = false; }
126
+ }
127
+ private async open(thread: Thread) {
128
+ if (this.current && this.current.conversationId === thread.conversationId) return this.current;
129
+ await this.host.close(); this.current = undefined;
130
+ const session = await this.host.open(thread.conversationId ? { conversationId: thread.conversationId } : { newTitle: thread.title });
131
+ const identity = this.state.threads.find(t => t.agentId)?.agentId;
132
+ if (identity && session.agentId !== identity) { await this.host.close(); throw new RuntimeFault('identity_mismatch'); }
133
+ this.current = session;
134
+ return session;
135
+ }
136
+ /** Display metadata only; timestamps are omitted for legacy threads that never recorded them. */
137
+ private summary({ id, title, state, archived, createdAt, lastActivityAt }: Thread) {
138
+ return { id, title, state, archived, ...(createdAt ? { createdAt } : {}), ...(lastActivityAt ? { lastActivityAt } : {}) };
139
+ }
140
+ list(owner: string) {
141
+ this.authorize(owner);
142
+ return this.state.threads.filter(t => t.owner === owner).map(t => this.summary(t));
143
+ }
144
+ /** Local display metadata only: never opens a session or edits backend history. */
145
+ updateMetadata(owner: string, id: string, input: unknown) {
146
+ const thread = this.thread(owner, id);
147
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new RuntimeFault('invalid_input', 400);
148
+ const fields = Object.keys(input);
149
+ if (!fields.length || fields.some(key => key !== 'title' && key !== 'archived')) throw new RuntimeFault('invalid_input', 400);
150
+ const patch = input as { title?: unknown; archived?: unknown };
151
+ let title = thread.title;
152
+ if (fields.includes('title')) {
153
+ // Reject terminal controls and invisible formatting/bidi controls before trimming.
154
+ if (typeof patch.title !== 'string' || /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(patch.title)) throw new RuntimeFault('invalid_input', 400);
155
+ title = patch.title.trim();
156
+ if (!title || title.length > 120) throw new RuntimeFault('invalid_input', 400);
157
+ }
158
+ if (fields.includes('archived') && typeof patch.archived !== 'boolean') throw new RuntimeFault('invalid_input', 400);
159
+ // locked covers session opening before start() has installed its active run.
160
+ if (patch.archived === true && (this.locked || this.active?.run.threadId === id)) throw new RuntimeFault('runtime_busy');
161
+ thread.title = title;
162
+ if (typeof patch.archived === 'boolean') thread.archived = patch.archived;
163
+ this.save();
164
+ return this.summary(thread);
165
+ }
166
+ async create(owner: string, id: string, title: string) {
167
+ this.authorize(owner);
168
+ if (!uuid.test(id) || typeof title !== 'string' || !title.trim() || title.length > 120) throw new RuntimeFault('invalid_input', 400);
169
+ const previous = this.state.threads.find(t => t.id === id);
170
+ if (previous) {
171
+ if (previous.owner !== owner || previous.title !== title) throw new RuntimeFault('id_conflict');
172
+ this.thread(owner, id); return { id, title };
173
+ }
174
+ if (this.state.threads.length >= 200) throw new RuntimeFault('capacity_reached');
175
+ return this.exclusive(async () => {
176
+ const now = new Date().toISOString();
177
+ const thread: Thread = { id, owner, title, archived: false, state: 'creating', createdAt: now, lastActivityAt: now };
178
+ this.state.threads.push(thread); this.save();
179
+ const session = await this.open(thread);
180
+ thread.agentId = session.agentId; thread.conversationId = session.conversationId; thread.state = 'ready'; this.save();
181
+ return { id, title };
182
+ });
183
+ }
184
+ async history(owner: string, id: string) {
185
+ const thread = this.thread(owner, id);
186
+ return this.exclusive(async () => {
187
+ // Refresh through backend history, not a display transcript replay into the agent.
188
+ await this.host.close(); this.current = undefined;
189
+ const session = await this.open(thread);
190
+ return { messages: session.history.filter(m => m.id !== 'session-status'), lastRunId: this.state.runs.filter(r => r.threadId === id).at(-1)?.id ?? null };
191
+ });
192
+ }
193
+ async view(owner: string, id: string) {
194
+ this.thread(owner, id);
195
+ const latest = this.state.runs.filter(r => r.threadId === id).at(-1);
196
+ if (this.active?.run.threadId === id && this.current) return {
197
+ messages: this.current.history.filter(m => m.id !== 'session-status'),
198
+ lastRunId: latest?.id ?? null,
199
+ live: { id: this.active.run.id, input: this.active.run.input, ...(this.active.run.images?.length ? { images: this.active.run.images.length } : {}) },
200
+ status: 'running',
201
+ };
202
+ if (latest && !['running', 'completed'].includes(latest.status)) return {
203
+ messages: this.state.runs.filter(r => r.threadId === id).flatMap(displayRun),
204
+ lastRunId: latest.id, live: null, status: latest.status, source: 'transport-observations',
205
+ };
206
+ return { ...await this.history(owner, id), live: null, status: latest?.status ?? null, source: 'backend-history' };
207
+ }
208
+ private emit(run: Run, type: string, data: Record<string, unknown>) {
209
+ const event = { sequence: run.events.length + 1, type, data };
210
+ run.events.push(event); this.save();
211
+ for (const listener of this.listeners.get(run.id) ?? []) listener(event);
212
+ }
213
+ /**
214
+ * Start one turn. Text, images or both; images are validated (type by
215
+ * content, size, count, total) and fail with a fixed `image_*` code.
216
+ * Only image metadata (type, size, SHA-256) is recorded in runtime state.
217
+ */
218
+ async start(owner: string, input: RunInput) {
219
+ this.authorize(owner);
220
+ if (!input || typeof input !== 'object' || !uuid.test(input.id) || typeof input.text !== 'string' || input.text.length > MAX_INPUT_CHARACTERS || (input.parentRunId !== null && !uuid.test(input.parentRunId)) || (input.images !== undefined && !Array.isArray(input.images))) throw new RuntimeFault('invalid_input', 400);
221
+ let images: DecodedImage[] = [];
222
+ try { images = validateImages(input.images ?? []); }
223
+ catch (error) { throw error instanceof ImageInputError ? new RuntimeFault(error.code, error.code === 'image_too_large' || error.code === 'images_too_large' ? 413 : 400) : error; }
224
+ if (!input.text.trim() && !images.length) throw new RuntimeFault('invalid_input', 400);
225
+ const imageMetadata: RunImage[] = images.map(({ mediaType, bytes, sha256 }) => ({ mediaType, bytes, sha256 }));
226
+ const thread = this.thread(owner, input.threadId);
227
+ const previous = this.state.runs.find(r => r.id === input.id);
228
+ if (previous) {
229
+ if (previous.threadId !== input.threadId || previous.input !== input.text || !sameImages(previous.images, imageMetadata) || previous.parentRunId !== input.parentRunId) throw new RuntimeFault('id_conflict');
230
+ return { id: previous.id, status: previous.status };
231
+ }
232
+ if (thread.archived) throw new RuntimeFault('thread_archived');
233
+ const latest = this.state.runs.filter(r => r.threadId === thread.id).at(-1);
234
+ if (input.parentRunId !== (latest?.id ?? null)) throw new RuntimeFault('history_conflict');
235
+ if (latest && latest.status !== 'completed') throw new RuntimeFault('delivery_uncertain');
236
+ if (this.state.runs.length >= 200) throw new RuntimeFault('capacity_reached');
237
+ return this.exclusive(async () => {
238
+ const session = await this.open(thread);
239
+ const startedAt = new Date().toISOString();
240
+ const run: Run = { id: input.id, threadId: input.threadId, input: input.text, ...(imageMetadata.length ? { images: imageMetadata } : {}), parentRunId: input.parentRunId, status: 'running', events: [], startedAt };
241
+ thread.lastActivityAt = startedAt;
242
+ this.state.runs.push(run); this.save();
243
+ const control = new AbortController(); this.active = { run, control };
244
+ // Exactly the new turn: text (if any) followed by the images, in the order given.
245
+ const content: UserContent = images.length
246
+ ? [...(input.text.trim() ? [{ type: 'text' as const, text: input.text }] : []), ...images.map(image => ({ type: 'image' as const, image: image.base64, mediaType: image.mediaType }))]
247
+ : input.text;
248
+ void this.drive(session, run, control, content);
249
+ return { id: run.id, status: run.status };
250
+ });
251
+ }
252
+ private async drive(session: RuntimeSession, run: Run, control: AbortController, content: UserContent) {
253
+ let timedOut = false;
254
+ const expire = () => { timedOut = true; control.abort(); };
255
+ let remaining = this.deadlineMs;
256
+ // The installed harness caps external tools at five minutes. Finish human
257
+ // waits explicitly before that limit, rather than accepting a detached result.
258
+ let remainingWait = this.humanWaitMs;
259
+ let resumedAt = Date.now();
260
+ let timer = setTimeout(expire, remaining);
261
+ // Human reading time does not consume inference time, but the whole turn
262
+ // remains bounded even if the browser is abandoned or prompts repeat.
263
+ const hardTimer = setTimeout(expire, this.deadlineMs + this.humanWaitMs);
264
+ let disconnect = () => {};
265
+ try {
266
+ disconnect = session.agent.interactions.connect((request, signal) => new Promise((resolve, reject) => {
267
+ clearTimeout(timer);
268
+ remaining = Math.max(1, remaining - (Date.now() - resumedAt));
269
+ const waitingAt = Date.now();
270
+ timer = setTimeout(expire, Math.max(1, remainingWait));
271
+ const resume = () => {
272
+ clearTimeout(timer); remainingWait -= Date.now() - waitingAt;
273
+ resumedAt = Date.now(); timer = setTimeout(expire, remaining);
274
+ };
275
+ const abort = () => {
276
+ if (this.pending?.request.id === request.id) {
277
+ this.pending = undefined;
278
+ this.emit(run, 'interaction_ended', { id: request.id, code: timedOut ? 'timed_out' : 'cancelled' });
279
+ resume();
280
+ }
281
+ reject(new RuntimeFault('interaction_cancelled'));
282
+ };
283
+ signal.addEventListener('abort', abort, { once: true });
284
+ this.pending = { runId: run.id, request, resolve: value => {
285
+ signal.removeEventListener('abort', abort);
286
+ if (this.pending?.request.id === request.id) this.pending = undefined;
287
+ resume(); resolve(value);
288
+ } };
289
+ if (signal.aborted) { abort(); return; }
290
+ this.emit(run, 'interaction', request);
291
+ }));
292
+ this.emit(run, 'started', { threadId: run.threadId });
293
+ const result = await session.agent.stream(typeof content === 'string'
294
+ ? { prompt: content, abortSignal: control.signal }
295
+ : { messages: [...session.agent.transcript, { role: 'user', content }], abortSignal: control.signal });
296
+ for await (const part of result.fullStream) {
297
+ if (part.type === 'text-delta') this.emit(run, 'text', { text: part.text });
298
+ else if (part.type === 'tool-call') this.emit(run, 'tool_started', { toolCallId: part.toolCallId, name: part.toolName, input: part.input, execution: 'external' });
299
+ else if (part.type === 'tool-result') this.emit(run, 'tool_completed', { toolCallId: part.toolCallId, name: part.toolName, output: part.output, execution: 'external' });
300
+ else if (part.type === 'tool-error') this.emit(run, 'tool_failed', { toolCallId: part.toolCallId, name: part.toolName, code: 'tool_failed', ...toolFailureReason(part.error), execution: 'external' });
301
+ else if (part.type === 'error' || part.type === 'abort') throw new RuntimeFault('runtime_failed');
302
+ }
303
+ if (this.pending?.runId === run.id) throw new RuntimeFault('interaction_incomplete');
304
+ if (control.signal.aborted || await result.finishReason !== 'stop') throw new RuntimeFault('runtime_failed');
305
+ run.status = 'completed'; this.emit(run, 'completed', {});
306
+ } catch (error) {
307
+ run.status = control.signal.aborted ? 'cancelled' : 'failed';
308
+ const code = timedOut ? 'timed_out' : run.status === 'cancelled' ? 'cancelled' : error instanceof RuntimeFault && error.message === 'interaction_incomplete' ? 'interaction_incomplete' : 'runtime_failed';
309
+ if (this.pending?.runId === run.id) this.emit(run, 'interaction_ended', { id: this.pending.request.id, code });
310
+ this.emit(run, 'failed', { code });
311
+ } finally {
312
+ // Keep the pre-run display snapshot current for browser reconnects without
313
+ // closing a running SDK session or replaying any transcript into the agent.
314
+ session.history = [...session.history, ...displayRun(run)];
315
+ this.pending = undefined; disconnect(); clearTimeout(timer); clearTimeout(hardTimer); this.active = undefined;
316
+ }
317
+ }
318
+ events(owner: string, id: string, after: number) {
319
+ const run = this.run(owner, id);
320
+ if (!Number.isSafeInteger(after) || after < 0 || after > run.events.length) throw new RuntimeFault('invalid_cursor', 400);
321
+ return { events: run.events.slice(after), status: run.status };
322
+ }
323
+ subscribe(id: string, listener: (event: RuntimeEvent) => void) {
324
+ const listeners = this.listeners.get(id) ?? new Set(); listeners.add(listener); this.listeners.set(id, listeners);
325
+ return () => { listeners.delete(listener); if (!listeners.size) this.listeners.delete(id); };
326
+ }
327
+ answer(owner: string, runId: string, response: InteractionResponse) {
328
+ const run = this.run(owner, runId);
329
+ const pending = this.pending;
330
+ if (!pending || pending.runId !== runId || run.status !== 'running') throw new RuntimeFault('stale_interaction');
331
+ let value: InteractionResponse;
332
+ try { value = validateResponse(pending.request, response); } catch { throw new RuntimeFault('invalid_response', 400); }
333
+ this.emit(run, 'interaction_resolved', { id: value.id }); pending.resolve(value);
334
+ }
335
+ cancel(owner: string, id: string) {
336
+ this.run(owner, id);
337
+ if (this.active?.run.id === id) this.active.control.abort();
338
+ }
339
+ async close() { this.active?.control.abort(); await this.host.close(); }
340
+ }
341
+