melchizedek-agents 1.5.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +115 -0
- package/DOCUMENTATION.md +28 -2
- package/README.md +1 -1
- package/config/agents/syndicate.schema.json +63 -0
- package/config/agents/syndicateSchema.yaml +50 -0
- package/db/hardening.sql +5 -2
- package/db/migrations/0015_model_pricing.sql +107 -0
- package/db/telemetry.sql +79 -1
- package/dist/lib/a2a/executor.js +21 -0
- package/dist/lib/compile.d.ts +4 -3
- package/dist/lib/compile.js +8 -3
- package/dist/lib/index.d.ts +9 -1
- package/dist/lib/index.js +5 -0
- package/dist/lib/loadSyndicate.d.ts +13 -0
- package/dist/lib/loadSyndicate.js +11 -1
- package/dist/lib/observability/supabaseSpanExporter.js +2 -1
- package/dist/lib/observability/tracer.d.ts +11 -2
- package/dist/lib/observability/tracer.js +17 -4
- package/dist/lib/runtime/native/agentLoop.d.ts +6 -0
- package/dist/lib/runtime/native/checkpoint.d.ts +2 -1
- package/dist/lib/runtime/native/checkpoint.js +6 -2
- package/dist/lib/runtime/native/delegate.js +30 -1
- package/dist/lib/runtime/native/interrupts.js +9 -2
- package/dist/lib/runtime/nativeTurn.d.ts +2 -1
- package/dist/lib/runtime/nativeTurn.js +5 -1
- package/dist/lib/runtime/syndicateTurn.d.ts +25 -2
- package/dist/lib/runtime/syndicateTurn.js +387 -5
- package/dist/lib/runtime/turnControl.d.ts +27 -0
- package/dist/lib/sandbox/sandbox.d.ts +121 -0
- package/dist/lib/sandbox/sandbox.js +276 -0
- package/dist/lib/sandbox/worker.d.ts +29 -0
- package/dist/lib/sandbox/worker.js +280 -0
- package/dist/lib/script/config.d.ts +35 -0
- package/dist/lib/script/config.js +17 -0
- package/dist/lib/script/journal.d.ts +137 -0
- package/dist/lib/script/journal.js +193 -0
- package/dist/lib/script/run.d.ts +138 -0
- package/dist/lib/script/run.js +364 -0
- package/dist/lib/syndicateSchema.d.ts +20 -0
- package/dist/lib/syndicateSchema.js +98 -6
- package/dist/lib/toolRegistry.js +4 -0
- package/dist/lib/tools/runCodeTool.d.ts +50 -0
- package/dist/lib/tools/runCodeTool.js +59 -0
- package/package.json +5 -1
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
*/
|
|
42
42
|
import { randomUUID } from 'node:crypto';
|
|
43
43
|
import { DEFAULT_MAX_STEPS } from '../config.js';
|
|
44
|
-
import { agentGates, compileEntrySpec, compileSpec, compileWorkflowSpec, workflowAgentSpecs } from '../compile.js';
|
|
44
|
+
import { agentGates, compileEntrySpec, compileSpec, compileSubagentSpec, compileWorkflowSpec, withReasoning, workflowAgentSpecs } from '../compile.js';
|
|
45
45
|
import { compileNative, compileNativeWorkflow, nativeAdapterFor } from '../compileNative.js';
|
|
46
46
|
import { nativeMemory, refuseOnNative, runNativeAgent } from './nativeTurn.js';
|
|
47
47
|
import { createTurnEvent, getFunctionCalls, getFunctionResponses } from './events.js';
|
|
@@ -77,6 +77,13 @@ import { INPUT_REQUEST } from '../workflowConfig.js';
|
|
|
77
77
|
export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.js';
|
|
78
78
|
export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.js';
|
|
79
79
|
import { RemoteA2AAgent, remoteContextId, remoteToolOutput } from '../a2a/remoteAgent.js';
|
|
80
|
+
import { RUN_CODE } from '../tools/runCodeTool.js';
|
|
81
|
+
import { resolveTools } from '../toolRegistry.js';
|
|
82
|
+
import { capResult, createToolContext, toolOf } from '../tools/tool.js';
|
|
83
|
+
import { isScriptSyndicate } from '../script/config.js';
|
|
84
|
+
import { journalsEveryCall, openRun } from '../script/journal.js';
|
|
85
|
+
import { toEngineAgent } from '../agentDialect.js';
|
|
86
|
+
import { ScriptFatal, runScript } from '../script/run.js';
|
|
80
87
|
import { createTurnControl, runWithTurnControl, stopCode, stopMessage } from './turnControl.js';
|
|
81
88
|
const THOUGHTS_PREVIEW_CHARS = 600;
|
|
82
89
|
const CLASSIFIER_PREAMBLE = `RECENT CONVERSATION, oldest first — context for your classification only. `
|
|
@@ -132,7 +139,10 @@ export async function drainAgentStream(stream, opts = {}) {
|
|
|
132
139
|
eventCount: 0,
|
|
133
140
|
inputRequests: [],
|
|
134
141
|
nodeErrors: [],
|
|
142
|
+
codeRuns: [],
|
|
135
143
|
};
|
|
144
|
+
/** run_code calls waiting for their result, by call id. */
|
|
145
|
+
const openCodeRuns = new Map();
|
|
136
146
|
const grounding = newGroundingState();
|
|
137
147
|
let groundingAnnounced = false;
|
|
138
148
|
let streamed = false;
|
|
@@ -207,6 +217,8 @@ export async function drainAgentStream(stream, opts = {}) {
|
|
|
207
217
|
if (!name)
|
|
208
218
|
continue;
|
|
209
219
|
d.invokedToolNames.add(name);
|
|
220
|
+
if (name === RUN_CODE && call.id && typeof args.code === 'string')
|
|
221
|
+
openCodeRuns.set(call.id, { agent: e.author ?? 'unknown', code: args.code });
|
|
210
222
|
if (opts.subagentNames?.has(name))
|
|
211
223
|
d.delegations.push(name);
|
|
212
224
|
d.toolCalls.push({ agent: e.author ?? 'unknown', name, args });
|
|
@@ -221,6 +233,12 @@ export async function drainAgentStream(stream, opts = {}) {
|
|
|
221
233
|
d.toolResponses++;
|
|
222
234
|
if (name)
|
|
223
235
|
d.invokedToolNames.add(name);
|
|
236
|
+
const ran = name === RUN_CODE && r.id ? openCodeRuns.get(r.id) : undefined;
|
|
237
|
+
if (ran) {
|
|
238
|
+
openCodeRuns.delete(r.id);
|
|
239
|
+
const error = typeof content?.error === 'string' && content.error ? content.error : undefined;
|
|
240
|
+
d.codeRuns.push({ type: 'code_run', agent: ran.agent, code: ran.code, result: content?.result ?? null, ...(error ? { error } : {}) });
|
|
241
|
+
}
|
|
224
242
|
const asString = typeof content === 'string' ? content : JSON.stringify(content);
|
|
225
243
|
ev.log?.(`← Result: ${name || 'unknown'} — ${asString.length.toLocaleString()} chars`);
|
|
226
244
|
// Only plain text results are relayable answers — a structured or
|
|
@@ -299,6 +317,10 @@ export async function runSyndicateTurn(opts) {
|
|
|
299
317
|
});
|
|
300
318
|
// A nested dispatch syndicate, wherever the turn reaches one, runs as a turn of its own under these controls (ADR 0120).
|
|
301
319
|
control.nestedDispatch = (run) => runNestedDispatch(opts, control, runtime, run);
|
|
320
|
+
// Delegated runs report to the caller as they run (ADR 0135).
|
|
321
|
+
const onAgentEvent = opts.events?.onAgentEvent;
|
|
322
|
+
if (onAgentEvent)
|
|
323
|
+
control.agentEvents = (event, branch) => onAgentEvent.call(opts.events, event, branch);
|
|
302
324
|
try {
|
|
303
325
|
const run = () => runWithTurnControl(control, () => runTurnInner(opts, control, runtime));
|
|
304
326
|
// A span a library opens through OpenTelemetry's own API (not the
|
|
@@ -403,7 +425,12 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
|
|
|
403
425
|
llmCalls: 0,
|
|
404
426
|
usage: { llmCalls: 0, inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
|
|
405
427
|
};
|
|
428
|
+
// run_code's attach (ADR 0137 item 3): the agents whose runs go on the reply.
|
|
429
|
+
const attaching = new Set([config.orchestrator, ...(config.subagents ?? [])].filter((a) => a?.run_code?.attach === true).map((a) => a.name));
|
|
406
430
|
const finish = () => {
|
|
431
|
+
const attached = attaching.size ? (result.answer?.codeRuns ?? []).filter((run) => attaching.has(run.agent)) : [];
|
|
432
|
+
if (attached.length)
|
|
433
|
+
result.codeRuns = attached;
|
|
407
434
|
result.llmCalls = control.llmCalls;
|
|
408
435
|
result.usage = {
|
|
409
436
|
llmCalls: control.llmCalls,
|
|
@@ -713,7 +740,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
|
|
|
713
740
|
taskId: trace.taskId,
|
|
714
741
|
stage: params.stage,
|
|
715
742
|
configHash: trace.configHash,
|
|
716
|
-
attributes: trace.attributes,
|
|
743
|
+
attributes: params.traceAttributes ? { ...trace.attributes, ...params.traceAttributes } : trace.attributes,
|
|
717
744
|
onSpanStart: trace.onSpanStart,
|
|
718
745
|
traceparent: trace.traceparent,
|
|
719
746
|
onEnd: () => ({
|
|
@@ -723,12 +750,30 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
|
|
|
723
750
|
}),
|
|
724
751
|
});
|
|
725
752
|
}
|
|
753
|
+
// A script's agent() call reports live, as a delegated run does (ADR 0135); an observer that throws is ignored.
|
|
754
|
+
const sink = params.branch ? control.agentEvents : undefined;
|
|
755
|
+
if (sink && params.branch) {
|
|
756
|
+
const source = stream;
|
|
757
|
+
const branch = params.branch;
|
|
758
|
+
stream = (async function* tap() {
|
|
759
|
+
for await (const event of source) {
|
|
760
|
+
try {
|
|
761
|
+
sink(event, branch);
|
|
762
|
+
}
|
|
763
|
+
catch {
|
|
764
|
+
// ignored
|
|
765
|
+
}
|
|
766
|
+
yield event;
|
|
767
|
+
}
|
|
768
|
+
})();
|
|
769
|
+
}
|
|
726
770
|
try {
|
|
771
|
+
const quiet = params.quiet ?? params.stage === 'classify';
|
|
727
772
|
return await drainAgentStream(stream, {
|
|
728
|
-
events:
|
|
773
|
+
events: quiet ? { ...ev, onEvent: undefined } : ev,
|
|
729
774
|
publishToolStatus: params.publishToolStatus,
|
|
730
775
|
subagentNames,
|
|
731
|
-
streamText:
|
|
776
|
+
streamText: !quiet && !guarded,
|
|
732
777
|
errorPolicy: params.errorPolicy,
|
|
733
778
|
});
|
|
734
779
|
}
|
|
@@ -890,12 +935,348 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
|
|
|
890
935
|
}
|
|
891
936
|
return { answer };
|
|
892
937
|
};
|
|
938
|
+
/**
|
|
939
|
+
* A script syndicate's turn (ADR 0137 Part B). The host side of
|
|
940
|
+
* lib/script/run.ts: what agent() and tool() do, where the journal goes,
|
|
941
|
+
* and what the conversation keeps. Returns the finished result when the
|
|
942
|
+
* turn ends here (paused, waiting for an approval, or failed); otherwise
|
|
943
|
+
* result.text and result.answer are set and the guards run on them.
|
|
944
|
+
*
|
|
945
|
+
* agent(name, input, opts) runs a declared agent on its own throwaway
|
|
946
|
+
* session seeing only `input` (text, or JSON sent as its text; null sends
|
|
947
|
+
* the run's message), as a workflow node does; with `conversation: true`
|
|
948
|
+
* it runs as a dispatch route runs, on the conversation through the
|
|
949
|
+
* projection, its input and answer stored there, its reply streamed.
|
|
950
|
+
* `model` (one of script.models), `reasoning` and `temperature` change the
|
|
951
|
+
* call's agent, compiled once per turn per combination, every pairing
|
|
952
|
+
* checked before the run starts (item 7). It returns the parsed JSON when
|
|
953
|
+
* the agent has an output schema, else its text; a run that failed throws
|
|
954
|
+
* into the script, which may catch it (fail-static). A `yaml_reference` to
|
|
955
|
+
* a workflow, dispatch or script syndicate runs as a turn or walk of its
|
|
956
|
+
* own on a throwaway lane, and a pause inside it fails the turn by name
|
|
957
|
+
* (item 12). Its events reach events.onAgentEvent with the branch path
|
|
958
|
+
* [<script>, <agent>] (ADR 0135).
|
|
959
|
+
*/
|
|
960
|
+
const runScriptTurn = async () => {
|
|
961
|
+
const script = config.script;
|
|
962
|
+
const scriptName = config.syndicate_name || config.orchestrator.name;
|
|
963
|
+
const key = { appName, userId, sessionId };
|
|
964
|
+
const before = existing?.events ?? [];
|
|
965
|
+
const invocationId = `e-${randomUUID()}`;
|
|
966
|
+
const appendHere = async (event) => {
|
|
967
|
+
const session = await sessionService.get(key);
|
|
968
|
+
if (!session)
|
|
969
|
+
throw new Error(`Session not found: ${sessionId} (appName=${appName}, userId=${userId})`);
|
|
970
|
+
await sessionService.append(session, event);
|
|
971
|
+
};
|
|
972
|
+
let userRecorded = false;
|
|
973
|
+
/** The person's message, once, unless a conversation call stored it. */
|
|
974
|
+
const recordUser = async () => {
|
|
975
|
+
if (userRecorded)
|
|
976
|
+
return;
|
|
977
|
+
userRecorded = true;
|
|
978
|
+
await appendHere(createTurnEvent({ invocationId, author: 'user', content: { role: 'user', parts } }));
|
|
979
|
+
};
|
|
980
|
+
// A durable run (ADR 0113) stores the message first and each call as it ends, so a restored checkpoint holds the
|
|
981
|
+
// run's journal; the restore answers that first append with the stored events, so the run is read after it.
|
|
982
|
+
const durable = journalsEveryCall(sessionService);
|
|
983
|
+
if (durable)
|
|
984
|
+
await recordUser();
|
|
985
|
+
const stored = durable ? ((await sessionService.get(key))?.events ?? before) : before;
|
|
986
|
+
const found = openRun(stored, scriptName);
|
|
987
|
+
// A run left mid-way resumes only under the durable store, whose retry is the same message; elsewhere the message is new.
|
|
988
|
+
const open = found && (found.state === 'paused' || durable) ? found : undefined;
|
|
989
|
+
const declared = new Map();
|
|
990
|
+
declared.set(config.orchestrator.name, { description: '', ...config.orchestrator });
|
|
991
|
+
for (const sub of config.subagents ?? [])
|
|
992
|
+
declared.set(sub.name, sub);
|
|
993
|
+
const models = new Set(script.models ?? []);
|
|
994
|
+
const allowedTools = new Set(script.tools ?? []);
|
|
995
|
+
const gatedTools = new Set(script.require_approval ?? []);
|
|
996
|
+
// Every agent × model pairing a call may ask for, checked before anything runs (item 7).
|
|
997
|
+
for (const model of models) {
|
|
998
|
+
try {
|
|
999
|
+
(compileOpts.resolveModel ?? ((m) => m))(model);
|
|
1000
|
+
for (const yaml of declared.values()) {
|
|
1001
|
+
if (yaml.yaml_reference || yaml.a2a_agent_url)
|
|
1002
|
+
continue;
|
|
1003
|
+
withReasoning(toEngineAgent(yaml), model);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
catch (err) {
|
|
1007
|
+
result.status = 'failed';
|
|
1008
|
+
result.failedStage = 'script';
|
|
1009
|
+
result.error = { code: 'SCRIPT_MODEL_INVALID', message: `script.models: ${model}: ${err instanceof Error ? err.message : String(err)}` };
|
|
1010
|
+
return finish();
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
// What the open run waits on decides what this message is (lib/script/journal.ts openRun).
|
|
1014
|
+
let resume;
|
|
1015
|
+
if (open?.state === 'paused' && open.pause?.kind === 'approval') {
|
|
1016
|
+
const waiting = pendingApproval(before);
|
|
1017
|
+
// A message that is not the decision repeats the request; nothing runs, as a workflow's gated node answers it.
|
|
1018
|
+
if (!decision)
|
|
1019
|
+
return waiting ? pause(waiting) : undefined;
|
|
1020
|
+
resume = { run: open.pin.run, entries: open.entries, decision: { approved: decision.approved } };
|
|
1021
|
+
}
|
|
1022
|
+
else if (open?.state === 'paused') {
|
|
1023
|
+
resume = { run: open.pin.run, entries: open.entries, answer: messageText };
|
|
1024
|
+
}
|
|
1025
|
+
else if (open) {
|
|
1026
|
+
resume = { run: open.pin.run, entries: open.entries };
|
|
1027
|
+
}
|
|
1028
|
+
const runMessage = open ? open.pin.message : messageText;
|
|
1029
|
+
const transcript = open ? open.pin.transcript : renderTranscriptDigest(before);
|
|
1030
|
+
const compiled = new Map();
|
|
1031
|
+
const aggregate = emptyRun();
|
|
1032
|
+
let conversationAnswer;
|
|
1033
|
+
const textOf = (value) => (value === null || value === undefined ? runMessage : typeof value === 'string' ? value : JSON.stringify(value));
|
|
1034
|
+
const unfenced = (raw) => {
|
|
1035
|
+
const fence = raw.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
1036
|
+
return fence ? fence[1].trim() : raw.trim();
|
|
1037
|
+
};
|
|
1038
|
+
const merge = (run) => {
|
|
1039
|
+
aggregate.toolResultTexts.push(...run.toolResultTexts);
|
|
1040
|
+
for (const name of run.invokedToolNames)
|
|
1041
|
+
aggregate.invokedToolNames.add(name);
|
|
1042
|
+
for (const name of run.agents)
|
|
1043
|
+
aggregate.agents.add(name);
|
|
1044
|
+
aggregate.toolCalls.push(...run.toolCalls);
|
|
1045
|
+
aggregate.toolResponses += run.toolResponses;
|
|
1046
|
+
aggregate.delegations.push(...run.delegations);
|
|
1047
|
+
aggregate.codeRuns.push(...run.codeRuns);
|
|
1048
|
+
aggregate.eventCount += run.eventCount;
|
|
1049
|
+
aggregate.tokens.input = Math.max(aggregate.tokens.input, run.tokens.input);
|
|
1050
|
+
aggregate.tokens.output += run.tokens.output;
|
|
1051
|
+
aggregate.tokens.thinking += run.tokens.thinking;
|
|
1052
|
+
if (run.lastToolResultText)
|
|
1053
|
+
aggregate.lastToolResultText = run.lastToolResultText;
|
|
1054
|
+
if (run.grounding) {
|
|
1055
|
+
aggregate.grounding ??= { queries: [], sources: [] };
|
|
1056
|
+
aggregate.grounding.queries.push(...run.grounding.queries);
|
|
1057
|
+
aggregate.grounding.sources.push(...run.grounding.sources);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
const stopped = () => {
|
|
1061
|
+
if (control.stopReason)
|
|
1062
|
+
throw new ScriptFatal(stopCode(control.stopReason), stopMessage(control.stopReason, control));
|
|
1063
|
+
};
|
|
1064
|
+
const OPTIONS = new Set(['model', 'reasoning', 'temperature', 'sampling', 'conversation']);
|
|
1065
|
+
/** A called agent's failed run, thrown into the script; uncaught, the turn fails with the run's own code. */
|
|
1066
|
+
const runFailed = (name, error) => {
|
|
1067
|
+
const err = new Error(`${name} failed [${error.code}]: ${error.message}`);
|
|
1068
|
+
err.name = 'AgentRunError';
|
|
1069
|
+
return err;
|
|
1070
|
+
};
|
|
1071
|
+
const pausedInside = (name) => new ScriptFatal('PAUSE_INSIDE_AGENT', `${name} paused for a person inside an agent() call, which a script cannot resume in this version (ADR 0137 item 12); pause in the script with askUser.`);
|
|
1072
|
+
/** A throwaway lane for one call: it sees only its input, and its journal entry is its record. */
|
|
1073
|
+
const lane = async (site) => {
|
|
1074
|
+
const sid = `${sessionId}::script:${site.run}.${site.seq}`;
|
|
1075
|
+
const sessions = new InProcessSessionService();
|
|
1076
|
+
await sessions.create({ appName, userId, sessionId: sid });
|
|
1077
|
+
return { sid, sessions };
|
|
1078
|
+
};
|
|
1079
|
+
const outcome = await runScript({ source: open ? open.pin.source : script.source, message: runMessage, transcript, variables: script.variables ?? {}, ...(resume ? { resume } : {}) }, {
|
|
1080
|
+
script: scriptName,
|
|
1081
|
+
invocationId,
|
|
1082
|
+
sessionId,
|
|
1083
|
+
signal: control.signal,
|
|
1084
|
+
journalEveryCall: durable,
|
|
1085
|
+
gated: (name) => gatedTools.has(name),
|
|
1086
|
+
log: (text) => {
|
|
1087
|
+
ev.log?.(`⇢ ${scriptName}: ${text.length.toLocaleString()} chars of progress`);
|
|
1088
|
+
ev.onProgress?.(text);
|
|
1089
|
+
},
|
|
1090
|
+
append: appendHere,
|
|
1091
|
+
beforePause: recordUser,
|
|
1092
|
+
agent: async (name, input, o, site) => {
|
|
1093
|
+
stopped();
|
|
1094
|
+
const yaml = declared.get(name);
|
|
1095
|
+
if (!yaml)
|
|
1096
|
+
throw new Error(`agent: '${name}' is not an agent of this syndicate (declared: ${[...declared.keys()].join(', ')})`);
|
|
1097
|
+
for (const k of Object.keys(o))
|
|
1098
|
+
if (!OPTIONS.has(k))
|
|
1099
|
+
throw new Error(`agent(${name}): unknown option '${k}' (model, reasoning, temperature or sampling.temperature, conversation)`);
|
|
1100
|
+
const sampling = o.sampling;
|
|
1101
|
+
if (sampling !== undefined && (typeof sampling !== 'object' || sampling === null || Object.keys(sampling).some((k) => k !== 'temperature'))) {
|
|
1102
|
+
throw new Error(`agent(${name}): sampling takes temperature only`);
|
|
1103
|
+
}
|
|
1104
|
+
const model = o.model;
|
|
1105
|
+
if (model !== undefined && (typeof model !== 'string' || (!models.has(model) && model !== yaml.model))) {
|
|
1106
|
+
throw new Error(`agent(${name}): the model '${String(model)}' is not in script.models (${[...models].join(', ') || 'none listed'})`);
|
|
1107
|
+
}
|
|
1108
|
+
const temperature = o.temperature ?? sampling?.temperature;
|
|
1109
|
+
if (temperature !== undefined && typeof temperature !== 'number')
|
|
1110
|
+
throw new Error(`agent(${name}): temperature must be a number`);
|
|
1111
|
+
const reasoning = o.reasoning;
|
|
1112
|
+
const conversation = o.conversation === true;
|
|
1113
|
+
const nested = !!yaml.yaml_reference || !!yaml.a2a_agent_url;
|
|
1114
|
+
if (nested && (model !== undefined || temperature !== undefined || reasoning !== undefined)) {
|
|
1115
|
+
throw new Error(`agent(${name}): a nested or remote agent sets its own model, reasoning and temperature`);
|
|
1116
|
+
}
|
|
1117
|
+
if (yaml.a2a_agent_url) {
|
|
1118
|
+
const remote = await runRemoteRoute(name, yaml.a2a_agent_url, textOf(input), sessionId, control, ev);
|
|
1119
|
+
stopped();
|
|
1120
|
+
merge(remote);
|
|
1121
|
+
if (remote.error)
|
|
1122
|
+
throw runFailed(name, remote.error);
|
|
1123
|
+
return remote.text;
|
|
1124
|
+
}
|
|
1125
|
+
const cacheKey = JSON.stringify([name, model ?? null, reasoning ?? null, temperature ?? null]);
|
|
1126
|
+
let entry = compiled.get(cacheKey);
|
|
1127
|
+
if (!entry) {
|
|
1128
|
+
entry = (async () => {
|
|
1129
|
+
let spec;
|
|
1130
|
+
if (yaml.yaml_reference) {
|
|
1131
|
+
const built = await compileEntrySpec(yaml, compileOpts);
|
|
1132
|
+
if (built.kind === 'workflow')
|
|
1133
|
+
return { agent: nativeWorkflowAgent(built.workflow, compileOpts), outputSchema: false };
|
|
1134
|
+
if (built.kind === 'dispatch')
|
|
1135
|
+
return { own: built.dispatch };
|
|
1136
|
+
spec = built.spec;
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
spec = await compileSubagentSpec({ ...yaml, ...(typeof model === 'string' ? { model } : {}) }, compileOpts);
|
|
1140
|
+
}
|
|
1141
|
+
if (reasoning !== undefined)
|
|
1142
|
+
spec.generateContentConfig = { ...spec.generateContentConfig, ...withReasoning({ name, reasoning, generateContentConfig: {} }, spec.modelId) };
|
|
1143
|
+
if (typeof temperature === 'number')
|
|
1144
|
+
spec.generateContentConfig = { ...spec.generateContentConfig, temperature };
|
|
1145
|
+
return { agent: nativeOf(spec), outputSchema: !!spec.outputSchema };
|
|
1146
|
+
})();
|
|
1147
|
+
compiled.set(cacheKey, entry);
|
|
1148
|
+
}
|
|
1149
|
+
const built = await entry;
|
|
1150
|
+
const branch = { agent: name, path: [scriptName, name], callId: `${site.run}.${site.seq}` };
|
|
1151
|
+
const traceAttributes = { 'script.name': scriptName, 'script.run': site.run, 'script.call.seq': site.seq };
|
|
1152
|
+
// A nested dispatch or script syndicate: its own turn, on a throwaway conversation (ADR 0120, item 15).
|
|
1153
|
+
if ('own' in built) {
|
|
1154
|
+
if (conversation)
|
|
1155
|
+
throw new Error(`agent(${name}): conversation: true applies to an agent; ${yaml.yaml_reference} runs as a syndicate of its own`);
|
|
1156
|
+
if (!control.nestedDispatch)
|
|
1157
|
+
throw new Error(`agent(${name}): nested syndicates need the turn's controls`);
|
|
1158
|
+
const { sid, sessions } = await lane(site);
|
|
1159
|
+
const end = await control.nestedDispatch({ config: built.own.config, compile: built.own.compile, name, sessions, appName, userId, sessionId: sid, parts: [{ text: textOf(input) }] });
|
|
1160
|
+
stopped();
|
|
1161
|
+
aggregate.agents.add(name);
|
|
1162
|
+
if (end.status === 'input-required')
|
|
1163
|
+
throw pausedInside(name);
|
|
1164
|
+
if (end.status === 'failed' || end.status === 'canceled')
|
|
1165
|
+
throw runFailed(name, end.error ?? { code: 'ROUTE_FAILED', message: `${name} failed.` });
|
|
1166
|
+
return end.text;
|
|
1167
|
+
}
|
|
1168
|
+
const { agent, outputSchema } = built;
|
|
1169
|
+
const workflow = agent.runtime === 'native-workflow';
|
|
1170
|
+
if (workflow && conversation)
|
|
1171
|
+
throw new Error(`agent(${name}): conversation: true applies to an agent; ${yaml.yaml_reference} runs as a workflow of its own`);
|
|
1172
|
+
const userParts = conversation && input === null && !open ? parts : [{ text: textOf(input) }];
|
|
1173
|
+
let sid = sessionId;
|
|
1174
|
+
let sessions;
|
|
1175
|
+
if (conversation) {
|
|
1176
|
+
// As a dispatch route answers: in the conversation, read through the projection (lib/session/transcript.ts).
|
|
1177
|
+
sessions = new ProjectedSessionService(sessionService, name, {});
|
|
1178
|
+
userRecorded = true;
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
({ sid, sessions } = await lane(site));
|
|
1182
|
+
}
|
|
1183
|
+
let drained;
|
|
1184
|
+
try {
|
|
1185
|
+
drained = await runAgent({ agent, sid, userParts, sessions, stage: 'script', publishToolStatus: true, quiet: !conversation, branch, traceAttributes, ...(workflow ? { errorPolicy: 'collect' } : {}) });
|
|
1186
|
+
}
|
|
1187
|
+
catch (err) {
|
|
1188
|
+
if (control.stopReason)
|
|
1189
|
+
throw new ScriptFatal(stopCode(control.stopReason), stopMessage(control.stopReason, control));
|
|
1190
|
+
// A workflow node that gave up ends its walk by throwing, as at the top.
|
|
1191
|
+
throw runFailed(name, { code: err instanceof NodeRunLimitError ? NODE_RUN_LIMIT : 'NODE_FAILED', message: err.message });
|
|
1192
|
+
}
|
|
1193
|
+
stopped();
|
|
1194
|
+
merge(drained);
|
|
1195
|
+
if (drained.error)
|
|
1196
|
+
throw runFailed(name, drained.error);
|
|
1197
|
+
// A pause inside the call (a gate, a question, a consent request) cannot be replayed in this version (item 12).
|
|
1198
|
+
const after = (await sessions.get({ appName, userId, sessionId: sid }))?.events ?? [];
|
|
1199
|
+
if (drained.inputRequests.length || (!conversation && (pendingApproval(after) || pendingConsent(after))))
|
|
1200
|
+
throw pausedInside(name);
|
|
1201
|
+
if (conversation)
|
|
1202
|
+
conversationAnswer = drained.text;
|
|
1203
|
+
if (!outputSchema)
|
|
1204
|
+
return drained.text;
|
|
1205
|
+
try {
|
|
1206
|
+
return JSON.parse(unfenced(drained.text));
|
|
1207
|
+
}
|
|
1208
|
+
catch {
|
|
1209
|
+
throw new Error(`${name} answered ${drained.text.trim().length} characters that are not the JSON its output schema asks for`);
|
|
1210
|
+
}
|
|
1211
|
+
},
|
|
1212
|
+
tool: async (name, args) => {
|
|
1213
|
+
stopped();
|
|
1214
|
+
if (!allowedTools.has(name))
|
|
1215
|
+
throw new Error(`tool: '${name}' is not in script.tools (${[...allowedTools].join(', ') || 'none listed'})`);
|
|
1216
|
+
const tool = toolOf(resolveTools([name], (n) => ev.warn?.(`Unknown tool '${n}' in script.tools`))[0]);
|
|
1217
|
+
if (!tool)
|
|
1218
|
+
throw new Error(`tool: '${name}' is not a registry tool a script can run (a provider-side or instruction tool runs inside a model call)`);
|
|
1219
|
+
if (tool.longRunning)
|
|
1220
|
+
throw new Error(`tool: '${name}' waits for a person; a script asks with askUser`);
|
|
1221
|
+
ev.onProgress?.(`Invoking tool: ${name}`);
|
|
1222
|
+
const ctx = createToolContext({ invocationId, agentName: scriptName, appName, userId, sessionId, signal: control.signal, ...(credentialStore ? { credentials: credentialStore } : {}) });
|
|
1223
|
+
const out = await tool.execute((args && typeof args === 'object' ? args : {}), ctx);
|
|
1224
|
+
stopped();
|
|
1225
|
+
if (typeof out === 'string' && out.trim())
|
|
1226
|
+
aggregate.toolResultTexts.push(out.trim());
|
|
1227
|
+
aggregate.invokedToolNames.add(name);
|
|
1228
|
+
return capResult(out);
|
|
1229
|
+
},
|
|
1230
|
+
});
|
|
1231
|
+
result.answer = aggregate;
|
|
1232
|
+
if (outcome.logs)
|
|
1233
|
+
ev.log?.(`⇢ ${scriptName}: console output, ${outcome.logs.length.toLocaleString()} chars`);
|
|
1234
|
+
if (control.stopReason)
|
|
1235
|
+
return finish();
|
|
1236
|
+
if (outcome.status === 'failed') {
|
|
1237
|
+
result.status = 'failed';
|
|
1238
|
+
result.failedStage = 'script';
|
|
1239
|
+
// An agent's failed run the script did not catch fails the turn as that run did, as a dispatch route's failure does.
|
|
1240
|
+
const uncaught = outcome.error.code === 'SCRIPT_ERROR' ? /^AgentRunError: (\S+) failed \[([A-Za-z0-9_]+)\]: ([\s\S]*)$/.exec(outcome.error.message) : null;
|
|
1241
|
+
result.error = uncaught ? { code: uncaught[2], message: uncaught[3] } : outcome.error;
|
|
1242
|
+
ev.warn?.(`Script ${scriptName} failed [${outcome.error.code}]: ${outcome.error.message}`);
|
|
1243
|
+
return finish();
|
|
1244
|
+
}
|
|
1245
|
+
if (outcome.status === 'paused')
|
|
1246
|
+
return question(outcome.input);
|
|
1247
|
+
if (outcome.status === 'approval')
|
|
1248
|
+
return pause(outcome.approval);
|
|
1249
|
+
const value = outcome.value;
|
|
1250
|
+
result.text = typeof value === 'string' ? value : value === null || value === undefined ? '' : JSON.stringify(value, null, 2);
|
|
1251
|
+
if (!result.text) {
|
|
1252
|
+
ev.warn?.(`Script ${scriptName} returned no answer.`);
|
|
1253
|
+
result.text = `${scriptName} returned no answer.`;
|
|
1254
|
+
}
|
|
1255
|
+
aggregate.text = result.text;
|
|
1256
|
+
// The conversation keeps the exchange, as for any route: the message, and the answer unless a conversation call already stored it.
|
|
1257
|
+
await recordUser();
|
|
1258
|
+
if (result.text !== conversationAnswer) {
|
|
1259
|
+
await appendHere(createTurnEvent({ invocationId, author: scriptName, content: { role: 'model', parts: [{ text: result.text }] } }));
|
|
1260
|
+
}
|
|
1261
|
+
return undefined;
|
|
1262
|
+
};
|
|
893
1263
|
// While a route's walk waits on a gated call (ADR 0119), a message that is not its decision repeats the request: the
|
|
894
1264
|
// walk cannot move past the waiting node, so nothing is stored and nothing runs, as a workflow turn answers it.
|
|
895
1265
|
if (!decision && routePaused?.approval)
|
|
896
1266
|
return pause(routePaused.approval);
|
|
897
1267
|
let answer;
|
|
898
|
-
if (
|
|
1268
|
+
if (isScriptSyndicate(config)) {
|
|
1269
|
+
// ══ SCRIPT ═══════════════════════════════════════════════════════════
|
|
1270
|
+
// The orchestration is code (lib/script/, ADR 0137): one module in the
|
|
1271
|
+
// sandbox calls the agents, the allowed tools and the person as
|
|
1272
|
+
// functions, every call journaled here; a paused run resumes by
|
|
1273
|
+
// replaying the journal on the source it pinned.
|
|
1274
|
+
const ran = await runScriptTurn();
|
|
1275
|
+
if (ran)
|
|
1276
|
+
return ran;
|
|
1277
|
+
answer = result.answer;
|
|
1278
|
+
}
|
|
1279
|
+
else if (isDispatchSyndicate(config)) {
|
|
899
1280
|
// ══ PLAN ═════════════════════════════════════════════════════════════
|
|
900
1281
|
// Deterministic overrides first: when the message itself decides the
|
|
901
1282
|
// route there is nothing to classify, and the classifier call is skipped.
|
|
@@ -1239,6 +1620,7 @@ function emptyRun(error) {
|
|
|
1239
1620
|
return {
|
|
1240
1621
|
inputRequests: [],
|
|
1241
1622
|
nodeErrors: [],
|
|
1623
|
+
codeRuns: [],
|
|
1242
1624
|
text: '',
|
|
1243
1625
|
lastToolResultText: '',
|
|
1244
1626
|
toolResultTexts: [],
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* to their provider SDK and a cancel or a deadline stops the HTTP call in
|
|
23
23
|
* flight, not just the loop around it.
|
|
24
24
|
*/
|
|
25
|
+
import type { TurnEvent } from './events.ts';
|
|
25
26
|
/** Why a turn was stopped before it finished on its own. */
|
|
26
27
|
export type TurnStopReason = 'canceled' | 'deadline' | 'step_limit';
|
|
27
28
|
export interface TurnControl {
|
|
@@ -53,6 +54,32 @@ export interface TurnControl {
|
|
|
53
54
|
* which sit below the turn runner and cannot import it.
|
|
54
55
|
*/
|
|
55
56
|
nestedDispatch?: (run: NestedDispatchRun) => Promise<NestedDispatchEnd>;
|
|
57
|
+
/**
|
|
58
|
+
* Receives every event a delegated run yields, as it yields it, with
|
|
59
|
+
* where that run sits (ADR 0135): set by the turn runner when the caller
|
|
60
|
+
* passed `events.onAgentEvent`, read where a delegated call runs
|
|
61
|
+
* (lib/runtime/native/delegate.ts), which sits below the turn runner.
|
|
62
|
+
* Its presence also makes each delegated run stream, so its thinking and
|
|
63
|
+
* text arrive as partial events.
|
|
64
|
+
*/
|
|
65
|
+
agentEvents?: (event: TurnEvent, branch: AgentBranch) => void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Where a delegated run sits in the turn (ADR 0135): the agent, the
|
|
69
|
+
* delegation path from the agent that took the call down to it, and the id
|
|
70
|
+
* of the call that started it.
|
|
71
|
+
*/
|
|
72
|
+
export interface AgentBranch {
|
|
73
|
+
/** The delegated agent: a subagent, or a nested workflow's name for an event its walk yielded. */
|
|
74
|
+
agent: string;
|
|
75
|
+
/**
|
|
76
|
+
* The agents from the caller at the top of the delegation down to this
|
|
77
|
+
* one, itself last: `['Boss', 'Researcher']`, `['Boss', 'Researcher',
|
|
78
|
+
* 'Fetcher']`. Below a nested workflow it starts again at the node's agent.
|
|
79
|
+
*/
|
|
80
|
+
path: readonly string[];
|
|
81
|
+
/** The id of the function call, in the caller's event, that started this run: what pairs it with the caller's tool call. */
|
|
82
|
+
callId?: string;
|
|
56
83
|
}
|
|
57
84
|
/** One run of a nested dispatch syndicate (ADR 0120): what it is, where its conversation is filed, and the message. */
|
|
58
85
|
export interface NestedDispatchRun {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/sandbox/sandbox.ts — run JavaScript the engine did not write, where
|
|
3
|
+
* it can reach nothing but what it is handed (ADR 0137 Part A).
|
|
4
|
+
*
|
|
5
|
+
* WHY this exists:
|
|
6
|
+
* Two features need code to run on this host: the `run_code` tool, whose
|
|
7
|
+
* code a model writes (lib/tools/runCodeTool.ts), and a syndicate's
|
|
8
|
+
* `script:`, whose code an operator deploys (lib/script/). ADR 0033 refused
|
|
9
|
+
* local execution because ADK's executors ran code on the host or needed a
|
|
10
|
+
* Docker daemon. This is neither: QuickJS compiled to wasm, with no host
|
|
11
|
+
* reach of any kind, on a worker thread (lib/sandbox/worker.ts) so a run's
|
|
12
|
+
* compute never stalls the turns, streams and MCP calls of the process.
|
|
13
|
+
*
|
|
14
|
+
* WHAT A RUN GETS: a fresh runtime in a fresh Worker, a memory limit
|
|
15
|
+
* (default 64 MB), a stack limit, and a CPU budget (default 5 s) measured
|
|
16
|
+
* only while the code computes. Values cross as JSON both ways. The code is
|
|
17
|
+
* a module whose default export is called with the input (`mode: 'input'`)
|
|
18
|
+
* or with an API object (`mode: 'api'`): the caller's values, its host
|
|
19
|
+
* functions as async functions, and `parallel`.
|
|
20
|
+
*
|
|
21
|
+
* THE STOPS: the interrupt handler inside the worker stops the code at its
|
|
22
|
+
* CPU budget; a watchdog here terminates the worker when it computed past
|
|
23
|
+
* the budget without answering; the caller's signal terminates it; and a
|
|
24
|
+
* host function may throw SandboxHalt to end the run on purpose (a script
|
|
25
|
+
* pausing for a person). Runs are capped by a small pool, so the memory
|
|
26
|
+
* they hold is bounded: MELCHIZEDEK_CODE_SANDBOX_WORKERS, default 16.
|
|
27
|
+
*
|
|
28
|
+
* THE POSTURE: nothing runs unless the operator set
|
|
29
|
+
* MELCHIZEDEK_CODE_SANDBOX=on (codeSandboxEnabled). The sandbox grants no
|
|
30
|
+
* power the code's caller lacks, so the switch is a posture, not a
|
|
31
|
+
* boundary: a deployment that never meant to run code does not start
|
|
32
|
+
* because a YAML changed (ADR 0137 item 4, as ADR 0132 gates stdio MCP).
|
|
33
|
+
*/
|
|
34
|
+
/** The operator's switch for running code (ADR 0137 item 4). */
|
|
35
|
+
export declare const CODE_SANDBOX_ENV = "MELCHIZEDEK_CODE_SANDBOX";
|
|
36
|
+
/** True when the operator turned the sandbox on: MELCHIZEDEK_CODE_SANDBOX=on (or true, 1). */
|
|
37
|
+
export declare function codeSandboxEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
38
|
+
/** The message a refusal carries when the switch is off. */
|
|
39
|
+
export declare function codeSandboxOffMessage(what: string): string;
|
|
40
|
+
/** A syndicate that needs the sandbox, refused while it is off: it names the variable that turns it on. */
|
|
41
|
+
export declare class CodeSandboxOffError extends Error {
|
|
42
|
+
readonly code = "CODE_SANDBOX_OFF";
|
|
43
|
+
constructor(what: string);
|
|
44
|
+
}
|
|
45
|
+
/** What in a syndicate runs code: its `script:`, and each agent that lists `run_code`. */
|
|
46
|
+
export declare function codeUsersOf(config: {
|
|
47
|
+
syndicate_name?: string;
|
|
48
|
+
script?: unknown;
|
|
49
|
+
orchestrator?: {
|
|
50
|
+
name?: string;
|
|
51
|
+
tools?: string[];
|
|
52
|
+
};
|
|
53
|
+
subagents?: Array<{
|
|
54
|
+
name?: string;
|
|
55
|
+
tools?: string[];
|
|
56
|
+
}>;
|
|
57
|
+
}): string[];
|
|
58
|
+
/** Throws CodeSandboxOffError when `config` runs code and the operator has not turned the sandbox on. */
|
|
59
|
+
export declare function refuseWithoutSandbox(config: Parameters<typeof codeUsersOf>[0], env?: NodeJS.ProcessEnv): void;
|
|
60
|
+
export interface SandboxLimits {
|
|
61
|
+
/** Heap the runtime may allocate. Default 64 MB. */
|
|
62
|
+
memoryBytes: number;
|
|
63
|
+
/** Stack the runtime may use. Default 1 MB. */
|
|
64
|
+
stackBytes: number;
|
|
65
|
+
/** Compute the code may spend, waits on the host not counted. Default 5,000 ms. */
|
|
66
|
+
cpuMs: number;
|
|
67
|
+
/** console output kept. Default 20,000 characters. */
|
|
68
|
+
maxLogChars: number;
|
|
69
|
+
/** The result's JSON text a run may return. Default 1,000,000 characters. */
|
|
70
|
+
maxResultChars: number;
|
|
71
|
+
/** The most calls `parallel` runs at once. Default 4 (32 at most). */
|
|
72
|
+
maxParallel: number;
|
|
73
|
+
}
|
|
74
|
+
export declare const DEFAULT_SANDBOX_LIMITS: Readonly<SandboxLimits>;
|
|
75
|
+
/** What can end a run other than its own return. */
|
|
76
|
+
export type SandboxErrorCode = 'CODE_ERROR' | 'CPU_LIMIT' | 'MEMORY_LIMIT' | 'STACK_LIMIT' | 'RESULT_TOO_LARGE' | 'CANCELED' | 'HALTED' | 'SANDBOX_CRASHED';
|
|
77
|
+
/** A host function in `mode: 'api'`: its arguments as the code passed them, already parsed from JSON. */
|
|
78
|
+
export type HostFunction = (args: unknown[]) => Promise<unknown> | unknown;
|
|
79
|
+
export interface SandboxRunOptions {
|
|
80
|
+
/** A JavaScript module whose default export the run calls. */
|
|
81
|
+
source: string;
|
|
82
|
+
/** `input`: the export gets `value`. `api`: an object of `value`'s keys, the host functions and `parallel`. Default `input`. */
|
|
83
|
+
mode?: 'input' | 'api';
|
|
84
|
+
/** JSON-serializable. */
|
|
85
|
+
value?: unknown;
|
|
86
|
+
/** The host functions the code may call (`mode: 'api'`). */
|
|
87
|
+
functions?: Record<string, HostFunction>;
|
|
88
|
+
limits?: Partial<SandboxLimits>;
|
|
89
|
+
/** Terminates the run when aborted. */
|
|
90
|
+
signal?: AbortSignal;
|
|
91
|
+
}
|
|
92
|
+
export interface SandboxResult {
|
|
93
|
+
ok: boolean;
|
|
94
|
+
/** The export's return value, parsed from JSON (`undefined` came back as null). */
|
|
95
|
+
result?: unknown;
|
|
96
|
+
error?: {
|
|
97
|
+
code: SandboxErrorCode;
|
|
98
|
+
message: string;
|
|
99
|
+
};
|
|
100
|
+
/** What a host function threw as SandboxHalt, when the run was halted. */
|
|
101
|
+
halt?: SandboxHalt;
|
|
102
|
+
/** console output, bounded by maxLogChars. */
|
|
103
|
+
logs: string;
|
|
104
|
+
logsTruncated: boolean;
|
|
105
|
+
/** Compute the run spent inside the interpreter. */
|
|
106
|
+
cpuMs: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Thrown by a host function to end the run on purpose: the worker is
|
|
110
|
+
* terminated, nothing more of the code runs, and the result carries this
|
|
111
|
+
* error as `halt`. A script pausing for a person halts (lib/script/).
|
|
112
|
+
*/
|
|
113
|
+
export declare class SandboxHalt extends Error {
|
|
114
|
+
readonly detail: unknown;
|
|
115
|
+
constructor(message: string, detail?: unknown);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Run `source` in a fresh sandbox. Never throws for anything the code does:
|
|
119
|
+
* a thrown error, a limit, a cancel or a halt come back in the result.
|
|
120
|
+
*/
|
|
121
|
+
export declare function runInSandbox(opts: SandboxRunOptions): Promise<SandboxResult>;
|