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.
- package/CHANGELOG.md +83 -0
- package/DOCUMENTATION.md +5 -1
- package/config/agents/syndicate.schema.json +63 -0
- package/config/agents/syndicateSchema.yaml +50 -0
- package/dist/lib/a2a/executor.js +21 -0
- package/dist/lib/compile.d.ts +4 -3
- package/dist/lib/compile.js +8 -3
- package/dist/lib/index.d.ts +8 -0
- package/dist/lib/index.js +5 -0
- package/dist/lib/loadSyndicate.d.ts +13 -0
- package/dist/lib/loadSyndicate.js +11 -1
- package/dist/lib/observability/supabaseSpanExporter.js +2 -1
- package/dist/lib/observability/tracer.d.ts +2 -2
- package/dist/lib/observability/tracer.js +2 -2
- package/dist/lib/runtime/native/checkpoint.d.ts +2 -1
- package/dist/lib/runtime/native/checkpoint.js +6 -2
- package/dist/lib/runtime/native/interrupts.js +9 -2
- package/dist/lib/runtime/nativeTurn.d.ts +2 -1
- package/dist/lib/runtime/nativeTurn.js +5 -1
- package/dist/lib/runtime/syndicateTurn.d.ts +11 -1
- package/dist/lib/runtime/syndicateTurn.js +383 -5
- package/dist/lib/sandbox/sandbox.d.ts +121 -0
- package/dist/lib/sandbox/sandbox.js +276 -0
- package/dist/lib/sandbox/worker.d.ts +29 -0
- package/dist/lib/sandbox/worker.js +280 -0
- package/dist/lib/script/config.d.ts +35 -0
- package/dist/lib/script/config.js +17 -0
- package/dist/lib/script/journal.d.ts +137 -0
- package/dist/lib/script/journal.js +193 -0
- package/dist/lib/script/run.d.ts +138 -0
- package/dist/lib/script/run.js +364 -0
- package/dist/lib/syndicateSchema.d.ts +20 -0
- package/dist/lib/syndicateSchema.js +98 -6
- package/dist/lib/toolRegistry.js +4 -0
- package/dist/lib/tools/runCodeTool.d.ts +50 -0
- package/dist/lib/tools/runCodeTool.js +59 -0
- package/package.json +5 -1
|
@@ -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:
|
|
773
|
+
events: quiet ? { ...ev, onEvent: undefined } : ev,
|
|
733
774
|
publishToolStatus: params.publishToolStatus,
|
|
734
775
|
subagentNames,
|
|
735
|
-
streamText:
|
|
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 (
|
|
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>;
|