@animalabs/connectome-host 0.3.8 → 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/bun.lock +6 -10
- package/package.json +5 -4
- 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 +140 -43
- 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/framework-fkm-composition.test.ts +4 -13
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-primary-summary-fallback-rejection.test.ts +24 -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
- package/test/recipe-primary-summary-fallback.test.ts +0 -40
|
@@ -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`
|
|
@@ -132,11 +158,6 @@ export interface RecipeAgent {
|
|
|
132
158
|
autoRewind?: boolean;
|
|
133
159
|
maxRewinds?: number;
|
|
134
160
|
announceHumanTurns?: boolean;
|
|
135
|
-
primarySummaryFallback?: {
|
|
136
|
-
enabled?: boolean;
|
|
137
|
-
maxNewSummaries?: number;
|
|
138
|
-
requestBudgetTokens?: number;
|
|
139
|
-
};
|
|
140
161
|
};
|
|
141
162
|
}
|
|
142
163
|
|
|
@@ -508,6 +529,31 @@ export interface RecipeFleetChild {
|
|
|
508
529
|
autoRestart?: boolean;
|
|
509
530
|
}
|
|
510
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
|
+
|
|
511
557
|
export interface Recipe {
|
|
512
558
|
name: string;
|
|
513
559
|
description?: string;
|
|
@@ -515,6 +561,8 @@ export interface Recipe {
|
|
|
515
561
|
agent: RecipeAgent;
|
|
516
562
|
mcpServers?: Record<string, RecipeMcpServer>;
|
|
517
563
|
modules?: RecipeModules;
|
|
564
|
+
/** Deployment-specific code extensions, keyed by a human-readable name. */
|
|
565
|
+
extensions?: Record<string, RecipeExtension>;
|
|
518
566
|
sessionNaming?: { examples?: string[] };
|
|
519
567
|
}
|
|
520
568
|
|
|
@@ -644,6 +692,7 @@ export async function loadRecipe(source: string): Promise<Recipe> {
|
|
|
644
692
|
raw = substituteEnvVars(raw, source);
|
|
645
693
|
const recipe = validateRecipe(raw);
|
|
646
694
|
resolveChildRecipePaths(recipe, sourceBase);
|
|
695
|
+
resolveExtensionPaths(recipe, sourceBase);
|
|
647
696
|
return resolveSystemPrompt(recipe);
|
|
648
697
|
}
|
|
649
698
|
|
|
@@ -668,6 +717,25 @@ function resolveChildRecipePaths(recipe: Recipe, base: RecipeSourceBase): void {
|
|
|
668
717
|
}
|
|
669
718
|
}
|
|
670
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
|
+
|
|
671
739
|
/**
|
|
672
740
|
* If systemPrompt is an HTTP(S) URL, fetch its contents as plain text.
|
|
673
741
|
* Only treated as a URL if it looks like a single URL (no spaces/newlines).
|
|
@@ -706,8 +774,16 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
706
774
|
throw new Error('Recipe agent must have a "systemPrompt" string');
|
|
707
775
|
}
|
|
708
776
|
|
|
709
|
-
if (agent.provider !== undefined &&
|
|
710
|
-
|
|
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
|
+
);
|
|
711
787
|
}
|
|
712
788
|
|
|
713
789
|
if (agent.timezone !== undefined) {
|
|
@@ -740,6 +816,20 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
740
816
|
(typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
|
|
741
817
|
throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
|
|
742
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
|
+
}
|
|
743
833
|
}
|
|
744
834
|
|
|
745
835
|
if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
|
|
@@ -787,6 +877,37 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
787
877
|
}
|
|
788
878
|
}
|
|
789
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
|
+
|
|
790
911
|
// Validate strategy type if present
|
|
791
912
|
if (agent.strategy) {
|
|
792
913
|
const strategy = agent.strategy as Record<string, unknown>;
|
|
@@ -794,10 +915,13 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
794
915
|
strategy.type &&
|
|
795
916
|
strategy.type !== 'autobiographical' &&
|
|
796
917
|
strategy.type !== 'passthrough' &&
|
|
797
|
-
strategy.type !== 'frontdesk'
|
|
918
|
+
strategy.type !== 'frontdesk' &&
|
|
919
|
+
!hasStrategyExtension
|
|
798
920
|
) {
|
|
799
921
|
throw new Error(
|
|
800
|
-
`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).`,
|
|
801
925
|
);
|
|
802
926
|
}
|
|
803
927
|
if (
|
|
@@ -823,35 +947,8 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
823
947
|
}
|
|
824
948
|
|
|
825
949
|
const refusalHandling = agent.refusalHandling as Record<string, unknown> | undefined;
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
if (!primarySummaryFallback || typeof primarySummaryFallback !== 'object' || Array.isArray(primarySummaryFallback)) {
|
|
829
|
-
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback must be an object.');
|
|
830
|
-
}
|
|
831
|
-
const fallback = primarySummaryFallback as Record<string, unknown>;
|
|
832
|
-
if (fallback.enabled !== undefined && typeof fallback.enabled !== 'boolean') {
|
|
833
|
-
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.enabled must be a boolean.');
|
|
834
|
-
}
|
|
835
|
-
if (
|
|
836
|
-
fallback.maxNewSummaries !== undefined &&
|
|
837
|
-
(
|
|
838
|
-
typeof fallback.maxNewSummaries !== 'number' ||
|
|
839
|
-
!Number.isSafeInteger(fallback.maxNewSummaries) ||
|
|
840
|
-
fallback.maxNewSummaries < 0
|
|
841
|
-
)
|
|
842
|
-
) {
|
|
843
|
-
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.maxNewSummaries must be a non-negative safe integer.');
|
|
844
|
-
}
|
|
845
|
-
if (
|
|
846
|
-
fallback.requestBudgetTokens !== undefined &&
|
|
847
|
-
(
|
|
848
|
-
typeof fallback.requestBudgetTokens !== 'number' ||
|
|
849
|
-
!Number.isSafeInteger(fallback.requestBudgetTokens) ||
|
|
850
|
-
fallback.requestBudgetTokens <= 0
|
|
851
|
-
)
|
|
852
|
-
) {
|
|
853
|
-
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.requestBudgetTokens must be a positive safe integer.');
|
|
854
|
-
}
|
|
950
|
+
if (refusalHandling && Object.hasOwn(refusalHandling, 'primarySummaryFallback')) {
|
|
951
|
+
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback was removed and is unsupported.');
|
|
855
952
|
}
|
|
856
953
|
|
|
857
954
|
// Validate mcpServers entries if present
|