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.
- package/CHANGELOG.md +51 -0
- package/dist/cli/cli.js +4 -1
- package/dist/cli/commands/config.js +6 -4
- package/dist/cli/commands/executions.d.ts +2 -0
- package/dist/cli/commands/executions.js +8 -1
- package/dist/cli/commands/init.js +7 -4
- package/dist/cli/commands/phase2.js +6 -2
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +15 -2
- package/dist/cli/interactive/repl.js +33 -2
- package/dist/cli/interactive/session.d.ts +4 -1
- package/dist/cli/interactive/session.js +25 -8
- package/dist/cli/render.d.ts +6 -0
- package/dist/cli/render.js +8 -0
- package/dist/core/fallback/fallback.d.ts +3 -0
- package/dist/core/fallback/fallback.js +1 -1
- package/dist/core/router/executor.d.ts +6 -1
- package/dist/core/router/executor.js +9 -2
- package/dist/core/router/normalize.d.ts +2 -0
- package/dist/core/router/request.js +2 -0
- package/dist/core/router/router.js +6 -0
- package/dist/index.d.ts +1 -1
- package/dist/providers/httpClient.d.ts +25 -1
- package/dist/providers/httpClient.js +93 -0
- package/dist/providers/httpProvider.d.ts +1 -0
- package/dist/providers/httpProvider.js +67 -1
- package/dist/providers/mock/mockProvider.d.ts +3 -0
- package/dist/providers/mock/mockProvider.js +54 -0
- package/dist/providers/mock/scenarios.d.ts +7 -0
- package/dist/providers/provider.d.ts +6 -0
- package/dist/providers/wire/anthropicWire.js +34 -0
- package/dist/providers/wire/openaiWire.js +30 -0
- package/dist/providers/wire/types.d.ts +16 -0
- package/dist/runtime/runtime.d.ts +5 -1
- package/dist/runtime/runtime.js +46 -8
- package/dist/runtime/types.d.ts +6 -0
- package/dist/types.d.ts +6 -0
- package/docs/GUIDE.md +3 -2
- package/docs/README.md +9 -2
- 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.
|
|
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 {
|
|
6
|
+
import { loadRuntimeConfig } from '../../runtime/config.js';
|
|
7
7
|
import { print } from '../render.js';
|
|
8
8
|
export function configCommand(options) {
|
|
9
|
-
|
|
10
|
-
|
|
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(
|
|
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}`);
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/dist/cli/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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 };
|
package/dist/cli/render.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/render.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
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
|
+
}
|
|
@@ -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
|
-
/**
|
|
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
|
}
|
package/dist/runtime/runtime.js
CHANGED
|
@@ -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:
|
|
354
|
-
|
|
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
|
-
/**
|
|
677
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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)
|
package/dist/runtime/types.d.ts
CHANGED
|
@@ -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
|
|
70
|
-
|
|
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`),
|
|
17
|
-
repository and are not part of
|
|
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.
|
|
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",
|