@animalabs/connectome-host 0.3.9 → 0.3.10
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/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +126 -0
- package/README.md +52 -5
- package/package.json +2 -1
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +45 -0
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +17 -9
- package/src/framework-strategy.ts +21 -0
- package/src/index.ts +102 -19
- package/src/logging-bedrock-adapter.ts +145 -0
- package/src/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +138 -9
- package/src/tui.ts +165 -34
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/fast-command.test.ts +39 -0
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-provider.test.ts +14 -1
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// BedrockAdapter wrapper that appends each LLM call's request summary, stop
|
|
2
|
+
// reason, and any error to the same `llm-calls.<iso>.jsonl` file the
|
|
3
|
+
// Anthropic path uses. Purpose-built during the Aria bring-up (2026-07-21)
|
|
4
|
+
// when a 0-token assistant turn proved undiagnosable: the bedrock transport
|
|
5
|
+
// had no wire visibility at all.
|
|
6
|
+
//
|
|
7
|
+
// Kept deliberately lighter than LoggingAnthropicAdapter: request summaries
|
|
8
|
+
// always include tool NAMES (were tools attached at all? — the question that
|
|
9
|
+
// motivated this file), message/system sizes, and the stop sequences; the
|
|
10
|
+
// full raw request is retained only on errors. Responses log stop_reason,
|
|
11
|
+
// usage, and content-block shape (type + size per block) on every call.
|
|
12
|
+
//
|
|
13
|
+
// Each line: { type: 'call'|'error', kind: 'complete'|'stream', timestamp,
|
|
14
|
+
// durationMs, requestSummary, response?, rawRequest?, error? }
|
|
15
|
+
|
|
16
|
+
import { BedrockAdapter } from '@animalabs/membrane';
|
|
17
|
+
import type {
|
|
18
|
+
ProviderRequest,
|
|
19
|
+
ProviderResponse,
|
|
20
|
+
ProviderRequestOptions,
|
|
21
|
+
StreamCallbacks,
|
|
22
|
+
} from '@animalabs/membrane';
|
|
23
|
+
import { appendFileSync } from 'node:fs';
|
|
24
|
+
|
|
25
|
+
type BedrockConfig = ConstructorParameters<typeof BedrockAdapter>[0];
|
|
26
|
+
|
|
27
|
+
function summarizeRequest(request: ProviderRequest): Record<string, unknown> {
|
|
28
|
+
const msgs = (request.messages ?? []) as Array<{ role?: string; content?: unknown }>;
|
|
29
|
+
const last = msgs[msgs.length - 1];
|
|
30
|
+
const lastPreview = typeof last?.content === 'string'
|
|
31
|
+
? last.content.slice(0, 200)
|
|
32
|
+
: Array.isArray(last?.content)
|
|
33
|
+
? (last.content as Array<{ type?: string; text?: string }>)
|
|
34
|
+
.map((b) => (b.type === 'text' ? (b.text ?? '').slice(0, 120) : `[${b.type}]`))
|
|
35
|
+
.join(' | ').slice(0, 300)
|
|
36
|
+
: undefined;
|
|
37
|
+
return {
|
|
38
|
+
model: request.model,
|
|
39
|
+
maxTokens: request.maxTokens,
|
|
40
|
+
messageCount: msgs.length,
|
|
41
|
+
systemChars: typeof request.system === 'string'
|
|
42
|
+
? request.system.length
|
|
43
|
+
: Array.isArray(request.system)
|
|
44
|
+
? JSON.stringify(request.system).length
|
|
45
|
+
: 0,
|
|
46
|
+
toolNames: (request.tools as Array<{ name?: string }> | undefined)?.map((t) => t.name) ?? null,
|
|
47
|
+
toolCount: (request.tools as unknown[] | undefined)?.length ?? 0,
|
|
48
|
+
stopSequences: request.stopSequences ?? null,
|
|
49
|
+
lastMessageRole: last?.role,
|
|
50
|
+
lastMessagePreview: lastPreview,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function summarizeResponse(response: ProviderResponse): Record<string, unknown> {
|
|
55
|
+
const content = (response.content ?? []) as Array<{ type?: string; text?: string; name?: string }>;
|
|
56
|
+
return {
|
|
57
|
+
stopReason: response.stopReason ?? (response.raw as { stop_reason?: string } | undefined)?.stop_reason ?? null,
|
|
58
|
+
usage: response.usage ?? null,
|
|
59
|
+
blocks: content.map((b) => ({
|
|
60
|
+
type: b.type,
|
|
61
|
+
...(b.type === 'text' ? { chars: (b.text ?? '').length } : {}),
|
|
62
|
+
...(b.type === 'tool_use' ? { name: b.name } : {}),
|
|
63
|
+
})),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class LoggingBedrockAdapter extends BedrockAdapter {
|
|
68
|
+
private readonly logPath: string;
|
|
69
|
+
|
|
70
|
+
constructor(config: BedrockConfig, logPath: string) {
|
|
71
|
+
super(config);
|
|
72
|
+
this.logPath = logPath;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private log(record: Record<string, unknown>): void {
|
|
76
|
+
try {
|
|
77
|
+
appendFileSync(this.logPath, JSON.stringify(record) + '\n');
|
|
78
|
+
} catch {
|
|
79
|
+
// Logging must never break inference.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
override async complete(
|
|
84
|
+
request: ProviderRequest,
|
|
85
|
+
options?: ProviderRequestOptions,
|
|
86
|
+
): Promise<ProviderResponse> {
|
|
87
|
+
const started = Date.now();
|
|
88
|
+
let rawRequest: unknown;
|
|
89
|
+
const opts: ProviderRequestOptions = {
|
|
90
|
+
...options,
|
|
91
|
+
onRequest: (req) => { rawRequest = req; options?.onRequest?.(req); },
|
|
92
|
+
};
|
|
93
|
+
try {
|
|
94
|
+
const response = await super.complete(request, opts);
|
|
95
|
+
this.log({
|
|
96
|
+
type: 'call', kind: 'complete', timestamp: new Date(started).toISOString(),
|
|
97
|
+
durationMs: Date.now() - started,
|
|
98
|
+
requestSummary: summarizeRequest(request),
|
|
99
|
+
response: summarizeResponse(response),
|
|
100
|
+
});
|
|
101
|
+
return response;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
this.log({
|
|
104
|
+
type: 'error', kind: 'complete', timestamp: new Date(started).toISOString(),
|
|
105
|
+
durationMs: Date.now() - started,
|
|
106
|
+
requestSummary: summarizeRequest(request),
|
|
107
|
+
rawRequest,
|
|
108
|
+
error: error instanceof Error ? { message: error.message, name: error.name } : String(error),
|
|
109
|
+
});
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
override async stream(
|
|
115
|
+
request: ProviderRequest,
|
|
116
|
+
callbacks: StreamCallbacks,
|
|
117
|
+
options?: ProviderRequestOptions,
|
|
118
|
+
): Promise<ProviderResponse> {
|
|
119
|
+
const started = Date.now();
|
|
120
|
+
let rawRequest: unknown;
|
|
121
|
+
const opts: ProviderRequestOptions = {
|
|
122
|
+
...options,
|
|
123
|
+
onRequest: (req) => { rawRequest = req; options?.onRequest?.(req); },
|
|
124
|
+
};
|
|
125
|
+
try {
|
|
126
|
+
const response = await super.stream(request, callbacks, opts);
|
|
127
|
+
this.log({
|
|
128
|
+
type: 'call', kind: 'stream', timestamp: new Date(started).toISOString(),
|
|
129
|
+
durationMs: Date.now() - started,
|
|
130
|
+
requestSummary: summarizeRequest(request),
|
|
131
|
+
response: summarizeResponse(response),
|
|
132
|
+
});
|
|
133
|
+
return response;
|
|
134
|
+
} catch (error) {
|
|
135
|
+
this.log({
|
|
136
|
+
type: 'error', kind: 'stream', timestamp: new Date(started).toISOString(),
|
|
137
|
+
durationMs: Date.now() - started,
|
|
138
|
+
requestSummary: summarizeRequest(request),
|
|
139
|
+
rawRequest,
|
|
140
|
+
error: error instanceof Error ? { message: error.message, name: error.name } : String(error),
|
|
141
|
+
});
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
* fan-out across many VMs are the reverse-proxy's job; an optional Basic-Auth
|
|
17
17
|
* check is available as defense-in-depth.
|
|
18
18
|
*
|
|
19
|
-
* See
|
|
19
|
+
* See src/web/protocol.ts for the wire shape and docs/webui-deployment.md
|
|
20
|
+
* for the deployment story.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import { CURVE_PAGE_HTML } from './web-ui-curve-page.js';
|
|
@@ -57,6 +58,7 @@ import {
|
|
|
57
58
|
type TokenUsage,
|
|
58
59
|
type CallLedgerSnapshot,
|
|
59
60
|
type McplListMessage,
|
|
61
|
+
type BranchesListMessage,
|
|
60
62
|
type LessonsListMessage,
|
|
61
63
|
} from '../web/protocol.js';
|
|
62
64
|
import {
|
|
@@ -1547,6 +1549,9 @@ export class WebUiModule implements Module {
|
|
|
1547
1549
|
if (client.auth !== 'observer') return false;
|
|
1548
1550
|
if (type === 'ping') return true;
|
|
1549
1551
|
if (type === 'request-history') return client.scopes?.has('messages') ?? false;
|
|
1552
|
+
// Branch listing is conversation-shape metadata (names, fork points) —
|
|
1553
|
+
// same sensitivity tier as the message window, so same scope.
|
|
1554
|
+
if (type === 'request-branches') return client.scopes?.has('messages') ?? false;
|
|
1550
1555
|
return false; // observers are read-only: no user-message/command/mcpl/fleet
|
|
1551
1556
|
}
|
|
1552
1557
|
|
|
@@ -1698,6 +1703,11 @@ export class WebUiModule implements Module {
|
|
|
1698
1703
|
return;
|
|
1699
1704
|
}
|
|
1700
1705
|
|
|
1706
|
+
case 'request-branches': {
|
|
1707
|
+
this.sendBranchesList(client);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1701
1711
|
case 'mcpl-add': {
|
|
1702
1712
|
try {
|
|
1703
1713
|
const servers = readMcplServersFile(DEFAULT_CONFIG_PATH);
|
|
@@ -1983,6 +1993,40 @@ export class WebUiModule implements Module {
|
|
|
1983
1993
|
this.send(client, out);
|
|
1984
1994
|
}
|
|
1985
1995
|
|
|
1996
|
+
/** Build a BranchesListMessage from the agent's context manager. Lineage
|
|
1997
|
+
* (parentId + branchPoint) comes straight from Chronicle's branch records;
|
|
1998
|
+
* the SPA folds it into a tree. */
|
|
1999
|
+
private sendBranchesList(client: ClientState): void {
|
|
2000
|
+
if (!sharedServer?.app) return;
|
|
2001
|
+
const cm = sharedServer.app.framework.getAllAgents()[0]?.getContextManager();
|
|
2002
|
+
if (!cm) {
|
|
2003
|
+
this.send(client, { type: 'error', message: 'no agent context manager' });
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
try {
|
|
2007
|
+
const branches = cm.listBranches();
|
|
2008
|
+
const current = cm.currentBranch();
|
|
2009
|
+
const out: BranchesListMessage = {
|
|
2010
|
+
type: 'branches-list',
|
|
2011
|
+
branches: branches.map((b) => ({
|
|
2012
|
+
id: b.id,
|
|
2013
|
+
name: b.name,
|
|
2014
|
+
head: b.head,
|
|
2015
|
+
...(b.parentId !== undefined ? { parentId: b.parentId } : {}),
|
|
2016
|
+
...(b.branchPoint !== undefined ? { branchPoint: b.branchPoint } : {}),
|
|
2017
|
+
created: b.created instanceof Date ? b.created.getTime() : Number(b.created),
|
|
2018
|
+
})),
|
|
2019
|
+
currentId: current.id,
|
|
2020
|
+
};
|
|
2021
|
+
this.send(client, out);
|
|
2022
|
+
} catch (err) {
|
|
2023
|
+
this.send(client, {
|
|
2024
|
+
type: 'error',
|
|
2025
|
+
message: `branch listing failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2026
|
+
});
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1986
2030
|
/** Build a LessonsListMessage from the bound LessonsModule, if present. */
|
|
1987
2031
|
private sendLessonsList(client: ClientState): void {
|
|
1988
2032
|
if (!sharedServer?.app) return;
|
package/src/recipe.ts
CHANGED
|
@@ -19,7 +19,11 @@ import { dirname, isAbsolute, resolve } from 'node:path';
|
|
|
19
19
|
// ---------------------------------------------------------------------------
|
|
20
20
|
|
|
21
21
|
export interface RecipeStrategy {
|
|
22
|
-
|
|
22
|
+
/** Built-in strategy name, or a custom type registered by a
|
|
23
|
+
* `kind: "strategy"` extension (see `Recipe.extensions`). The
|
|
24
|
+
* `string & {}` arm keeps literal autocompletion while admitting
|
|
25
|
+
* extension-registered names. */
|
|
26
|
+
type: 'autobiographical' | 'passthrough' | 'frontdesk' | (string & {});
|
|
23
27
|
// Core window/compression sizing
|
|
24
28
|
headWindowTokens?: number;
|
|
25
29
|
recentWindowTokens?: number;
|
|
@@ -85,7 +89,15 @@ export interface RecipeAgent {
|
|
|
85
89
|
/** IANA zone used when rendering wall-clock times to the agent. */
|
|
86
90
|
timezone?: string;
|
|
87
91
|
/** Provider transport. Omitted preserves the historical Anthropic default. */
|
|
88
|
-
provider?: 'anthropic' | 'openai-responses';
|
|
92
|
+
provider?: 'anthropic' | 'openai-responses' | 'openai-codex' | 'openrouter' | 'bedrock';
|
|
93
|
+
/** Message formatter. 'native' (default) = structured user/assistant turns.
|
|
94
|
+
* 'anthropic-xml' = classic prefill format ("participant: text" runs, XML
|
|
95
|
+
* tools) — for migrating prefill-era bots (chapterx borgs) with their exact
|
|
96
|
+
* prompting structure intact. */
|
|
97
|
+
formatter?: 'native' | 'anthropic-xml';
|
|
98
|
+
/** Prefill scaffold user message appended after the conversation (e.g.
|
|
99
|
+
* chapterx CLI-sim's "<cmd>cat untitled.txt</cmd>"). Prefill formatter only. */
|
|
100
|
+
prefillUserMessage?: string;
|
|
89
101
|
systemPrompt: string;
|
|
90
102
|
maxTokens?: number;
|
|
91
103
|
/**
|
|
@@ -105,6 +117,13 @@ export interface RecipeAgent {
|
|
|
105
117
|
* Omitted preserves the compatibility carry-forward in Agent Framework.
|
|
106
118
|
*/
|
|
107
119
|
sameRoundThinkTextPolicy?: 'public' | 'private';
|
|
120
|
+
/**
|
|
121
|
+
* Extra Anthropic beta flags sent as the `anthropic-beta` header on every
|
|
122
|
+
* request (e.g. `["context-1m-2025-08-07"]` for the 1M context window on
|
|
123
|
+
* models where it is opt-in, like claude-sonnet-4-5). Merged with any
|
|
124
|
+
* transport-required betas (OAuth). Anthropic provider only.
|
|
125
|
+
*/
|
|
126
|
+
anthropicBetas?: string[];
|
|
108
127
|
strategy?: RecipeStrategy;
|
|
109
128
|
/**
|
|
110
129
|
* Native extended thinking. When `enabled: true`, the agent's API requests
|
|
@@ -115,13 +134,20 @@ export interface RecipeAgent {
|
|
|
115
134
|
enabled: boolean;
|
|
116
135
|
budgetTokens?: number;
|
|
117
136
|
};
|
|
118
|
-
/**
|
|
119
|
-
*
|
|
137
|
+
/** OpenAI Responses settings. Reasoning applies to both OpenAI providers;
|
|
138
|
+
* compaction and serviceTier are API-key transport settings. */
|
|
120
139
|
responses?: {
|
|
121
140
|
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
122
141
|
reasoningContext?: 'current_turn' | 'all_turns';
|
|
123
|
-
serviceTier?: string;
|
|
124
142
|
compactThreshold?: number;
|
|
143
|
+
/** OpenAI Responses API processing tier. `priority` is the API-key
|
|
144
|
+
* equivalent of Codex fast mode. */
|
|
145
|
+
serviceTier?: 'auto' | 'default' | 'flex' | 'priority';
|
|
146
|
+
};
|
|
147
|
+
/** ChatGPT-subscription Codex settings. Only used with
|
|
148
|
+
* `provider: "openai-codex"`. Runtime `/fast` overrides fastMode. */
|
|
149
|
+
codex?: {
|
|
150
|
+
fastMode?: boolean;
|
|
125
151
|
};
|
|
126
152
|
/**
|
|
127
153
|
* Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
|
|
@@ -503,6 +529,31 @@ export interface RecipeFleetChild {
|
|
|
503
529
|
autoRestart?: boolean;
|
|
504
530
|
}
|
|
505
531
|
|
|
532
|
+
/**
|
|
533
|
+
* A deployment-specific code extension: a local TS/JS module that registers
|
|
534
|
+
* custom context strategies and/or framework modules at boot. See
|
|
535
|
+
* src/extensions.ts for the loader and the `register(api, config)` contract.
|
|
536
|
+
*/
|
|
537
|
+
export interface RecipeExtension {
|
|
538
|
+
/** Primary purpose. Gates validation (a non-built-in `agent.strategy.type`
|
|
539
|
+
* requires at least one `kind: "strategy"` extension) and informs build
|
|
540
|
+
* tooling; does not restrict what `register` may call. */
|
|
541
|
+
kind: 'strategy' | 'module';
|
|
542
|
+
/** Path to the extension entry module. Relative paths resolve against the
|
|
543
|
+
* recipe file's directory at load time; URL-loaded recipes must use
|
|
544
|
+
* absolute paths (the host never imports code over the network). */
|
|
545
|
+
path: string;
|
|
546
|
+
/** Opaque blob passed verbatim to the extension's `register` function and
|
|
547
|
+
* to module factory contexts. */
|
|
548
|
+
config?: Record<string, unknown>;
|
|
549
|
+
/** Build-tooling acquisition metadata (git url/ref/install pattern) —
|
|
550
|
+
* consumed by connectome-cook at build time, ignored at runtime. */
|
|
551
|
+
source?: Record<string, unknown>;
|
|
552
|
+
/** Build-only provenance: `source` demoted by build tooling into the
|
|
553
|
+
* shipped recipe. Ignored at runtime. */
|
|
554
|
+
sourceMeta?: Record<string, unknown>;
|
|
555
|
+
}
|
|
556
|
+
|
|
506
557
|
export interface Recipe {
|
|
507
558
|
name: string;
|
|
508
559
|
description?: string;
|
|
@@ -510,6 +561,8 @@ export interface Recipe {
|
|
|
510
561
|
agent: RecipeAgent;
|
|
511
562
|
mcpServers?: Record<string, RecipeMcpServer>;
|
|
512
563
|
modules?: RecipeModules;
|
|
564
|
+
/** Deployment-specific code extensions, keyed by a human-readable name. */
|
|
565
|
+
extensions?: Record<string, RecipeExtension>;
|
|
513
566
|
sessionNaming?: { examples?: string[] };
|
|
514
567
|
}
|
|
515
568
|
|
|
@@ -639,6 +692,7 @@ export async function loadRecipe(source: string): Promise<Recipe> {
|
|
|
639
692
|
raw = substituteEnvVars(raw, source);
|
|
640
693
|
const recipe = validateRecipe(raw);
|
|
641
694
|
resolveChildRecipePaths(recipe, sourceBase);
|
|
695
|
+
resolveExtensionPaths(recipe, sourceBase);
|
|
642
696
|
return resolveSystemPrompt(recipe);
|
|
643
697
|
}
|
|
644
698
|
|
|
@@ -663,6 +717,25 @@ function resolveChildRecipePaths(recipe: Recipe, base: RecipeSourceBase): void {
|
|
|
663
717
|
}
|
|
664
718
|
}
|
|
665
719
|
|
|
720
|
+
/**
|
|
721
|
+
* Resolve relative `extensions[].path` values against the recipe file's
|
|
722
|
+
* directory. URL-loaded recipes cannot carry relative extension paths — the
|
|
723
|
+
* host never fetches code over the network, so there is nothing local to
|
|
724
|
+
* resolve them against.
|
|
725
|
+
*/
|
|
726
|
+
function resolveExtensionPaths(recipe: Recipe, base: RecipeSourceBase): void {
|
|
727
|
+
for (const [name, ext] of Object.entries(recipe.extensions ?? {})) {
|
|
728
|
+
if (isAbsolute(ext.path)) continue;
|
|
729
|
+
if (base.kind === 'url') {
|
|
730
|
+
throw new Error(
|
|
731
|
+
`extensions.${name}.path "${ext.path}" is relative, but the recipe was loaded from a URL. ` +
|
|
732
|
+
`Extension code is never fetched remotely — use an absolute path to a local install.`,
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
ext.path = resolve(base.dir, ext.path);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
666
739
|
/**
|
|
667
740
|
* If systemPrompt is an HTTP(S) URL, fetch its contents as plain text.
|
|
668
741
|
* Only treated as a URL if it looks like a single URL (no spaces/newlines).
|
|
@@ -701,8 +774,16 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
701
774
|
throw new Error('Recipe agent must have a "systemPrompt" string');
|
|
702
775
|
}
|
|
703
776
|
|
|
704
|
-
if (agent.provider !== undefined &&
|
|
705
|
-
|
|
777
|
+
if (agent.provider !== undefined &&
|
|
778
|
+
agent.provider !== 'anthropic' &&
|
|
779
|
+
agent.provider !== 'openai-responses' &&
|
|
780
|
+
agent.provider !== 'openai-codex' &&
|
|
781
|
+
agent.provider !== 'openrouter' &&
|
|
782
|
+
agent.provider !== 'bedrock') {
|
|
783
|
+
throw new Error(
|
|
784
|
+
`Recipe agent.provider must be 'anthropic', 'openai-responses', 'openai-codex', 'openrouter', or 'bedrock', ` +
|
|
785
|
+
`got ${JSON.stringify(agent.provider)}.`,
|
|
786
|
+
);
|
|
706
787
|
}
|
|
707
788
|
|
|
708
789
|
if (agent.timezone !== undefined) {
|
|
@@ -735,6 +816,20 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
735
816
|
(typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
|
|
736
817
|
throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
|
|
737
818
|
}
|
|
819
|
+
const serviceTiers = ['auto', 'default', 'flex', 'priority'];
|
|
820
|
+
if (responses.serviceTier !== undefined && !serviceTiers.includes(String(responses.serviceTier))) {
|
|
821
|
+
throw new Error(`Invalid agent.responses.serviceTier ${JSON.stringify(responses.serviceTier)}.`);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (agent.codex !== undefined) {
|
|
826
|
+
if (!agent.codex || typeof agent.codex !== 'object' || Array.isArray(agent.codex)) {
|
|
827
|
+
throw new Error('Recipe agent.codex must be an object.');
|
|
828
|
+
}
|
|
829
|
+
const codex = agent.codex as Record<string, unknown>;
|
|
830
|
+
if (codex.fastMode !== undefined && typeof codex.fastMode !== 'boolean') {
|
|
831
|
+
throw new Error('Recipe agent.codex.fastMode must be a boolean.');
|
|
832
|
+
}
|
|
738
833
|
}
|
|
739
834
|
|
|
740
835
|
if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
|
|
@@ -782,6 +877,37 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
782
877
|
}
|
|
783
878
|
}
|
|
784
879
|
|
|
880
|
+
// Validate the extensions block if present (before the strategy check,
|
|
881
|
+
// which consults it to admit custom strategy types).
|
|
882
|
+
let hasStrategyExtension = false;
|
|
883
|
+
if (obj.extensions !== undefined) {
|
|
884
|
+
if (!obj.extensions || typeof obj.extensions !== 'object' || Array.isArray(obj.extensions)) {
|
|
885
|
+
throw new Error('Recipe "extensions" must be an object mapping names to extension entries.');
|
|
886
|
+
}
|
|
887
|
+
for (const [name, entry] of Object.entries(obj.extensions as Record<string, unknown>)) {
|
|
888
|
+
if (!name) throw new Error('Recipe extensions keys must be non-empty names.');
|
|
889
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
890
|
+
throw new Error(`extensions.${name} must be an object.`);
|
|
891
|
+
}
|
|
892
|
+
const ext = entry as Record<string, unknown>;
|
|
893
|
+
if (ext.kind !== 'strategy' && ext.kind !== 'module') {
|
|
894
|
+
throw new Error(`extensions.${name}.kind must be "strategy" or "module", got ${JSON.stringify(ext.kind)}.`);
|
|
895
|
+
}
|
|
896
|
+
if (typeof ext.path !== 'string' || !ext.path) {
|
|
897
|
+
throw new Error(`extensions.${name}.path must be a non-empty string.`);
|
|
898
|
+
}
|
|
899
|
+
if (ext.config !== undefined && (!ext.config || typeof ext.config !== 'object' || Array.isArray(ext.config))) {
|
|
900
|
+
throw new Error(`extensions.${name}.config must be an object.`);
|
|
901
|
+
}
|
|
902
|
+
for (const metaKey of ['source', 'sourceMeta'] as const) {
|
|
903
|
+
if (ext[metaKey] !== undefined && (!ext[metaKey] || typeof ext[metaKey] !== 'object' || Array.isArray(ext[metaKey]))) {
|
|
904
|
+
throw new Error(`extensions.${name}.${metaKey} must be an object.`);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (ext.kind === 'strategy') hasStrategyExtension = true;
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
785
911
|
// Validate strategy type if present
|
|
786
912
|
if (agent.strategy) {
|
|
787
913
|
const strategy = agent.strategy as Record<string, unknown>;
|
|
@@ -789,10 +915,13 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
789
915
|
strategy.type &&
|
|
790
916
|
strategy.type !== 'autobiographical' &&
|
|
791
917
|
strategy.type !== 'passthrough' &&
|
|
792
|
-
strategy.type !== 'frontdesk'
|
|
918
|
+
strategy.type !== 'frontdesk' &&
|
|
919
|
+
!hasStrategyExtension
|
|
793
920
|
) {
|
|
794
921
|
throw new Error(
|
|
795
|
-
`Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough",
|
|
922
|
+
`Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", ` +
|
|
923
|
+
`or "frontdesk" — or a custom type registered by a "kind": "strategy" entry in the ` +
|
|
924
|
+
`recipe's "extensions" block (none is declared).`,
|
|
796
925
|
);
|
|
797
926
|
}
|
|
798
927
|
if (
|