melchizedek-agents 1.3.1 → 1.6.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/.env.example +15 -0
- package/CHANGELOG.md +166 -0
- package/DOCUMENTATION.md +84 -6
- package/ONBOARDING.md +3 -3
- package/README.md +2 -1
- package/config/agents/examples/connectors.yaml +4 -1
- package/config/agents/examples/event_assistant.yaml +82 -0
- package/config/agents/syndicate.schema.json +80 -6
- package/db/hardening.sql +43 -14
- package/db/migrations/0015_model_pricing.sql +107 -0
- package/db/telemetry.sql +79 -1
- package/dist/lib/a2a/app.js +2 -1
- package/dist/lib/chatgpt/state.js +4 -4
- package/dist/lib/compile.js +15 -2
- package/dist/lib/compileNative.js +1 -0
- package/dist/lib/index.d.ts +3 -1
- package/dist/lib/index.js +2 -0
- package/dist/lib/loadSyndicate.js +4 -0
- package/dist/lib/memory/providers.js +2 -1
- package/dist/lib/models/claudeAdapter.js +2 -1
- package/dist/lib/models/credentialSource.d.ts +28 -0
- package/dist/lib/models/credentialSource.js +49 -0
- package/dist/lib/models/endpoints.js +2 -1
- package/dist/lib/models/gateway.js +2 -1
- package/dist/lib/models/gatewayAdapter.js +2 -1
- package/dist/lib/models/geminiAdapter.js +2 -1
- package/dist/lib/models/gptAdapter.js +2 -1
- package/dist/lib/models/grokAdapter.js +2 -1
- package/dist/lib/models/kimiAdapter.js +2 -1
- package/dist/lib/models/providerMap.js +3 -2
- package/dist/lib/observability/embeddings.js +2 -1
- package/dist/lib/observability/lineage.js +11 -3
- package/dist/lib/observability/supabaseSpanExporter.d.ts +19 -1
- package/dist/lib/observability/supabaseSpanExporter.js +37 -1
- package/dist/lib/observability/tracer.d.ts +18 -0
- package/dist/lib/observability/tracer.js +60 -2
- package/dist/lib/paths.d.ts +31 -0
- package/dist/lib/paths.js +76 -0
- package/dist/lib/runtime/credentials.d.ts +21 -0
- package/dist/lib/runtime/credentials.js +32 -0
- package/dist/lib/runtime/native/agentLoop.d.ts +6 -0
- package/dist/lib/runtime/native/delegate.d.ts +2 -0
- package/dist/lib/runtime/native/delegate.js +39 -3
- package/dist/lib/runtime/native/interrupts.d.ts +33 -3
- package/dist/lib/runtime/native/interrupts.js +99 -9
- package/dist/lib/runtime/syndicateTurn.d.ts +14 -1
- package/dist/lib/runtime/syndicateTurn.js +45 -11
- package/dist/lib/runtime/turnControl.d.ts +27 -0
- package/dist/lib/storage/postgres/index.d.ts +25 -0
- package/dist/lib/storage/postgres/index.js +10 -1
- package/dist/lib/syndicateSchema.d.ts +26 -4
- package/dist/lib/syndicateSchema.js +63 -1
- package/dist/lib/tools/generateImageTool.js +6 -6
- package/dist/lib/tools/inspectImageTool.d.ts +2 -0
- package/dist/lib/tools/inspectImageTool.js +17 -10
- package/dist/lib/tools/mcpToolFactory.d.ts +34 -1
- package/dist/lib/tools/mcpToolFactory.js +200 -27
- package/dist/lib/tools/mcpTransports.d.ts +4 -2
- package/dist/lib/tools/mcpTransports.js +4 -2
- package/dist/lib/tools/taskTools.js +3 -3
- package/dist/lib/tools/xApiSearchTool.js +2 -1
- package/dist/lib/workflow/agentNode.d.ts +19 -3
- package/dist/lib/workflow/agentNode.js +106 -13
- package/dist/lib/workflow/resume.d.ts +8 -7
- package/dist/lib/workflow/resume.js +17 -10
- package/dist/lib/workflow/turn.d.ts +12 -2
- package/dist/lib/workflow/turn.js +22 -12
- package/dist/scripts/a2a_server.js +5 -0
- package/package.json +11 -4
|
@@ -28,6 +28,8 @@ export declare const REASONING_LEVELS: readonly ['none', 'low', 'medium', 'high'
|
|
|
28
28
|
export declare const OUTPUT_MIMES: readonly ['application/json', 'text/plain'];
|
|
29
29
|
export declare const TOOL_CHOICE_MODES: readonly ['auto', 'none', 'required'];
|
|
30
30
|
export declare const SKILL_SCRIPT_MODES: readonly ['none', 'local'];
|
|
31
|
+
/** An `env` key that names a credential: such a value belongs in pass_env, never in the YAML (ADR 0132). */
|
|
32
|
+
export declare const SECRET_ENV_KEY: RegExp;
|
|
31
33
|
/** An `mcp_servers` entry's name: lowercase, bounded, one character class (no backtracking). */
|
|
32
34
|
export declare const MCP_SERVER_NAME: RegExp;
|
|
33
35
|
export declare const agentSchema: z.ZodObject<{
|
|
@@ -150,7 +152,12 @@ export declare const agentSchema: z.ZodObject<{
|
|
|
150
152
|
}>>;
|
|
151
153
|
mcp_servers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
152
154
|
name: z.ZodString;
|
|
153
|
-
url: z.ZodString
|
|
155
|
+
url: z.ZodOptional<z.ZodString>;
|
|
156
|
+
command: z.ZodOptional<z.ZodString>;
|
|
157
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
158
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
159
|
+
pass_env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
160
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
154
161
|
tools: z.ZodArray<z.ZodString>;
|
|
155
162
|
auth: z.ZodOptional<z.ZodObject<{
|
|
156
163
|
oauth2: z.ZodObject<{
|
|
@@ -359,7 +366,12 @@ export declare const subagentSchema: z.ZodObject<{
|
|
|
359
366
|
}>>;
|
|
360
367
|
mcp_servers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
361
368
|
name: z.ZodString;
|
|
362
|
-
url: z.ZodString
|
|
369
|
+
url: z.ZodOptional<z.ZodString>;
|
|
370
|
+
command: z.ZodOptional<z.ZodString>;
|
|
371
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
372
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
373
|
+
pass_env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
374
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
363
375
|
tools: z.ZodArray<z.ZodString>;
|
|
364
376
|
auth: z.ZodOptional<z.ZodObject<{
|
|
365
377
|
oauth2: z.ZodObject<{
|
|
@@ -572,7 +584,12 @@ export declare const syndicateSchema: z.ZodObject<{
|
|
|
572
584
|
}>>;
|
|
573
585
|
mcp_servers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
574
586
|
name: z.ZodString;
|
|
575
|
-
url: z.ZodString
|
|
587
|
+
url: z.ZodOptional<z.ZodString>;
|
|
588
|
+
command: z.ZodOptional<z.ZodString>;
|
|
589
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
590
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
591
|
+
pass_env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
592
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
576
593
|
tools: z.ZodArray<z.ZodString>;
|
|
577
594
|
auth: z.ZodOptional<z.ZodObject<{
|
|
578
595
|
oauth2: z.ZodObject<{
|
|
@@ -775,7 +792,12 @@ export declare const syndicateSchema: z.ZodObject<{
|
|
|
775
792
|
}>>;
|
|
776
793
|
mcp_servers: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
777
794
|
name: z.ZodString;
|
|
778
|
-
url: z.ZodString
|
|
795
|
+
url: z.ZodOptional<z.ZodString>;
|
|
796
|
+
command: z.ZodOptional<z.ZodString>;
|
|
797
|
+
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
798
|
+
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
799
|
+
pass_env: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
800
|
+
cwd: z.ZodOptional<z.ZodString>;
|
|
779
801
|
tools: z.ZodArray<z.ZodString>;
|
|
780
802
|
auth: z.ZodOptional<z.ZodObject<{
|
|
781
803
|
oauth2: z.ZodObject<{
|
|
@@ -272,6 +272,10 @@ const oauth2Auth = z
|
|
|
272
272
|
const mcpTransport = z
|
|
273
273
|
.enum(MCP_TRANSPORTS)
|
|
274
274
|
.describe('How the MCP server is reached: auto (the default) tries Streamable HTTP and falls back to the legacy SSE transport on the spec\'s signal; streamable_http or sse use that one only (ADR 0124).');
|
|
275
|
+
/** An environment variable name, as a stdio MCP entry's env and pass_env keys must be. */
|
|
276
|
+
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
|
|
277
|
+
/** An `env` key that names a credential: such a value belongs in pass_env, never in the YAML (ADR 0132). */
|
|
278
|
+
export const SECRET_ENV_KEY = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|AUTH)/i;
|
|
275
279
|
/** An `mcp_servers` entry's name: lowercase, bounded, one character class (no backtracking). */
|
|
276
280
|
export const MCP_SERVER_NAME = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
277
281
|
const mcpServerEntry = z
|
|
@@ -280,7 +284,23 @@ const mcpServerEntry = z
|
|
|
280
284
|
.string()
|
|
281
285
|
.regex(MCP_SERVER_NAME, 'lowercase letters, digits, _ and -, starting with a letter (at most 64)')
|
|
282
286
|
.describe('A short name for the server, unique on this agent: logs and errors name it.'),
|
|
283
|
-
url: z.string().url().describe('The MCP server\'s URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard.'),
|
|
287
|
+
url: z.string().url().optional().describe('The MCP server\'s URL: its Streamable HTTP endpoint, or a legacy SSE endpoint. Checked by the SSRF guard. Give url or command, not both.'),
|
|
288
|
+
command: z
|
|
289
|
+
.string()
|
|
290
|
+
.min(1)
|
|
291
|
+
.optional()
|
|
292
|
+
.describe('A local MCP server to start and speak to over stdio (ADR 0132), instead of url: the program to run. It runs only when the operator lists this exact value in MELCHIZEDEK_STDIO_MCP. One process per distinct command, args, env and cwd, kept for the life of the engine.'),
|
|
293
|
+
args: z.array(z.string()).max(64).optional().describe('The command\'s arguments (stdio only).'),
|
|
294
|
+
env: z
|
|
295
|
+
.record(z.string().regex(ENV_NAME, 'an environment variable name'), z.string())
|
|
296
|
+
.optional()
|
|
297
|
+
.describe('Non-secret variables set for the command (stdio only). A credential is never written here: name it in pass_env.'),
|
|
298
|
+
pass_env: z
|
|
299
|
+
.array(z.string().regex(ENV_NAME, 'an environment variable name'))
|
|
300
|
+
.max(32)
|
|
301
|
+
.optional()
|
|
302
|
+
.describe('Variables copied by name from the engine\'s environment into the command\'s (stdio only); the command sees no other variable of the engine\'s beyond a minimal default set (PATH, HOME, …). A name that is unset fails the compile.'),
|
|
303
|
+
cwd: z.string().min(1).optional().describe('The command\'s working directory (stdio only); relative to the YAML file\'s directory.'),
|
|
284
304
|
tools: z
|
|
285
305
|
.array(z.string().min(1))
|
|
286
306
|
.min(1)
|
|
@@ -759,6 +779,30 @@ function mcpServersProblems(agent, path) {
|
|
|
759
779
|
if (!isObj(entry))
|
|
760
780
|
return;
|
|
761
781
|
const name = typeof entry.name === 'string' ? entry.name : `#${i}`;
|
|
782
|
+
// url or command (ADR 0132), and each one's own keys only.
|
|
783
|
+
const at = (key) => [...path, 'mcp_servers', i, key];
|
|
784
|
+
if (entry.url === undefined && entry.command === undefined)
|
|
785
|
+
out.push({ path: at('url'), message: 'give url (a remote server) or command (a local stdio server) (ADR 0132)' });
|
|
786
|
+
if (entry.url !== undefined && entry.command !== undefined)
|
|
787
|
+
out.push({ path: at('command'), message: 'url and command cannot both be given: one entry is one server (ADR 0132)' });
|
|
788
|
+
if (entry.command !== undefined) {
|
|
789
|
+
for (const key of ['transport', 'auth']) {
|
|
790
|
+
if (entry[key] !== undefined)
|
|
791
|
+
out.push({ path: at(key), message: `${key} is how a url is reached; a command entry speaks stdio and takes no ${key} (ADR 0132)` });
|
|
792
|
+
}
|
|
793
|
+
if (isObj(entry.env)) {
|
|
794
|
+
for (const key of Object.keys(entry.env)) {
|
|
795
|
+
if (SECRET_ENV_KEY.test(key))
|
|
796
|
+
out.push({ path: [...at('env'), key], message: `'${key}' looks like a credential: never write its value in the YAML; list the name in pass_env instead (ADR 0132)` });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
for (const key of ['args', 'env', 'pass_env', 'cwd']) {
|
|
802
|
+
if (entry[key] !== undefined)
|
|
803
|
+
out.push({ path: at(key), message: `${key} configures a command entry; this entry has a url (ADR 0132)` });
|
|
804
|
+
}
|
|
805
|
+
}
|
|
762
806
|
if (typeof entry.name === 'string') {
|
|
763
807
|
if (names.has(entry.name))
|
|
764
808
|
out.push({ path: [...path, 'mcp_servers', i, 'name'], message: `'${entry.name}' names mcp_servers entry ${names.get(entry.name)} too; each server's name is unique on the agent` });
|
|
@@ -1153,12 +1197,30 @@ function workflowProblems(raw, subs) {
|
|
|
1153
1197
|
if (typeof agent.name === 'string' && mapped.has(agent.name) && isObj(agent.skills) && agent.skills.scripts === 'local') {
|
|
1154
1198
|
out.push({ path: [...path, 'skills', 'scripts'], message: 'skill scripts (an approval pause) are not supported on an agent a map node runs: a map item cannot pause the walk' });
|
|
1155
1199
|
}
|
|
1200
|
+
// An authorization_code grant pauses for consent (ADR 0128): allowed on a node, not on a map item.
|
|
1201
|
+
if (typeof agent.name === 'string' && mapped.has(agent.name)) {
|
|
1202
|
+
for (const where of authorizationCodeGrants(agent)) {
|
|
1203
|
+
out.push({ path: [...path, ...where], message: 'an authorization_code grant (a consent pause) is not supported on an agent a map node runs: a map item cannot pause the walk' });
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1156
1206
|
if (typeof agent.a2a_agent_url === 'string') {
|
|
1157
1207
|
out.push({ path: [...path, 'a2a_agent_url'], message: 'a remote agent cannot be a workflow node yet' });
|
|
1158
1208
|
}
|
|
1159
1209
|
}
|
|
1160
1210
|
return out;
|
|
1161
1211
|
}
|
|
1212
|
+
/** Where an agent declares an authorization_code grant (mcp_auth, mcp_servers[].auth, openapi[].auth), by its path below the agent. */
|
|
1213
|
+
function authorizationCodeGrants(agent) {
|
|
1214
|
+
const out = [];
|
|
1215
|
+
const isCode = (auth) => isObj(auth) && isObj(auth.oauth2) && auth.oauth2.grant === 'authorization_code';
|
|
1216
|
+
if (isCode(agent.mcp_auth))
|
|
1217
|
+
out.push(['mcp_auth', 'oauth2', 'grant']);
|
|
1218
|
+
if (Array.isArray(agent.mcp_servers))
|
|
1219
|
+
agent.mcp_servers.forEach((e, i) => isObj(e) && isCode(e.auth) && out.push(['mcp_servers', i, 'auth', 'oauth2', 'grant']));
|
|
1220
|
+
if (Array.isArray(agent.openapi))
|
|
1221
|
+
agent.openapi.forEach((e, i) => isObj(e) && isCode(e.auth) && out.push(['openapi', i, 'auth', 'oauth2', 'grant']));
|
|
1222
|
+
return out;
|
|
1223
|
+
}
|
|
1162
1224
|
// ── Public API ───────────────────────────────────────────────────────────────
|
|
1163
1225
|
/**
|
|
1164
1226
|
* Thrown by validateSyndicateConfig. `problems` holds one `<file>: <path> —
|
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { GoogleGenAI } from '@google/genai';
|
|
18
18
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
19
|
-
import { join
|
|
19
|
+
import { join } from 'node:path';
|
|
20
20
|
import { z } from 'zod';
|
|
21
21
|
import { asTool, defineTool } from './toolContract.js';
|
|
22
|
+
import { outputsDir } from '../paths.js';
|
|
23
|
+
import { credentialValue } from '../models/credentialSource.js';
|
|
22
24
|
const IMAGE_MODEL = 'gemini-3.1-flash-image-preview';
|
|
23
25
|
export const generateImageContract = defineTool({
|
|
24
26
|
name: 'generate_image',
|
|
@@ -32,7 +34,7 @@ export const generateImageContract = defineTool({
|
|
|
32
34
|
}),
|
|
33
35
|
execute: async ({ prompt, style, aspect_ratio, color_palette }) => {
|
|
34
36
|
console.log("[ImageTool] Starting image generation execution...");
|
|
35
|
-
const apiKey =
|
|
37
|
+
const apiKey = credentialValue('GEMINI_API_KEY') || credentialValue('GOOGLE_GENAI_API_KEY');
|
|
36
38
|
if (!apiKey) {
|
|
37
39
|
const err = new Error('Gemini API Key is not configured (neither GEMINI_API_KEY nor GOOGLE_GENAI_API_KEY was found in environment).');
|
|
38
40
|
console.error(`[ImageTool ERROR] Initialization failed: ${err.message}`);
|
|
@@ -74,10 +76,8 @@ export const generateImageContract = defineTool({
|
|
|
74
76
|
if (inlineData?.data) {
|
|
75
77
|
const { mimeType, data } = inlineData;
|
|
76
78
|
const ext = mimeType?.split('/')[1] ?? 'png';
|
|
77
|
-
// OUTPUTS_DIR
|
|
78
|
-
const outputDir =
|
|
79
|
-
? resolve(process.env.OUTPUTS_DIR)
|
|
80
|
-
: join(process.cwd(), 'outputs');
|
|
79
|
+
// OUTPUTS_DIR, else MELCHIZEDEK_DATA_DIR/outputs, else ./outputs (lib/paths.ts).
|
|
80
|
+
const outputDir = outputsDir();
|
|
81
81
|
console.log(`[ImageTool] Found inline image data. MimeType: ${mimeType}, Data Length: ${data.length} chars.`);
|
|
82
82
|
console.log(`[ImageTool] Ensuring output directory exists: ${outputDir}`);
|
|
83
83
|
mkdirSync(outputDir, { recursive: true });
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* SpecAuditor subagent, which sees spec + inventory but never the image.
|
|
18
18
|
*/
|
|
19
19
|
import { z } from 'zod';
|
|
20
|
+
/** A path generate_image reported, inside the outputs directory: relative paths are read there, a leading `outputs/` dropped. */
|
|
21
|
+
export declare function resolveOutputPath(imagePath: string): string;
|
|
20
22
|
export declare const inspectImageContract: import("./toolContract.ts").DefinedTool<z.ZodObject<{
|
|
21
23
|
image_path: z.ZodString;
|
|
22
24
|
}, z.core.$strip>, string>;
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { GoogleGenAI } from '@google/genai';
|
|
20
20
|
import { readFileSync } from 'node:fs';
|
|
21
|
-
import { resolve, extname, sep } from 'node:path';
|
|
21
|
+
import { isAbsolute, resolve, extname, sep } from 'node:path';
|
|
22
22
|
import { z } from 'zod';
|
|
23
23
|
import { asTool, defineTool } from './toolContract.js';
|
|
24
|
+
import { outputsDir } from '../paths.js';
|
|
25
|
+
import { credentialValue } from '../models/credentialSource.js';
|
|
24
26
|
const VISION_MODEL = 'gemini-3.8-flash';
|
|
25
27
|
const INVENTORY_PROTOCOL = `You are a blind visual inventory agent. You know NOTHING about what this image was supposed to be, who requested it, or why. Enumerate ONLY what is actually present:
|
|
26
28
|
|
|
@@ -37,6 +39,13 @@ RULES:
|
|
|
37
39
|
- ZERO guesses about intent or purpose.
|
|
38
40
|
- If something is genuinely ambiguous, write "uncertain:" and describe what is visible.
|
|
39
41
|
Return a plain, factual, numbered inventory.`;
|
|
42
|
+
/** A path generate_image reported, inside the outputs directory: relative paths are read there, a leading `outputs/` dropped. */
|
|
43
|
+
export function resolveOutputPath(imagePath) {
|
|
44
|
+
if (isAbsolute(imagePath))
|
|
45
|
+
return resolve(imagePath);
|
|
46
|
+
const rel = imagePath.replace(/^(\.\/)?outputs[\\/]/, '');
|
|
47
|
+
return resolve(outputsDir(), rel);
|
|
48
|
+
}
|
|
40
49
|
export const inspectImageContract = defineTool({
|
|
41
50
|
name: 'inspect_image',
|
|
42
51
|
description: 'Performs a BLIND visual inventory of a previously saved image (subjects with exact counts, ' +
|
|
@@ -47,17 +56,15 @@ export const inspectImageContract = defineTool({
|
|
|
47
56
|
}),
|
|
48
57
|
execute: async ({ image_path }) => {
|
|
49
58
|
console.log(`[InspectTool] Blind inventory requested (path ${image_path.length} chars).`);
|
|
50
|
-
// Confine reads to the outputs
|
|
51
|
-
// generated artifacts, not the filesystem.
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
const fullPath = resolve(process.cwd(), image_path);
|
|
57
|
-
if (!fullPath.startsWith(outputsDir + sep)) {
|
|
59
|
+
// Confine reads to the outputs directory — this tool inventories
|
|
60
|
+
// generated artifacts, not the filesystem. generate_image reports
|
|
61
|
+
// `outputs/<file>` wherever that directory is (lib/paths.ts), so a
|
|
62
|
+
// relative path is read inside it, with that prefix dropped.
|
|
63
|
+
const fullPath = resolveOutputPath(image_path);
|
|
64
|
+
if (!fullPath.startsWith(outputsDir() + sep)) {
|
|
58
65
|
throw new Error('inspect_image only reads files under outputs/');
|
|
59
66
|
}
|
|
60
|
-
const apiKey =
|
|
67
|
+
const apiKey = credentialValue('GEMINI_API_KEY') || credentialValue('GOOGLE_GENAI_API_KEY');
|
|
61
68
|
if (!apiKey) {
|
|
62
69
|
throw new Error('Gemini API Key is not configured (neither GEMINI_API_KEY nor GOOGLE_GENAI_API_KEY was found in environment).');
|
|
63
70
|
}
|
|
@@ -34,6 +34,30 @@ export type { McpTransportKind };
|
|
|
34
34
|
* the credential, which another transport would not change.
|
|
35
35
|
*/
|
|
36
36
|
export declare function isSseFallbackSignal(error: unknown): boolean;
|
|
37
|
+
/** A local MCP server: the program, its arguments, its environment and its directory. */
|
|
38
|
+
export interface StdioMcpSpec {
|
|
39
|
+
command: string;
|
|
40
|
+
args?: string[];
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
pass_env?: string[];
|
|
43
|
+
cwd?: string;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Whether the operator allows `command` to run as an MCP server: its exact
|
|
47
|
+
* value is one of MELCHIZEDEK_STDIO_MCP's comma-separated entries. Unset
|
|
48
|
+
* means none. Read at each connect, so an embedding application can set it
|
|
49
|
+
* as its user approves servers.
|
|
50
|
+
*/
|
|
51
|
+
export declare function stdioCommandAllowed(command: string, env?: NodeJS.ProcessEnv): boolean;
|
|
52
|
+
/** The command's environment: the SDK's minimal default set, then `pass_env` by name (the credential source first, then the environment), then `env`. Throws naming an unset variable, never a value. */
|
|
53
|
+
export declare function stdioEnvironment(spec: StdioMcpSpec, label: string, env?: NodeJS.ProcessEnv): Record<string, string>;
|
|
54
|
+
/**
|
|
55
|
+
* The tools a local stdio MCP server offers, as own Tools (ADR 0132). A
|
|
56
|
+
* command the operator has not allowed (MELCHIZEDEK_STDIO_MCP) or an unset
|
|
57
|
+
* pass_env variable fails the compile (thrown); a server that cannot be
|
|
58
|
+
* started or listed is logged and yields no tools, as a remote one does.
|
|
59
|
+
*/
|
|
60
|
+
export declare function loadStdioMcpTools(name: string, spec: StdioMcpSpec): Promise<Tool[]>;
|
|
37
61
|
/**
|
|
38
62
|
* Close every open MCP connection. A connection otherwise lives as long as
|
|
39
63
|
* the process (its tools are called for the life of the compiled agent), and
|
|
@@ -69,7 +93,16 @@ export interface McpToolOptions {
|
|
|
69
93
|
export interface McpServerConfig {
|
|
70
94
|
/** A short name for the server, unique on the agent: logs and errors name it. */
|
|
71
95
|
name: string;
|
|
72
|
-
url
|
|
96
|
+
/** A remote server. Exactly one of url and command is set (ADR 0132). */
|
|
97
|
+
url?: string;
|
|
98
|
+
/** A local server, started and spoken to over stdio (ADR 0132). */
|
|
99
|
+
command?: string;
|
|
100
|
+
args?: string[];
|
|
101
|
+
/** Non-secret variables for the command. */
|
|
102
|
+
env?: Record<string, string>;
|
|
103
|
+
/** Variables copied by name from the engine's environment. */
|
|
104
|
+
pass_env?: string[];
|
|
105
|
+
cwd?: string;
|
|
73
106
|
/** The server's tools this agent may use, by name. Required: names across servers are refused on collision at load. */
|
|
74
107
|
tools: string[];
|
|
75
108
|
auth?: {
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* An agent's `mcp_server_url:` (or each entry of `mcp_servers:`, ADR 0124)
|
|
6
6
|
* connects here over Streamable HTTP, falling back to the legacy SSE
|
|
7
7
|
* transport when the server answers the initialize POST with the spec's
|
|
8
|
-
* fallback signal (a 4xx other than 401/403), or
|
|
9
|
-
*
|
|
8
|
+
* fallback signal (a 4xx other than 401/403), or with a 401 when no OAuth
|
|
9
|
+
* grant is configured for the connection, or over the transport the YAML
|
|
10
|
+
* names; the transport that worked is remembered per URL. Every tool the server lists becomes an own Tool whose declaration is the server's
|
|
10
11
|
* description (bounded) and input schema, and whose execute calls the
|
|
11
12
|
* server and returns its text (bounded). loadMcpTools returns them;
|
|
12
13
|
* createMcpTools is the name the compiler calls.
|
|
@@ -24,6 +25,8 @@
|
|
|
24
25
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
25
26
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
|
26
27
|
import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
28
|
+
import { StdioClientTransport, getDefaultEnvironment } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
29
|
+
import { credentialValue } from '../models/credentialSource.js';
|
|
27
30
|
import { toContractJsonSchema } from '../models/schemaNormalize.js';
|
|
28
31
|
import { checkHost } from '../net/addressGuard.js';
|
|
29
32
|
import { fetchWithRedirectPolicy } from '../net/redirects.js';
|
|
@@ -134,14 +137,56 @@ export function isSseFallbackSignal(error) {
|
|
|
134
137
|
const code = error.code;
|
|
135
138
|
return typeof code === 'number' && code >= 400 && code < 500 && code !== 401 && code !== 403;
|
|
136
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* True when a Streamable HTTP connect failed with a 401: the server refused
|
|
142
|
+
* the request's credential (or its absence). Where the connection carries no
|
|
143
|
+
* OAuth grant, an SSE-only server behind a bearer can answer the initialize
|
|
144
|
+
* POST to its stream URL that way, so connectMcp tries SSE once (ADR 0124's
|
|
145
|
+
* note of 2026-10-11).
|
|
146
|
+
*/
|
|
147
|
+
const isUnauthorized = (error) => error instanceof StreamableHTTPError && error.code === 401;
|
|
148
|
+
/** Server URLs whose transport `auto` has learned, at most; the least recently used is forgotten first. */
|
|
149
|
+
const MAX_REMEMBERED_MCP_TRANSPORTS = 256;
|
|
150
|
+
/**
|
|
151
|
+
* The transport that last connected to each server URL under `auto`, for the
|
|
152
|
+
* life of the process, so a later connection skips the probe. It holds a URL
|
|
153
|
+
* and a transport name, never a credential.
|
|
154
|
+
*/
|
|
155
|
+
const rememberedTransports = new Map();
|
|
156
|
+
function rememberTransport(href, kind) {
|
|
157
|
+
rememberedTransports.delete(href);
|
|
158
|
+
rememberedTransports.set(href, kind);
|
|
159
|
+
while (rememberedTransports.size > MAX_REMEMBERED_MCP_TRANSPORTS) {
|
|
160
|
+
const oldest = rememberedTransports.keys().next().value;
|
|
161
|
+
rememberedTransports.delete(oldest);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const STREAMABLE_PREFIX = 'Streamable HTTP error: ';
|
|
165
|
+
/** `message` with the credential header's value, and its token, cut out. */
|
|
166
|
+
function withoutCredential(message, headers) {
|
|
167
|
+
const auth = headers.Authorization;
|
|
168
|
+
if (!auth)
|
|
169
|
+
return message;
|
|
170
|
+
const token = auth.startsWith('Bearer ') ? auth.slice('Bearer '.length) : undefined;
|
|
171
|
+
return redact(redact(message, auth), token);
|
|
172
|
+
}
|
|
137
173
|
/**
|
|
138
174
|
* A client connected to `url` over `kind`, every request through `fetchFn`
|
|
139
175
|
* (the redirect rule, and the credential when there is one). A transport
|
|
140
176
|
* that fails to connect is closed before the error leaves, so nothing keeps
|
|
141
|
-
* reconnecting
|
|
142
|
-
*
|
|
177
|
+
* reconnecting. With `auto`:
|
|
178
|
+
* - a transport remembered for this URL is tried first; if it fails, it is
|
|
179
|
+
* forgotten and the probe runs;
|
|
180
|
+
* - the probe posts over Streamable HTTP. On the spec's fallback signal, or
|
|
181
|
+
* on a 401 when the connection has no OAuth grant (`MCP_BEARER_TOKENS`
|
|
182
|
+
* or no credential), it closes that attempt and tries SSE once with the
|
|
183
|
+
* same fetch and headers. When SSE fails after a 401, the 401 is what is
|
|
184
|
+
* thrown, naming both attempts and no credential. A 401 under an OAuth
|
|
185
|
+
* grant (its consent and discovery path, ADR 0112 and 0124), a 403, a 5xx
|
|
186
|
+
* and a network failure do not fall back;
|
|
187
|
+
* - the transport that connected is remembered for this URL.
|
|
143
188
|
*/
|
|
144
|
-
async function connectMcp(url, kind, fetchFn, headers = {}) {
|
|
189
|
+
async function connectMcp(url, kind, fetchFn, headers = {}, options = {}) {
|
|
145
190
|
const attempt = async (which) => {
|
|
146
191
|
const requestInit = Object.keys(headers).length ? { requestInit: { headers } } : {};
|
|
147
192
|
const transport = which === 'sse' ? new SSEClientTransport(url, { ...requestInit, fetch: fetchFn }) : new StreamableHTTPClientTransport(url, { ...requestInit, fetch: fetchFn });
|
|
@@ -160,14 +205,154 @@ async function connectMcp(url, kind, fetchFn, headers = {}) {
|
|
|
160
205
|
return attempt('sse');
|
|
161
206
|
if (kind === 'streamable_http')
|
|
162
207
|
return attempt('streamable_http');
|
|
208
|
+
const href = url.href;
|
|
209
|
+
const known = rememberedTransports.get(href);
|
|
210
|
+
if (known) {
|
|
211
|
+
try {
|
|
212
|
+
const conn = await attempt(known);
|
|
213
|
+
rememberTransport(href, known);
|
|
214
|
+
return conn;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// The server may have changed: forget it and probe as if new.
|
|
218
|
+
rememberedTransports.delete(href);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
let conn;
|
|
163
222
|
try {
|
|
164
|
-
|
|
223
|
+
conn = await attempt('streamable_http');
|
|
165
224
|
}
|
|
166
225
|
catch (error) {
|
|
167
|
-
if (
|
|
226
|
+
if (isSseFallbackSignal(error)) {
|
|
227
|
+
conn = await attempt('sse');
|
|
228
|
+
}
|
|
229
|
+
else if (isUnauthorized(error) && !options.oauth) {
|
|
230
|
+
try {
|
|
231
|
+
conn = await attempt('sse');
|
|
232
|
+
}
|
|
233
|
+
catch (sseError) {
|
|
234
|
+
const original = error.message.startsWith(STREAMABLE_PREFIX) ? error.message.slice(STREAMABLE_PREFIX.length) : error.message;
|
|
235
|
+
const sseMessage = sseError instanceof Error ? sseError.message : String(sseError);
|
|
236
|
+
throw new StreamableHTTPError(401, withoutCredential(`${original} (Streamable HTTP answered 401; the SSE transport was tried once after it and failed too: ${sseMessage})`, headers));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
168
240
|
throw error;
|
|
169
|
-
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
rememberTransport(href, conn.kind);
|
|
244
|
+
return conn;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Whether the operator allows `command` to run as an MCP server: its exact
|
|
248
|
+
* value is one of MELCHIZEDEK_STDIO_MCP's comma-separated entries. Unset
|
|
249
|
+
* means none. Read at each connect, so an embedding application can set it
|
|
250
|
+
* as its user approves servers.
|
|
251
|
+
*/
|
|
252
|
+
export function stdioCommandAllowed(command, env = process.env) {
|
|
253
|
+
const list = (env.MELCHIZEDEK_STDIO_MCP ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
254
|
+
return list.includes(command);
|
|
255
|
+
}
|
|
256
|
+
/** The command's environment: the SDK's minimal default set, then `pass_env` by name (the credential source first, then the environment), then `env`. Throws naming an unset variable, never a value. */
|
|
257
|
+
export function stdioEnvironment(spec, label, env = process.env) {
|
|
258
|
+
const out = { ...getDefaultEnvironment() };
|
|
259
|
+
for (const name of spec.pass_env ?? []) {
|
|
260
|
+
// Through the credential source (ADR 0133): a key held in an application's keychain reaches the server by name.
|
|
261
|
+
const value = credentialValue(name, env);
|
|
262
|
+
if (value === undefined || value === '')
|
|
263
|
+
throw new Error(`${label}: pass_env names ${name}, which is not set in the engine's environment`);
|
|
264
|
+
out[name] = value;
|
|
265
|
+
}
|
|
266
|
+
return { ...out, ...(spec.env ?? {}) };
|
|
267
|
+
}
|
|
268
|
+
/** Live stdio connections by spec: one process per distinct spec, for the life of the engine. */
|
|
269
|
+
const stdioConnections = new Map();
|
|
270
|
+
/** The last stderr a stdio server wrote, for a failure message. */
|
|
271
|
+
const STDERR_TAIL = 1_000;
|
|
272
|
+
function stdioKey(spec) {
|
|
273
|
+
return JSON.stringify([spec.command, spec.args ?? [], Object.entries(spec.env ?? {}).sort(), [...(spec.pass_env ?? [])].sort(), spec.cwd ?? null]);
|
|
274
|
+
}
|
|
275
|
+
async function connectStdio(spec, label) {
|
|
276
|
+
const key = stdioKey(spec);
|
|
277
|
+
const live = stdioConnections.get(key);
|
|
278
|
+
if (live)
|
|
279
|
+
return live;
|
|
280
|
+
const env = stdioEnvironment(spec, label);
|
|
281
|
+
const attempt = (async () => {
|
|
282
|
+
const transport = new StdioClientTransport({ command: spec.command, args: spec.args ?? [], env, ...(spec.cwd ? { cwd: spec.cwd } : {}), stderr: 'pipe' });
|
|
283
|
+
let tail = '';
|
|
284
|
+
transport.stderr?.on('data', (chunk) => {
|
|
285
|
+
tail = (tail + chunk.toString('utf-8')).slice(-STDERR_TAIL);
|
|
286
|
+
});
|
|
287
|
+
const client = new Client({ name: 'melchizedek-a2a-client', version: '1.0.0' }, { capabilities: {} });
|
|
288
|
+
try {
|
|
289
|
+
await client.connect(transport);
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
await transport.close().catch(() => { });
|
|
293
|
+
// The tail reaches a log line: every value pass_env copied in is cut out of it.
|
|
294
|
+
const secrets = (spec.pass_env ?? []).map((n) => env[n]).filter((v) => !!v);
|
|
295
|
+
const clean = secrets.reduce((text, v) => text.split(v).join('[redacted]'), tail.trim());
|
|
296
|
+
const said = clean ? ` (stderr: ${clean.split('\n').slice(-3).join(' | ')})` : '';
|
|
297
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}${said}`);
|
|
298
|
+
}
|
|
299
|
+
// A server that exits is started again by the next compile that needs it.
|
|
300
|
+
transport.onclose = () => {
|
|
301
|
+
stdioConnections.delete(key);
|
|
302
|
+
openTransports.delete(transport);
|
|
303
|
+
};
|
|
304
|
+
openTransports.add(transport);
|
|
305
|
+
return client;
|
|
306
|
+
})();
|
|
307
|
+
stdioConnections.set(key, attempt);
|
|
308
|
+
attempt.catch(() => stdioConnections.delete(key));
|
|
309
|
+
return attempt;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The tools a local stdio MCP server offers, as own Tools (ADR 0132). A
|
|
313
|
+
* command the operator has not allowed (MELCHIZEDEK_STDIO_MCP) or an unset
|
|
314
|
+
* pass_env variable fails the compile (thrown); a server that cannot be
|
|
315
|
+
* started or listed is logged and yields no tools, as a remote one does.
|
|
316
|
+
*/
|
|
317
|
+
export async function loadStdioMcpTools(name, spec) {
|
|
318
|
+
const label = `mcp_servers ${name}`;
|
|
319
|
+
if (!stdioCommandAllowed(spec.command)) {
|
|
320
|
+
throw new Error(`${label}: the command '${spec.command}' is not allowed to run; the operator lists it in MELCHIZEDEK_STDIO_MCP (ADR 0132)`);
|
|
170
321
|
}
|
|
322
|
+
let client;
|
|
323
|
+
try {
|
|
324
|
+
client = await connectStdio(spec, label);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error instanceof Error && error.message.startsWith(label))
|
|
328
|
+
throw error; // pass_env: a configuration error
|
|
329
|
+
console.warn(`[MCP] Failed to start or connect to ${label} (stdio: ${spec.command})`, error instanceof Error ? error.message : error);
|
|
330
|
+
return [];
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const { tools } = await client.listTools();
|
|
334
|
+
return tools.map((tool) => listedTool(client, tool));
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
console.warn(`[MCP] Failed to list tools from ${label} (stdio: ${spec.command})`, error instanceof Error ? error.message : error);
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** One listed tool as an own Tool on `client`'s connection: the same declaration and result handling for every transport. */
|
|
342
|
+
function listedTool(client, tool) {
|
|
343
|
+
const declaration = declarationOf(tool);
|
|
344
|
+
return {
|
|
345
|
+
name: tool.name,
|
|
346
|
+
declaration: () => declaration,
|
|
347
|
+
execute: async (input) => {
|
|
348
|
+
try {
|
|
349
|
+
return resultText(await client.callTool({ name: tool.name, arguments: input }));
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
return `[MCP ERROR] Tool ${tool.name} failed: ${error instanceof ToolCredentialError ? error.message : error?.message}`;
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
};
|
|
171
356
|
}
|
|
172
357
|
/**
|
|
173
358
|
* Close every open MCP connection. A connection otherwise lives as long as
|
|
@@ -180,6 +365,8 @@ export async function closeMcpConnections() {
|
|
|
180
365
|
openTransports.clear();
|
|
181
366
|
for (const pool of userPools)
|
|
182
367
|
pool.clear();
|
|
368
|
+
stdioConnections.clear();
|
|
369
|
+
rememberedTransports.clear();
|
|
183
370
|
await Promise.all(all.map((t) => t.close().catch(() => { })));
|
|
184
371
|
}
|
|
185
372
|
/**
|
|
@@ -265,28 +452,14 @@ export async function loadMcpTools(mcpServerUrl, options = {}) {
|
|
|
265
452
|
try {
|
|
266
453
|
const url = await assertSafeMcpUrl(mcpServerUrl);
|
|
267
454
|
conn = tokenSource
|
|
268
|
-
? await connectMcp(url, kind, bearerFetch(oauth2, (destination) => tokenSource(undefined, destination)))
|
|
455
|
+
? await connectMcp(url, kind, bearerFetch(oauth2, (destination) => tokenSource(undefined, destination)), {}, { oauth: true })
|
|
269
456
|
: await connectMcp(url, kind, mcpFetch, mcpAuthHeaders(url));
|
|
270
457
|
const client = conn.client;
|
|
271
458
|
// Fetch available tools from the MCP server
|
|
272
459
|
const toolsResponse = await client.listTools();
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const declaration = declarationOf(tool);
|
|
277
|
-
return {
|
|
278
|
-
name: tool.name,
|
|
279
|
-
declaration: () => declaration,
|
|
280
|
-
execute: async (input) => {
|
|
281
|
-
try {
|
|
282
|
-
return resultText(await client.callTool({ name: tool.name, arguments: input }));
|
|
283
|
-
}
|
|
284
|
-
catch (error) {
|
|
285
|
-
return `[MCP ERROR] Tool ${tool.name} failed: ${error instanceof ToolCredentialError ? error.message : error?.message}`;
|
|
286
|
-
}
|
|
287
|
-
},
|
|
288
|
-
};
|
|
289
|
-
});
|
|
460
|
+
// MCP servers describe tools in standard lowercase JSON Schema, which is
|
|
461
|
+
// the contract's dialect; mcpToolParameters derives the parameters.
|
|
462
|
+
return toolsResponse.tools.map((tool) => listedTool(client, tool));
|
|
290
463
|
}
|
|
291
464
|
catch (error) {
|
|
292
465
|
// A token that cannot be had is reported by kind; the rest as before.
|
|
@@ -335,7 +508,7 @@ function userGrantMcpTools(mcpServerUrl, oauth2, names, kind) {
|
|
|
335
508
|
conn.holder = holder;
|
|
336
509
|
conn.ready = (async () => {
|
|
337
510
|
const url = await assertSafeMcpUrl(mcpServerUrl);
|
|
338
|
-
const { client, transport } = await connectMcp(url, kind, bearerFetch(oauth2, async () => holder.token));
|
|
511
|
+
const { client, transport } = await connectMcp(url, kind, bearerFetch(oauth2, async () => holder.token), {}, { oauth: true });
|
|
339
512
|
conn.transport = transport;
|
|
340
513
|
if (!listed) {
|
|
341
514
|
const offered = await client.listTools();
|
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `auto` (the default) posts the initialize request over Streamable HTTP, the
|
|
7
7
|
* spec's current transport, and falls back to the legacy HTTP+SSE transport
|
|
8
|
-
* on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal)
|
|
9
|
-
*
|
|
8
|
+
* on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal), or
|
|
9
|
+
* on a 401 when the connection carries no OAuth grant, remembering per URL
|
|
10
|
+
* the transport that worked; `streamable_http` and `sse` use that one
|
|
11
|
+
* transport only.
|
|
10
12
|
*/
|
|
11
13
|
export declare const MCP_TRANSPORTS: readonly ['auto', 'streamable_http', 'sse'];
|
|
12
14
|
export type McpTransportKind = (typeof MCP_TRANSPORTS)[number];
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* `auto` (the default) posts the initialize request over Streamable HTTP, the
|
|
7
7
|
* spec's current transport, and falls back to the legacy HTTP+SSE transport
|
|
8
|
-
* on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal)
|
|
9
|
-
*
|
|
8
|
+
* on the spec's signal (lib/tools/mcpToolFactory.ts isSseFallbackSignal), or
|
|
9
|
+
* on a 401 when the connection carries no OAuth grant, remembering per URL
|
|
10
|
+
* the transport that worked; `streamable_http` and `sse` use that one
|
|
11
|
+
* transport only.
|
|
10
12
|
*/
|
|
11
13
|
export const MCP_TRANSPORTS = ['auto', 'streamable_http', 'sse'];
|
|
@@ -54,9 +54,10 @@
|
|
|
54
54
|
* transition, or an unreadable store comes back as an `Error:` string.
|
|
55
55
|
*/
|
|
56
56
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
57
|
-
import { dirname
|
|
57
|
+
import { dirname } from 'node:path';
|
|
58
58
|
import { z } from 'zod';
|
|
59
59
|
import { defineTool } from './toolContract.js';
|
|
60
|
+
import { tasksFile } from '../paths.js';
|
|
60
61
|
/**
|
|
61
62
|
* The largest checkpoint a backend stores by default: 5 MiB of serialized
|
|
62
63
|
* JSON (ADR 0113). A save above the cap is skipped and the job's previous
|
|
@@ -104,8 +105,7 @@ export const MAX_ATTEMPTS = 2;
|
|
|
104
105
|
const RECENT_JOB_MS = 7 * 24 * 60 * 60 * 1000;
|
|
105
106
|
const FINISHED = ['done', 'cancelled', 'failed'];
|
|
106
107
|
export function taskStorePath() {
|
|
107
|
-
|
|
108
|
-
return configured ? resolve(configured) : join(process.cwd(), 'outputs', 'tasks.json');
|
|
108
|
+
return tasksFile();
|
|
109
109
|
}
|
|
110
110
|
function emptyStore() {
|
|
111
111
|
return { version: 1, next_id: 1, tasks: [] };
|