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.
Files changed (44) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/DOCUMENTATION.md +28 -2
  3. package/README.md +1 -1
  4. package/config/agents/syndicate.schema.json +63 -0
  5. package/config/agents/syndicateSchema.yaml +50 -0
  6. package/db/hardening.sql +5 -2
  7. package/db/migrations/0015_model_pricing.sql +107 -0
  8. package/db/telemetry.sql +79 -1
  9. package/dist/lib/a2a/executor.js +21 -0
  10. package/dist/lib/compile.d.ts +4 -3
  11. package/dist/lib/compile.js +8 -3
  12. package/dist/lib/index.d.ts +9 -1
  13. package/dist/lib/index.js +5 -0
  14. package/dist/lib/loadSyndicate.d.ts +13 -0
  15. package/dist/lib/loadSyndicate.js +11 -1
  16. package/dist/lib/observability/supabaseSpanExporter.js +2 -1
  17. package/dist/lib/observability/tracer.d.ts +11 -2
  18. package/dist/lib/observability/tracer.js +17 -4
  19. package/dist/lib/runtime/native/agentLoop.d.ts +6 -0
  20. package/dist/lib/runtime/native/checkpoint.d.ts +2 -1
  21. package/dist/lib/runtime/native/checkpoint.js +6 -2
  22. package/dist/lib/runtime/native/delegate.js +30 -1
  23. package/dist/lib/runtime/native/interrupts.js +9 -2
  24. package/dist/lib/runtime/nativeTurn.d.ts +2 -1
  25. package/dist/lib/runtime/nativeTurn.js +5 -1
  26. package/dist/lib/runtime/syndicateTurn.d.ts +25 -2
  27. package/dist/lib/runtime/syndicateTurn.js +387 -5
  28. package/dist/lib/runtime/turnControl.d.ts +27 -0
  29. package/dist/lib/sandbox/sandbox.d.ts +121 -0
  30. package/dist/lib/sandbox/sandbox.js +276 -0
  31. package/dist/lib/sandbox/worker.d.ts +29 -0
  32. package/dist/lib/sandbox/worker.js +280 -0
  33. package/dist/lib/script/config.d.ts +35 -0
  34. package/dist/lib/script/config.js +17 -0
  35. package/dist/lib/script/journal.d.ts +137 -0
  36. package/dist/lib/script/journal.js +193 -0
  37. package/dist/lib/script/run.d.ts +138 -0
  38. package/dist/lib/script/run.js +364 -0
  39. package/dist/lib/syndicateSchema.d.ts +20 -0
  40. package/dist/lib/syndicateSchema.js +98 -6
  41. package/dist/lib/toolRegistry.js +4 -0
  42. package/dist/lib/tools/runCodeTool.d.ts +50 -0
  43. package/dist/lib/tools/runCodeTool.js +59 -0
  44. 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: params.stage === 'classify' ? { ...ev, onEvent: undefined } : ev,
773
+ events: quiet ? { ...ev, onEvent: undefined } : ev,
729
774
  publishToolStatus: params.publishToolStatus,
730
775
  subagentNames,
731
- streamText: params.stage !== 'classify' && !guarded,
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 (isDispatchSyndicate(config)) {
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>;