melchizedek-agents 1.6.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 (37) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/DOCUMENTATION.md +5 -1
  3. package/config/agents/syndicate.schema.json +63 -0
  4. package/config/agents/syndicateSchema.yaml +50 -0
  5. package/dist/lib/a2a/executor.js +21 -0
  6. package/dist/lib/compile.d.ts +4 -3
  7. package/dist/lib/compile.js +8 -3
  8. package/dist/lib/index.d.ts +8 -0
  9. package/dist/lib/index.js +5 -0
  10. package/dist/lib/loadSyndicate.d.ts +13 -0
  11. package/dist/lib/loadSyndicate.js +11 -1
  12. package/dist/lib/observability/supabaseSpanExporter.js +2 -1
  13. package/dist/lib/observability/tracer.d.ts +2 -2
  14. package/dist/lib/observability/tracer.js +2 -2
  15. package/dist/lib/runtime/native/checkpoint.d.ts +2 -1
  16. package/dist/lib/runtime/native/checkpoint.js +6 -2
  17. package/dist/lib/runtime/native/interrupts.js +9 -2
  18. package/dist/lib/runtime/nativeTurn.d.ts +2 -1
  19. package/dist/lib/runtime/nativeTurn.js +5 -1
  20. package/dist/lib/runtime/syndicateTurn.d.ts +11 -1
  21. package/dist/lib/runtime/syndicateTurn.js +383 -5
  22. package/dist/lib/sandbox/sandbox.d.ts +121 -0
  23. package/dist/lib/sandbox/sandbox.js +276 -0
  24. package/dist/lib/sandbox/worker.d.ts +29 -0
  25. package/dist/lib/sandbox/worker.js +280 -0
  26. package/dist/lib/script/config.d.ts +35 -0
  27. package/dist/lib/script/config.js +17 -0
  28. package/dist/lib/script/journal.d.ts +137 -0
  29. package/dist/lib/script/journal.js +193 -0
  30. package/dist/lib/script/run.d.ts +138 -0
  31. package/dist/lib/script/run.js +364 -0
  32. package/dist/lib/syndicateSchema.d.ts +20 -0
  33. package/dist/lib/syndicateSchema.js +98 -6
  34. package/dist/lib/toolRegistry.js +4 -0
  35. package/dist/lib/tools/runCodeTool.d.ts +50 -0
  36. package/dist/lib/tools/runCodeTool.js +59 -0
  37. package/package.json +5 -1
@@ -64,6 +64,8 @@ export { ASK_USER, pendingQuestion, questionAnswerPart } from './questions.ts';
64
64
  import type { PendingApproval } from './approvals.ts';
65
65
  export { approvalResponsePart, describeApproval, pendingApproval } from './approvals.ts';
66
66
  export type { PendingApproval } from './approvals.ts';
67
+ import type { CodeRun } from '../tools/runCodeTool.ts';
68
+ export type { CodeRun } from '../tools/runCodeTool.ts';
67
69
  import type { AgentBranch, TurnStopReason } from './turnControl.ts';
68
70
  export type { AgentBranch } from './turnControl.ts';
69
71
  /** One part of the user's message. Text is the common case; the genai part
@@ -222,8 +224,10 @@ export interface DrainedRun {
222
224
  code: string;
223
225
  message: string;
224
226
  }>;
227
+ /** Every run_code call that got its result, with the agent that made it (ADR 0137). */
228
+ codeRuns: CodeRun[];
225
229
  }
226
- export type TurnStage = 'classify' | 'dispatch' | 'delegate' | 'workflow';
230
+ export type TurnStage = 'classify' | 'dispatch' | 'delegate' | 'workflow' | 'script';
227
231
  export interface RouteDecision extends RouteResolution {
228
232
  /** `approval`: the turn resumed the route that asked for an approval (ADR 0028).
229
233
  * `answer`: the message answered the route's open `ask_user` question.
@@ -272,6 +276,12 @@ export interface SyndicateTurnResult {
272
276
  usage: TurnUsage;
273
277
  /** Set when a control (cancel, deadline, step limit) stopped the turn. */
274
278
  stopReason?: TurnStopReason;
279
+ /**
280
+ * The run_code runs of agents whose YAML sets `run_code: { attach: true }`
281
+ * (ADR 0137 item 3), in order: the reply's `code_run` data parts. Absent
282
+ * when there are none.
283
+ */
284
+ codeRuns?: CodeRun[];
275
285
  }
276
286
  /** A turn's spend: model calls and the tokens the providers reported. */
277
287
  export interface TurnUsage {
@@ -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
@@ -407,7 +425,12 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
407
425
  llmCalls: 0,
408
426
  usage: { llmCalls: 0, inputTokens: 0, outputTokens: 0, thinkingTokens: 0 },
409
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));
410
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;
411
434
  result.llmCalls = control.llmCalls;
412
435
  result.usage = {
413
436
  llmCalls: control.llmCalls,
@@ -717,7 +740,7 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
717
740
  taskId: trace.taskId,
718
741
  stage: params.stage,
719
742
  configHash: trace.configHash,
720
- attributes: trace.attributes,
743
+ attributes: params.traceAttributes ? { ...trace.attributes, ...params.traceAttributes } : trace.attributes,
721
744
  onSpanStart: trace.onSpanStart,
722
745
  traceparent: trace.traceparent,
723
746
  onEnd: () => ({
@@ -727,12 +750,30 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
727
750
  }),
728
751
  });
729
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
+ }
730
770
  try {
771
+ const quiet = params.quiet ?? params.stage === 'classify';
731
772
  return await drainAgentStream(stream, {
732
- events: params.stage === 'classify' ? { ...ev, onEvent: undefined } : ev,
773
+ events: quiet ? { ...ev, onEvent: undefined } : ev,
733
774
  publishToolStatus: params.publishToolStatus,
734
775
  subagentNames,
735
- streamText: params.stage !== 'classify' && !guarded,
776
+ streamText: !quiet && !guarded,
736
777
  errorPolicy: params.errorPolicy,
737
778
  });
738
779
  }
@@ -894,12 +935,348 @@ async function runTurnInner(opts, control, _runtime, nestedTurn) {
894
935
  }
895
936
  return { answer };
896
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
+ };
897
1263
  // While a route's walk waits on a gated call (ADR 0119), a message that is not its decision repeats the request: the
898
1264
  // walk cannot move past the waiting node, so nothing is stored and nothing runs, as a workflow turn answers it.
899
1265
  if (!decision && routePaused?.approval)
900
1266
  return pause(routePaused.approval);
901
1267
  let answer;
902
- 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)) {
903
1280
  // ══ PLAN ═════════════════════════════════════════════════════════════
904
1281
  // Deterministic overrides first: when the message itself decides the
905
1282
  // route there is nothing to classify, and the classifier call is skipped.
@@ -1243,6 +1620,7 @@ function emptyRun(error) {
1243
1620
  return {
1244
1621
  inputRequests: [],
1245
1622
  nodeErrors: [],
1623
+ codeRuns: [],
1246
1624
  text: '',
1247
1625
  lastToolResultText: '',
1248
1626
  toolResultTexts: [],
@@ -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>;