ai-runtime-engine 1.1.1 → 1.3.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/cli/cli.js +4 -1
  3. package/dist/cli/commands/config.js +6 -4
  4. package/dist/cli/commands/executions.d.ts +2 -0
  5. package/dist/cli/commands/executions.js +8 -1
  6. package/dist/cli/commands/init.js +7 -4
  7. package/dist/cli/commands/phase2.js +6 -2
  8. package/dist/cli/commands/run.d.ts +1 -0
  9. package/dist/cli/commands/run.js +15 -2
  10. package/dist/cli/interactive/repl.js +33 -2
  11. package/dist/cli/interactive/session.d.ts +4 -1
  12. package/dist/cli/interactive/session.js +25 -8
  13. package/dist/cli/render.d.ts +6 -0
  14. package/dist/cli/render.js +8 -0
  15. package/dist/core/fallback/fallback.d.ts +3 -0
  16. package/dist/core/fallback/fallback.js +1 -1
  17. package/dist/core/router/executor.d.ts +6 -1
  18. package/dist/core/router/executor.js +9 -2
  19. package/dist/core/router/normalize.d.ts +2 -0
  20. package/dist/core/router/request.js +2 -0
  21. package/dist/core/router/router.js +6 -0
  22. package/dist/index.d.ts +1 -1
  23. package/dist/providers/httpClient.d.ts +25 -1
  24. package/dist/providers/httpClient.js +93 -0
  25. package/dist/providers/httpProvider.d.ts +1 -0
  26. package/dist/providers/httpProvider.js +67 -1
  27. package/dist/providers/mock/mockProvider.d.ts +3 -0
  28. package/dist/providers/mock/mockProvider.js +54 -0
  29. package/dist/providers/mock/scenarios.d.ts +7 -0
  30. package/dist/providers/provider.d.ts +6 -0
  31. package/dist/providers/wire/anthropicWire.js +34 -0
  32. package/dist/providers/wire/openaiWire.js +30 -0
  33. package/dist/providers/wire/types.d.ts +16 -0
  34. package/dist/runtime/runtime.d.ts +5 -1
  35. package/dist/runtime/runtime.js +46 -8
  36. package/dist/runtime/types.d.ts +6 -0
  37. package/dist/types.d.ts +6 -0
  38. package/docs/GUIDE.md +3 -2
  39. package/docs/README.md +9 -2
  40. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,54 @@ All notable changes to `ai-runtime` are documented here. The format follows
5
5
  Versioning](https://semver.org/). Development history and rationale live in
6
6
  [docs/DECISIONS.md](docs/DECISIONS.md) and [docs/PROGRESS.md](docs/PROGRESS.md).
7
7
 
8
+ ## [1.3.0] — 2026-09-01
9
+
10
+ Token-by-token streaming. Additive and backward compatible — every new field is optional and, with
11
+ streaming unconfigured, behavior is identical to 1.2.0.
12
+
13
+ ### Added
14
+
15
+ - **Streaming responses end-to-end (`response.delta`)** — the reserved streaming event is now emitted for
16
+ real. Opt in with `stream: true` + `onDelta` on `AI.run()`/`RunRequest`, or `runtime.run({ stream: true })`
17
+ in chat mode. Text output only (a JSON/structured request never streams). Implemented through the one
18
+ router: optional `AIProvider.executeStream`, optional wire `buildStreamRequest`/`readDelta` for the
19
+ OpenAI-compatible and Anthropic shapes, and an SSE reader (`callHttpStream`) that buffers across network
20
+ boundaries. `AIResponse` is unchanged and remains the full aggregate — deltas ride the event channel.
21
+ - **Live rendering in the terminal** — the interactive REPL renders answers token-by-token (toggle with
22
+ `/stream`), and one-shot `ai-runtime run --stream` does the same. Streamed text is redacted like every
23
+ egress and is never reprinted after it streams.
24
+
25
+ ### Reliability
26
+
27
+ - A provider whose gateway doesn't support SSE **degrades gracefully to a buffered call** before the first
28
+ token, so enabling streaming never breaks a non-streaming endpoint. A mid-stream drop is categorized and
29
+ sanitized (never leaks the request URL) and falls back to the next candidate.
30
+
31
+ ## [1.2.0] — 2026-08-31
32
+
33
+ Closes the known gaps between what the CLI/REPL exposed and what the Runtime supported. Additive and
34
+ backward compatible, with three small behavior corrections noted under Changed.
35
+
36
+ ### Added
37
+
38
+ - **Approve / deny from the terminal** — `resume-execution --approve` / `--deny`, and REPL `/approve <id>`
39
+ / `/deny <id>`. A plan that stops at `waiting_for_approval` can now be advanced or cancelled from the
40
+ CLI or interactive terminal (previously reachable only programmatically or via a host `ApprovalProvider`).
41
+ - **Live progress in the interactive terminal** — the REPL now prints concise mode/routing/fallback lines
42
+ during a run by subscribing to lifecycle events (it always claimed to, but never did).
43
+
44
+ ### Changed
45
+
46
+ - **`chat` mode now honors `--dry-run` / `dryRun`** — a chat dry run makes **no** model call and captures
47
+ **no** memory; it reports what it would send. (Orchestration modes already did; chat used to run live.)
48
+ - **`ai-runtime config`** now resolves `.ai-runtime/config.yaml` through the runtime loader, matching
49
+ every other command. It previously used the legacy root-only loader and reported "no config file found"
50
+ even when the canonical file was in use.
51
+ - **`ai-runtime init`** now scaffolds the canonical `.ai-runtime/config.yaml` (same location as `setup`)
52
+ instead of a root `ai-runtime.yaml`. A root `ai-runtime.yaml` still works as a fallback if present.
53
+ - **`ai-runtime telemetry`** empty-state message now explains that telemetry is buffered in-process and
54
+ points to `telemetry: { sink: file }` for history that survives across CLI invocations.
55
+
8
56
  ## [1.1.1] — 2026-08-31
9
57
 
10
58
  Documentation only — no code changes.
@@ -82,6 +130,9 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
82
130
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
83
131
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
84
132
 
133
+ [1.3.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.3.0
134
+ [1.2.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.2.0
135
+ [1.1.1]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.1.1
85
136
  [1.1.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.1.0
86
137
  [1.0.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v1.0.0
87
138
  [0.1.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v0.1.0
package/dist/cli/cli.js CHANGED
@@ -20,7 +20,7 @@ import { skillsCommand } from './commands/skills.js';
20
20
  import { startRepl } from './interactive/repl.js';
21
21
  import { printError } from './render.js';
22
22
  const program = new Command();
23
- program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('1.1.1');
23
+ program.name('ai-runtime').description('Universal, provider-agnostic AI Runtime & Orchestration Platform').version('1.3.0');
24
24
  const configOpt = ['-c, --config <path>', 'path to an ai-runtime config file'];
25
25
  // Bare `ai-runtime` (no subcommand) opens the interactive terminal. `allowExcessArguments(false)` keeps
26
26
  // a mistyped subcommand (e.g. `ai-runtime porviders`) failing fast instead of silently opening the REPL.
@@ -35,6 +35,7 @@ program
35
35
  .option(...configOpt)
36
36
  .option('--json', 'print the full RuntimeResult as JSON')
37
37
  .option('--dry-run', 'plan and report what would happen without making any changes')
38
+ .option('--stream', 'stream the answer token-by-token (text output only)')
38
39
  .action((input, o) => runCommand(input, o));
39
40
  program
40
41
  .command('executions')
@@ -48,6 +49,8 @@ program
48
49
  .description('Resume a persisted execution (reconciles the workspace first)')
49
50
  .option(...configOpt)
50
51
  .option('--answer <text>', 'answer a pending clarification')
52
+ .option('--approve', 'approve a plan that is waiting for approval, then continue')
53
+ .option('--deny', 'deny a plan waiting for approval (cancels the execution)')
51
54
  .action((id, o) => resumeExecutionCommand(id, o));
52
55
  program.command('init').description('Scaffold ai-runtime.yaml, .env.example, and .gitignore entries').action(() => initCommand());
53
56
  program
@@ -3,16 +3,18 @@
3
3
  * defaults). Shows weights, strategy, policy posture, and timeouts. No secrets are read or printed.
4
4
  */
5
5
  import { resolveConfig } from '../../config/defaults.js';
6
- import { loadConfig, findConfigFile } from '../../config/load.js';
6
+ import { loadRuntimeConfig } from '../../runtime/config.js';
7
7
  import { print } from '../render.js';
8
8
  export function configCommand(options) {
9
- const file = options.config ?? findConfigFile();
10
- const resolved = resolveConfig(loadConfig(options.config));
9
+ // Use the runtime loader so `.ai-runtime/config.yaml` is honored (root `ai-runtime.yaml` still works
10
+ // as a fallback) — matching every other command, instead of the legacy root-only loader.
11
+ const loaded = loadRuntimeConfig({ workspaceRoot: process.cwd(), ...(options.config ? { explicitPath: options.config } : {}) });
12
+ const resolved = resolveConfig(loaded.config.router);
11
13
  if (options.json) {
12
14
  print(JSON.stringify(resolved, null, 2));
13
15
  return;
14
16
  }
15
- print(file ? `Resolved config (${file}):` : 'Resolved defaults (no config file found):');
17
+ print(loaded.configFile ? `Resolved config (${loaded.configFile}):` : 'Resolved defaults (no config file found):');
16
18
  print(` providers: ${resolved.providers.length}`);
17
19
  print(` strategy: ${resolved.strategy}`);
18
20
  print(` timeoutMs: ${resolved.timeoutMs}`);
@@ -6,4 +6,6 @@ export declare function executionsCommand(options: {
6
6
  export declare function resumeExecutionCommand(id: string, options: {
7
7
  config?: string;
8
8
  answer?: string;
9
+ approve?: boolean;
10
+ deny?: boolean;
9
11
  }): Promise<void>;
@@ -14,10 +14,17 @@ export async function executionsCommand(options) {
14
14
  }
15
15
  export async function resumeExecutionCommand(id, options) {
16
16
  const rt = await Runtime.load({ ...(options.config ? { config: options.config } : {}) });
17
- const r = await rt.resumeExecution(id, { ...(options.answer ? { clarificationAnswer: options.answer } : {}) });
17
+ // --approve proceeds past a waiting-for-approval gate; --deny cancels it. Neither leave the decision.
18
+ const approve = options.approve ? true : options.deny ? false : undefined;
19
+ const r = await rt.resumeExecution(id, {
20
+ ...(options.answer ? { clarificationAnswer: options.answer } : {}),
21
+ ...(approve !== undefined ? { approve } : {}),
22
+ });
18
23
  if (r.response?.text)
19
24
  print(r.response.text);
20
25
  print(`status: ${r.status}`);
26
+ if (r.status === 'waiting_for_approval')
27
+ print('(waiting for approval — re-run with --approve to proceed or --deny to cancel)');
21
28
  if (r.clarification)
22
29
  print(`? ${r.clarification.question}`);
23
30
  if (!r.ok)
@@ -2,8 +2,8 @@
2
2
  * `ai-runtime init` — scaffold a config, an env template, and gitignore entries. Never writes secrets;
3
3
  * the config carries only env-var NAMES, and `.env` is added to .gitignore.
4
4
  */
5
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
6
- import { resolve } from 'node:path';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { dirname, resolve } from 'node:path';
7
7
  import { print } from '../render.js';
8
8
  const EXAMPLE_YAML = `# ai-runtime configuration. Secret VALUES live in .env (gitignored); this file names env vars only.
9
9
  strategy: best
@@ -48,6 +48,7 @@ function writeIfAbsent(file, contents) {
48
48
  print(` • ${file} already exists — left unchanged`);
49
49
  return;
50
50
  }
51
+ mkdirSync(dirname(full), { recursive: true });
51
52
  writeFileSync(full, contents);
52
53
  print(` ✓ wrote ${file}`);
53
54
  }
@@ -65,11 +66,13 @@ function ensureGitignore() {
65
66
  }
66
67
  export function initCommand() {
67
68
  print('Initializing ai-runtime...');
68
- writeIfAbsent('ai-runtime.yaml', EXAMPLE_YAML);
69
+ // Write the canonical `.ai-runtime/config.yaml` (the location the runtime loader prefers) so `init`
70
+ // and `setup` scaffold the same file. A root `ai-runtime.yaml` still works as a fallback if present.
71
+ writeIfAbsent('.ai-runtime/config.yaml', EXAMPLE_YAML);
69
72
  writeIfAbsent('.env.example', EXAMPLE_ENV);
70
73
  ensureGitignore();
71
74
  print('\nNext steps:');
72
75
  print(' 1. Copy .env.example to .env and fill in the keys you have');
73
76
  print(' 2. Run `ai-runtime doctor` to verify configuration');
74
- print(' 3. Run `ai-runtime route <task>` to route a task');
77
+ print(' 3. Run `ai-runtime` to open the interactive terminal (or `ai-runtime run "<task>"`)');
75
78
  }
@@ -65,8 +65,12 @@ export function telemetryCommand(opts) {
65
65
  const events = ai.telemetryEvents();
66
66
  const limit = opts.limit ? Number(opts.limit) : 20;
67
67
  const recent = events.slice(-limit);
68
- if (recent.length === 0)
69
- return print('No telemetry recorded in this session.');
68
+ if (recent.length === 0) {
69
+ print('No telemetry recorded in this session.');
70
+ print('(Telemetry is buffered in-process, so a fresh CLI invocation starts empty. For telemetry that');
71
+ print(' persists across processes, set `telemetry: { sink: file }` in your config.)');
72
+ return;
73
+ }
70
74
  for (const e of recent)
71
75
  print(JSON.stringify(e));
72
76
  }
@@ -7,5 +7,6 @@ export interface RunOptions {
7
7
  config?: string;
8
8
  json?: boolean;
9
9
  dryRun?: boolean;
10
+ stream?: boolean;
10
11
  }
11
12
  export declare function runCommand(input: string, options: RunOptions): Promise<void>;
@@ -3,13 +3,24 @@
3
3
  * the interactive REPL uses. Prints the response (or a structured result with --json).
4
4
  */
5
5
  import { Runtime } from '../../runtime/runtime.js';
6
- import { print } from '../render.js';
6
+ import { print, printChunk } from '../render.js';
7
7
  import { RUNTIME_MODES } from '../../runtime/types.js';
8
8
  export async function runCommand(input, options) {
9
9
  const rt = await Runtime.load({ ...(options.config ? { config: options.config } : {}) });
10
10
  const req = { input };
11
11
  if (options.dryRun)
12
12
  req.dryRun = true;
13
+ // --stream renders tokens live (incompatible with --json, which needs the whole object).
14
+ let streamedAny = false;
15
+ if (options.stream && !options.json) {
16
+ req.stream = true;
17
+ rt.on((e) => {
18
+ if (e.type === 'response.delta') {
19
+ printChunk(e.text);
20
+ streamedAny = true;
21
+ }
22
+ });
23
+ }
13
24
  if (options.mode) {
14
25
  if (!RUNTIME_MODES.includes(options.mode)) {
15
26
  print(`invalid mode '${options.mode}'. valid: ${RUNTIME_MODES.join(', ')}`);
@@ -19,9 +30,11 @@ export async function runCommand(input, options) {
19
30
  req.mode = options.mode;
20
31
  }
21
32
  const result = await rt.run(req);
33
+ if (streamedAny)
34
+ process.stdout.write('\n');
22
35
  if (options.json)
23
36
  return print(JSON.stringify(result, null, 2));
24
- if (result.response?.text)
37
+ if (result.response?.text && !result.response.streamed)
25
38
  print(result.response.text);
26
39
  else if (result.response?.json !== undefined)
27
40
  print(JSON.stringify(result.response.json, null, 2));
@@ -4,7 +4,7 @@
4
4
  */
5
5
  import { createInterface } from 'node:readline';
6
6
  import { Runtime } from '../../runtime/runtime.js';
7
- import { print, printError } from '../render.js';
7
+ import { print, printChunk, printError } from '../render.js';
8
8
  import { summarizeWorkspace } from '../../runtime/workspace/workspace.js';
9
9
  import { ReplSession } from './session.js';
10
10
  function banner(rt) {
@@ -15,23 +15,54 @@ function banner(rt) {
15
15
  print(`mode: auto · ${rt.ai.providers().length} providers · type /help or a request, /exit to quit`);
16
16
  print('');
17
17
  }
18
+ /** A concise, redacted progress line for a lifecycle event — the REPL's live feedback during a run. */
19
+ function progressLine(e) {
20
+ switch (e.type) {
21
+ case 'mode.selected':
22
+ return e.source === 'auto' ? ` · mode: ${e.selected} (auto, ${Math.round(e.confidence * 100)}%)` : ` · mode: ${e.selected}`;
23
+ case 'provider.selected':
24
+ return ` · routed → ${e.providerId}/${e.model}`;
25
+ case 'provider.failed':
26
+ return ` · ${e.providerId}/${e.model} failed${e.category ? ` (${e.category})` : ''} — falling back`;
27
+ default:
28
+ return undefined; // runtime.started / clarification.requested / run.completed are covered by the result block
29
+ }
30
+ }
18
31
  /** Start the interactive session. Resolves when the user exits (or stdin closes). */
19
32
  export async function startRepl(configPath) {
20
33
  const rt = await Runtime.load({ ...(configPath ? { config: configPath } : {}) });
21
- const session = new ReplSession(rt);
34
+ const session = new ReplSession(rt, { streaming: true });
35
+ // Tracks whether the CURRENT line produced any streamed output, so we can close the line cleanly.
36
+ let streamedThisRun = false;
37
+ // Live progress + token streaming. Events are already redacted; a throwing observer can't break a run.
38
+ rt.on((e) => {
39
+ if (e.type === 'response.delta') {
40
+ printChunk(e.text);
41
+ streamedThisRun = true;
42
+ return;
43
+ }
44
+ const line = progressLine(e);
45
+ if (line)
46
+ print(line);
47
+ });
22
48
  banner(rt);
23
49
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: '> ' });
24
50
  rl.prompt();
25
51
  for await (const line of rl) {
26
52
  let result;
53
+ streamedThisRun = false;
27
54
  try {
28
55
  result = await session.handle(line);
29
56
  }
30
57
  catch (err) {
58
+ if (streamedThisRun)
59
+ process.stdout.write('\n');
31
60
  printError(`error: ${err instanceof Error ? err.message : String(err)}`);
32
61
  rl.prompt();
33
62
  continue;
34
63
  }
64
+ if (streamedThisRun)
65
+ process.stdout.write('\n'); // close the streamed line before printing result lines
35
66
  if (result.clear)
36
67
  process.stdout.write('\x1b[2J\x1b[H');
37
68
  for (const l of result.lines)
@@ -16,7 +16,10 @@ export declare class ReplSession {
16
16
  private viewCache?;
17
17
  private conversationId?;
18
18
  private dryRunMode;
19
- constructor(runtime: Runtime);
19
+ private streaming;
20
+ constructor(runtime: Runtime, opts?: {
21
+ streaming?: boolean;
22
+ });
20
23
  currentMode(): RuntimeMode;
21
24
  private views;
22
25
  handle(raw: string): Promise<HandleResult>;
@@ -36,10 +36,13 @@ const HELP = [
36
36
  ' /resume <id> resume a conversation',
37
37
  ' /executions list persisted executions',
38
38
  ' /resume-execution <id> resume an execution',
39
+ ' /approve <id> approve an execution waiting for approval, then continue',
40
+ ' /deny <id> deny an execution waiting for approval (cancels it)',
39
41
  ' /pause <id> pause an execution',
40
42
  ' /cancel <id> cancel an execution',
41
43
  ' /config show the resolved configuration',
42
44
  ' /dry-run toggle dry-run (plan only, no changes)',
45
+ ' /stream toggle token-by-token streaming of answers',
43
46
  ' /clear clear the screen',
44
47
  ' /exit leave the session',
45
48
  '',
@@ -51,8 +54,10 @@ export class ReplSession {
51
54
  viewCache;
52
55
  conversationId;
53
56
  dryRunMode = false;
54
- constructor(runtime) {
57
+ streaming;
58
+ constructor(runtime, opts = {}) {
55
59
  this.runtime = runtime;
60
+ this.streaming = opts.streaming ?? false;
56
61
  }
57
62
  currentMode() {
58
63
  return this.mode;
@@ -131,6 +136,9 @@ export class ReplSession {
131
136
  case 'dryrun':
132
137
  this.dryRunMode = !this.dryRunMode;
133
138
  return { lines: [`dry-run ${this.dryRunMode ? 'ON — plans will be shown, nothing executed' : 'OFF'}`] };
139
+ case 'stream':
140
+ this.streaming = !this.streaming;
141
+ return { lines: [`streaming ${this.streaming ? 'ON — answers render token-by-token' : 'OFF'}`] };
134
142
  case 'memory':
135
143
  return this.memory(args);
136
144
  case 'conversations':
@@ -141,6 +149,10 @@ export class ReplSession {
141
149
  return this.executionsList();
142
150
  case 'resume-execution':
143
151
  return this.resumeExecution(args[0]);
152
+ case 'approve':
153
+ return this.resumeExecution(args[0], true);
154
+ case 'deny':
155
+ return this.resumeExecution(args[0], false);
144
156
  case 'pause':
145
157
  return args[0] ? { lines: [this.runtime.pauseExecution(args[0]) ? `paused ${args[0]}` : `cannot pause '${args[0]}'`] } : { lines: ['usage: /pause <execution-id>'] };
146
158
  case 'cancel':
@@ -155,9 +167,10 @@ export class ReplSession {
155
167
  this.conversationId = this.runtime.conversations.start();
156
168
  this.runtime.conversations.append(this.conversationId, 'user', input);
157
169
  }
158
- const result = await this.runtime.run({ input, mode: forceMode ?? this.mode, ...(this.dryRunMode ? { dryRun: true } : {}) });
170
+ const result = await this.runtime.run({ input, mode: forceMode ?? this.mode, ...(this.dryRunMode ? { dryRun: true } : {}), ...(this.streaming ? { stream: true } : {}) });
159
171
  const lines = [];
160
- if (result.response?.text)
172
+ // When the answer already streamed live (response.streamed), don't reprint it.
173
+ if (result.response?.text && !result.response.streamed)
161
174
  lines.push(result.response.text);
162
175
  else if (result.response?.json !== undefined)
163
176
  lines.push(JSON.stringify(result.response.json, null, 2));
@@ -165,8 +178,10 @@ export class ReplSession {
165
178
  lines.push(`[${result.error.category}] ${result.error.message}`);
166
179
  if (result.mode.executed !== result.mode.selected)
167
180
  lines.push(`(mode: ${result.mode.selected} → ${result.mode.executed})`);
168
- if (result.status === 'waiting_for_approval')
169
- lines.push('(approval required before execution — resume the execution to approve)');
181
+ if (result.status === 'waiting_for_approval') {
182
+ const execId = result.execution?.id;
183
+ lines.push(execId ? `(approval required — /approve ${execId} to proceed, or /deny ${execId})` : '(approval required — approve the execution via /executions then /approve <id>)');
184
+ }
170
185
  if (result.status === 'failed')
171
186
  lines.push('(did not complete)');
172
187
  if (result.clarification)
@@ -249,14 +264,16 @@ export class ReplSession {
249
264
  return { lines: ['no executions yet.'] };
250
265
  return { lines: ['Executions:', ...list.map((e) => ` ${e.id} [${e.status}] ${e.mode}: ${e.goal.slice(0, 50)}`)] };
251
266
  }
252
- async resumeExecution(id) {
267
+ async resumeExecution(id, approve) {
253
268
  if (!id)
254
- return { lines: ['usage: /resume-execution <execution-id>'] };
255
- const r = await this.runtime.resumeExecution(id);
269
+ return { lines: [`usage: /${approve === undefined ? 'resume-execution' : approve ? 'approve' : 'deny'} <execution-id>`] };
270
+ const r = await this.runtime.resumeExecution(id, approve !== undefined ? { approve } : {});
256
271
  const lines = [];
257
272
  if (r.response?.text)
258
273
  lines.push(r.response.text);
259
274
  lines.push(`status: ${r.status}`);
275
+ if (r.status === 'waiting_for_approval')
276
+ lines.push('(waiting for approval — /approve ' + id + ' to proceed, /deny ' + id + ' to cancel)');
260
277
  if (r.clarification)
261
278
  lines.push(`? ${r.clarification.question}`);
262
279
  return { lines };
@@ -4,4 +4,10 @@
4
4
  * directly from a command.
5
5
  */
6
6
  export declare function print(line: string): void;
7
+ /**
8
+ * Write a streamed chunk WITHOUT a trailing newline, redacted like every other egress. (Redaction is
9
+ * per-chunk, matching the lifecycle emitter; a secret split across chunk boundaries is the known limit of
10
+ * any token stream — but model output never contains the env-var secrets the redactor tracks.)
11
+ */
12
+ export declare function printChunk(chunk: string): void;
7
13
  export declare function printError(line: string): void;
@@ -8,6 +8,14 @@ export function print(line) {
8
8
  // eslint-disable-next-line no-console
9
9
  console.log(redactString(line));
10
10
  }
11
+ /**
12
+ * Write a streamed chunk WITHOUT a trailing newline, redacted like every other egress. (Redaction is
13
+ * per-chunk, matching the lifecycle emitter; a secret split across chunk boundaries is the known limit of
14
+ * any token stream — but model output never contains the env-var secrets the redactor tracks.)
15
+ */
16
+ export function printChunk(chunk) {
17
+ process.stdout.write(redactString(chunk));
18
+ }
11
19
  export function printError(line) {
12
20
  // eslint-disable-next-line no-console
13
21
  console.error(redactString(line));
@@ -19,6 +19,9 @@ export interface FallbackInput {
19
19
  signal?: AbortSignal;
20
20
  clock?: Clock;
21
21
  onAttempt?: (record: AttemptRecord) => void;
22
+ /** Phase-13 streaming: forwarded to each attempt's `executeOnce`; deltas ride this callback, the final
23
+ * aggregate rides the return value. Only meaningful when `template.stream` is set. */
24
+ onDelta?: (chunk: string) => void;
22
25
  /** Optional Phase-6 validation. A failing report drops this candidate and continues (no poisoning). */
23
26
  validate?: (response: AIResponse, model: string) => ValidationReport;
24
27
  /** Optional spend guardrail. When it cannot afford the next call, the run STOPS with BUDGET. */
@@ -34,7 +34,7 @@ export async function runWithFallback(input) {
34
34
  tried += 1;
35
35
  const started = clock.now();
36
36
  const request = buildRequest(input.template, model.id, input.signal);
37
- const outcome = await executeOnce(provider, request);
37
+ const outcome = await executeOnce(provider, request, input.onDelta);
38
38
  const latencyMs = clock.now() - started;
39
39
  input.budget?.recordCall(estCost);
40
40
  if (outcome.ok) {
@@ -13,4 +13,9 @@ export type ExecOutcome = {
13
13
  ok: false;
14
14
  error: AIError;
15
15
  };
16
- export declare function executeOnce(provider: AIProvider, request: AIRequest): Promise<ExecOutcome>;
16
+ /**
17
+ * Run one attempt. When `onDelta` is supplied AND the request asked to stream AND the provider supports
18
+ * `executeStream`, the answer streams token-by-token; otherwise the normal single-shot `execute()` runs.
19
+ * Either way the resolved `AIResponse` is the full aggregate.
20
+ */
21
+ export declare function executeOnce(provider: AIProvider, request: AIRequest, onDelta?: (chunk: string) => void): Promise<ExecOutcome>;
@@ -4,9 +4,16 @@
4
4
  * raw vendor error.
5
5
  */
6
6
  import { AIError, toAIError } from '../fallback/errors.js';
7
- export async function executeOnce(provider, request) {
7
+ /**
8
+ * Run one attempt. When `onDelta` is supplied AND the request asked to stream AND the provider supports
9
+ * `executeStream`, the answer streams token-by-token; otherwise the normal single-shot `execute()` runs.
10
+ * Either way the resolved `AIResponse` is the full aggregate.
11
+ */
12
+ export async function executeOnce(provider, request, onDelta) {
8
13
  try {
9
- const response = await provider.execute(request);
14
+ const response = onDelta && request.stream && provider.executeStream
15
+ ? await provider.executeStream(request, onDelta)
16
+ : await provider.execute(request);
10
17
  return { ok: true, response };
11
18
  }
12
19
  catch (e) {
@@ -22,6 +22,8 @@ export interface RequestTemplate {
22
22
  params?: AIRequest['params'];
23
23
  timeoutMs: number;
24
24
  sensitivity: Sensitivity;
25
+ /** Stream the answer token-by-token (Phase 13). Set only for text output; never for JSON. */
26
+ stream?: boolean;
25
27
  }
26
28
  export interface NormalizeResult {
27
29
  task: NormalizedTask;
@@ -15,6 +15,8 @@ export function buildRequest(template, model, signal) {
15
15
  req.tools = template.tools;
16
16
  if (template.params !== undefined)
17
17
  req.params = template.params;
18
+ if (template.stream)
19
+ req.stream = true;
18
20
  if (signal !== undefined)
19
21
  req.signal = signal;
20
22
  return req;
@@ -31,6 +31,11 @@ export class Router {
31
31
  // Phase 1 — normalize
32
32
  const { task, template } = normalize(req, tasks, config);
33
33
  const pin = { provider: req.provider, model: req.model };
34
+ // Phase 13 — streaming is opt-in and TEXT-ONLY: a JSON/structured request never streams (partial
35
+ // JSON is useless). The delta callback rides `req.onDelta`; the final aggregate rides the return value.
36
+ const wantsJsonOut = template.output?.format === 'json' || template.output?.format === 'structured_output';
37
+ if (req.stream && req.onDelta && !wantsJsonOut)
38
+ template.stream = true;
34
39
  // Team policy (org-level guardrails) merged over per-run constraints.
35
40
  const policy = config.policy;
36
41
  if (policy.requireLocal)
@@ -136,6 +141,7 @@ export class Router {
136
141
  maxFallbacks: config.maxFallbacks,
137
142
  clock: this.clock,
138
143
  onAttempt,
144
+ ...(template.stream && req.onDelta ? { onDelta: req.onDelta } : {}),
139
145
  validate: (response) => validateResponse({ response, ...(template.output ? { output: template.output } : {}), ...(template.tools ? { tools: template.tools } : {}) }),
140
146
  ...(budget ? { budget, costOf } : {}),
141
147
  });
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export type { FetchLike } from './providers/httpClient.js';
20
20
  export { PROVIDER_DEFAULTS } from './config/providerDefaults.js';
21
21
  export { resolveModelMetadata } from './discovery/modelCatalog.js';
22
22
  export { registerWire } from './providers/wire/registry.js';
23
- export type { WireModule, WireShape } from './providers/wire/types.js';
23
+ export type { WireDelta, WireModule, WireShape } from './providers/wire/types.js';
24
24
  export { AIError, isRetryable, statusToCategory, toAIError } from './core/fallback/errors.js';
25
25
  export { capabilitySatisfies, emptyProfile, getCapability, mergeProfiles, profileFromDeclared, rankOf, } from './core/capabilities/evidence.js';
26
26
  export { AGENT_CAPABILITIES, INPUT_MODALITIES, INTELLIGENCE_SKILLS, OUTPUT_MODALITIES, } from './core/capabilities/taxonomy.js';
@@ -11,7 +11,8 @@
11
11
  * caller built, never in anything this function stores or throws.
12
12
  */
13
13
  import type { Clock } from '../util/clock.js';
14
- import type { WireRequest } from './wire/types.js';
14
+ import type { FinishReason } from '../types.js';
15
+ import type { WireDelta, WireRequest } from './wire/types.js';
15
16
  export type FetchLike = typeof fetch;
16
17
  export interface CallHttpInput {
17
18
  build: (jsonMode: boolean) => WireRequest;
@@ -32,3 +33,26 @@ export interface CallHttpResult {
32
33
  jsonModeUsed: boolean;
33
34
  }
34
35
  export declare function callHttp(input: CallHttpInput): Promise<CallHttpResult>;
36
+ export interface CallHttpStreamInput {
37
+ build: (jsonMode: boolean) => WireRequest;
38
+ readDelta: (data: string) => WireDelta;
39
+ onDelta: (text: string) => void;
40
+ providerId: string;
41
+ model: string;
42
+ timeoutMs: number;
43
+ jsonMode: boolean;
44
+ fetchImpl?: FetchLike;
45
+ clock?: Clock;
46
+ signal?: AbortSignal;
47
+ }
48
+ export interface CallHttpStreamResult {
49
+ status: number;
50
+ text: string;
51
+ usage?: {
52
+ inputTokens?: number;
53
+ outputTokens?: number;
54
+ };
55
+ finishReason: FinishReason;
56
+ durationMs: number;
57
+ }
58
+ export declare function callHttpStream(input: CallHttpStreamInput): Promise<CallHttpStreamResult>;
@@ -78,3 +78,96 @@ export async function callHttp(input) {
78
78
  }
79
79
  throw lastError ?? new AIError(`${providerId} request failed`, { category: 'NETWORK', retryable: true, providerId, model });
80
80
  }
81
+ export async function callHttpStream(input) {
82
+ const fetchImpl = input.fetchImpl ?? fetch;
83
+ const clock = input.clock ?? systemClock;
84
+ const { providerId, model } = input;
85
+ const started = clock.now();
86
+ const req = input.build(input.jsonMode);
87
+ // Combine the per-request timeout with any caller abort signal (cancellation).
88
+ const signals = [AbortSignal.timeout(input.timeoutMs)];
89
+ if (input.signal)
90
+ signals.push(input.signal);
91
+ const signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
92
+ let res;
93
+ try {
94
+ res = await fetchImpl(req.url, { method: 'POST', headers: req.headers, body: JSON.stringify(req.body), signal });
95
+ }
96
+ catch (e) {
97
+ // undici embeds the full URL in the message — use only the error NAME, never the message.
98
+ const name = e instanceof Error ? e.name : 'Error';
99
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
100
+ throw new AIError(`${providerId} stream request failed (${name})`, { category: isTimeout ? 'TIMEOUT' : 'NETWORK', retryable: true, providerId, model });
101
+ }
102
+ if (!res.ok || !res.body) {
103
+ const text = await res.text().catch(() => '');
104
+ const excerpt = redactString(text.slice(0, 300));
105
+ const category = statusToCategory(res.status);
106
+ throw new AIError(`${providerId} stream returned HTTP ${res.status}: ${excerpt}`, { category, status: res.status, retryable: isRetryable(category), providerId, model });
107
+ }
108
+ const reader = res.body.getReader();
109
+ const decoder = new TextDecoder();
110
+ let buffer = '';
111
+ let text = '';
112
+ let usage;
113
+ let finishReason = 'stop';
114
+ let done = false;
115
+ const handleData = (payload) => {
116
+ if (!payload)
117
+ return;
118
+ const delta = input.readDelta(payload);
119
+ if (delta.text) {
120
+ text += delta.text;
121
+ input.onDelta(delta.text);
122
+ }
123
+ if (delta.usage)
124
+ usage = { ...usage, ...delta.usage };
125
+ if (delta.finishReason)
126
+ finishReason = delta.finishReason;
127
+ if (delta.done)
128
+ done = true;
129
+ };
130
+ try {
131
+ try {
132
+ while (!done) {
133
+ const { value, done: streamDone } = await reader.read();
134
+ if (streamDone)
135
+ break;
136
+ buffer += decoder.decode(value, { stream: true });
137
+ // Process complete lines; keep the trailing partial in the buffer.
138
+ let nl;
139
+ while ((nl = buffer.indexOf('\n')) >= 0) {
140
+ const line = buffer.slice(0, nl).replace(/\r$/, '');
141
+ buffer = buffer.slice(nl + 1);
142
+ if (line.startsWith('data:'))
143
+ handleData(line.slice(5).trim());
144
+ // `event:` / blank / comment (`:`) lines are ignored — the type is in the data JSON.
145
+ if (done)
146
+ break;
147
+ }
148
+ }
149
+ // Flush a final line that had no trailing newline.
150
+ if (!done && buffer.startsWith('data:'))
151
+ handleData(buffer.slice(5).trim());
152
+ }
153
+ finally {
154
+ try {
155
+ await reader.cancel();
156
+ }
157
+ catch {
158
+ /* nothing to do */
159
+ }
160
+ }
161
+ }
162
+ catch (e) {
163
+ // A mid-stream drop/timeout after 200 OK: categorize and sanitize like the initial-fetch path (undici
164
+ // embeds the full URL in the message — use only the error NAME, never the message).
165
+ const name = e instanceof Error ? e.name : 'Error';
166
+ const isTimeout = name === 'TimeoutError' || name === 'AbortError';
167
+ throw new AIError(`${providerId} stream interrupted (${name})`, { category: isTimeout ? 'TIMEOUT' : 'NETWORK', retryable: true, providerId, model });
168
+ }
169
+ const result = { status: res.status, text, finishReason, durationMs: clock.now() - started };
170
+ if (usage)
171
+ result.usage = usage;
172
+ return result;
173
+ }
@@ -46,4 +46,5 @@ export declare class HttpProvider implements AIProvider {
46
46
  getCapabilities(model: string): Promise<CapabilityProfile>;
47
47
  estimate(request: AIRequest): Promise<ExecutionEstimate>;
48
48
  execute(request: AIRequest): Promise<AIResponse>;
49
+ executeStream(request: AIRequest, onDelta: (chunk: string) => void): Promise<AIResponse>;
49
50
  }
@@ -12,7 +12,7 @@ import { AIError } from '../core/fallback/errors.js';
12
12
  import { emptyProfile } from '../core/capabilities/evidence.js';
13
13
  import { extractJson } from '../util/extractJson.js';
14
14
  import { getWire } from './wire/registry.js';
15
- import { callHttp } from './httpClient.js';
15
+ import { callHttp, callHttpStream } from './httpClient.js';
16
16
  function originOf(url) {
17
17
  try {
18
18
  return new URL(url).origin;
@@ -132,4 +132,70 @@ export class HttpProvider {
132
132
  }
133
133
  return response;
134
134
  }
135
+ async executeStream(request, onDelta) {
136
+ const wire = getWire(this.cfg.wireShape);
137
+ const wantsJson = request.output?.format === 'json' || request.output?.format === 'structured_output';
138
+ // JSON output, or a wire without streaming support, degrades to the normal single-shot path.
139
+ if (wantsJson || !wire.buildStreamRequest || !wire.readDelta)
140
+ return this.execute(request);
141
+ const needsKey = this.cfg.requiresKey !== false;
142
+ const apiKey = this.cfg.credential.use();
143
+ if (needsKey && !apiKey) {
144
+ throw new AIError(`no ${this.cfg.credential.envName ?? 'API key'} in the environment`, { category: 'AUTHENTICATION', retryable: false, providerId: this.id, model: request.model });
145
+ }
146
+ const maxTokens = request.params?.maxTokens ?? 2000;
147
+ const ctx = {
148
+ baseUrl: this.cfg.baseUrl,
149
+ model: request.model,
150
+ maxTokens,
151
+ ...(apiKey ? { apiKey } : {}),
152
+ ...(this.cfg.headers ? { headers: this.cfg.headers } : {}),
153
+ };
154
+ // Graceful degrade: if the stream fails BEFORE any token (a gateway that doesn't do SSE, or 400s on
155
+ // the streaming body), fall back to buffered execute() so a non-streaming endpoint still works — the
156
+ // caller just doesn't get live tokens. Once tokens have flowed we can't degrade (would double-emit),
157
+ // so we rethrow and let the router fall back to a different candidate.
158
+ let emitted = 0;
159
+ let result;
160
+ try {
161
+ result = await callHttpStream({
162
+ build: (jsonMode) => wire.buildStreamRequest(request, ctx, jsonMode),
163
+ readDelta: (data) => wire.readDelta(data),
164
+ onDelta: (chunk) => {
165
+ emitted += 1;
166
+ onDelta(chunk);
167
+ },
168
+ providerId: this.id,
169
+ model: request.model,
170
+ timeoutMs: request.timeoutMs || this.cfg.timeoutMs || 90_000,
171
+ jsonMode: this.jsonMode,
172
+ ...(this.cfg.fetchImpl ? { fetchImpl: this.cfg.fetchImpl } : {}),
173
+ ...(this.cfg.clock ? { clock: this.cfg.clock } : {}),
174
+ ...(request.signal ? { signal: request.signal } : {}),
175
+ });
176
+ }
177
+ catch (e) {
178
+ if (emitted === 0)
179
+ return this.execute(request);
180
+ throw e;
181
+ }
182
+ const response = {
183
+ finishReason: result.finishReason,
184
+ providerId: this.id,
185
+ model: request.model,
186
+ latencyMs: result.durationMs,
187
+ };
188
+ if (result.text)
189
+ response.text = result.text;
190
+ if (result.usage) {
191
+ const inTok = result.usage.inputTokens;
192
+ const outTok = result.usage.outputTokens;
193
+ response.usage = {
194
+ ...(inTok !== undefined ? { inputTokens: inTok } : {}),
195
+ ...(outTok !== undefined ? { outputTokens: outTok } : {}),
196
+ ...(inTok !== undefined && outTok !== undefined ? { totalTokens: inTok + outTok } : {}),
197
+ };
198
+ }
199
+ return response;
200
+ }
135
201
  }
@@ -32,4 +32,7 @@ export declare class MockProvider implements AIProvider {
32
32
  getCapabilities(model: string): Promise<CapabilityProfile>;
33
33
  estimate(request: AIRequest): Promise<ExecutionEstimate>;
34
34
  execute(request: AIRequest): Promise<AIResponse>;
35
+ /** Stream text chunks then resolve with the full aggregate (Phase 13). Failure behaviors still throw. */
36
+ executeStream(request: AIRequest, onDelta: (chunk: string) => void): Promise<AIResponse>;
37
+ private respond;
35
38
  }
@@ -67,11 +67,45 @@ export class MockProvider {
67
67
  }
68
68
  async execute(request) {
69
69
  this.executeCalls += 1;
70
+ return this.respond(request);
71
+ }
72
+ /** Stream text chunks then resolve with the full aggregate (Phase 13). Failure behaviors still throw. */
73
+ async executeStream(request, onDelta) {
74
+ this.executeCalls += 1;
75
+ const behavior = typeof this.behavior === 'function' ? this.behavior(request) : this.behavior;
76
+ // JSON output never streams — fall back to the single-shot response (no deltas).
77
+ const wantsJson = request.output?.format === 'json' || request.output?.format === 'structured_output';
78
+ if (wantsJson || behavior.kind === 'malformed' || behavior.kind === 'timeout' || behavior.kind === 'rate_limit' || behavior.kind === 'server_error' || behavior.kind === 'auth_fail') {
79
+ return this.respond(request); // throws for failure kinds, single-shot for json
80
+ }
81
+ // Stream some chunks, THEN fail mid-stream (exercises the partial-then-fallback path).
82
+ if (behavior.kind === 'stream_then_fail') {
83
+ for (const c of behavior.chunks)
84
+ onDelta(c);
85
+ throw new AIError(`${this.id} dropped mid-stream`, { category: 'PROVIDER', status: 503, providerId: this.id, model: request.model });
86
+ }
87
+ const full = behavior.kind === 'ok_stream' || behavior.kind === 'ok' || behavior.kind === 'ok_text' ? behavior.text ?? `mock(${this.id}/${request.model}) streamed ${request.taskId}` : '';
88
+ const chunks = behavior.kind === 'ok_stream' && behavior.chunks ? behavior.chunks : chunkText(full);
89
+ for (const c of chunks)
90
+ onDelta(c);
91
+ return {
92
+ finishReason: 'stop',
93
+ providerId: this.id,
94
+ model: request.model,
95
+ latencyMs: this.latencyMs,
96
+ text: chunks.join(''),
97
+ usage: { inputTokens: Math.ceil((request.input.text?.length ?? 0) / 4), outputTokens: chunks.length },
98
+ };
99
+ }
100
+ respond(request) {
70
101
  const behavior = typeof this.behavior === 'function' ? this.behavior(request) : this.behavior;
71
102
  const base = { providerId: this.id, model: request.model };
72
103
  switch (behavior.kind) {
73
104
  case 'timeout':
74
105
  throw new AIError(`${this.id} timed out`, { category: 'TIMEOUT', ...base });
106
+ case 'stream_then_fail':
107
+ // Non-streaming caller: no partial output, just the failure.
108
+ throw new AIError(`${this.id} dropped mid-stream`, { category: 'PROVIDER', status: 503, ...base });
75
109
  case 'rate_limit':
76
110
  throw new AIError(`${this.id} rate limited`, { category: 'RATE_LIMIT', status: 429, ...base });
77
111
  case 'server_error':
@@ -116,6 +150,26 @@ export class MockProvider {
116
150
  usage: { inputTokens: 4, outputTokens: 8 },
117
151
  };
118
152
  }
153
+ case 'ok_stream': {
154
+ // Reached only via execute() (non-streaming caller): return the aggregate directly.
155
+ return {
156
+ finishReason: 'stop',
157
+ providerId: this.id,
158
+ model: request.model,
159
+ latencyMs: this.latencyMs,
160
+ text: behavior.chunks ? behavior.chunks.join('') : behavior.text ?? `mock(${this.id}/${request.model}) streamed ${request.taskId}`,
161
+ usage: { inputTokens: 4, outputTokens: 8 },
162
+ };
163
+ }
119
164
  }
120
165
  }
121
166
  }
167
+ /** Split text into small deterministic chunks to simulate token streaming (join(chunks) === text). */
168
+ function chunkText(text, size = 3) {
169
+ if (!text)
170
+ return [];
171
+ const out = [];
172
+ for (let i = 0; i < text.length; i += size)
173
+ out.push(text.slice(i, i + size));
174
+ return out;
175
+ }
@@ -12,6 +12,13 @@ export type MockBehavior = {
12
12
  } | {
13
13
  kind: 'ok_text';
14
14
  text?: string;
15
+ } | {
16
+ kind: 'ok_stream';
17
+ text?: string;
18
+ chunks?: string[];
19
+ } | {
20
+ kind: 'stream_then_fail';
21
+ chunks: string[];
15
22
  } | {
16
23
  kind: 'timeout';
17
24
  } | {
@@ -22,5 +22,11 @@ export interface AIProvider {
22
22
  /** Resolve capabilities for one model. Providers own capability resolution; core never reads the catalog directly. */
23
23
  getCapabilities(model: string): Promise<CapabilityProfile>;
24
24
  execute(request: AIRequest): Promise<AIResponse>;
25
+ /**
26
+ * OPTIONAL streaming execution (Phase 13). Calls `onDelta` with each text chunk and resolves with the
27
+ * full aggregated `AIResponse`. A provider that omits it is still valid — the caller falls back to
28
+ * `execute()`. Text output only; a JSON-mode request should degrade to `execute()`.
29
+ */
30
+ executeStream?(request: AIRequest, onDelta: (chunk: string) => void): Promise<AIResponse>;
25
31
  estimate(request: AIRequest): Promise<ExecutionEstimate>;
26
32
  }
@@ -80,4 +80,38 @@ export const anthropicWire = {
80
80
  }
81
81
  return parsed;
82
82
  },
83
+ buildStreamRequest(req, ctx, jsonMode) {
84
+ const built = anthropicWire.buildRequest(req, ctx, jsonMode);
85
+ built.body.stream = true;
86
+ return built;
87
+ },
88
+ readDelta(data) {
89
+ // Anthropic SSE data payloads are always JSON objects with a `type`. The `event:` lines carry the
90
+ // same type, so we parse the data alone (the caller skips non-`data:` lines).
91
+ let j;
92
+ try {
93
+ j = JSON.parse(data);
94
+ }
95
+ catch {
96
+ return {};
97
+ }
98
+ switch (j.type) {
99
+ case 'content_block_delta':
100
+ return j.delta?.text ? { text: j.delta.text } : {};
101
+ case 'message_start':
102
+ return j.message?.usage?.input_tokens !== undefined ? { usage: { inputTokens: j.message.usage.input_tokens } } : {};
103
+ case 'message_delta': {
104
+ const out = {};
105
+ if (j.usage?.output_tokens !== undefined)
106
+ out.usage = { outputTokens: j.usage.output_tokens };
107
+ if (j.delta?.stop_reason)
108
+ out.finishReason = mapStop(j.delta.stop_reason);
109
+ return out;
110
+ }
111
+ case 'message_stop':
112
+ return { done: true };
113
+ default:
114
+ return {}; // ping / content_block_start / content_block_stop
115
+ }
116
+ },
83
117
  };
@@ -78,4 +78,34 @@ export const openaiWire = {
78
78
  }
79
79
  return parsed;
80
80
  },
81
+ buildStreamRequest(req, ctx, jsonMode) {
82
+ // Same as buildRequest, plus `stream:true` and `stream_options.include_usage` so the final chunk
83
+ // carries token usage (supported by OpenAI and most compatible gateways; harmless where ignored).
84
+ const built = openaiWire.buildRequest(req, ctx, jsonMode);
85
+ const body = built.body;
86
+ body.stream = true;
87
+ body.stream_options = { include_usage: true };
88
+ return built;
89
+ },
90
+ readDelta(data) {
91
+ if (data.trim() === '[DONE]')
92
+ return { done: true };
93
+ let j;
94
+ try {
95
+ j = JSON.parse(data);
96
+ }
97
+ catch {
98
+ return {}; // a partial/keep-alive line — ignore
99
+ }
100
+ const out = {};
101
+ const content = j.choices?.[0]?.delta?.content;
102
+ if (content)
103
+ out.text = content;
104
+ const fr = j.choices?.[0]?.finish_reason;
105
+ if (fr)
106
+ out.finishReason = mapFinish(fr);
107
+ if (j.usage)
108
+ out.usage = { inputTokens: j.usage.prompt_tokens, outputTokens: j.usage.completion_tokens };
109
+ return out;
110
+ },
81
111
  };
@@ -28,12 +28,28 @@ export interface WireParsed {
28
28
  };
29
29
  finishReason: FinishReason;
30
30
  }
31
+ /** One parsed streaming event (from a single SSE `data:` payload). All fields optional; `done` ends the stream. */
32
+ export interface WireDelta {
33
+ /** A text chunk to append to the running answer (and surface to the caller). */
34
+ text?: string;
35
+ usage?: {
36
+ inputTokens?: number;
37
+ outputTokens?: number;
38
+ };
39
+ finishReason?: FinishReason;
40
+ /** The stream is complete (e.g. OpenAI `[DONE]`, Anthropic `message_stop`). */
41
+ done?: boolean;
42
+ }
31
43
  export interface WireModule {
32
44
  readonly shape: WireShape;
33
45
  /** Pure request builder. `jsonMode` requests a JSON response format where the shape supports it. */
34
46
  buildRequest(req: AIRequest, ctx: WireContext, jsonMode: boolean): WireRequest;
35
47
  /** Parse a successful response body into normalized pieces. */
36
48
  readResponse(json: unknown): WireParsed;
49
+ /** OPTIONAL: build a streaming request (sets the vendor's `stream` flag). Absence ⇒ no streaming. */
50
+ buildStreamRequest?(req: AIRequest, ctx: WireContext, jsonMode: boolean): WireRequest;
51
+ /** OPTIONAL: parse one SSE `data:` payload string into a WireDelta. Pure (no socket). */
52
+ readDelta?(data: string): WireDelta;
37
53
  }
38
54
  /** Turn input parts into an OpenAI-style content value (string when text-only, array when multimodal). */
39
55
  export declare function toMultimodalContent(text: string | undefined, parts: AIRequest['input']['parts']): unknown;
@@ -186,6 +186,10 @@ export declare class Runtime {
186
186
  private contextBlocks;
187
187
  /** Best-effort estimator calibration from provider-reported input usage (estimates never claim to be exact). */
188
188
  private calibrate;
189
- /** Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled. */
189
+ /**
190
+ * Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled.
191
+ * `capture` is false on a dry run — retrieval is read-only, but writing a fact is a mutation the dry
192
+ * run must not perform.
193
+ */
190
194
  private applyMemory;
191
195
  }
@@ -350,8 +350,9 @@ export class Runtime {
350
350
  const clarification = buildClarification(modeResult);
351
351
  if (clarification)
352
352
  this.emitter.emit({ type: 'clarification.requested', runId, question: clarification.question });
353
- // Memory: capture an explicit "remember …" and retrieve relevant facts into context.
354
- const memTrace = this.applyMemory(text);
353
+ // Memory: retrieve relevant facts into context, and (unless this is a dry run) capture an explicit
354
+ // "remember …". A dry run performs zero mutations, so it retrieves but never writes.
355
+ const memTrace = this.applyMemory(text, !policy.dryRun);
355
356
  // Compile the model context (workspace summary + memory facts + user system) under a token budget.
356
357
  const budget = resolveContextBudget({
357
358
  ...(req.context?.maxTokens !== undefined ? { perRun: req.context.maxTokens } : {}),
@@ -360,7 +361,32 @@ export class Runtime {
360
361
  });
361
362
  const compiled = compileContext(this.contextBlocks(req, context.workspace, memTrace?.retrieved), { budgetTokens: budget, estimator: this.estimator });
362
363
  const contextReport = { metrics: compiled.metrics, validation: compiled.validation };
364
+ // Dry-run: chat's only "action" is the model call itself, so a dry run makes NO call. It reports
365
+ // what it would send (mode, compiled-context size) and performs zero mutations.
366
+ if (policy.dryRun) {
367
+ const status = clarification ? 'waiting_for_clarification' : 'completed';
368
+ this.emitter.emit({ type: 'run.completed', runId, ok: true, status, confidence: 1 });
369
+ const preview = `[dry-run] chat — no model call made. Would compile ${compiled.metrics.compiledTokens} context token(s) (budget ${budget}) and send one request to the best-matching provider. Run without --dry-run to execute.`;
370
+ const result = { ok: true, runId, mode: resolution, status, response: { text: preview }, context: contextReport, artifacts: [] };
371
+ if (clarification)
372
+ result.clarification = clarification;
373
+ if (memTrace)
374
+ result.memory = memTrace;
375
+ return result;
376
+ }
363
377
  const runRequest = buildChatRequest(req, strategy, compiled.system || undefined, routing);
378
+ // Streaming (Phase 13): text-only, chat-mode only, never on a dry run (handled above). Each chunk is
379
+ // emitted as a `response.delta` lifecycle event (redacted by the emitter). We also accumulate the raw
380
+ // chunks so we can mark the result `streamed` when the streamed text IS the final answer.
381
+ const streaming = req.stream === true && req.output?.format !== 'json' && req.output?.format !== 'structured_output';
382
+ let streamedText = '';
383
+ if (streaming) {
384
+ runRequest.stream = true;
385
+ runRequest.onDelta = (chunk) => {
386
+ streamedText += chunk;
387
+ this.emitter.emit({ type: 'response.delta', runId, text: chunk });
388
+ };
389
+ }
364
390
  const runResult = await this._ai.run(runRequest);
365
391
  this.calibrate(runRequest.system, text, runResult);
366
392
  const sel = runResult.routing.selected;
@@ -379,6 +405,12 @@ export class Runtime {
379
405
  if (memTrace)
380
406
  result.memory = memTrace;
381
407
  result.context = contextReport;
408
+ // Mark `streamed` only when the streamed chunks ARE the final answer (a clean single stream). If a
409
+ // streamed attempt failed and a fallback produced different text, they won't match → not streamed, so
410
+ // a renderer reprints the authoritative final text.
411
+ if (streaming && runResult.ok && result.response && streamedText.length > 0 && (result.response.text ?? '') === streamedText) {
412
+ result.response.streamed = true;
413
+ }
382
414
  return result;
383
415
  }
384
416
  /** The persistent execution store and its owner-lease machinery. */
@@ -673,16 +705,22 @@ export class Runtime {
673
705
  if (tokens && tokens > 0)
674
706
  this.estimator.calibrate((system?.length ?? 0) + inputText.length, tokens);
675
707
  }
676
- /** Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled. */
677
- applyMemory(text) {
708
+ /**
709
+ * Capture an explicit "remember …" fact and retrieve relevant facts for context. Off when disabled.
710
+ * `capture` is false on a dry run — retrieval is read-only, but writing a fact is a mutation the dry
711
+ * run must not perform.
712
+ */
713
+ applyMemory(text, capture = true) {
678
714
  if (!this.memoryEnabled())
679
715
  return undefined;
680
716
  try {
681
717
  const trace = { retrieved: [] };
682
- const candidate = classifyMemory(text);
683
- if (candidate) {
684
- const rec = this._memory.remember(candidate);
685
- trace.captured = { id: rec.id, scope: rec.scope };
718
+ if (capture) {
719
+ const candidate = classifyMemory(text);
720
+ if (candidate) {
721
+ const rec = this._memory.remember(candidate);
722
+ trace.captured = { id: rec.id, scope: rec.scope };
723
+ }
686
724
  }
687
725
  trace.retrieved = this._memory.search(text, { limit: 3 }).map((h) => h.text);
688
726
  if (trace.retrieved.length === 0 && !trace.captured)
@@ -89,6 +89,9 @@ export interface RuntimeRunInput {
89
89
  };
90
90
  /** Dry-run: plan + report what WOULD happen, performing zero mutations (plan/execute/orchestrate). */
91
91
  dryRun?: boolean;
92
+ /** Stream the answer token-by-token in chat mode: emits `response.delta` lifecycle events (Phase 13).
93
+ * Text output only; ignored for JSON output and for dry runs. */
94
+ stream?: boolean;
92
95
  /** Caller idempotency identity (dedup enforced from Phase 7). */
93
96
  requestId?: string;
94
97
  /** Per-run exclude/prefer routing (highest precedence; merged with config + env). */
@@ -116,9 +119,12 @@ export interface RuntimeResult {
116
119
  runId: string;
117
120
  mode: ModeResolution;
118
121
  status: RuntimeStatus;
122
+ /** `streamed` is true when `text` was already delivered via `response.delta` events (Phase 13) — a
123
+ * renderer that showed the deltas live should not reprint it. */
119
124
  response?: {
120
125
  text?: string;
121
126
  json?: unknown;
127
+ streamed?: boolean;
122
128
  };
123
129
  /** Present when the runtime needs the user to disambiguate. Never a failure. */
124
130
  clarification?: Clarification;
package/dist/types.d.ts CHANGED
@@ -80,6 +80,8 @@ export interface AIRequest {
80
80
  signal?: AbortSignal;
81
81
  sensitivity: Sensitivity;
82
82
  metadata?: Record<string, unknown>;
83
+ /** Request token-by-token streaming (text output only). A provider without `executeStream` ignores it. */
84
+ stream?: boolean;
83
85
  }
84
86
  export type FinishReason = 'stop' | 'length' | 'tool_calls' | 'content_filter' | 'error';
85
87
  /** What every provider adapter returns — normalized so callers never parse vendor JSON. */
@@ -335,6 +337,10 @@ export interface RunRequest {
335
337
  verification?: boolean;
336
338
  /** Attach tools from registered MCP sources to this run (requires a tool-calling model). */
337
339
  mcp?: boolean;
340
+ /** Stream the answer token-by-token (text output only; ignored for JSON output). Needs `onDelta`. */
341
+ stream?: boolean;
342
+ /** Called with each text chunk as it streams. The final `AIResponse.text` is still the full aggregate. */
343
+ onDelta?: (chunk: string) => void;
338
344
  }
339
345
  /**
340
346
  * User exclude/prefer routing (all optional). EXCLUDE is a HARD filter — an excluded candidate is never
package/docs/GUIDE.md CHANGED
@@ -66,8 +66,9 @@ You can run the whole pipeline with a **mock provider** — no credentials, no n
66
66
  ```js
67
67
  import { Runtime, MockProvider, makeModel } from 'ai-runtime-engine';
68
68
 
69
- // A fresh runtime with no persistence (nothing written to disk).
70
- const runtime = new Runtime();
69
+ // A fresh runtime in stateless mode, so this demo writes nothing to disk.
70
+ // (Persistence is ON by default; pass { persistence: 'disabled' } to turn it off.)
71
+ const runtime = new Runtime(undefined, { persistence: 'disabled' });
71
72
 
72
73
  // Register one fake provider with one fake model that can "reason" and output "text".
73
74
  runtime.ai.registerProvider(new MockProvider({
package/docs/README.md CHANGED
@@ -13,8 +13,15 @@ with the `ai-runtime-engine` package:
13
13
  permissions, credentials, and the learning safety rail.
14
14
 
15
15
  Additional development records — the append-only decision log (`DECISIONS.md`), the phase-by-phase build
16
- log (`PROGRESS.md`), and the release checklist (`NPM_PUBLISHING.md`) live in the (private) source
17
- repository and are not part of the published package.
16
+ log (`PROGRESS.md`), the release checklist (`NPM_PUBLISHING.md`), and the complete project **handbook**
17
+ (`handbook/00-index.md` and its chapters) — live in the (private) source repository and are not part of
18
+ the published package. The handbook is the deepest reference: architecture, a file-by-file source tour,
19
+ the CLI/REPL, the npm story, testing, and maintenance.
20
+
21
+ > Note: this index file (`docs/README.md`) itself *does* end up in the npm tarball, because npm always
22
+ > includes any `README*` file regardless of the `files` allowlist. It only links to shipped docs, so that
23
+ > is harmless — but keep anything private out of `README*`-named files. (The handbook index is named
24
+ > `00-index.md`, not `README.md`, for exactly this reason.)
18
25
 
19
26
  Per-subsystem detail is documented at the source: each module under `src/**` opens with a doc comment
20
27
  describing its contract and invariants, and the README's [How to use it](../README.md#how-to-use-it)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-runtime-engine",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "AI Runtime — a provider-agnostic AI runtime and orchestration platform. Point it at whatever AI providers you have; it routes each task to the best available model. Ships the `ai-runtime` CLI and the `Runtime`/`AI` library API.",
5
5
  "type": "module",
6
6
  "license": "ISC",