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
@@ -0,0 +1,364 @@
1
+ /**
2
+ * lib/script/run.ts — one run of a syndicate's `script:` (ADR 0137 Part B):
3
+ * the script in the sandbox, its calls journaled, a paused or interrupted
4
+ * run resumed by replaying them.
5
+ *
6
+ * WHAT THE SCRIPT IS PASSED (item 6): `message`, `transcript` and
7
+ * `variables`, and the functions `agent`, `tool`, `askUser`, `now`,
8
+ * `random`, `log`, plus the sandbox's `parallel`. Nothing else reaches it.
9
+ * The host (lib/runtime/syndicateTurn.ts) supplies what `agent` and `tool`
10
+ * do; this module owns the journal, the replay, the pauses and the spans.
11
+ *
12
+ * REPLAY (item 9). A paused run's next message runs the script again from
13
+ * the top, on the source, message and transcript the run pinned. Each call
14
+ * is answered from the journal while one is recorded for it: a call is
15
+ * matched on its kind, its name and the hash of its arguments, the first
16
+ * unconsumed entry that matches, so concurrency under `parallel` (whose
17
+ * calls start in an order that depends on what finished first) replays the
18
+ * same. The open pause takes the person's answer; the calls after it run
19
+ * live. A run that ends with recorded calls it never made again diverged:
20
+ * it fails SCRIPT_DIVERGED, naming the first. Replayed calls are never
21
+ * charged again, open no span, and log() lines are not repeated while the
22
+ * run replays.
23
+ *
24
+ * THE PAUSES (items 8 and 10). A live askUser, and a tool() call to a tool
25
+ * in `script.require_approval`, let the calls already in flight settle,
26
+ * store the run's journal and the pause, then halt the sandbox; a call
27
+ * started after it waits forever and dies with the run, so a second pause
28
+ * is reached only once the first is answered. A question's answer is the
29
+ * next message (a JSON reply to a question with a schema is parsed, plain
30
+ * text passes as is, as an ask_user node's). An approval's answer is the
31
+ * decision: approved, the pinned arguments run; refused, the script gets
32
+ * `{ error }`, as a refused tool node's output is.
33
+ *
34
+ * WHEN THE JOURNAL IS STORED. Only a paused run reads it, so by default it
35
+ * is stored with the pause and a run that finishes stores none (on the
36
+ * Supabase store every append rewrites the session's events). A durable
37
+ * run's store (JOURNAL_EVERY_CALL, lib/runtime/native/checkpoint.ts) takes
38
+ * each event as its call ends, the first carrying the pin, and treats each
39
+ * as a checkpoint boundary, so a worker killed mid-script resumes from its
40
+ * last call. A resumed run that ends, and a durable run that finishes,
41
+ * store an `end` marker.
42
+ *
43
+ * THE SPANS (item 16). `script.invoke <name>` around the run and
44
+ * `script.call <kind>:<name>` around each live call, in their own trace,
45
+ * with no argument and no result on them; each agent() call's own turn
46
+ * span carries `script.run` and `script.call.seq` (the host adds them), so
47
+ * a trace view pairs them.
48
+ */
49
+ import { randomUUID } from 'node:crypto';
50
+ import { ROOT_CONTEXT, SpanStatusCode, trace } from '@opentelemetry/api';
51
+ import { getFunctionCalls } from '../runtime/events.js';
52
+ import { approvalRequestOf } from '../runtime/approvals.js';
53
+ import { RUNTIME_SPAN_SCOPE } from '../observability/lineage.js';
54
+ import { startEngineSpan } from '../observability/tracer.js';
55
+ import { SandboxHalt, runInSandbox } from '../sandbox/sandbox.js';
56
+ import { APPROVAL_TEXTS } from '../tools/tool.js';
57
+ import { inputRequestFrom } from '../workflowConfig.js';
58
+ import { approvalEvent, callEvent, callHash, endEvent, pauseEvent, sourceHash } from './journal.js';
59
+ // A paused run's journal, for a client that shows it (melch-app's journal view): the reader and its shapes.
60
+ export { openRun, scriptPauseIn, SCRIPT_CALL, SCRIPT_PAUSE, SCRIPT_RUN } from './journal.js';
61
+ /** The most calls one run may journal (item 13), as a walk has NODE_RUN_LIMIT. */
62
+ export const SCRIPT_CALL_LIMIT = 1_000;
63
+ /** Fatal: thrown by a host function to fail the turn with a code. */
64
+ export class ScriptFatal extends SandboxHalt {
65
+ code;
66
+ constructor(code, message) {
67
+ super(message, { code });
68
+ this.code = code;
69
+ }
70
+ }
71
+ const never = () => new Promise(() => { });
72
+ function replayed(outcome) {
73
+ if ('error' in outcome) {
74
+ const err = new Error(outcome.error.message);
75
+ err.name = outcome.error.name;
76
+ throw err;
77
+ }
78
+ return outcome.value;
79
+ }
80
+ function describe(entry) {
81
+ return `${entry.kind}(${JSON.stringify(entry.name)}) recorded as call ${entry.seq}`;
82
+ }
83
+ /** A question's reply: parsed when the question declared a schema and the reply is JSON, else the text. */
84
+ function replyOf(answer, schema) {
85
+ if (!schema)
86
+ return answer;
87
+ const text = answer.trim();
88
+ if (!/^[[{"]|^-?\d|^(true|false|null)$/.test(text))
89
+ return answer;
90
+ try {
91
+ return JSON.parse(text);
92
+ }
93
+ catch {
94
+ return answer;
95
+ }
96
+ }
97
+ export async function runScript(input, host) {
98
+ const run = input.resume?.run ?? `r-${randomUUID()}`;
99
+ const pending = input.resume ? input.resume.entries.filter((e) => e.outcome || e.pause) : [];
100
+ let nextSeq = (input.resume?.entries ?? []).reduce((max, e) => Math.max(max, e.seq), 0) + 1;
101
+ const limit = input.callLimit ?? SCRIPT_CALL_LIMIT;
102
+ const inflight = new Set();
103
+ const askedIds = new Set();
104
+ const pin = { run, source: input.source, source_hash: sourceHash(input.source), message: input.message, transcript: input.transcript };
105
+ // A run stored call by call pins itself on its first event; a resumed one already did.
106
+ let pinStored = !!input.resume;
107
+ let calls = 0;
108
+ let replayedCalls = 0;
109
+ let pausing = false;
110
+ let paused;
111
+ let approval;
112
+ let answeredPause = false;
113
+ /** Journal events not yet stored: all of them until a pause, unless the host takes each as its call ends. */
114
+ const journal = [];
115
+ const flush = async () => {
116
+ for (const event of journal.splice(0))
117
+ await host.append(event);
118
+ };
119
+ const record = async (event) => {
120
+ journal.push(event);
121
+ if (host.journalEveryCall)
122
+ await flush();
123
+ };
124
+ const outcomeEvent = (call, outcome) => {
125
+ const withPin = host.journalEveryCall && !pinStored;
126
+ if (withPin)
127
+ pinStored = true;
128
+ return callEvent(host.script, host.invocationId, call, outcome, withPin ? pin : undefined);
129
+ };
130
+ // ── Spans ──────────────────────────────────────────────────────────────────
131
+ const invokeSpan = startEngineSpan(RUNTIME_SPAN_SCOPE, `script.invoke ${host.script}`, {
132
+ root: true,
133
+ attributes: {
134
+ 'gen_ai.operation.name': 'invoke_script',
135
+ 'syndicate.name': host.script,
136
+ 'script.name': host.script,
137
+ 'script.run': run,
138
+ 'script.source_hash': pin.source_hash,
139
+ 'script.resumed': !!input.resume,
140
+ 'adk.invocation_id': host.invocationId,
141
+ ...(host.sessionId ? { 'session.id': host.sessionId, 'gen_ai.conversation.id': host.sessionId } : {}),
142
+ },
143
+ });
144
+ const parent = trace.setSpan(ROOT_CONTEXT, invokeSpan);
145
+ const callSpan = (call) => startEngineSpan(RUNTIME_SPAN_SCOPE, `script.call ${call.kind}:${call.name}`, {
146
+ attributes: {
147
+ 'gen_ai.operation.name': 'execute_script_call',
148
+ 'syndicate.name': host.script,
149
+ 'script.name': host.script,
150
+ 'script.run': run,
151
+ 'script.call.kind': call.kind,
152
+ 'script.call.name': call.name,
153
+ 'script.call.seq': call.seq,
154
+ ...(host.sessionId ? { 'session.id': host.sessionId } : {}),
155
+ },
156
+ }, parent);
157
+ const endCall = (span, status) => {
158
+ span.setAttribute('script.call.status', status);
159
+ if (status === 'error')
160
+ span.setStatus({ code: SpanStatusCode.ERROR });
161
+ span.end();
162
+ };
163
+ /** The first recorded call with this kind, name and hash, removed from the journal still to replay. */
164
+ const take = (kind, name, hash) => {
165
+ const at = pending.findIndex((e) => e.kind === kind && e.name === name && e.hash === hash);
166
+ return at === -1 ? undefined : pending.splice(at, 1)[0];
167
+ };
168
+ const count = () => {
169
+ if (++calls > limit)
170
+ throw new ScriptFatal('SCRIPT_CALL_LIMIT', `The script made more than ${limit} calls in one run.`);
171
+ };
172
+ const track = (running) => {
173
+ inflight.add(running);
174
+ running.then(() => inflight.delete(running), () => inflight.delete(running));
175
+ return running;
176
+ };
177
+ /** Runs `exec` as a live call: its span, its journal event, its outcome thrown or returned into the script. */
178
+ const live = (record_, exec) => track((async () => {
179
+ const span = callSpan(record_);
180
+ let outcome;
181
+ try {
182
+ outcome = { value: (await exec()) ?? null };
183
+ }
184
+ catch (err) {
185
+ if (err instanceof SandboxHalt || host.signal.aborted) {
186
+ endCall(span, 'error');
187
+ throw err;
188
+ }
189
+ const e = err instanceof Error ? err : new Error(String(err));
190
+ outcome = { error: { name: e.name || 'Error', message: e.message } };
191
+ }
192
+ endCall(span, 'error' in outcome ? 'error' : 'completed');
193
+ await record(outcomeEvent(record_, outcome));
194
+ return replayed(outcome);
195
+ })());
196
+ /** A call: replayed from the journal when recorded, else run and journaled. */
197
+ const call = (kind, name, args, exec) => {
198
+ if (pausing)
199
+ return never();
200
+ count();
201
+ const hash = callHash(kind, name, args);
202
+ const recorded = take(kind, name, hash);
203
+ if (recorded?.outcome) {
204
+ replayedCalls++;
205
+ return Promise.resolve().then(() => replayed(recorded.outcome));
206
+ }
207
+ const rec = { run, seq: nextSeq++, kind, name, hash };
208
+ return live(rec, () => exec({ run, seq: rec.seq }));
209
+ };
210
+ /** The run pauses here: the calls in flight settle and are stored, then the journal and the pause, then the sandbox halts. */
211
+ const pause = async (rec, event) => {
212
+ pausing = true;
213
+ await Promise.allSettled([...inflight]);
214
+ const interruptId = (host.newInterruptId ?? randomUUID)();
215
+ await host.beforePause?.();
216
+ await flush();
217
+ const stored = event(interruptId);
218
+ await host.append(stored);
219
+ endCall(callSpan(rec), 'waiting');
220
+ throw new SandboxHalt('waiting for the person');
221
+ };
222
+ const askUser = async (id, question, opts) => {
223
+ if (typeof id !== 'string' || !id)
224
+ throw new TypeError('askUser(id, question, { payload?, schema? }): id must be a non-empty string, unique within the run');
225
+ if (typeof question !== 'string' || !question)
226
+ throw new TypeError('askUser(id, question, …): question must be a non-empty string');
227
+ const o = opts && typeof opts === 'object' ? opts : {};
228
+ const schema = o.schema && typeof o.schema === 'object' ? o.schema : undefined;
229
+ if (askedIds.has(id))
230
+ throw new Error(`askUser: the id '${id}' was already asked in this run; each question needs its own id`);
231
+ askedIds.add(id);
232
+ const args = { question, payload: o.payload ?? null, schema: schema ?? null };
233
+ const hash = callHash('askUser', id, args);
234
+ const recorded = take('askUser', id, hash);
235
+ if (recorded?.outcome) {
236
+ replayedCalls++;
237
+ return replayed(recorded.outcome);
238
+ }
239
+ if (recorded?.pause && input.resume?.answer !== undefined && recorded.pause.kind !== 'approval') {
240
+ // The question the run waits on: this turn's message is the answer.
241
+ const value = { reply: replyOf(input.resume.answer, schema), input: args.payload };
242
+ await record(callEvent(host.script, host.invocationId, { run, seq: recorded.seq, kind: 'askUser', name: id, hash }, { value }));
243
+ answeredPause = true;
244
+ return value;
245
+ }
246
+ if (pausing)
247
+ return never();
248
+ count();
249
+ const rec = { run, seq: nextSeq++, kind: 'askUser', name: id, hash };
250
+ return pause(rec, (interruptId) => {
251
+ const event = pauseEvent(host.script, host.invocationId, rec, { ...pin, interrupt_id: interruptId, kind: 'question' }, {
252
+ message: question,
253
+ ...(o.payload !== undefined ? { payload: o.payload } : {}),
254
+ ...(schema ? { schema } : {}),
255
+ });
256
+ const [request] = getFunctionCalls(event);
257
+ paused = inputRequestFrom(host.script, request);
258
+ return event;
259
+ });
260
+ };
261
+ const tool = async (name, args) => {
262
+ if (typeof name !== 'string' || !name)
263
+ throw new TypeError('tool(name, args): name must be a tool in script.tools');
264
+ const a = args ?? {};
265
+ if (!host.gated?.(name))
266
+ return call('tool', name, a, (site) => host.tool(name, a, site));
267
+ // A gated tool (`script.require_approval`): a person approves the exact call first.
268
+ if (pausing)
269
+ return never();
270
+ count();
271
+ const hash = callHash('tool', name, a);
272
+ const recorded = take('tool', name, hash);
273
+ if (recorded?.outcome) {
274
+ replayedCalls++;
275
+ return replayed(recorded.outcome);
276
+ }
277
+ if (recorded?.pause && input.resume?.decision && recorded.pause.kind === 'approval') {
278
+ answeredPause = true;
279
+ const rec = { run, seq: recorded.seq, kind: 'tool', name, hash };
280
+ if (input.resume.decision.approved)
281
+ return live(rec, () => host.tool(name, a, { run, seq: rec.seq }));
282
+ const refused = { error: APPROVAL_TEXTS.rejected };
283
+ endCall(callSpan(rec), 'refused');
284
+ await record(callEvent(host.script, host.invocationId, rec, { value: refused }));
285
+ return refused;
286
+ }
287
+ const rec = { run, seq: nextSeq++, kind: 'tool', name, hash };
288
+ return pause(rec, (interruptId) => {
289
+ const event = approvalEvent(host.script, host.invocationId, rec, { ...pin, interrupt_id: interruptId, kind: 'approval' }, { tool: name, args: a, hint: APPROVAL_TEXTS.hint(name) });
290
+ const [request] = getFunctionCalls(event);
291
+ approval = approvalRequestOf(event, request);
292
+ return event;
293
+ });
294
+ };
295
+ const sandboxed = await runInSandbox({
296
+ source: input.source,
297
+ mode: 'api',
298
+ value: { message: input.message, transcript: input.transcript, variables: input.variables },
299
+ signal: host.signal,
300
+ limits: { ...input.limits },
301
+ functions: {
302
+ agent: ([name, agentInput, opts]) => {
303
+ if (typeof name !== 'string' || !name)
304
+ throw new TypeError('agent(name, input, opts?): name must be a declared agent\'s name');
305
+ const o = opts === undefined || opts === null ? {} : opts;
306
+ if (typeof o !== 'object' || Array.isArray(o))
307
+ throw new TypeError('agent(name, input, opts?): opts must be an object');
308
+ return call('agent', name, { input: agentInput ?? null, opts: o }, (site) => host.agent(name, agentInput ?? null, o, site));
309
+ },
310
+ tool: ([name, args]) => tool(name, args),
311
+ askUser: ([id, question, opts]) => askUser(id, question, opts),
312
+ now: () => call('now', 'now', null, async () => Date.now()),
313
+ random: () => call('random', 'random', null, async () => Math.random()),
314
+ log: ([text]) => {
315
+ // A replayed run already said these.
316
+ if (!pending.length)
317
+ host.log(typeof text === 'string' ? text : JSON.stringify(text));
318
+ return null;
319
+ },
320
+ },
321
+ });
322
+ const logs = sandboxed.logs;
323
+ const waiting = !!(paused || approval);
324
+ // A resumed run, or one stored call by call, that ended (not one the turn's signal stopped) is closed, so the next message starts a new one.
325
+ if (!waiting && !host.signal.aborted && (input.resume || (host.journalEveryCall && pinStored))) {
326
+ await flush();
327
+ await host.append(endEvent(host.script, host.invocationId, run, nextSeq++));
328
+ }
329
+ const unreplayed = () => {
330
+ const left = pending[0];
331
+ return left ? { code: 'SCRIPT_DIVERGED', message: `The resumed run did not make the call its journal holds: ${describe(left)}. A script must make the same calls, with the same arguments, on every run (ADR 0137 item 9).` } : undefined;
332
+ };
333
+ const finish = (outcome) => {
334
+ invokeSpan.setAttributes({ 'script.status': outcome.status, 'script.calls': calls, 'script.replayed_calls': replayedCalls });
335
+ if (outcome.status === 'failed') {
336
+ invokeSpan.setAttribute('script.error_code', outcome.error.code);
337
+ invokeSpan.setStatus({ code: SpanStatusCode.ERROR });
338
+ }
339
+ invokeSpan.end();
340
+ return outcome;
341
+ };
342
+ if (waiting) {
343
+ const diverged = unreplayed();
344
+ if (diverged)
345
+ return finish({ status: 'failed', run, error: diverged, logs });
346
+ return finish(approval ? { status: 'approval', run, approval, logs } : { status: 'paused', run, input: paused, logs });
347
+ }
348
+ if (sandboxed.halt) {
349
+ const code = sandboxed.halt instanceof ScriptFatal ? sandboxed.halt.code : 'SCRIPT_HALTED';
350
+ return finish({ status: 'failed', run, error: { code, message: sandboxed.halt.message }, logs });
351
+ }
352
+ if (!sandboxed.ok) {
353
+ const error = sandboxed.error;
354
+ return finish({ status: 'failed', run, error: { code: error.code === 'CODE_ERROR' ? 'SCRIPT_ERROR' : error.code, message: error.message }, logs });
355
+ }
356
+ const awaited = input.resume?.answer !== undefined || input.resume?.decision !== undefined;
357
+ if (awaited && !answeredPause) {
358
+ return finish({ status: 'failed', run, error: { code: 'SCRIPT_DIVERGED', message: 'The resumed run never reached the pause it was waiting on.' }, logs });
359
+ }
360
+ const diverged = unreplayed();
361
+ if (diverged)
362
+ return finish({ status: 'failed', run, error: diverged, logs });
363
+ return finish({ status: 'completed', run, value: sandboxed.result, logs });
364
+ }
@@ -225,6 +225,9 @@ export declare const agentSchema: z.ZodObject<{
225
225
  }, z.core.$strict>>;
226
226
  }, z.core.$strict>>>;
227
227
  code_execution: z.ZodOptional<z.ZodLiteral<"gemini">>;
228
+ run_code: z.ZodOptional<z.ZodObject<{
229
+ attach: z.ZodOptional<z.ZodBoolean>;
230
+ }, z.core.$strict>>;
228
231
  context: z.ZodOptional<z.ZodObject<{
229
232
  compact_after_tokens: z.ZodNumber;
230
233
  keep_recent_events: z.ZodOptional<z.ZodNumber>;
@@ -439,6 +442,9 @@ export declare const subagentSchema: z.ZodObject<{
439
442
  }, z.core.$strict>>;
440
443
  }, z.core.$strict>>>;
441
444
  code_execution: z.ZodOptional<z.ZodLiteral<"gemini">>;
445
+ run_code: z.ZodOptional<z.ZodObject<{
446
+ attach: z.ZodOptional<z.ZodBoolean>;
447
+ }, z.core.$strict>>;
442
448
  context: z.ZodOptional<z.ZodObject<{
443
449
  compact_after_tokens: z.ZodNumber;
444
450
  keep_recent_events: z.ZodOptional<z.ZodNumber>;
@@ -462,6 +468,8 @@ export declare const subagentSchema: z.ZodObject<{
462
468
  yaml_reference: z.ZodOptional<z.ZodString>;
463
469
  a2a_agent_url: z.ZodOptional<z.ZodString>;
464
470
  }, z.core.$strict>;
471
+ /** The largest `script.source` a syndicate may carry. */
472
+ export declare const MAX_SCRIPT_SOURCE_CHARS: number;
465
473
  export declare const syndicateSchema: z.ZodObject<{
466
474
  syndicate_name: z.ZodString;
467
475
  orchestrator: z.ZodObject<{
@@ -657,6 +665,9 @@ export declare const syndicateSchema: z.ZodObject<{
657
665
  }, z.core.$strict>>;
658
666
  }, z.core.$strict>>>;
659
667
  code_execution: z.ZodOptional<z.ZodLiteral<"gemini">>;
668
+ run_code: z.ZodOptional<z.ZodObject<{
669
+ attach: z.ZodOptional<z.ZodBoolean>;
670
+ }, z.core.$strict>>;
660
671
  context: z.ZodOptional<z.ZodObject<{
661
672
  compact_after_tokens: z.ZodNumber;
662
673
  keep_recent_events: z.ZodOptional<z.ZodNumber>;
@@ -865,6 +876,9 @@ export declare const syndicateSchema: z.ZodObject<{
865
876
  }, z.core.$strict>>;
866
877
  }, z.core.$strict>>>;
867
878
  code_execution: z.ZodOptional<z.ZodLiteral<"gemini">>;
879
+ run_code: z.ZodOptional<z.ZodObject<{
880
+ attach: z.ZodOptional<z.ZodBoolean>;
881
+ }, z.core.$strict>>;
868
882
  context: z.ZodOptional<z.ZodObject<{
869
883
  compact_after_tokens: z.ZodNumber;
870
884
  keep_recent_events: z.ZodOptional<z.ZodNumber>;
@@ -921,6 +935,12 @@ export declare const syndicateSchema: z.ZodObject<{
921
935
  }, z.core.$strict>>>;
922
936
  max_concurrency: z.ZodOptional<z.ZodNumber>;
923
937
  }, z.core.$strict>>;
938
+ script: z.ZodOptional<z.ZodObject<{
939
+ source: z.ZodString;
940
+ models: z.ZodOptional<z.ZodArray<z.ZodString>>;
941
+ tools: z.ZodOptional<z.ZodArray<z.ZodString>>;
942
+ require_approval: z.ZodOptional<z.ZodArray<z.ZodString>>;
943
+ }, z.core.$strict>>;
924
944
  retries: z.ZodOptional<z.ZodObject<{
925
945
  model_errors: z.ZodOptional<z.ZodNumber>;
926
946
  tool_errors: z.ZodOptional<z.ZodNumber>;
@@ -410,6 +410,12 @@ const agentFields = {
410
410
  .literal('gemini')
411
411
  .optional()
412
412
  .describe('"gemini": the model writes and runs Python in Gemini\'s server-side sandbox; nothing runs on this host. Gemini models only.'),
413
+ run_code: z
414
+ .strictObject({
415
+ attach: z.boolean().optional().describe('Put each run\'s code and result on the reply as a code_run data part. Default false.'),
416
+ })
417
+ .optional()
418
+ .describe('How this agent\'s run_code tool behaves (ADR 0137). Needs run_code in `tools`.'),
413
419
  context: z
414
420
  .strictObject({
415
421
  compact_after_tokens: z.number().int().min(1000).describe('Compact when the last request\'s prompt passed this many tokens.'),
@@ -533,6 +539,31 @@ const workflowSchema = z
533
539
  max_concurrency: z.number().int().positive().optional().describe('Nodes that may run at once. Default: unbounded.'),
534
540
  })
535
541
  .describe('Opts into a WORKFLOW: the syndicate as a graph (lib/workflow.ts). Cannot be combined with dispatch.');
542
+ // ── Script (ADR 0137) ────────────────────────────────────────────────────────
543
+ /** The largest `script.source` a syndicate may carry. */
544
+ export const MAX_SCRIPT_SOURCE_CHARS = 64 * 1024;
545
+ const scriptSchema = z
546
+ .strictObject({
547
+ source: z
548
+ .string()
549
+ .min(1)
550
+ .max(MAX_SCRIPT_SOURCE_CHARS)
551
+ .describe('One JavaScript module whose default export is an async function of { message, variables, agent, tool, askUser, parallel, now, random, log }: the orchestration. Its return value is the answer. {{token}} interpolation does not touch it; it reads `variables`.'),
552
+ models: z
553
+ .array(z.string().min(1))
554
+ .max(16)
555
+ .optional()
556
+ .describe('The models agent(name, input, { model }) may move a call to; each agent is compiled and checked on every one at load.'),
557
+ tools: z
558
+ .array(z.string().min(1))
559
+ .optional()
560
+ .describe('The registry tools tool(name, args) may run; the only path from the script to the network or the host.'),
561
+ require_approval: z
562
+ .array(z.string().min(1))
563
+ .optional()
564
+ .describe('Tools from script.tools whose every call waits for a person to approve the exact call (ADR 0028): the turn ends input-required; approved, the pinned arguments run, refused, the script gets { error }.'),
565
+ })
566
+ .describe('Opts into SCRIPT orchestration (lib/script/): the syndicate\'s logic as code run in the engine\'s sandbox. Needs MELCHIZEDEK_CODE_SANDBOX=on. Cannot be combined with dispatch or workflow.');
536
567
  // ── Syndicate ────────────────────────────────────────────────────────────────
537
568
  export const syndicateSchema = z
538
569
  .strictObject({
@@ -544,6 +575,7 @@ export const syndicateSchema = z
544
575
  .describe('The orchestrator\'s team. Omit it (or write `subagents: []`) for a single-agent syndicate.'),
545
576
  dispatch: dispatchSchema.optional(),
546
577
  workflow: workflowSchema.optional(),
578
+ script: scriptSchema.optional(),
547
579
  retries: z
548
580
  .strictObject({
549
581
  model_errors: z.number().int().min(0).max(5).optional().describe('Retries of a malformed model reply. Default 2; 0 off.'),
@@ -667,6 +699,8 @@ function knownKeysAt(p) {
667
699
  return Object.keys(dispatchSchema.shape);
668
700
  case 'dispatch/route_overrides/#':
669
701
  return Object.keys(routeOverride.shape);
702
+ case 'script':
703
+ return Object.keys(scriptSchema.shape);
670
704
  case 'orchestrator/orchestration':
671
705
  case 'subagents/#/orchestration':
672
706
  return ['role', 'delegates'];
@@ -901,14 +935,23 @@ function crossFieldProblems(raw) {
901
935
  if (agent.code_execution !== undefined && model && !/^gemini-/.test(model)) {
902
936
  out.push({ path: [...path, 'code_execution'], message: `code_execution: gemini needs a gemini-* model (this agent's is ${model})` });
903
937
  }
904
- if (agent.context !== undefined && (!isOrchestrator || isObj(raw.dispatch) || isObj(raw.workflow))) {
938
+ if (agent.context !== undefined && (!isOrchestrator || isObj(raw.dispatch) || isObj(raw.workflow) || isObj(raw.script))) {
905
939
  out.push({
906
940
  path: [...path, 'context'],
907
- message: 'context compaction applies to the orchestrator of a delegate syndicate: a dispatch route reads a bounded projection, a workflow node sees only its input, and a subagent starts fresh on every call',
941
+ message: 'context compaction applies to the orchestrator of a delegate syndicate: a dispatch route reads a bounded projection, a workflow node sees only its input, a script\'s agent() call starts fresh, and a subagent starts fresh on every call',
908
942
  });
909
943
  }
910
- if (agent.mode !== undefined && !isObj(raw.workflow)) {
911
- out.push({ path: [...path, 'mode'], message: 'mode: task applies to workflow nodes, whose output is the finish_task arguments' });
944
+ if (agent.mode !== undefined && !isObj(raw.workflow) && !isObj(raw.script)) {
945
+ out.push({ path: [...path, 'mode'], message: 'mode: task applies to workflow nodes and script agents, whose output is the finish_task arguments' });
946
+ }
947
+ // run_code (ADR 0137): the key configures the tool, so the tool must be listed; a nested or remote agent sets its own.
948
+ if (agent.run_code !== undefined) {
949
+ if (typeof agent.yaml_reference === 'string' || typeof agent.a2a_agent_url === 'string') {
950
+ out.push({ path: [...path, 'run_code'], message: 'applies to an inline agent; a nested syndicate (yaml_reference) or a remote agent (a2a_agent_url) sets its own' });
951
+ }
952
+ else if (!(Array.isArray(agent.tools) && agent.tools.includes('run_code'))) {
953
+ out.push({ path: [...path, 'run_code'], message: 'configures the run_code tool, which this agent\'s tools do not list; add run_code to tools' });
954
+ }
912
955
  }
913
956
  if (agent.mcp_tools !== undefined && typeof agent.mcp_server_url !== 'string') {
914
957
  out.push({ path: [...path, 'mcp_tools'], message: 'mcp_tools chooses among an MCP server\'s tools; it needs mcp_server_url' });
@@ -927,12 +970,14 @@ function crossFieldProblems(raw) {
927
970
  if (isObj(raw.orchestrator))
928
971
  executionProblems(raw.orchestrator, ['orchestrator'], true);
929
972
  // max_concurrency bounds an orchestrator's delegated calls (ADR 0116): a workflow bounds its nodes under workflow:, and a dispatch classifier delegates nothing.
930
- if (raw.max_concurrency !== undefined && (isObj(raw.workflow) || isObj(raw.dispatch))) {
973
+ if (raw.max_concurrency !== undefined && (isObj(raw.workflow) || isObj(raw.dispatch) || isObj(raw.script))) {
931
974
  out.push({
932
975
  path: ['max_concurrency'],
933
976
  message: isObj(raw.workflow)
934
977
  ? 'max_concurrency at the root bounds a delegate orchestrator\'s subagent calls; a workflow bounds its nodes with workflow.max_concurrency'
935
- : 'max_concurrency bounds a delegate orchestrator\'s subagent calls; a dispatch syndicate\'s classifier delegates nothing (set it in a route\'s own syndicate file)',
978
+ : isObj(raw.script)
979
+ ? 'max_concurrency at the root bounds a delegate orchestrator\'s subagent calls; a script bounds its own with parallel(thunks, { max })'
980
+ : 'max_concurrency bounds a delegate orchestrator\'s subagent calls; a dispatch syndicate\'s classifier delegates nothing (set it in a route\'s own syndicate file)',
936
981
  });
937
982
  }
938
983
  for (const [i, sub] of (Array.isArray(raw.subagents) ? raw.subagents : []).entries())
@@ -1024,6 +1069,8 @@ function crossFieldProblems(raw) {
1024
1069
  });
1025
1070
  if (isObj(raw.workflow))
1026
1071
  out.push(...workflowProblems(raw, subs));
1072
+ if (isObj(raw.script))
1073
+ out.push(...scriptProblems(raw, subs));
1027
1074
  // Names must be unique across the tree this file declares: two AgentTools
1028
1075
  // with one name collide, and dispatch routes by name.
1029
1076
  const seen = new Map();
@@ -1210,6 +1257,51 @@ function workflowProblems(raw, subs) {
1210
1257
  return out;
1211
1258
  }
1212
1259
  /** Where an agent declares an authorization_code grant (mcp_auth, mcp_servers[].auth, openapi[].auth), by its path below the agent. */
1260
+ /**
1261
+ * The rules a `script:` syndicate keeps (ADR 0137), which a JSON Schema
1262
+ * cannot say: it is one orchestration method, not three; a pause inside a
1263
+ * called agent cannot be replayed in this version (item 12), so the gates,
1264
+ * the ask_user tool, skill scripts and per-user OAuth consent are refused on
1265
+ * its agents by name, and the script's own askUser is its pause; and an
1266
+ * agent that runs Gemini's code execution can be moved only to Gemini models.
1267
+ */
1268
+ function scriptProblems(raw, subs) {
1269
+ const out = [];
1270
+ const script = raw.script;
1271
+ if (isObj(raw.dispatch))
1272
+ out.push({ path: ['script'], message: 'a syndicate orchestrates with a script or a plan-dispatch router, not both; remove `dispatch` (the script can route)' });
1273
+ if (isObj(raw.workflow))
1274
+ out.push({ path: ['script'], message: 'a syndicate orchestrates with a script or a workflow graph, not both; remove `workflow`' });
1275
+ const allowed = Array.isArray(script.tools) ? script.tools : [];
1276
+ (Array.isArray(script.require_approval) ? script.require_approval : []).forEach((name, j) => {
1277
+ if (typeof name === 'string' && !allowed.includes(name))
1278
+ out.push({ path: ['script', 'require_approval', j], message: `'${name}' is not in script.tools` });
1279
+ });
1280
+ const models = Array.isArray(script.models) ? script.models.filter((m) => typeof m === 'string') : [];
1281
+ const agents = [];
1282
+ if (isObj(raw.orchestrator))
1283
+ agents.push([['orchestrator'], raw.orchestrator]);
1284
+ subs.forEach((sub, i) => isObj(sub) && agents.push([['subagents', i], sub]));
1285
+ const pausesInside = 'a pause inside an agent a script calls cannot be resumed in this version (ADR 0137 item 12); pause in the script with askUser';
1286
+ for (const [path, agent] of agents) {
1287
+ if (agent.require_approval !== undefined)
1288
+ out.push({ path: [...path, 'require_approval'], message: pausesInside });
1289
+ const tools = Array.isArray(agent.tools) ? agent.tools : [];
1290
+ const asks = tools.indexOf('ask_user');
1291
+ if (asks !== -1)
1292
+ out.push({ path: [...path, 'tools', asks], message: pausesInside });
1293
+ if (isObj(agent.skills) && agent.skills.scripts === 'local')
1294
+ out.push({ path: [...path, 'skills', 'scripts'], message: `skill scripts pause for approval; ${pausesInside}` });
1295
+ for (const grant of authorizationCodeGrants(agent))
1296
+ out.push({ path: [...path, ...grant], message: `an authorization_code grant pauses for the person's consent; ${pausesInside}` });
1297
+ if (agent.code_execution !== undefined) {
1298
+ const other = models.find((m) => !/^gemini-/.test(m));
1299
+ if (other)
1300
+ out.push({ path: [...path, 'code_execution'], message: `code_execution: gemini needs a gemini-* model, and script.models lets agent() move this agent to ${other}` });
1301
+ }
1302
+ }
1303
+ return out;
1304
+ }
1213
1305
  function authorizationCodeGrants(agent) {
1214
1306
  const out = [];
1215
1307
  const isCode = (auth) => isObj(auth) && isObj(auth.oauth2) && auth.oauth2.grant === 'authorization_code';
@@ -24,6 +24,7 @@ import { SCIENCE_TOOL_CONTRACTS } from './tools/scienceTools.js';
24
24
  import { TASK_TOOL_CONTRACTS } from './tools/taskTools.js';
25
25
  import { askUserTool } from './runtime/questions.js';
26
26
  import { xApiSearchTool } from './tools/xApiSearchTool.js';
27
+ import { runCodeTool } from './tools/runCodeTool.js';
27
28
  // Knowledge-bundle tools, derived from their contracts so the YAML names
28
29
  // can never drift from the definitions. The agentic composites
29
30
  // (wiki_query/wiki_garden) are deliberately absent: a syndicate reaches
@@ -79,6 +80,9 @@ const BUILTIN_TOOLS = {
79
80
  // Both reach the run's memory service, pinned to the root namespace.
80
81
  load_memory: asTool(loadMemoryTool),
81
82
  preload_memory: preloadMemoryTool,
83
+ // JavaScript in the engine's QuickJS sandbox (lib/tools/runCodeTool.ts,
84
+ // ADR 0137): any provider; runs only under MELCHIZEDEK_CODE_SANDBOX=on.
85
+ run_code: runCodeTool,
82
86
  };
83
87
  // Null-prototype copy (as GUARD_MAP is): resolution and registration go
84
88
  // through this map, never the literal above.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * lib/tools/runCodeTool.ts — `run_code`: an agent runs JavaScript in the
3
+ * engine's sandbox and rests its answer on what the code returned
4
+ * (ADR 0137 items 2 to 4).
5
+ *
6
+ * WHY a registry tool: any agent on any provider can list it
7
+ * (`tools: [run_code]`), since the model only writes text, and it gets what
8
+ * every registry tool gets: `require_approval`, MCP exposure (ADR 0125), the
9
+ * result bound and `retries.tool_errors`. `code_execution: gemini` stays,
10
+ * for Python in Gemini's own sandbox on Gemini models (ADR 0033).
11
+ *
12
+ * THE CONTRACT: `{ code, input? }`. The code is a JavaScript module whose
13
+ * default export is called with `input`; the tool returns
14
+ * `{ result, logs, error }`: the export's JSON-serializable return, the
15
+ * `console.log` output, and what stopped it, if anything. Each is bounded at
16
+ * MAX_RESULT_CHARS. The code reaches nothing but `input` and `console`: no
17
+ * network, no host, no keys, no clock, no randomness (lib/sandbox/).
18
+ *
19
+ * THE SWITCH: nothing runs unless the operator set MELCHIZEDEK_CODE_SANDBOX=on;
20
+ * a syndicate listing run_code without it is refused at load (refuseOnNative),
21
+ * and a call that reaches here anyway answers with the reason.
22
+ *
23
+ * ATTACH: an agent's `run_code: { attach: true }` puts each run's code and
24
+ * result on the reply as a `code_run` data part (lib/runtime/syndicateTurn.ts
25
+ * collects them, the A2A executor publishes them). Off by default.
26
+ */
27
+ import { z } from 'zod';
28
+ /** The registry name. */
29
+ export declare const RUN_CODE = "run_code";
30
+ /** The largest program a call may send. */
31
+ export declare const MAX_CODE_CHARS = 64000;
32
+ /** What a call returns. */
33
+ export interface RunCodeOutput {
34
+ result: unknown;
35
+ logs: string;
36
+ error: string | null;
37
+ }
38
+ export declare const runCodeTool: import("./toolContract.ts").DefinedTool<z.ZodObject<{
39
+ code: z.ZodString;
40
+ input: z.ZodOptional<z.ZodUnknown>;
41
+ }, z.core.$strip>, RunCodeOutput>;
42
+ /** One run an agent made, as a `code_run` data part carries it. */
43
+ export interface CodeRun {
44
+ type: 'code_run';
45
+ /** The agent that ran it. */
46
+ agent: string;
47
+ code: string;
48
+ result: unknown;
49
+ error?: string;
50
+ }