@desplega.ai/agent-swarm 1.167.0 → 1.169.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/dist/{acp-adapter-zpvy6321.js → acp-adapter-m47zfx4a.js} +2 -2
- package/dist/{actions-fq2zrwtf.js → actions-ws7k3tvq.js} +25 -19
- package/dist/{amp-adapter-5gbk7501.js → amp-adapter-nvv8k8j5.js} +1 -1
- package/dist/{app-nctetn1e.js → app-80w6qspx.js} +5 -5
- package/dist/{assistant-0aq8gjvj.js → assistant-jtq439kw.js} +29 -20
- package/dist/{boot-reembed-1ze5853n.js → boot-reembed-3nc0aaed.js} +4 -4
- package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-feda8wn8.js} +4 -4
- package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-grsa2er0.js} +5 -5
- package/dist/{boot-seeds-22tx8a53.js → boot-seeds-gcfgq44f.js} +7 -7
- package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-pjv2q7t8.js} +7 -7
- package/dist/{claude-managed-adapter-tzwxy803.js → claude-managed-adapter-0wa8se82.js} +2 -2
- package/dist/{claude-managed-setup-nrhfx5b0.js → claude-managed-setup-zxa9p9tx.js} +1 -1
- package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-c9j6k9kx.js} +8772 -8663
- package/dist/{cli-c2tpw9ck.js → cli-3ry5gx4f.js} +1 -1
- package/dist/{cli-wydz3feg.js → cli-61tbnjpf.js} +214 -2472
- package/dist/{cli-cexctgn5.js → cli-7vz49g7h.js} +101 -34
- package/dist/{cli-6qxmjpem.js → cli-8xjst8q2.js} +34 -22
- package/dist/{cli-tx2zvn23.js → cli-91r17hb1.js} +3 -3
- package/dist/{cli-577bxkf6.js → cli-a8vps458.js} +182 -2208
- package/dist/{cli-tc5zec7v.js → cli-b7y8113e.js} +1 -1
- package/dist/{cli-69egj654.js → cli-bqrs5ycj.js} +1 -1
- package/dist/{cli-cf2vgn9d.js → cli-dv76vq5d.js} +30 -28
- package/dist/{cli-g5erf0zt.js → cli-e2prcx5m.js} +31 -0
- package/dist/{cli-c8r99akr.js → cli-e5qk3m2a.js} +209 -31
- package/dist/{cli-z1dqhc9y.js → cli-eqfbsxsn.js} +3 -3
- package/dist/{cli-kw0jcfje.js → cli-fyeft04q.js} +1 -1
- package/dist/{cli-5qkcdv8c.js → cli-gmdajfw5.js} +3 -3
- package/dist/{cli-ypgba1a8.js → cli-jkfxpg1b.js} +1 -1
- package/dist/{cli-mxvnnv5q.js → cli-jv36y8wz.js} +1 -1
- package/dist/{cli-m7vmeepg.js → cli-km8559dm.js} +6 -6
- package/dist/{cli-ykt8f7va.js → cli-kp3vfknv.js} +1 -1
- package/dist/{cli-ymmc1zxg.js → cli-m23r88re.js} +8 -5
- package/dist/{cli-v1pwxreq.js → cli-n4z21pt8.js} +1 -1
- package/dist/{cli-mxry91sa.js → cli-qg2zhjfc.js} +73 -4
- package/dist/{cli-1yrcfa5s.js → cli-rbr91whd.js} +4 -4
- package/dist/{cli-mkdsy1nq.js → cli-rg1c8ccw.js} +93 -7
- package/dist/cli-rh44xx46.js +2659 -0
- package/dist/cli-s0k2j7q9.js +2084 -0
- package/dist/{cli-ty4bzkqr.js → cli-sajr098b.js} +1 -1
- package/dist/{cli-6vfz44sr.js → cli-swrz3mj8.js} +1 -1
- package/dist/{cli-p8rt8spp.js → cli-vejds37t.js} +2 -2
- package/dist/{cli-jbzbr36n.js → cli-w6xa5nf5.js} +1 -1
- package/dist/{cli-9q3wt23f.js → cli-x6z90nq9.js} +1 -1
- package/dist/{cli-qzmzwegq.js → cli-ysnkvdx6.js} +102 -9
- package/dist/{cli-y0kjpfm9.js → cli-yvxfaem2.js} +2 -2
- package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
- package/dist/{cli-6bpvv8h4.js → cli-zzev32jw.js} +1 -1
- package/dist/cli.js +20 -17
- package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-0kpg09ca.js} +3 -3
- package/dist/{codex-hook-15dgc4cb.js → codex-hook-295jpbfa.js} +2 -2
- package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-8n672hxh.js} +3 -3
- package/dist/{commands-h6zaaxym.js → commands-mhmg5n0x.js} +4 -4
- package/dist/{cursor-adapter-v8mm6bp5.js → cursor-adapter-wn8n5zzk.js} +2 -2
- package/dist/{db-qg3myn53.js → db-wa6s1mwv.js} +4 -4
- package/dist/{dispatcher-7vnwj9qw.js → dispatcher-nhs2pkr8.js} +16 -14
- package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
- package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-28pkdt80.js} +3 -3
- package/dist/{handlers-2qtkpncr.js → handlers-5rtf9nng.js} +19 -17
- package/dist/{hook-51yfcgsv.js → hook-krjfkpzy.js} +4 -4
- package/dist/{hook-qnbdgwvd.js → hook-qkm73zk6.js} +5 -5
- package/dist/{http-jsmtag08.js → http-a397xcn4.js} +4845 -3484
- package/dist/{index-2t9kesn1.js → index-ac0v8v9d.js} +4 -4
- package/dist/{index-ekywyv2v.js → index-kqre45fr.js} +16 -14
- package/dist/{index-bt8wkv1w.js → index-rtxg15ah.js} +17 -15
- package/dist/{index-t4g06w93.js → index-vvsq7v45.js} +26 -23
- package/dist/{index-cx1sb9bq.js → index-ytdhtdry.js} +50 -11
- package/dist/{keepalive-4aezx4bj.js → keepalive-psfh8ysx.js} +6 -6
- package/dist/{lead-r44bkpm5.js → lead-m6rppjn7.js} +29 -27
- package/dist/{maintenance-grz5jrzh.js → maintenance-41f837ke.js} +4 -4
- package/dist/{node-qm9eb6z0.js → node-fkajaq96.js} +1 -1
- package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-6hr55hm8.js} +5 -5
- package/dist/{onboard-v60zgqf0.js → onboard-ktrtd7cz.js} +2 -2
- package/dist/{otel-impl-ghh4dyf8.js → otel-impl-dq4f2k3m.js} +1 -1
- package/dist/{pi-mono-adapter-0eg8kw2c.js → pi-mono-adapter-e5v9w0hv.js} +3 -2
- package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-kmk7j1ra.js} +6 -6
- package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-9w1dkkxd.js} +4 -4
- package/dist/{rbac-roles-axv4d74x.js → rbac-roles-9ykx9tc5.js} +5 -5
- package/dist/{render-v2-k50j33qd.js → render-v2-ycqm53np.js} +5 -5
- package/dist/resume-cq2pkp8q.js +70 -0
- package/dist/{setup-4syh0pd2.js → setup-0dcmdvbn.js} +2 -2
- package/dist/{virtual-modules-ftr9kwe8.js → virtual-modules-1x9k4jcs.js} +1 -1
- package/dist/{work-objects-jcdbg8rr.js → work-objects-x01x2fh8.js} +19 -17
- package/dist/{worker-x8j8c9sx.js → worker-py1ssb1h.js} +29 -27
- package/openapi.json +15622 -15042
- package/package.json +8 -5
- package/src/apps/store.ts +1 -0
- package/src/artifact-sdk/feedback-overlay.ts +528 -0
- package/src/be/db/workflow-runs.ts +95 -0
- package/src/be/db-queries/slack-inbound.ts +489 -0
- package/src/be/db.ts +9 -2
- package/src/be/extensions/assets.ts +1 -1
- package/src/be/migrations/203_workflow_run_parent_step.sql +10 -0
- package/src/be/migrations/204_slack_inbound_receipts.sql +51 -0
- package/src/be/script-connections.ts +41 -7
- package/src/be/scripts/run-saved.ts +40 -20
- package/src/be/swarm-config-guard.ts +4 -0
- package/src/extensions/catalog.generated.json +17 -0
- package/src/extensions/catalog.ts +1 -1
- package/src/extensions/dispatcher.ts +3 -3
- package/src/heartbeat/heartbeat.ts +152 -2
- package/src/heartbeat/templates.ts +16 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/index.ts +2 -0
- package/src/http/integrations.ts +3 -1
- package/src/http/mcp-bridge.ts +1 -0
- package/src/http/pages-public.ts +7 -2
- package/src/http/pages.ts +178 -0
- package/src/http/script-connections.ts +35 -0
- package/src/http/slack-inbound.ts +107 -0
- package/src/http/tasks.ts +45 -27
- package/src/http/users.ts +1 -1
- package/src/http/webhooks.ts +20 -20
- package/src/prompts/session-templates.ts +32 -0
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +2 -2
- package/src/providers/claude-adapter.ts +6 -0
- package/src/providers/claude-background-keepalive.ts +100 -0
- package/src/providers/claude-managed-adapter.ts +2 -11
- package/src/providers/claude-sdk-session.ts +7 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +1 -2
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/realtime/browser.generated.txt +8 -8
- package/src/realtime/browser.ts +3 -3
- package/src/scheduler/scheduler.ts +82 -13
- package/src/scripts-runtime/executors/quickjs-pool-config.ts +26 -0
- package/src/scripts-runtime/executors/quickjs.ts +9 -2
- package/src/scripts-runtime/executors/registry.ts +1 -1
- package/src/server.ts +1 -0
- package/src/slack/actions.ts +6 -2
- package/src/slack/app.ts +5 -0
- package/src/slack/assistant.ts +15 -5
- package/src/slack/egress-scrub.ts +3 -2
- package/src/slack/handlers.ts +36 -18
- package/src/slack/inbound-dispatch.ts +549 -0
- package/src/slack/inbound-files.ts +2 -0
- package/src/telemetry-context.ts +1 -0
- package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
- package/src/tests/claude-background-keepalive.fixture.ts +162 -0
- package/src/tests/claude-background-keepalive.test.ts +139 -0
- package/src/tests/codex-oauth-device.test.ts +1 -1
- package/src/tests/connection-embedded-auth.test.ts +51 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/extensions-catalog.test.ts +1 -0
- package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
- package/src/tests/heartbeat-checklist.test.ts +243 -0
- package/src/tests/pages-feedback.test.ts +218 -0
- package/src/tests/schedule-target-type.test.ts +136 -0
- package/src/tests/script-connections-http.test.ts +45 -0
- package/src/tests/script-connections-mcp.test.ts +41 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/script-executor-quickjs-pool.test.ts +106 -0
- package/src/tests/script-executor-registry.test.ts +56 -0
- package/src/tests/send-task-self-send.test.ts +107 -0
- package/src/tests/slack-inbound-diagnostics-auth.test.ts +93 -0
- package/src/tests/slack-inbound-receipts.test.ts +684 -0
- package/src/tests/slack-session-status.test.ts +4 -2
- package/src/tests/ui-e2e-ingest-payload.test.ts +5 -1
- package/src/tests/user-token-routes.test.ts +1 -1
- package/src/tests/workflow-executors.test.ts +3 -2
- package/src/tests/workflow-sub-workflow.test.ts +363 -0
- package/src/tools/script-connections/tool.ts +35 -3
- package/src/tools/send-task.ts +9 -1
- package/src/utils/internal-ai/complete-structured.ts +6 -3
- package/src/utils/runtime-model-catalog.ts +1 -1
- package/src/workflows/engine.ts +17 -1
- package/src/workflows/executors/agent-task.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +3 -3
- package/src/workflows/executors/registry.ts +2 -0
- package/src/workflows/executors/script.ts +1 -1
- package/src/workflows/executors/sub-workflow.ts +135 -0
- package/src/workflows/executors/wait.ts +3 -3
- package/src/workflows/recovery.ts +11 -1
- package/src/workflows/resume.ts +77 -0
- package/src/workflows/wait-filter.ts +5 -7
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/slack-reply-gate/README.md +98 -0
- package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
- package/templates/extensions/slack-reply-gate/manifest.json +8 -0
- package/templates/skills/heartbeat-runbook/content.md +3 -1
- package/templates/skills/pages/content.md +25 -0
- package/templates/skills/swarm-scripts/SKILL.md +10 -0
- package/templates/skills/swarm-scripts/content.md +10 -0
|
@@ -0,0 +1,2084 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MODEL,
|
|
3
|
+
resolveCredential
|
|
4
|
+
} from "./cli-7mhy3ths.js";
|
|
5
|
+
import {
|
|
6
|
+
getSlackApp
|
|
7
|
+
} from "./cli-e5qk3m2a.js";
|
|
8
|
+
import {
|
|
9
|
+
claimApprovalCancellationNotification,
|
|
10
|
+
completeApprovalCancellationNotificationClaim,
|
|
11
|
+
createTaskExtended,
|
|
12
|
+
getTaskById,
|
|
13
|
+
init_db1 as init_db,
|
|
14
|
+
releaseApprovalCancellationNotificationClaim
|
|
15
|
+
} from "./cli-rg1c8ccw.js";
|
|
16
|
+
import {
|
|
17
|
+
TERMINAL_TASK_STATUSES,
|
|
18
|
+
init_types
|
|
19
|
+
} from "./cli-xvdgasgy.js";
|
|
20
|
+
import {
|
|
21
|
+
init_resolver,
|
|
22
|
+
resolveTemplate
|
|
23
|
+
} from "./cli-5c2j5yvx.js";
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_OPENROUTER_BASE_URL,
|
|
26
|
+
getOpenRouterAttributionHeaders,
|
|
27
|
+
getOpenRouterBaseUrl,
|
|
28
|
+
init_openrouter_base_url
|
|
29
|
+
} from "./cli-a35hzdve.js";
|
|
30
|
+
import {
|
|
31
|
+
getAppUrl,
|
|
32
|
+
init_constants
|
|
33
|
+
} from "./cli-gd4favx9.js";
|
|
34
|
+
import {
|
|
35
|
+
init_zod
|
|
36
|
+
} from "./cli-sd0mxa3c.js";
|
|
37
|
+
import {
|
|
38
|
+
exports_external
|
|
39
|
+
} from "./cli-w5e1d1vv.js";
|
|
40
|
+
import {
|
|
41
|
+
init_secret_scrubber,
|
|
42
|
+
scrubSecrets
|
|
43
|
+
} from "./cli-wt7ccy4r.js";
|
|
44
|
+
import {
|
|
45
|
+
__require
|
|
46
|
+
} from "./cli-bc05qbss.js";
|
|
47
|
+
|
|
48
|
+
// src/workflows/approval-notifications.ts
|
|
49
|
+
init_db();
|
|
50
|
+
init_resolver();
|
|
51
|
+
init_types();
|
|
52
|
+
init_secret_scrubber();
|
|
53
|
+
async function postApprovalCancellationUpdates(approvals, reason, client = getSlackApp()?.client) {
|
|
54
|
+
if (!client)
|
|
55
|
+
return;
|
|
56
|
+
const text = `This approval is no longer actionable: ${reason}`;
|
|
57
|
+
for (const approval of approvals) {
|
|
58
|
+
const channels = approval.notificationChannels;
|
|
59
|
+
const slackThreads = (channels ?? []).filter((notification) => notification.channel === "slack" && notification.messageTs);
|
|
60
|
+
if (slackThreads.length === 0)
|
|
61
|
+
continue;
|
|
62
|
+
for (const notification of slackThreads) {
|
|
63
|
+
if (notification.channel !== "slack" || !notification.messageTs)
|
|
64
|
+
continue;
|
|
65
|
+
const notificationKey = `${notification.target}:${notification.messageTs}`;
|
|
66
|
+
const claim = await claimApprovalCancellationNotification(approval.id, notificationKey);
|
|
67
|
+
if (!claim)
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
await client.chat.postMessage({
|
|
71
|
+
channel: notification.target,
|
|
72
|
+
thread_ts: notification.messageTs,
|
|
73
|
+
text,
|
|
74
|
+
unfurl_links: false,
|
|
75
|
+
unfurl_media: false
|
|
76
|
+
});
|
|
77
|
+
await completeApprovalCancellationNotificationClaim(approval.id, notificationKey, claim.leaseToken);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
await releaseApprovalCancellationNotificationClaim(approval.id, notificationKey, claim.leaseToken);
|
|
80
|
+
console.error(`[HITL] Failed to post cancellation update for approval ${approval.id}:`, scrubSecrets(error instanceof Error ? error.message : String(error)));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function createApprovalFollowUpTask(request, templateEventType) {
|
|
86
|
+
if (request.workflowRunId || !request.sourceTaskId)
|
|
87
|
+
return false;
|
|
88
|
+
const sourceTask = await getTaskById(request.sourceTaskId);
|
|
89
|
+
if (!sourceTask)
|
|
90
|
+
return false;
|
|
91
|
+
if (templateEventType === "hitl.timeout" && TERMINAL_TASK_STATUSES.includes(sourceTask.status)) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const { text: taskText } = templateEventType === "hitl.follow_up" ? resolveTemplate("hitl.follow_up", {
|
|
95
|
+
request_id: request.id,
|
|
96
|
+
title: request.title,
|
|
97
|
+
status: request.status,
|
|
98
|
+
responses: formatResponses(request.questions, request.responses ?? {})
|
|
99
|
+
}) : resolveTemplate("hitl.timeout", {
|
|
100
|
+
request_id: request.id,
|
|
101
|
+
title: request.title,
|
|
102
|
+
expires_at: request.expiresAt ?? "",
|
|
103
|
+
reason: request.resolutionReason ?? ""
|
|
104
|
+
});
|
|
105
|
+
await createTaskExtended(taskText, {
|
|
106
|
+
agentId: sourceTask.agentId,
|
|
107
|
+
routingReason: sourceTask.agentId ? "continuity" : undefined,
|
|
108
|
+
routingSource: sourceTask.agentId ? "engine_default" : undefined,
|
|
109
|
+
parentTaskId: request.sourceTaskId,
|
|
110
|
+
source: "system",
|
|
111
|
+
taskType: "hitl-follow-up",
|
|
112
|
+
tags: ["hitl", "follow-up"],
|
|
113
|
+
slackChannelId: sourceTask.slackChannelId ?? undefined,
|
|
114
|
+
slackThreadTs: sourceTask.slackThreadTs ?? undefined,
|
|
115
|
+
slackUserId: sourceTask.slackUserId ?? undefined
|
|
116
|
+
});
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
function formatResponses(questions, responses) {
|
|
120
|
+
return questions.map((q) => {
|
|
121
|
+
const answer = responses[q.id];
|
|
122
|
+
let answerText;
|
|
123
|
+
if (answer == null) {
|
|
124
|
+
answerText = "(no answer)";
|
|
125
|
+
} else if (q.type === "approval") {
|
|
126
|
+
const a = answer;
|
|
127
|
+
answerText = a.approved ? "Approved" : "Rejected";
|
|
128
|
+
if (a.comment)
|
|
129
|
+
answerText += ` — ${a.comment}`;
|
|
130
|
+
} else if (typeof answer === "object") {
|
|
131
|
+
answerText = JSON.stringify(answer);
|
|
132
|
+
} else {
|
|
133
|
+
answerText = String(answer);
|
|
134
|
+
}
|
|
135
|
+
return `- ${q.label}: ${answerText}`;
|
|
136
|
+
}).join(`
|
|
137
|
+
`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/workflows/executors/system-one-decision.ts
|
|
141
|
+
init_zod();
|
|
142
|
+
init_openrouter_base_url();
|
|
143
|
+
init_secret_scrubber();
|
|
144
|
+
|
|
145
|
+
// src/workflows/executors/base.ts
|
|
146
|
+
class BaseExecutor {
|
|
147
|
+
deps;
|
|
148
|
+
retryPolicy;
|
|
149
|
+
constructor(deps) {
|
|
150
|
+
this.deps = deps;
|
|
151
|
+
}
|
|
152
|
+
async checkReadiness(_nodes) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
resolveApproval(_parkedOutput, _approval) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
async run(input) {
|
|
159
|
+
const configResult = this.configSchema.safeParse(input.config);
|
|
160
|
+
if (!configResult.success) {
|
|
161
|
+
return {
|
|
162
|
+
status: "failed",
|
|
163
|
+
error: `Input validation failed: ${configResult.error.message}`
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
let result;
|
|
167
|
+
try {
|
|
168
|
+
result = await this.execute(configResult.data, input.context, input.meta);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return {
|
|
171
|
+
status: "failed",
|
|
172
|
+
error: `Executor threw: ${err instanceof Error ? err.message : String(err)}`
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (result.status === "success" && result.output !== undefined) {
|
|
176
|
+
const outputResult = this.outputSchema.safeParse(result.output);
|
|
177
|
+
if (!outputResult.success) {
|
|
178
|
+
return {
|
|
179
|
+
status: "failed",
|
|
180
|
+
error: `Output validation failed: ${outputResult.error.message}`
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/workflows/executors/human-in-the-loop.ts
|
|
189
|
+
init_zod();
|
|
190
|
+
init_constants();
|
|
191
|
+
init_secret_scrubber();
|
|
192
|
+
var SelectOptionSchema = exports_external.object({
|
|
193
|
+
value: exports_external.string(),
|
|
194
|
+
label: exports_external.string(),
|
|
195
|
+
description: exports_external.string().optional()
|
|
196
|
+
});
|
|
197
|
+
var QuestionSchema = exports_external.discriminatedUnion("type", [
|
|
198
|
+
exports_external.object({
|
|
199
|
+
id: exports_external.string(),
|
|
200
|
+
type: exports_external.literal("approval"),
|
|
201
|
+
label: exports_external.string(),
|
|
202
|
+
required: exports_external.boolean().default(true),
|
|
203
|
+
description: exports_external.string().optional()
|
|
204
|
+
}),
|
|
205
|
+
exports_external.object({
|
|
206
|
+
id: exports_external.string(),
|
|
207
|
+
type: exports_external.literal("text"),
|
|
208
|
+
label: exports_external.string(),
|
|
209
|
+
required: exports_external.boolean().default(true),
|
|
210
|
+
description: exports_external.string().optional(),
|
|
211
|
+
placeholder: exports_external.string().optional(),
|
|
212
|
+
multiline: exports_external.boolean().optional()
|
|
213
|
+
}),
|
|
214
|
+
exports_external.object({
|
|
215
|
+
id: exports_external.string(),
|
|
216
|
+
type: exports_external.literal("single-select"),
|
|
217
|
+
label: exports_external.string(),
|
|
218
|
+
required: exports_external.boolean().default(true),
|
|
219
|
+
description: exports_external.string().optional(),
|
|
220
|
+
options: exports_external.array(SelectOptionSchema)
|
|
221
|
+
}),
|
|
222
|
+
exports_external.object({
|
|
223
|
+
id: exports_external.string(),
|
|
224
|
+
type: exports_external.literal("multi-select"),
|
|
225
|
+
label: exports_external.string(),
|
|
226
|
+
required: exports_external.boolean().default(true),
|
|
227
|
+
description: exports_external.string().optional(),
|
|
228
|
+
options: exports_external.array(SelectOptionSchema),
|
|
229
|
+
minSelections: exports_external.number().int().min(0).optional(),
|
|
230
|
+
maxSelections: exports_external.number().int().min(1).optional()
|
|
231
|
+
}),
|
|
232
|
+
exports_external.object({
|
|
233
|
+
id: exports_external.string(),
|
|
234
|
+
type: exports_external.literal("boolean"),
|
|
235
|
+
label: exports_external.string(),
|
|
236
|
+
required: exports_external.boolean().default(true),
|
|
237
|
+
description: exports_external.string().optional(),
|
|
238
|
+
defaultValue: exports_external.boolean().optional()
|
|
239
|
+
})
|
|
240
|
+
]);
|
|
241
|
+
var ApproverConfigSchema = exports_external.object({
|
|
242
|
+
users: exports_external.array(exports_external.string()).optional(),
|
|
243
|
+
roles: exports_external.array(exports_external.string()).optional(),
|
|
244
|
+
policy: exports_external.union([exports_external.literal("any"), exports_external.literal("all"), exports_external.object({ min: exports_external.number().int().min(1) })])
|
|
245
|
+
});
|
|
246
|
+
var NotificationConfigSchema = exports_external.object({
|
|
247
|
+
channel: exports_external.enum(["slack", "email"]),
|
|
248
|
+
target: exports_external.string()
|
|
249
|
+
});
|
|
250
|
+
var HITLTimeoutSchema = exports_external.object({
|
|
251
|
+
seconds: exports_external.number().int().min(1),
|
|
252
|
+
action: exports_external.literal("reject")
|
|
253
|
+
});
|
|
254
|
+
var DYNAMIC_QUESTIONS_TOKEN_RE = /^\{\{[^}]+\}\}$/;
|
|
255
|
+
var MAX_HITL_QUESTIONS = 100;
|
|
256
|
+
var SLACK_SECTION_TEXT_LIMIT = 3000;
|
|
257
|
+
var MAX_REPORTED_ISSUES = 5;
|
|
258
|
+
var HITLConfigSchema = exports_external.object({
|
|
259
|
+
title: exports_external.string(),
|
|
260
|
+
questions: exports_external.union([
|
|
261
|
+
exports_external.array(QuestionSchema).min(1),
|
|
262
|
+
exports_external.string().regex(DYNAMIC_QUESTIONS_TOKEN_RE, "questions must be an array or one exact {{interpolation}} token")
|
|
263
|
+
]),
|
|
264
|
+
approvers: ApproverConfigSchema,
|
|
265
|
+
timeout: HITLTimeoutSchema.optional(),
|
|
266
|
+
notifications: exports_external.array(NotificationConfigSchema).optional()
|
|
267
|
+
});
|
|
268
|
+
var HITLOutputSchema = exports_external.object({
|
|
269
|
+
requestId: exports_external.string().uuid(),
|
|
270
|
+
status: exports_external.string(),
|
|
271
|
+
responses: exports_external.record(exports_external.string(), exports_external.unknown()).nullable()
|
|
272
|
+
});
|
|
273
|
+
function resolveHitlQuestions(value) {
|
|
274
|
+
const fail = (reason) => ({
|
|
275
|
+
ok: false,
|
|
276
|
+
error: `human-in-the-loop questions ${reason}`
|
|
277
|
+
});
|
|
278
|
+
if (typeof value === "string") {
|
|
279
|
+
return fail(value.trim() === "" ? "resolved to an empty value: the {{token}} did not resolve (check the node's inputs mapping and the upstream output)" : "resolved to a string; they must resolve to an array of question objects (use one exact {{token}} pointing at an array)");
|
|
280
|
+
}
|
|
281
|
+
if (!Array.isArray(value)) {
|
|
282
|
+
return fail(`must resolve to an array of question objects, got ${value === null ? "null" : typeof value}`);
|
|
283
|
+
}
|
|
284
|
+
if (value.length === 0) {
|
|
285
|
+
return fail("resolved to an empty array; refusing to create an approval card with no questions");
|
|
286
|
+
}
|
|
287
|
+
if (value.length > MAX_HITL_QUESTIONS) {
|
|
288
|
+
return fail(`resolved to ${value.length} questions, over the limit of ${MAX_HITL_QUESTIONS} per card; split or group them upstream`);
|
|
289
|
+
}
|
|
290
|
+
const issues = [];
|
|
291
|
+
const questions = [];
|
|
292
|
+
const seenIds = new Set;
|
|
293
|
+
value.forEach((raw, index) => {
|
|
294
|
+
const parsed = QuestionSchema.safeParse(raw);
|
|
295
|
+
if (!parsed.success) {
|
|
296
|
+
for (const issue of parsed.error.issues) {
|
|
297
|
+
const path = issue.path.length > 0 ? `.${issue.path.map(String).join(".")}` : "";
|
|
298
|
+
issues.push(`[${index}]${path}: ${issue.message}`);
|
|
299
|
+
}
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const question = parsed.data;
|
|
303
|
+
if (question.id.trim() === "") {
|
|
304
|
+
issues.push(`[${index}].id: must be a non-empty string`);
|
|
305
|
+
} else if (seenIds.has(question.id)) {
|
|
306
|
+
issues.push(`[${index}].id: duplicate id "${question.id}" (responses are keyed by id)`);
|
|
307
|
+
}
|
|
308
|
+
seenIds.add(question.id);
|
|
309
|
+
if ((question.type === "single-select" || question.type === "multi-select") && question.options.length === 0) {
|
|
310
|
+
issues.push(`[${index}].options: a ${question.type} question needs at least one option`);
|
|
311
|
+
}
|
|
312
|
+
questions.push(question);
|
|
313
|
+
});
|
|
314
|
+
if (issues.length > 0) {
|
|
315
|
+
const shown = issues.slice(0, MAX_REPORTED_ISSUES).join("; ");
|
|
316
|
+
const more = issues.length > MAX_REPORTED_ISSUES ? `; and ${issues.length - MAX_REPORTED_ISSUES} more issue(s)` : "";
|
|
317
|
+
return fail(`are invalid: ${shown}${more}`);
|
|
318
|
+
}
|
|
319
|
+
return { ok: true, questions };
|
|
320
|
+
}
|
|
321
|
+
function escapeSlackMrkdwn(text) {
|
|
322
|
+
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
323
|
+
}
|
|
324
|
+
function buildSlackQuestionsSummary(questions, timeoutText = "") {
|
|
325
|
+
const header = `*Questions:*
|
|
326
|
+
`;
|
|
327
|
+
const lines = questions.map((q) => `• ${escapeSlackMrkdwn(q.label.replace(/\s+/g, " ").trim())}`);
|
|
328
|
+
const fits = (shown, omitted) => {
|
|
329
|
+
const tail = omitted > 0 ? `
|
|
330
|
+
_…and ${omitted} more. Answer all of them on the dashboard._` : "";
|
|
331
|
+
const text = `${header}${shown.join(`
|
|
332
|
+
`)}${tail}${timeoutText}`;
|
|
333
|
+
return text.length <= SLACK_SECTION_TEXT_LIMIT ? text : null;
|
|
334
|
+
};
|
|
335
|
+
const full = fits(lines, 0);
|
|
336
|
+
if (full)
|
|
337
|
+
return full;
|
|
338
|
+
for (let count = lines.length - 1;count >= 0; count--) {
|
|
339
|
+
const text = fits(lines.slice(0, count), lines.length - count);
|
|
340
|
+
if (text)
|
|
341
|
+
return text;
|
|
342
|
+
}
|
|
343
|
+
return `${header}_${questions.length} questions. Answer them on the dashboard._`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
class HumanInTheLoopExecutor extends BaseExecutor {
|
|
347
|
+
type = "human-in-the-loop";
|
|
348
|
+
mode = "async";
|
|
349
|
+
configSchema = HITLConfigSchema;
|
|
350
|
+
outputSchema = HITLOutputSchema;
|
|
351
|
+
async run(input) {
|
|
352
|
+
const resolved = resolveHitlQuestions(input.config?.questions);
|
|
353
|
+
if (!resolved.ok)
|
|
354
|
+
return { status: "failed", error: resolved.error };
|
|
355
|
+
return super.run({ ...input, config: { ...input.config, questions: resolved.questions } });
|
|
356
|
+
}
|
|
357
|
+
async execute(config, _context, meta) {
|
|
358
|
+
const { db } = this.deps;
|
|
359
|
+
const existing = await db.getApprovalRequestByStepId(meta.stepId);
|
|
360
|
+
if (existing) {
|
|
361
|
+
if (existing.status !== "pending") {
|
|
362
|
+
if (existing.status === "cancelled") {
|
|
363
|
+
return {
|
|
364
|
+
status: "failed",
|
|
365
|
+
error: `Approval request was cancelled: ${existing.resolutionReason ?? "workflow run cancelled"}`
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
const nextPort = existing.status === "timeout" ? "timeout" : existing.status === "rejected" ? "rejected" : "approved";
|
|
369
|
+
return {
|
|
370
|
+
status: "success",
|
|
371
|
+
output: {
|
|
372
|
+
requestId: existing.id,
|
|
373
|
+
status: existing.status,
|
|
374
|
+
responses: existing.responses
|
|
375
|
+
},
|
|
376
|
+
nextPort
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
status: "success",
|
|
381
|
+
async: true,
|
|
382
|
+
waitFor: "approval.resolved",
|
|
383
|
+
correlationId: existing.id
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
if (!Array.isArray(config.questions)) {
|
|
387
|
+
return { status: "failed", error: "human-in-the-loop questions did not resolve to an array" };
|
|
388
|
+
}
|
|
389
|
+
const requestId = crypto.randomUUID();
|
|
390
|
+
const created = await db.createApprovalRequest({
|
|
391
|
+
id: requestId,
|
|
392
|
+
title: config.title,
|
|
393
|
+
questions: config.questions,
|
|
394
|
+
approvers: config.approvers,
|
|
395
|
+
workflowRunId: meta.runId,
|
|
396
|
+
workflowRunStepId: meta.stepId,
|
|
397
|
+
timeoutSeconds: config.timeout?.seconds,
|
|
398
|
+
notificationChannels: config.notifications,
|
|
399
|
+
createdBy: meta.requestedByUserId,
|
|
400
|
+
requireActionableWorkflow: true
|
|
401
|
+
});
|
|
402
|
+
if (created.status === "cancelled") {
|
|
403
|
+
return {
|
|
404
|
+
status: "success",
|
|
405
|
+
async: true,
|
|
406
|
+
waitFor: "approval.resolved",
|
|
407
|
+
correlationId: requestId
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (config.notifications?.length) {
|
|
411
|
+
this.dispatchNotifications(requestId, config, db).catch((err) => {
|
|
412
|
+
console.error("[HITL] Unexpected error dispatching notifications:", scrubSecrets(err instanceof Error ? err.message : String(err)));
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
status: "success",
|
|
417
|
+
async: true,
|
|
418
|
+
waitFor: "approval.resolved",
|
|
419
|
+
correlationId: requestId
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
async dispatchNotifications(requestId, config, db) {
|
|
423
|
+
if (!config.notifications?.length)
|
|
424
|
+
return;
|
|
425
|
+
const approvalUrl = `${getAppUrl()}/approval-requests/${requestId}`;
|
|
426
|
+
const updatedChannels = [...config.notifications];
|
|
427
|
+
let updated = false;
|
|
428
|
+
for (let i = 0;i < config.notifications.length; i++) {
|
|
429
|
+
const notification = config.notifications[i];
|
|
430
|
+
if (notification.channel === "email") {
|
|
431
|
+
console.warn(`[HITL] Email notifications not yet supported (target: ${notification.target})`);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (notification.channel === "slack") {
|
|
435
|
+
try {
|
|
436
|
+
const { getSlackApp: getSlackApp2 } = await import("./app-80w6qspx.js");
|
|
437
|
+
const slackApp = getSlackApp2();
|
|
438
|
+
if (!slackApp) {
|
|
439
|
+
console.warn("[HITL] Slack not initialized — cannot send notification");
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
const questions = Array.isArray(config.questions) ? config.questions : [];
|
|
443
|
+
const questionsText = buildSlackQuestionsSummary(questions);
|
|
444
|
+
const timeoutCaption = config.timeout ? [
|
|
445
|
+
{
|
|
446
|
+
type: "context",
|
|
447
|
+
elements: [
|
|
448
|
+
{
|
|
449
|
+
type: "mrkdwn",
|
|
450
|
+
text: `⏱ Timeout: ${formatTimeout(config.timeout.seconds)} — times out if not responded`
|
|
451
|
+
}
|
|
452
|
+
]
|
|
453
|
+
}
|
|
454
|
+
] : [];
|
|
455
|
+
const blocks = [
|
|
456
|
+
{
|
|
457
|
+
type: "section",
|
|
458
|
+
text: {
|
|
459
|
+
type: "mrkdwn",
|
|
460
|
+
text: `\uD83D\uDD14 *Approval Required: ${config.title}*`
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
type: "section",
|
|
465
|
+
text: {
|
|
466
|
+
type: "mrkdwn",
|
|
467
|
+
text: questionsText
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
type: "actions",
|
|
472
|
+
elements: [
|
|
473
|
+
{
|
|
474
|
+
type: "button",
|
|
475
|
+
text: { type: "plain_text", text: "Review & Respond" },
|
|
476
|
+
url: approvalUrl,
|
|
477
|
+
style: "primary"
|
|
478
|
+
}
|
|
479
|
+
]
|
|
480
|
+
},
|
|
481
|
+
...timeoutCaption
|
|
482
|
+
];
|
|
483
|
+
const result = await slackApp.client.chat.postMessage({
|
|
484
|
+
channel: notification.target,
|
|
485
|
+
text: `Approval Required: ${config.title} — ${approvalUrl}`,
|
|
486
|
+
unfurl_links: false,
|
|
487
|
+
unfurl_media: false,
|
|
488
|
+
blocks
|
|
489
|
+
});
|
|
490
|
+
if (result.ts) {
|
|
491
|
+
updatedChannels[i] = { ...notification, messageTs: result.ts };
|
|
492
|
+
updated = true;
|
|
493
|
+
}
|
|
494
|
+
console.log(`[HITL] Slack notification sent to ${notification.target} for request ${requestId}`);
|
|
495
|
+
} catch (err) {
|
|
496
|
+
console.error(`[HITL] Failed to send Slack notification to ${notification.target}:`, scrubSecrets(err instanceof Error ? err.message : String(err)));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (updated) {
|
|
501
|
+
try {
|
|
502
|
+
await db.updateApprovalRequestNotifications(requestId, updatedChannels);
|
|
503
|
+
const latest = await db.getApprovalRequestById(requestId);
|
|
504
|
+
if (latest?.status === "cancelled") {
|
|
505
|
+
await postApprovalCancellationUpdates([latest], latest.resolutionReason ?? "Workflow run cancelled");
|
|
506
|
+
}
|
|
507
|
+
} catch (err) {
|
|
508
|
+
console.error("[HITL] Failed to update notification channels in DB:", scrubSecrets(err instanceof Error ? err.message : String(err)));
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
function formatTimeout(seconds) {
|
|
514
|
+
if (seconds < 60)
|
|
515
|
+
return `${seconds}s`;
|
|
516
|
+
if (seconds < 3600)
|
|
517
|
+
return `${Math.round(seconds / 60)}m`;
|
|
518
|
+
const hours = Math.floor(seconds / 3600);
|
|
519
|
+
const mins = Math.round(seconds % 3600 / 60);
|
|
520
|
+
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/workflows/executors/system-one-providers.ts
|
|
524
|
+
init_openrouter_base_url();
|
|
525
|
+
|
|
526
|
+
// src/workflows/executors/system-one-wire.ts
|
|
527
|
+
class SystemOneWireError extends Error {
|
|
528
|
+
}
|
|
529
|
+
var VALIDATION_PREFIX = "SystemOne response failed validation: ";
|
|
530
|
+
function wireContractError(message) {
|
|
531
|
+
throw new SystemOneWireError(`${VALIDATION_PREFIX}${message}`);
|
|
532
|
+
}
|
|
533
|
+
function isRecord(value) {
|
|
534
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
535
|
+
}
|
|
536
|
+
var SYSTEM_ONE_WIRE = {
|
|
537
|
+
buildRequest: (config, model) => ({
|
|
538
|
+
state: config.state,
|
|
539
|
+
...model === undefined ? {} : { model },
|
|
540
|
+
questions: config.questions
|
|
541
|
+
}),
|
|
542
|
+
toSystemOne: (body) => body
|
|
543
|
+
};
|
|
544
|
+
var CLOUDFLARE_WIRE = {
|
|
545
|
+
buildRequest: SYSTEM_ONE_WIRE.buildRequest,
|
|
546
|
+
toSystemOne(body) {
|
|
547
|
+
if (!isRecord(body))
|
|
548
|
+
wireContractError("response body must be a JSON object");
|
|
549
|
+
if (body.success !== true) {
|
|
550
|
+
const code = cloudflareErrorCode(body);
|
|
551
|
+
throw new SystemOneWireError(`Cloudflare Workers AI reported a failed run${code ? ` (${code})` : ""}. No decision was made.`);
|
|
552
|
+
}
|
|
553
|
+
if (!isRecord(body.result))
|
|
554
|
+
wireContractError("Cloudflare response is missing its result");
|
|
555
|
+
return body.result;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
var ERROR_CODE_RE = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
559
|
+
function cloudflareErrorCode(body) {
|
|
560
|
+
const first = Array.isArray(body.errors) ? body.errors[0] : undefined;
|
|
561
|
+
const code = isRecord(first) ? first.code : undefined;
|
|
562
|
+
if (typeof code === "number" && Number.isSafeInteger(code))
|
|
563
|
+
return String(code);
|
|
564
|
+
return typeof code === "string" && ERROR_CODE_RE.test(code) ? code : undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/workflows/executors/system-one-openai-wire.ts
|
|
568
|
+
function entryText(entry) {
|
|
569
|
+
if (entry === undefined || entry === null)
|
|
570
|
+
return;
|
|
571
|
+
return typeof entry === "string" ? entry : JSON.stringify(entry);
|
|
572
|
+
}
|
|
573
|
+
function openaiInput(state) {
|
|
574
|
+
if (typeof state === "string")
|
|
575
|
+
return state;
|
|
576
|
+
if (Array.isArray(state)) {
|
|
577
|
+
return [
|
|
578
|
+
{
|
|
579
|
+
role: "user",
|
|
580
|
+
content: state.map((text) => ({ type: "input_text", text: String(text) }))
|
|
581
|
+
}
|
|
582
|
+
];
|
|
583
|
+
}
|
|
584
|
+
return JSON.stringify(state);
|
|
585
|
+
}
|
|
586
|
+
function openaiQuestion(name, question) {
|
|
587
|
+
const instructions = entryText(question.instructions) ?? "";
|
|
588
|
+
switch (question.type) {
|
|
589
|
+
case "noul": {
|
|
590
|
+
const whenTrue = entryText(question.criteria?.true);
|
|
591
|
+
const whenFalse = entryText(question.criteria?.false);
|
|
592
|
+
const parts = [instructions];
|
|
593
|
+
if (whenTrue)
|
|
594
|
+
parts.push(`True when: ${whenTrue}`);
|
|
595
|
+
if (whenFalse)
|
|
596
|
+
parts.push(`False when: ${whenFalse}`);
|
|
597
|
+
return { type: "predicate", name, instructions: parts.join(`
|
|
598
|
+
|
|
599
|
+
`) };
|
|
600
|
+
}
|
|
601
|
+
case "choice":
|
|
602
|
+
return {
|
|
603
|
+
type: "choice",
|
|
604
|
+
name,
|
|
605
|
+
instructions,
|
|
606
|
+
choices: Object.entries(question.criteria).map(([value, entry]) => {
|
|
607
|
+
const description = entryText(entry);
|
|
608
|
+
return description === undefined ? { value } : { value, description };
|
|
609
|
+
})
|
|
610
|
+
};
|
|
611
|
+
case "score":
|
|
612
|
+
return {
|
|
613
|
+
type: "score",
|
|
614
|
+
name,
|
|
615
|
+
instructions,
|
|
616
|
+
levels: question.criteria.map((entry, index) => ({
|
|
617
|
+
label: entryText(entry) ?? String(index)
|
|
618
|
+
}))
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function probabilityMap(value, at) {
|
|
623
|
+
if (!Array.isArray(value))
|
|
624
|
+
return value;
|
|
625
|
+
const out = {};
|
|
626
|
+
for (const item of value) {
|
|
627
|
+
if (!isRecord(item) || typeof item.value !== "string" && typeof item.value !== "number") {
|
|
628
|
+
wireContractError(`${at} entries must carry a string or number value`);
|
|
629
|
+
}
|
|
630
|
+
const key = String(item.value);
|
|
631
|
+
if (Object.hasOwn(out, key))
|
|
632
|
+
wireContractError(`${at} lists an option twice`);
|
|
633
|
+
out[key] = item.probability;
|
|
634
|
+
}
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
function toSystemOneAnswer(id, question, raw) {
|
|
638
|
+
const at = `answer "${id}".probabilities`;
|
|
639
|
+
switch (raw.type) {
|
|
640
|
+
case "predicate":
|
|
641
|
+
return { type: "noul", noul: raw.probability };
|
|
642
|
+
case "choice":
|
|
643
|
+
return {
|
|
644
|
+
type: "choice",
|
|
645
|
+
choice: raw.choice,
|
|
646
|
+
probabilities: probabilityMap(raw.probabilities, at),
|
|
647
|
+
confidence: raw.confidence
|
|
648
|
+
};
|
|
649
|
+
case "score":
|
|
650
|
+
return {
|
|
651
|
+
type: "score",
|
|
652
|
+
score: raw.score,
|
|
653
|
+
probabilities: probabilityMap(raw.probabilities, at),
|
|
654
|
+
legend: question.type === "score" ? Object.fromEntries(question.criteria.map((entry, index) => [String(index), entry])) : undefined,
|
|
655
|
+
confidence: raw.confidence
|
|
656
|
+
};
|
|
657
|
+
default:
|
|
658
|
+
return raw;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
var OPENAI_DECISIONS_WIRE = {
|
|
662
|
+
buildRequest: (config, model) => ({
|
|
663
|
+
...model === undefined ? {} : { model },
|
|
664
|
+
input: openaiInput(config.state),
|
|
665
|
+
questions: Object.entries(config.questions).map(([name, question]) => openaiQuestion(name, question))
|
|
666
|
+
}),
|
|
667
|
+
toSystemOne(body, { questions }) {
|
|
668
|
+
if (!isRecord(body))
|
|
669
|
+
wireContractError("response body must be a JSON object");
|
|
670
|
+
if (!Array.isArray(body.answers))
|
|
671
|
+
wireContractError("response is missing the answers array");
|
|
672
|
+
const byName = new Map;
|
|
673
|
+
for (const answer of body.answers) {
|
|
674
|
+
if (!isRecord(answer) || typeof answer.name !== "string") {
|
|
675
|
+
wireContractError("every answer must be an object naming its question");
|
|
676
|
+
}
|
|
677
|
+
if (!Object.hasOwn(questions, answer.name)) {
|
|
678
|
+
wireContractError("response contains an answer for an undeclared question");
|
|
679
|
+
}
|
|
680
|
+
if (byName.has(answer.name)) {
|
|
681
|
+
wireContractError(`response answers question "${answer.name}" twice`);
|
|
682
|
+
}
|
|
683
|
+
byName.set(answer.name, answer);
|
|
684
|
+
}
|
|
685
|
+
const refused = [...byName.values()].filter((answer) => answer.type === "refusal").map((answer) => `"${answer.name}"`);
|
|
686
|
+
if (refused.length > 0) {
|
|
687
|
+
throw new SystemOneWireError(`OpenAI refused question${refused.length > 1 ? "s" : ""} ${refused.join(", ")}; no decision was made`);
|
|
688
|
+
}
|
|
689
|
+
const answers = {};
|
|
690
|
+
for (const [id, answer] of byName) {
|
|
691
|
+
const question = questions[id];
|
|
692
|
+
if (question)
|
|
693
|
+
answers[id] = toSystemOneAnswer(id, question, answer);
|
|
694
|
+
}
|
|
695
|
+
const usage = isRecord(body.usage) ? { input_tokens: body.usage.input_tokens, output_tokens: body.usage.output_tokens } : body.usage;
|
|
696
|
+
return { model: body.model, answers, usage };
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// src/workflows/executors/workflow-llm.ts
|
|
701
|
+
init_openrouter_base_url();
|
|
702
|
+
async function resolveWorkflowLlmConfig(requestedModel, env = process.env, options = {}) {
|
|
703
|
+
const credential = await resolveCredential({ env });
|
|
704
|
+
if (!credential) {
|
|
705
|
+
throw new Error("No workflow LLM credential found. Set OPENROUTER_API_KEY or OPENAI_API_KEY.");
|
|
706
|
+
}
|
|
707
|
+
if (options.requireKind && credential.kind !== options.requireKind) {
|
|
708
|
+
throw new Error(`No ${options.requireKind} credential found for this workflow LLM call.`);
|
|
709
|
+
}
|
|
710
|
+
if (credential.kind !== "openrouter" && credential.kind !== "openai") {
|
|
711
|
+
throw new Error(`Workflow LLM nodes do not support the resolved ${credential.kind} credential yet. Set OPENROUTER_API_KEY or OPENAI_API_KEY.`);
|
|
712
|
+
}
|
|
713
|
+
const providerPrefix = `${credential.kind}/`;
|
|
714
|
+
const model = requestedModel ?? DEFAULT_MODEL[credential.kind];
|
|
715
|
+
const baseURL = credential.kind === "openrouter" ? getOpenRouterBaseUrl(env) : undefined;
|
|
716
|
+
return {
|
|
717
|
+
apiKey: credential.apiKey,
|
|
718
|
+
baseURL,
|
|
719
|
+
headers: baseURL ? getOpenRouterAttributionHeaders(baseURL, env) : {},
|
|
720
|
+
model: model.startsWith(providerPrefix) ? model.slice(providerPrefix.length) : model
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// src/workflows/executors/system-one-providers.ts
|
|
725
|
+
var TYPESAFE_KEY = "TYPESAFE_API_KEY";
|
|
726
|
+
var OPENROUTER_KEY = "OPENROUTER_API_KEY";
|
|
727
|
+
var LAYA_KEY = "LAYA_API_KEY";
|
|
728
|
+
var LAYA_URL_KEY = "LAYA_URL";
|
|
729
|
+
var OPENAI_DECISIONS_KEY = "OPENAI_DECISIONS_API_KEY";
|
|
730
|
+
var CLOUDFLARE_TOKEN_KEY = "CLOUDFLARE_API_TOKEN";
|
|
731
|
+
var CLOUDFLARE_ACCOUNT_KEY = "CLOUDFLARE_ACCOUNT_ID";
|
|
732
|
+
var CLOUDFLARE_ACCOUNT_RE = /^[0-9a-f]{32}$/;
|
|
733
|
+
async function readGlobalConfig(db, key) {
|
|
734
|
+
const rows = await db.getSwarmConfigs({ scope: "global", key });
|
|
735
|
+
return rows.find((row) => row.scope === "global" && row.key === key)?.value;
|
|
736
|
+
}
|
|
737
|
+
var SYSTEM_ONE_PROVIDERS = {
|
|
738
|
+
typesafe: {
|
|
739
|
+
label: "TypeSafe",
|
|
740
|
+
keyName: TYPESAFE_KEY,
|
|
741
|
+
endpoint: "https://api.typesafe.ai/v1/systemone",
|
|
742
|
+
defaultModel: "jev-latest",
|
|
743
|
+
confidenceField: "confidence",
|
|
744
|
+
readKey: ({ db }) => readGlobalConfig(db, TYPESAFE_KEY)
|
|
745
|
+
},
|
|
746
|
+
openrouter: {
|
|
747
|
+
label: "OpenRouter",
|
|
748
|
+
keyName: OPENROUTER_KEY,
|
|
749
|
+
endpoint: "https://openrouter.ai/api/alpha/decisions",
|
|
750
|
+
defaultModel: "~typesafe/jev-latest",
|
|
751
|
+
confidenceField: "confidence",
|
|
752
|
+
deploymentProblem(env) {
|
|
753
|
+
return getOpenRouterBaseUrl(env) === DEFAULT_OPENROUTER_BASE_URL ? null : 'OPENROUTER_BASE_URL points at a gateway. SystemOne provider "openrouter" calls openrouter.ai directly and is not supported through a gateway; unset OPENROUTER_BASE_URL or use provider "typesafe".';
|
|
754
|
+
},
|
|
755
|
+
async readKey({ env }) {
|
|
756
|
+
try {
|
|
757
|
+
return (await resolveWorkflowLlmConfig(undefined, env, { requireKind: "openrouter" })).apiKey;
|
|
758
|
+
} catch {
|
|
759
|
+
return null;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
laya: {
|
|
764
|
+
label: "laya",
|
|
765
|
+
keyName: LAYA_KEY,
|
|
766
|
+
endpoint: { configKey: LAYA_URL_KEY, path: "/v1/systemone" },
|
|
767
|
+
confidenceField: "answer_confidence",
|
|
768
|
+
readKey: ({ db }) => readGlobalConfig(db, LAYA_KEY)
|
|
769
|
+
},
|
|
770
|
+
openai: {
|
|
771
|
+
label: "OpenAI",
|
|
772
|
+
keyName: OPENAI_DECISIONS_KEY,
|
|
773
|
+
endpoint: "https://api.openai.com/v1/decisions",
|
|
774
|
+
defaultModel: "gpt-6-luna",
|
|
775
|
+
confidenceField: "confidence",
|
|
776
|
+
wire: OPENAI_DECISIONS_WIRE,
|
|
777
|
+
readKey: ({ db }) => readGlobalConfig(db, OPENAI_DECISIONS_KEY)
|
|
778
|
+
},
|
|
779
|
+
cloudflare: {
|
|
780
|
+
label: "Cloudflare Workers AI",
|
|
781
|
+
keyName: CLOUDFLARE_TOKEN_KEY,
|
|
782
|
+
endpoint: {
|
|
783
|
+
configKey: CLOUDFLARE_ACCOUNT_KEY,
|
|
784
|
+
describe: "the 32-character id of a Cloudflare account",
|
|
785
|
+
build: (accountId, model) => {
|
|
786
|
+
const id = accountId.trim();
|
|
787
|
+
return CLOUDFLARE_ACCOUNT_RE.test(id) ? `https://api.cloudflare.com/client/v4/accounts/${id}/ai/run/@cf/cloudflare/${model}` : null;
|
|
788
|
+
}
|
|
789
|
+
},
|
|
790
|
+
defaultModel: "clef",
|
|
791
|
+
allowedModels: ["clef", "clef-flash"],
|
|
792
|
+
limits: { maxQuestions: 64 },
|
|
793
|
+
confidenceField: "confidence",
|
|
794
|
+
wire: CLOUDFLARE_WIRE,
|
|
795
|
+
readKey: ({ db }) => readGlobalConfig(db, CLOUDFLARE_TOKEN_KEY)
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
var SYSTEM_ONE_PROVIDER_IDS = Object.keys(SYSTEM_ONE_PROVIDERS);
|
|
799
|
+
var SYSTEM_ONE_DEFAULT_PROVIDER = "typesafe";
|
|
800
|
+
function isSystemOneProviderId(value) {
|
|
801
|
+
return typeof value === "string" && Object.hasOwn(SYSTEM_ONE_PROVIDERS, value);
|
|
802
|
+
}
|
|
803
|
+
function systemOneProviderOf(config) {
|
|
804
|
+
if (config.provider === undefined)
|
|
805
|
+
return SYSTEM_ONE_DEFAULT_PROVIDER;
|
|
806
|
+
return isSystemOneProviderId(config.provider) ? config.provider : undefined;
|
|
807
|
+
}
|
|
808
|
+
var SET_IT = (keyName) => `Set ${keyName} as a global secret on the Secrets page (Settings > Secrets).`;
|
|
809
|
+
var SET_URL = (configKey) => `Set ${configKey} as a global config value (set-config with scope global).`;
|
|
810
|
+
function systemOneKeyMissingMessage(id) {
|
|
811
|
+
const { label, keyName } = SYSTEM_ONE_PROVIDERS[id];
|
|
812
|
+
return `${keyName} is not configured. SystemOne provider "${id}" needs a working ${label} API key. ${SET_IT(keyName)}`;
|
|
813
|
+
}
|
|
814
|
+
function systemOneKeyUnreadableMessage(id) {
|
|
815
|
+
return `Could not read ${SYSTEM_ONE_PROVIDERS[id].keyName} from swarm config`;
|
|
816
|
+
}
|
|
817
|
+
function systemOneKeyMalformedMessage(id) {
|
|
818
|
+
return `${SYSTEM_ONE_PROVIDERS[id].keyName} is not a valid bearer token value. ${SET_IT(SYSTEM_ONE_PROVIDERS[id].keyName)}`;
|
|
819
|
+
}
|
|
820
|
+
function systemOneKeyRejectedMessage(id, status, code) {
|
|
821
|
+
const { label, keyName } = SYSTEM_ONE_PROVIDERS[id];
|
|
822
|
+
const detail = code ? ` (${code})` : "";
|
|
823
|
+
return `${keyName} was rejected by ${label} (HTTP ${status}${detail}). ` + `The key is invalid, revoked, or lacks access to the model. Replace it on the Secrets page (Settings > Secrets). No decision was made.`;
|
|
824
|
+
}
|
|
825
|
+
function urlKeyOf(provider) {
|
|
826
|
+
return typeof provider.endpoint === "string" ? null : provider.endpoint.configKey;
|
|
827
|
+
}
|
|
828
|
+
function isBuiltEndpoint(endpoint) {
|
|
829
|
+
return typeof endpoint !== "string" && "build" in endpoint;
|
|
830
|
+
}
|
|
831
|
+
function urlDescriptionOf(provider) {
|
|
832
|
+
return isBuiltEndpoint(provider.endpoint) ? provider.endpoint.describe : `the URL of a ${provider.label} server`;
|
|
833
|
+
}
|
|
834
|
+
function systemOneUrlMissingMessage(id) {
|
|
835
|
+
const provider = SYSTEM_ONE_PROVIDERS[id];
|
|
836
|
+
const configKey = urlKeyOf(provider) ?? "";
|
|
837
|
+
return `${configKey} is not configured. SystemOne provider "${id}" needs ${urlDescriptionOf(provider)}. ${SET_URL(configKey)}`;
|
|
838
|
+
}
|
|
839
|
+
function systemOneUrlUnreadableMessage(id) {
|
|
840
|
+
return `Could not read ${urlKeyOf(SYSTEM_ONE_PROVIDERS[id]) ?? ""} from swarm config`;
|
|
841
|
+
}
|
|
842
|
+
function systemOneUrlInvalidMessage(id) {
|
|
843
|
+
const provider = SYSTEM_ONE_PROVIDERS[id];
|
|
844
|
+
const configKey = urlKeyOf(provider) ?? "";
|
|
845
|
+
if (isBuiltEndpoint(provider.endpoint)) {
|
|
846
|
+
return `${configKey} is not ${provider.endpoint.describe}. ${SET_URL(configKey)}`;
|
|
847
|
+
}
|
|
848
|
+
return `${configKey} is not a usable ${provider.label} server URL. It must be an https URL (http only for localhost) with no credentials, query, or fragment. ${SET_URL(configKey)}`;
|
|
849
|
+
}
|
|
850
|
+
function systemOneModelProblem(id, model) {
|
|
851
|
+
const { allowedModels } = SYSTEM_ONE_PROVIDERS[id];
|
|
852
|
+
if (!allowedModels || model !== undefined && allowedModels.includes(model))
|
|
853
|
+
return null;
|
|
854
|
+
return `SystemOne provider "${id}" serves only the models ${allowedModels.join(", ")}; set model to one of them or leave it unset.`;
|
|
855
|
+
}
|
|
856
|
+
function systemOneQuestionLimitProblem(id, count) {
|
|
857
|
+
const max = SYSTEM_ONE_PROVIDERS[id].limits?.maxQuestions;
|
|
858
|
+
return max !== undefined && count > max ? `SystemOne provider "${id}" takes at most ${max} questions per node; this node has ${count}.` : null;
|
|
859
|
+
}
|
|
860
|
+
function hasWhitespaceOrControl(value) {
|
|
861
|
+
for (const char of value) {
|
|
862
|
+
const code = char.charCodeAt(0);
|
|
863
|
+
if (code <= 32 || code === 127)
|
|
864
|
+
return true;
|
|
865
|
+
}
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
var LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
869
|
+
function systemOneEndpointFromBase(base, path) {
|
|
870
|
+
let url;
|
|
871
|
+
try {
|
|
872
|
+
url = new URL(base.trim());
|
|
873
|
+
} catch {
|
|
874
|
+
return null;
|
|
875
|
+
}
|
|
876
|
+
const secure = url.protocol === "https:";
|
|
877
|
+
const loopback = url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname);
|
|
878
|
+
if (!secure && !loopback)
|
|
879
|
+
return null;
|
|
880
|
+
if (url.username || url.password || url.search || url.hash)
|
|
881
|
+
return null;
|
|
882
|
+
return `${url.origin}${url.pathname.replace(/\/+$/, "")}${path}`;
|
|
883
|
+
}
|
|
884
|
+
async function resolveSystemOneTarget(id, ctx, overrides = {}) {
|
|
885
|
+
const provider = SYSTEM_ONE_PROVIDERS[id];
|
|
886
|
+
const deploymentProblem = provider.deploymentProblem?.(ctx.env);
|
|
887
|
+
if (deploymentProblem)
|
|
888
|
+
return { ok: false, error: deploymentProblem };
|
|
889
|
+
const errors = [];
|
|
890
|
+
let endpoint;
|
|
891
|
+
if (typeof provider.endpoint === "string") {
|
|
892
|
+
endpoint = provider.endpoint;
|
|
893
|
+
} else {
|
|
894
|
+
const { configKey } = provider.endpoint;
|
|
895
|
+
let base;
|
|
896
|
+
let readable = true;
|
|
897
|
+
try {
|
|
898
|
+
base = await (overrides.serverUrl ? overrides.serverUrl(id) : readGlobalConfig(ctx.db, configKey));
|
|
899
|
+
} catch {
|
|
900
|
+
readable = false;
|
|
901
|
+
errors.push(systemOneUrlUnreadableMessage(id));
|
|
902
|
+
}
|
|
903
|
+
if (readable) {
|
|
904
|
+
if (!base || base.trim() === "") {
|
|
905
|
+
errors.push(systemOneUrlMissingMessage(id));
|
|
906
|
+
} else {
|
|
907
|
+
const resolved = isBuiltEndpoint(provider.endpoint) ? provider.endpoint.build(base, overrides.model ?? provider.defaultModel ?? "") : systemOneEndpointFromBase(base, provider.endpoint.path);
|
|
908
|
+
if (resolved)
|
|
909
|
+
endpoint = resolved;
|
|
910
|
+
else
|
|
911
|
+
errors.push(systemOneUrlInvalidMessage(id));
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
let apiKey;
|
|
916
|
+
let keyProblem;
|
|
917
|
+
try {
|
|
918
|
+
apiKey = await (overrides.apiKey ? overrides.apiKey(id) : provider.readKey(ctx));
|
|
919
|
+
} catch {
|
|
920
|
+
keyProblem = systemOneKeyUnreadableMessage(id);
|
|
921
|
+
}
|
|
922
|
+
if (!keyProblem) {
|
|
923
|
+
if (!apiKey || apiKey.trim() === "")
|
|
924
|
+
keyProblem = systemOneKeyMissingMessage(id);
|
|
925
|
+
else if (hasWhitespaceOrControl(apiKey))
|
|
926
|
+
keyProblem = systemOneKeyMalformedMessage(id);
|
|
927
|
+
}
|
|
928
|
+
if (keyProblem)
|
|
929
|
+
errors.push(keyProblem);
|
|
930
|
+
if (errors.length > 0 || !endpoint || !apiKey)
|
|
931
|
+
return { ok: false, error: errors.join(" ") };
|
|
932
|
+
return { ok: true, apiKey, endpoint };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/workflows/executors/system-one-decision.ts
|
|
936
|
+
var SYSTEM_ONE_DECISION_NODE_TYPE = "system-one-decision";
|
|
937
|
+
var SYSTEM_ONE_ENDPOINT = SYSTEM_ONE_PROVIDERS.typesafe.endpoint;
|
|
938
|
+
var SYSTEM_ONE_API_KEY_CONFIG_KEY = SYSTEM_ONE_PROVIDERS.typesafe.keyName;
|
|
939
|
+
var SYSTEM_ONE_DEFAULT_TIMEOUT_MS = 30000;
|
|
940
|
+
var SYSTEM_ONE_MIN_TIMEOUT_MS = 1000;
|
|
941
|
+
var SYSTEM_ONE_MAX_TIMEOUT_MS = 300000;
|
|
942
|
+
var SYSTEM_ONE_DEFAULT_MAX_RETRIES = 2;
|
|
943
|
+
var SYSTEM_ONE_MAX_RETRIES_LIMIT = 3;
|
|
944
|
+
var CHOICE_MIN_OPTIONS = 2;
|
|
945
|
+
var CHOICE_MAX_OPTIONS = 255;
|
|
946
|
+
var SCORE_MIN_LEVELS = 2;
|
|
947
|
+
var SCORE_MAX_LEVELS = 10;
|
|
948
|
+
var DEADLINE_MARGIN_MS = 250;
|
|
949
|
+
var BACKOFF_BASE_MS = 500;
|
|
950
|
+
var BACKOFF_CAP_MS = 8000;
|
|
951
|
+
var DISTRIBUTION_TOLERANCE = 0.02;
|
|
952
|
+
var MAX_RESPONSE_CHARS = 1048576;
|
|
953
|
+
var MAX_ERROR_BODY_CHARS = 65536;
|
|
954
|
+
var REVIEW_PORTS = ["approved", "rejected", "timeout"];
|
|
955
|
+
var ENGINE_RETRY_DEFAULT = 3;
|
|
956
|
+
var SystemOneEntrySchema = exports_external.union([
|
|
957
|
+
exports_external.string(),
|
|
958
|
+
exports_external.null(),
|
|
959
|
+
exports_external.array(exports_external.unknown()),
|
|
960
|
+
exports_external.record(exports_external.string(), exports_external.unknown())
|
|
961
|
+
]);
|
|
962
|
+
var SystemOneInstructionsSchema = SystemOneEntrySchema.refine((value) => value !== null && !(typeof value === "string" && value.trim() === ""), { message: "instructions must not be null or blank" });
|
|
963
|
+
var SystemOneStateSchema = exports_external.union([
|
|
964
|
+
exports_external.string().min(1, "state must not be empty"),
|
|
965
|
+
exports_external.array(exports_external.string()).min(1, "state must not be an empty array"),
|
|
966
|
+
exports_external.record(exports_external.string(), exports_external.unknown())
|
|
967
|
+
]);
|
|
968
|
+
var NoulQuestionSchema = exports_external.strictObject({
|
|
969
|
+
type: exports_external.literal("noul"),
|
|
970
|
+
instructions: SystemOneInstructionsSchema,
|
|
971
|
+
criteria: exports_external.strictObject({ true: SystemOneEntrySchema.optional(), false: SystemOneEntrySchema.optional() }).refine((criteria) => criteria.true !== undefined || criteria.false !== undefined, {
|
|
972
|
+
message: "noul criteria must describe at least one of true / false"
|
|
973
|
+
}).optional()
|
|
974
|
+
});
|
|
975
|
+
var ChoiceQuestionSchema = exports_external.strictObject({
|
|
976
|
+
type: exports_external.literal("choice"),
|
|
977
|
+
instructions: SystemOneInstructionsSchema,
|
|
978
|
+
criteria: exports_external.record(exports_external.string().min(1), SystemOneEntrySchema).refine((criteria) => {
|
|
979
|
+
const count = Object.keys(criteria).length;
|
|
980
|
+
return count >= CHOICE_MIN_OPTIONS && count <= CHOICE_MAX_OPTIONS;
|
|
981
|
+
}, {
|
|
982
|
+
message: `choice criteria must define ${CHOICE_MIN_OPTIONS}-${CHOICE_MAX_OPTIONS} options`
|
|
983
|
+
})
|
|
984
|
+
});
|
|
985
|
+
var ScoreQuestionSchema = exports_external.strictObject({
|
|
986
|
+
type: exports_external.literal("score"),
|
|
987
|
+
instructions: SystemOneInstructionsSchema,
|
|
988
|
+
criteria: exports_external.array(SystemOneEntrySchema).min(SCORE_MIN_LEVELS).max(SCORE_MAX_LEVELS)
|
|
989
|
+
});
|
|
990
|
+
var SystemOneQuestionSchema = exports_external.discriminatedUnion("type", [
|
|
991
|
+
NoulQuestionSchema,
|
|
992
|
+
ChoiceQuestionSchema,
|
|
993
|
+
ScoreQuestionSchema
|
|
994
|
+
]);
|
|
995
|
+
var Unit = exports_external.number().min(0).max(1);
|
|
996
|
+
var QUESTION_ID_RE = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
997
|
+
var RESERVED_QUESTION_IDS = new Set([
|
|
998
|
+
...Object.getOwnPropertyNames(Object.prototype),
|
|
999
|
+
"prototype"
|
|
1000
|
+
]);
|
|
1001
|
+
var SystemOnePrimitiveTypeSchema = exports_external.enum(["noul", "choice", "score"]);
|
|
1002
|
+
var HumanReviewSchema = exports_external.strictObject({
|
|
1003
|
+
band: exports_external.strictObject({ min: Unit, max: Unit }).refine((band) => band.min <= band.max, { message: "band.min must not exceed band.max" }),
|
|
1004
|
+
approvers: ApproverConfigSchema,
|
|
1005
|
+
title: exports_external.string().min(1).optional(),
|
|
1006
|
+
timeout: HITLTimeoutSchema.optional(),
|
|
1007
|
+
notifications: exports_external.array(NotificationConfigSchema).optional()
|
|
1008
|
+
});
|
|
1009
|
+
var SystemOneDecisionConfigSchema = exports_external.strictObject({
|
|
1010
|
+
provider: exports_external.enum(SYSTEM_ONE_PROVIDER_IDS).default(SYSTEM_ONE_DEFAULT_PROVIDER),
|
|
1011
|
+
model: exports_external.string().min(1).optional(),
|
|
1012
|
+
state: SystemOneStateSchema,
|
|
1013
|
+
timeoutMs: exports_external.number().int().min(SYSTEM_ONE_MIN_TIMEOUT_MS).max(SYSTEM_ONE_MAX_TIMEOUT_MS).default(SYSTEM_ONE_DEFAULT_TIMEOUT_MS),
|
|
1014
|
+
maxRetries: exports_external.number().int().min(0).max(SYSTEM_ONE_MAX_RETRIES_LIMIT).default(SYSTEM_ONE_DEFAULT_MAX_RETRIES),
|
|
1015
|
+
questions: exports_external.record(exports_external.string().regex(QUESTION_ID_RE, "question ids must start with a letter or underscore and use only letters, digits, _ or -"), SystemOneQuestionSchema),
|
|
1016
|
+
returns: exports_external.record(exports_external.string(), exports_external.strictObject({ type: SystemOnePrimitiveTypeSchema })),
|
|
1017
|
+
humanReview: HumanReviewSchema.optional()
|
|
1018
|
+
}).superRefine((config, ctx) => {
|
|
1019
|
+
const questionIds = Object.keys(config.questions);
|
|
1020
|
+
if (questionIds.length === 0) {
|
|
1021
|
+
ctx.addIssue({
|
|
1022
|
+
code: "custom",
|
|
1023
|
+
path: ["questions"],
|
|
1024
|
+
message: "system-one-decision needs at least one question"
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
for (const id of questionIds) {
|
|
1028
|
+
if (RESERVED_QUESTION_IDS.has(id)) {
|
|
1029
|
+
ctx.addIssue({
|
|
1030
|
+
code: "custom",
|
|
1031
|
+
path: ["questions", id],
|
|
1032
|
+
message: `"${id}" is reserved and cannot be a question id`
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
const declared = Object.hasOwn(config.returns, id) ? config.returns[id] : undefined;
|
|
1036
|
+
if (!declared) {
|
|
1037
|
+
ctx.addIssue({
|
|
1038
|
+
code: "custom",
|
|
1039
|
+
path: ["returns"],
|
|
1040
|
+
message: `returns must declare question "${id}"`
|
|
1041
|
+
});
|
|
1042
|
+
} else if (declared.type !== config.questions[id]?.type) {
|
|
1043
|
+
ctx.addIssue({
|
|
1044
|
+
code: "custom",
|
|
1045
|
+
path: ["returns", id, "type"],
|
|
1046
|
+
message: `returns.${id}.type is "${declared.type}" but question "${id}" is "${config.questions[id]?.type}"`
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
for (const id of Object.keys(config.returns)) {
|
|
1051
|
+
if (!Object.hasOwn(config.questions, id)) {
|
|
1052
|
+
ctx.addIssue({
|
|
1053
|
+
code: "custom",
|
|
1054
|
+
path: ["returns", id],
|
|
1055
|
+
message: `returns declares "${id}" but no question has that id`
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const modelProblem = systemOneModelProblem(config.provider, config.model ?? SYSTEM_ONE_PROVIDERS[config.provider].defaultModel);
|
|
1060
|
+
if (modelProblem)
|
|
1061
|
+
ctx.addIssue({ code: "custom", path: ["model"], message: modelProblem });
|
|
1062
|
+
const limitProblem = systemOneQuestionLimitProblem(config.provider, questionIds.length);
|
|
1063
|
+
if (limitProblem)
|
|
1064
|
+
ctx.addIssue({ code: "custom", path: ["questions"], message: limitProblem });
|
|
1065
|
+
});
|
|
1066
|
+
var NoulAnswerSchema = exports_external.strictObject({ type: exports_external.literal("noul"), noul: Unit });
|
|
1067
|
+
var ChoiceAnswerSchema = exports_external.strictObject({
|
|
1068
|
+
type: exports_external.literal("choice"),
|
|
1069
|
+
choice: exports_external.string(),
|
|
1070
|
+
probabilities: exports_external.record(exports_external.string(), Unit),
|
|
1071
|
+
confidence: Unit
|
|
1072
|
+
});
|
|
1073
|
+
var ScoreAnswerSchema = exports_external.strictObject({
|
|
1074
|
+
type: exports_external.literal("score"),
|
|
1075
|
+
score: exports_external.number(),
|
|
1076
|
+
legend: exports_external.record(exports_external.string(), SystemOneEntrySchema),
|
|
1077
|
+
probabilities: exports_external.record(exports_external.string(), Unit),
|
|
1078
|
+
confidence: Unit
|
|
1079
|
+
});
|
|
1080
|
+
var SystemOneAnswerSchema = exports_external.discriminatedUnion("type", [
|
|
1081
|
+
NoulAnswerSchema,
|
|
1082
|
+
ChoiceAnswerSchema,
|
|
1083
|
+
ScoreAnswerSchema
|
|
1084
|
+
]);
|
|
1085
|
+
var ReviewQuestionSchema = exports_external.strictObject({
|
|
1086
|
+
confidence: Unit,
|
|
1087
|
+
inBand: exports_external.boolean(),
|
|
1088
|
+
decidedBy: exports_external.enum(["model", "human"]),
|
|
1089
|
+
modelAnswer: exports_external.union([exports_external.string(), exports_external.number()]).optional()
|
|
1090
|
+
});
|
|
1091
|
+
var ReviewSchema = exports_external.strictObject({
|
|
1092
|
+
status: exports_external.enum(["not_required", "pending", "approved", "rejected", "timeout"]),
|
|
1093
|
+
approvalRequestId: exports_external.string().optional(),
|
|
1094
|
+
reason: exports_external.string().optional(),
|
|
1095
|
+
questions: exports_external.record(exports_external.string(), ReviewQuestionSchema)
|
|
1096
|
+
});
|
|
1097
|
+
var SystemOneDecisionOutputSchema = exports_external.object({
|
|
1098
|
+
model: exports_external.string().min(1),
|
|
1099
|
+
answers: exports_external.record(exports_external.string(), SystemOneAnswerSchema),
|
|
1100
|
+
usage: exports_external.object({
|
|
1101
|
+
input_tokens: exports_external.number().int().min(0),
|
|
1102
|
+
output_tokens: exports_external.number().int().min(0)
|
|
1103
|
+
}),
|
|
1104
|
+
requestId: exports_external.string().optional(),
|
|
1105
|
+
routing: exports_external.strictObject({ model: exports_external.string().min(1) }).optional(),
|
|
1106
|
+
review: ReviewSchema.optional()
|
|
1107
|
+
});
|
|
1108
|
+
function systemOneRetryViolations(node) {
|
|
1109
|
+
if (node.type !== SYSTEM_ONE_DECISION_NODE_TYPE)
|
|
1110
|
+
return [];
|
|
1111
|
+
const violations = [];
|
|
1112
|
+
const attempts = (retry) => retry ? retry.maxRetries ?? ENGINE_RETRY_DEFAULT : 0;
|
|
1113
|
+
if (attempts(node.retry) > 0) {
|
|
1114
|
+
violations.push(`Node "${node.id}" (system-one-decision) must not set retry.maxRetries > 0: the system-one-decision executor retries transient transport errors itself (config.maxRetries), and engine retries would re-send rejected requests`);
|
|
1115
|
+
}
|
|
1116
|
+
if (attempts(node.validation?.retry) > 0) {
|
|
1117
|
+
violations.push(`Node "${node.id}" (system-one-decision) must not set validation.retry.maxRetries > 0: a validation-driven retry would send another paid request`);
|
|
1118
|
+
}
|
|
1119
|
+
return violations;
|
|
1120
|
+
}
|
|
1121
|
+
function systemOneStaticShapeViolations(node) {
|
|
1122
|
+
if (node.type !== SYSTEM_ONE_DECISION_NODE_TYPE)
|
|
1123
|
+
return [];
|
|
1124
|
+
const violations = [];
|
|
1125
|
+
for (const field of ["questions", "returns"]) {
|
|
1126
|
+
const value = node.config[field];
|
|
1127
|
+
if (value !== undefined && (typeof value !== "object" || value === null || Array.isArray(value))) {
|
|
1128
|
+
violations.push(`Node "${node.id}" (system-one-decision) config.${field} must be an object: question ids and return types are static, only state and descriptions may use {{tokens}}`);
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
const provider = node.config.provider;
|
|
1132
|
+
if (typeof provider === "string" && provider.includes("{{")) {
|
|
1133
|
+
violations.push(`Node "${node.id}" (system-one-decision) config.provider must be a literal (${SYSTEM_ONE_PROVIDER_IDS.join(", ")}), not a {{token}}: the provider decides which credential is checked before the run starts`);
|
|
1134
|
+
}
|
|
1135
|
+
if (isRecord2(node.config.humanReview) && node.next != null) {
|
|
1136
|
+
if (typeof node.next === "string" || Array.isArray(node.next)) {
|
|
1137
|
+
violations.push(`Node "${node.id}" (system-one-decision) sets humanReview, so next must map output ports (${REVIEW_PORTS.join(", ")}): a single or list next would run the same successors after a rejection or a timeout`);
|
|
1138
|
+
} else {
|
|
1139
|
+
const ports = Object.keys(node.next);
|
|
1140
|
+
const unknown = ports.filter((port) => !REVIEW_PORTS.includes(port));
|
|
1141
|
+
if (unknown.length > 0) {
|
|
1142
|
+
violations.push(`Node "${node.id}" (system-one-decision) sets humanReview, so next ports must be ${REVIEW_PORTS.join(", ")}; got ${unknown.map((port) => `"${port}"`).join(", ")}`);
|
|
1143
|
+
}
|
|
1144
|
+
if (!ports.includes("approved")) {
|
|
1145
|
+
violations.push(`Node "${node.id}" (system-one-decision) sets humanReview, so next must define the "approved" port: it carries every answer that is accepted, by the model or by a person`);
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
return violations;
|
|
1150
|
+
}
|
|
1151
|
+
function systemOneUnresolvedError(nodeId, tokens) {
|
|
1152
|
+
const rendered = [...new Set(tokens)].map((token) => `{{${token}}}`).join(", ");
|
|
1153
|
+
return `SystemOne Decision node "${nodeId}" has unresolved interpolation token(s): ${rendered}. ` + "No request was sent. Declare the value in the node's inputs mapping or fix the path.";
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
class SystemOneContractError extends Error {
|
|
1157
|
+
}
|
|
1158
|
+
function fail(message) {
|
|
1159
|
+
throw new SystemOneContractError(message);
|
|
1160
|
+
}
|
|
1161
|
+
function isRecord2(value) {
|
|
1162
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1163
|
+
}
|
|
1164
|
+
function unit(value, what) {
|
|
1165
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1) {
|
|
1166
|
+
fail(`${what} must be a finite number between 0 and 1`);
|
|
1167
|
+
}
|
|
1168
|
+
return value;
|
|
1169
|
+
}
|
|
1170
|
+
function distribution(value, keys, what) {
|
|
1171
|
+
if (!isRecord2(value))
|
|
1172
|
+
fail(`${what} must be an object`);
|
|
1173
|
+
const actual = Object.keys(value);
|
|
1174
|
+
const missing = keys.filter((key) => !Object.hasOwn(value, key));
|
|
1175
|
+
const extra = actual.filter((key) => !keys.includes(key));
|
|
1176
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
1177
|
+
fail(`${what} keys must be exactly the declared ${keys.length} option(s)` + (missing.length > 0 ? `; missing ${missing.length}` : "") + (extra.length > 0 ? `; ${extra.length} unexpected` : ""));
|
|
1178
|
+
}
|
|
1179
|
+
const out = {};
|
|
1180
|
+
let sum = 0;
|
|
1181
|
+
for (const key of keys) {
|
|
1182
|
+
const p = unit(value[key], `${what}["${key}"]`);
|
|
1183
|
+
out[key] = p;
|
|
1184
|
+
sum += p;
|
|
1185
|
+
}
|
|
1186
|
+
if (Math.abs(sum - 1) > DISTRIBUTION_TOLERANCE) {
|
|
1187
|
+
fail(`${what} must sum to 1 (within ${DISTRIBUTION_TOLERANCE})`);
|
|
1188
|
+
}
|
|
1189
|
+
return out;
|
|
1190
|
+
}
|
|
1191
|
+
function validateAnswer(id, question, raw, confidenceField) {
|
|
1192
|
+
const at = `answer "${id}"`;
|
|
1193
|
+
if (!isRecord2(raw))
|
|
1194
|
+
fail(`${at} must be an object`);
|
|
1195
|
+
if (raw.type !== question.type) {
|
|
1196
|
+
fail(`${at} has type ${JSON.stringify(raw.type)} but the question declares "${question.type}"`);
|
|
1197
|
+
}
|
|
1198
|
+
switch (question.type) {
|
|
1199
|
+
case "noul":
|
|
1200
|
+
return { type: "noul", noul: unit(raw.noul, `${at}.noul`) };
|
|
1201
|
+
case "choice": {
|
|
1202
|
+
const options = Object.keys(question.criteria);
|
|
1203
|
+
if (typeof raw.choice !== "string" || !Object.hasOwn(question.criteria, raw.choice)) {
|
|
1204
|
+
fail(`${at}.choice is not one of the declared options`);
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
type: "choice",
|
|
1208
|
+
choice: raw.choice,
|
|
1209
|
+
probabilities: distribution(raw.probabilities, options, `${at}.probabilities`),
|
|
1210
|
+
confidence: unit(raw[confidenceField], `${at}.${confidenceField}`)
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
case "score": {
|
|
1214
|
+
const indices = question.criteria.map((_, index) => String(index));
|
|
1215
|
+
const top = indices.length - 1;
|
|
1216
|
+
if (typeof raw.score !== "number" || !Number.isFinite(raw.score) || raw.score < 0 || raw.score > top) {
|
|
1217
|
+
fail(`${at}.score must be a finite number between 0 and ${top}`);
|
|
1218
|
+
}
|
|
1219
|
+
if (!isRecord2(raw.legend))
|
|
1220
|
+
fail(`${at}.legend must be an object`);
|
|
1221
|
+
const legend = {};
|
|
1222
|
+
for (const key of indices) {
|
|
1223
|
+
if (!Object.hasOwn(raw.legend, key))
|
|
1224
|
+
fail(`${at}.legend is missing level ${key}`);
|
|
1225
|
+
const entry = SystemOneEntrySchema.safeParse(raw.legend[key]);
|
|
1226
|
+
if (!entry.success)
|
|
1227
|
+
fail(`${at}.legend["${key}"] must be a string, object, array, or null`);
|
|
1228
|
+
legend[key] = entry.data;
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
type: "score",
|
|
1232
|
+
score: raw.score,
|
|
1233
|
+
legend,
|
|
1234
|
+
probabilities: distribution(raw.probabilities, indices, `${at}.probabilities`),
|
|
1235
|
+
confidence: unit(raw[confidenceField], `${at}.${confidenceField}`)
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
var MAX_ROUTING_MODEL_CHARS = 128;
|
|
1241
|
+
function readRoutingModel(routing) {
|
|
1242
|
+
if (!isRecord2(routing))
|
|
1243
|
+
return;
|
|
1244
|
+
const { model } = routing;
|
|
1245
|
+
return typeof model === "string" && model !== "" && model.length <= MAX_ROUTING_MODEL_CHARS ? model : undefined;
|
|
1246
|
+
}
|
|
1247
|
+
function validateSystemOneResponse(questions, body, requestId, confidenceField = "confidence") {
|
|
1248
|
+
if (!isRecord2(body))
|
|
1249
|
+
fail("response body must be a JSON object");
|
|
1250
|
+
if (typeof body.model !== "string" || body.model === "") {
|
|
1251
|
+
fail("response is missing the model that produced the answers");
|
|
1252
|
+
}
|
|
1253
|
+
if (!isRecord2(body.answers))
|
|
1254
|
+
fail("response is missing the answers object");
|
|
1255
|
+
const usage = exports_external.object({ input_tokens: exports_external.number().int().min(0), output_tokens: exports_external.number().int().min(0) }).safeParse(body.usage);
|
|
1256
|
+
if (!usage.success)
|
|
1257
|
+
fail("response usage must report non-negative integer input_tokens and output_tokens");
|
|
1258
|
+
const declared = Object.keys(questions);
|
|
1259
|
+
const unexpected = Object.keys(body.answers).filter((id) => !Object.hasOwn(questions, id));
|
|
1260
|
+
if (unexpected.length > 0)
|
|
1261
|
+
fail(`response contains ${unexpected.length} answer(s) for undeclared questions`);
|
|
1262
|
+
const answers = {};
|
|
1263
|
+
for (const id of declared) {
|
|
1264
|
+
if (!Object.hasOwn(body.answers, id))
|
|
1265
|
+
fail(`response is missing the answer for question "${id}"`);
|
|
1266
|
+
const question = questions[id];
|
|
1267
|
+
if (!question)
|
|
1268
|
+
fail(`question "${id}" is not defined`);
|
|
1269
|
+
answers[id] = validateAnswer(id, question, body.answers[id], confidenceField);
|
|
1270
|
+
}
|
|
1271
|
+
if (declared.length === 0)
|
|
1272
|
+
fail("a system-one-decision call needs at least one question");
|
|
1273
|
+
const routingModel = readRoutingModel(body.routing);
|
|
1274
|
+
return {
|
|
1275
|
+
model: body.model,
|
|
1276
|
+
answers,
|
|
1277
|
+
usage: usage.data,
|
|
1278
|
+
...requestId ? { requestId } : {},
|
|
1279
|
+
...routingModel ? { routing: { model: routingModel } } : {}
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
var REVIEW_CONFIRM_ID = "$confirm";
|
|
1283
|
+
var REVIEW_STATE_CHARS = 2000;
|
|
1284
|
+
var REVIEW_LABEL_CHARS = 160;
|
|
1285
|
+
var REVIEW_OPTION_CHARS = 200;
|
|
1286
|
+
var round6 = (value) => Math.round(value * 1e6) / 1e6;
|
|
1287
|
+
function answerConfidence(answer) {
|
|
1288
|
+
return answer.type === "noul" ? round6(Math.max(answer.noul, 1 - answer.noul)) : answer.confidence;
|
|
1289
|
+
}
|
|
1290
|
+
function clip(text, max) {
|
|
1291
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
1292
|
+
}
|
|
1293
|
+
function showEntry(entry, max) {
|
|
1294
|
+
return clip(typeof entry === "string" ? entry : JSON.stringify(entry) ?? "", max);
|
|
1295
|
+
}
|
|
1296
|
+
function modelAnswerOf(answer) {
|
|
1297
|
+
return answer.type === "choice" ? answer.choice : answer.type === "score" ? answer.score : answer.noul;
|
|
1298
|
+
}
|
|
1299
|
+
function buildReviewCard(nodeId, config, review, parked) {
|
|
1300
|
+
const state = scrubSecrets(typeof config.state === "string" ? config.state : JSON.stringify(config.state) ?? "");
|
|
1301
|
+
const questions = [
|
|
1302
|
+
{
|
|
1303
|
+
id: REVIEW_CONFIRM_ID,
|
|
1304
|
+
type: "approval",
|
|
1305
|
+
label: "Accept these answers and continue?",
|
|
1306
|
+
required: true,
|
|
1307
|
+
description: `Model ${parked.model}. Input: ${clip(state, REVIEW_STATE_CHARS)}`
|
|
1308
|
+
}
|
|
1309
|
+
];
|
|
1310
|
+
for (const [id, info] of Object.entries(parked.review?.questions ?? {})) {
|
|
1311
|
+
if (!info.inBand)
|
|
1312
|
+
continue;
|
|
1313
|
+
const question = config.questions[id];
|
|
1314
|
+
const answer = parked.answers[id];
|
|
1315
|
+
if (!question || !answer)
|
|
1316
|
+
continue;
|
|
1317
|
+
const label = `${id}: ${showEntry(question.instructions, REVIEW_LABEL_CHARS)}`;
|
|
1318
|
+
const said = `The model answered ${JSON.stringify(modelAnswerOf(answer))} at confidence ${info.confidence}.`;
|
|
1319
|
+
if (question.type === "choice" && answer.type === "choice") {
|
|
1320
|
+
questions.push({
|
|
1321
|
+
id,
|
|
1322
|
+
type: "single-select",
|
|
1323
|
+
label,
|
|
1324
|
+
required: false,
|
|
1325
|
+
description: `${said} Leave empty to confirm it, or pick another option.`,
|
|
1326
|
+
options: Object.entries(question.criteria).map(([value, entry]) => ({
|
|
1327
|
+
value,
|
|
1328
|
+
label: value,
|
|
1329
|
+
...typeof entry === "string" && entry ? { description: clip(entry, REVIEW_OPTION_CHARS) } : {}
|
|
1330
|
+
}))
|
|
1331
|
+
});
|
|
1332
|
+
} else if (question.type === "score" && answer.type === "score") {
|
|
1333
|
+
questions.push({
|
|
1334
|
+
id,
|
|
1335
|
+
type: "single-select",
|
|
1336
|
+
label,
|
|
1337
|
+
required: false,
|
|
1338
|
+
description: `${said} Leave empty to confirm it, or pick a level.`,
|
|
1339
|
+
options: question.criteria.map((entry, level) => ({
|
|
1340
|
+
value: String(level),
|
|
1341
|
+
label: `${level}: ${showEntry(entry, REVIEW_OPTION_CHARS)}`
|
|
1342
|
+
}))
|
|
1343
|
+
});
|
|
1344
|
+
} else if (question.type === "noul" && answer.type === "noul") {
|
|
1345
|
+
questions.push({
|
|
1346
|
+
id,
|
|
1347
|
+
type: "boolean",
|
|
1348
|
+
label,
|
|
1349
|
+
required: false,
|
|
1350
|
+
defaultValue: answer.noul >= 0.5,
|
|
1351
|
+
description: `${said} The switch starts on the side the model took.`
|
|
1352
|
+
});
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
return {
|
|
1356
|
+
title: review.title ?? `Review decision: ${nodeId}`,
|
|
1357
|
+
questions,
|
|
1358
|
+
approvers: review.approvers,
|
|
1359
|
+
...review.timeout ? { timeout: review.timeout } : {},
|
|
1360
|
+
...review.notifications ? { notifications: review.notifications } : {}
|
|
1361
|
+
};
|
|
1362
|
+
}
|
|
1363
|
+
function readHumanAnswer(answer, response) {
|
|
1364
|
+
if (response === undefined || response === null || response === "")
|
|
1365
|
+
return { kind: "confirm" };
|
|
1366
|
+
switch (answer.type) {
|
|
1367
|
+
case "choice":
|
|
1368
|
+
if (typeof response !== "string" || !Object.hasOwn(answer.probabilities, response)) {
|
|
1369
|
+
return { kind: "invalid" };
|
|
1370
|
+
}
|
|
1371
|
+
return response === answer.choice ? { kind: "confirm" } : { kind: "override", answer: { ...answer, choice: response } };
|
|
1372
|
+
case "score": {
|
|
1373
|
+
if (typeof response !== "string" || !Object.hasOwn(answer.legend, response)) {
|
|
1374
|
+
return { kind: "invalid" };
|
|
1375
|
+
}
|
|
1376
|
+
const level = Number(response);
|
|
1377
|
+
return level === answer.score ? { kind: "confirm" } : { kind: "override", answer: { ...answer, score: level } };
|
|
1378
|
+
}
|
|
1379
|
+
case "noul":
|
|
1380
|
+
if (typeof response !== "boolean")
|
|
1381
|
+
return { kind: "invalid" };
|
|
1382
|
+
return response === answer.noul >= 0.5 ? { kind: "confirm" } : { kind: "override", answer: { ...answer, noul: response ? 1 : 0 } };
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
function resolveReviewedDecision(parkedOutput, approval) {
|
|
1386
|
+
const parked = SystemOneDecisionOutputSchema.safeParse(parkedOutput);
|
|
1387
|
+
if (!parked.success || parked.data.review?.status !== "pending")
|
|
1388
|
+
return null;
|
|
1389
|
+
const decision = parked.data;
|
|
1390
|
+
const parkedQuestions = decision.review?.questions ?? {};
|
|
1391
|
+
const finish = (status, answers2, questions2, reason) => ({
|
|
1392
|
+
output: {
|
|
1393
|
+
...decision,
|
|
1394
|
+
answers: answers2,
|
|
1395
|
+
review: {
|
|
1396
|
+
status,
|
|
1397
|
+
approvalRequestId: approval.requestId,
|
|
1398
|
+
...reason ? { reason } : {},
|
|
1399
|
+
questions: questions2
|
|
1400
|
+
}
|
|
1401
|
+
},
|
|
1402
|
+
nextPort: status
|
|
1403
|
+
});
|
|
1404
|
+
if (approval.status !== "approved") {
|
|
1405
|
+
return finish(approval.status, decision.answers, parkedQuestions);
|
|
1406
|
+
}
|
|
1407
|
+
const answers = { ...decision.answers };
|
|
1408
|
+
const questions = { ...parkedQuestions };
|
|
1409
|
+
for (const [id, info] of Object.entries(parkedQuestions)) {
|
|
1410
|
+
const modelAnswer = decision.answers[id];
|
|
1411
|
+
if (!info.inBand || !modelAnswer)
|
|
1412
|
+
continue;
|
|
1413
|
+
const human = readHumanAnswer(modelAnswer, approval.responses?.[id]);
|
|
1414
|
+
if (human.kind === "invalid") {
|
|
1415
|
+
return finish("rejected", decision.answers, parkedQuestions, `The response for "${id}" is not one of its options`);
|
|
1416
|
+
}
|
|
1417
|
+
if (human.kind === "override")
|
|
1418
|
+
answers[id] = human.answer;
|
|
1419
|
+
questions[id] = { ...info, decidedBy: "human", modelAnswer: modelAnswerOf(modelAnswer) };
|
|
1420
|
+
}
|
|
1421
|
+
return finish("approved", answers, questions);
|
|
1422
|
+
}
|
|
1423
|
+
var REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
1424
|
+
var ERROR_CODE_RE2 = /^[A-Za-z0-9._:-]{1,64}$/;
|
|
1425
|
+
function readRequestId(headers) {
|
|
1426
|
+
const value = headers.get("x-request-id") ?? headers.get("request-id") ?? headers.get("cf-ray");
|
|
1427
|
+
return value && REQUEST_ID_RE.test(value) ? value : undefined;
|
|
1428
|
+
}
|
|
1429
|
+
function parseRetryAfter(value, now) {
|
|
1430
|
+
if (!value)
|
|
1431
|
+
return;
|
|
1432
|
+
const trimmed = value.trim();
|
|
1433
|
+
if (/^\d+(\.\d+)?$/.test(trimmed))
|
|
1434
|
+
return Math.round(Number(trimmed) * 1000);
|
|
1435
|
+
const at = Date.parse(trimmed);
|
|
1436
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - now);
|
|
1437
|
+
}
|
|
1438
|
+
function readErrorCode(text) {
|
|
1439
|
+
if (text.length === 0 || text.length > MAX_ERROR_BODY_CHARS)
|
|
1440
|
+
return;
|
|
1441
|
+
let parsed;
|
|
1442
|
+
try {
|
|
1443
|
+
parsed = JSON.parse(text);
|
|
1444
|
+
} catch {
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (!isRecord2(parsed))
|
|
1448
|
+
return;
|
|
1449
|
+
const nested = isRecord2(parsed.error) ? parsed.error : undefined;
|
|
1450
|
+
const detail = isRecord2(parsed.detail) ? parsed.detail : undefined;
|
|
1451
|
+
for (const candidate of [
|
|
1452
|
+
nested?.code,
|
|
1453
|
+
nested?.type,
|
|
1454
|
+
detail?.error_type,
|
|
1455
|
+
parsed.code,
|
|
1456
|
+
parsed.type
|
|
1457
|
+
]) {
|
|
1458
|
+
if (typeof candidate === "string" && ERROR_CODE_RE2.test(candidate))
|
|
1459
|
+
return candidate;
|
|
1460
|
+
}
|
|
1461
|
+
return cloudflareErrorCode(parsed);
|
|
1462
|
+
}
|
|
1463
|
+
function defaultSleep(ms, signal) {
|
|
1464
|
+
return new Promise((resolve) => {
|
|
1465
|
+
if (signal.aborted)
|
|
1466
|
+
return resolve();
|
|
1467
|
+
const timer = setTimeout(done, ms);
|
|
1468
|
+
function done() {
|
|
1469
|
+
clearTimeout(timer);
|
|
1470
|
+
signal.removeEventListener("abort", done);
|
|
1471
|
+
resolve();
|
|
1472
|
+
}
|
|
1473
|
+
signal.addEventListener("abort", done, { once: true });
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
class SystemOneDecisionExecutor extends BaseExecutor {
|
|
1478
|
+
options;
|
|
1479
|
+
type = SYSTEM_ONE_DECISION_NODE_TYPE;
|
|
1480
|
+
mode = "async";
|
|
1481
|
+
configSchema = SystemOneDecisionConfigSchema;
|
|
1482
|
+
outputSchema = SystemOneDecisionOutputSchema;
|
|
1483
|
+
constructor(deps, options = {}) {
|
|
1484
|
+
super(deps);
|
|
1485
|
+
this.options = options;
|
|
1486
|
+
}
|
|
1487
|
+
providerContext() {
|
|
1488
|
+
return { db: this.deps.db, env: this.options.env ?? process.env };
|
|
1489
|
+
}
|
|
1490
|
+
resolveTarget(id, model) {
|
|
1491
|
+
return resolveSystemOneTarget(id, this.providerContext(), {
|
|
1492
|
+
apiKey: this.options.getApiKey,
|
|
1493
|
+
serverUrl: this.options.getServerUrl,
|
|
1494
|
+
model
|
|
1495
|
+
});
|
|
1496
|
+
}
|
|
1497
|
+
async checkReadiness(nodes) {
|
|
1498
|
+
const nodeIdsByProvider = new Map;
|
|
1499
|
+
for (const node of nodes) {
|
|
1500
|
+
const id = systemOneProviderOf(node.config);
|
|
1501
|
+
if (id)
|
|
1502
|
+
nodeIdsByProvider.set(id, [...nodeIdsByProvider.get(id) ?? [], node.id]);
|
|
1503
|
+
}
|
|
1504
|
+
const problems = [];
|
|
1505
|
+
for (const [id, nodeIds] of nodeIdsByProvider) {
|
|
1506
|
+
const target = await this.resolveTarget(id);
|
|
1507
|
+
if (!target.ok)
|
|
1508
|
+
problems.push({ nodeIds, message: target.error });
|
|
1509
|
+
}
|
|
1510
|
+
return problems;
|
|
1511
|
+
}
|
|
1512
|
+
async execute(config, context, meta) {
|
|
1513
|
+
if (config.humanReview) {
|
|
1514
|
+
const existing = await this.deps.db.getApprovalRequestByStepId(meta.stepId);
|
|
1515
|
+
if (existing) {
|
|
1516
|
+
return this.handOffToReviewer(config, await this.readParked(meta.stepId), context, meta);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
const providerId = config.provider;
|
|
1520
|
+
const provider = SYSTEM_ONE_PROVIDERS[providerId];
|
|
1521
|
+
const model = config.model ?? provider.defaultModel;
|
|
1522
|
+
const modelProblem = systemOneModelProblem(providerId, model);
|
|
1523
|
+
if (modelProblem)
|
|
1524
|
+
return { status: "failed", error: modelProblem };
|
|
1525
|
+
const target = await this.resolveTarget(providerId, model);
|
|
1526
|
+
if (!target.ok)
|
|
1527
|
+
return { status: "failed", error: target.error };
|
|
1528
|
+
const { apiKey, endpoint } = target;
|
|
1529
|
+
const wire = provider.wire ?? SYSTEM_ONE_WIRE;
|
|
1530
|
+
const body = JSON.stringify(wire.buildRequest(config, model));
|
|
1531
|
+
const scrub = (message) => message.split(apiKey).join("[REDACTED]");
|
|
1532
|
+
const sent = await this.send(providerId, endpoint, apiKey, body, config.timeoutMs, config.maxRetries);
|
|
1533
|
+
if (!sent.ok)
|
|
1534
|
+
return { status: "failed", error: scrub(sent.error) };
|
|
1535
|
+
let parsed;
|
|
1536
|
+
try {
|
|
1537
|
+
parsed = JSON.parse(sent.text);
|
|
1538
|
+
} catch {
|
|
1539
|
+
return { status: "failed", error: "SystemOne response was not valid JSON" };
|
|
1540
|
+
}
|
|
1541
|
+
let decision;
|
|
1542
|
+
try {
|
|
1543
|
+
decision = validateSystemOneResponse(config.questions, wire.toSystemOne(parsed, { questions: config.questions, model }), sent.requestId, provider.confidenceField);
|
|
1544
|
+
} catch (err) {
|
|
1545
|
+
if (err instanceof SystemOneWireError) {
|
|
1546
|
+
const withId = sent.requestId ? `${err.message} [request ${sent.requestId}]` : err.message;
|
|
1547
|
+
return { status: "failed", error: scrub(withId) };
|
|
1548
|
+
}
|
|
1549
|
+
if (err instanceof SystemOneContractError) {
|
|
1550
|
+
return {
|
|
1551
|
+
status: "failed",
|
|
1552
|
+
error: scrub(`SystemOne response failed validation: ${err.message}`)
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
throw err;
|
|
1556
|
+
}
|
|
1557
|
+
if (!config.humanReview)
|
|
1558
|
+
return { status: "success", output: decision };
|
|
1559
|
+
return this.reviewDecision(config, config.humanReview, decision, context, meta);
|
|
1560
|
+
}
|
|
1561
|
+
async reviewDecision(config, review, decision, context, meta) {
|
|
1562
|
+
const questions = {};
|
|
1563
|
+
for (const [id, answer] of Object.entries(decision.answers)) {
|
|
1564
|
+
const confidence = answerConfidence(answer);
|
|
1565
|
+
questions[id] = {
|
|
1566
|
+
confidence,
|
|
1567
|
+
inBand: confidence >= review.band.min && confidence <= review.band.max,
|
|
1568
|
+
decidedBy: "model"
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
if (!Object.values(questions).some((question) => question.inBand)) {
|
|
1572
|
+
return {
|
|
1573
|
+
status: "success",
|
|
1574
|
+
output: { ...decision, review: { status: "not_required", questions } },
|
|
1575
|
+
nextPort: "approved"
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
const parked = {
|
|
1579
|
+
...decision,
|
|
1580
|
+
review: { status: "pending", questions }
|
|
1581
|
+
};
|
|
1582
|
+
await this.deps.db.updateWorkflowRunStep(meta.stepId, { output: parked });
|
|
1583
|
+
return this.handOffToReviewer(config, parked, context, meta);
|
|
1584
|
+
}
|
|
1585
|
+
async readParked(stepId) {
|
|
1586
|
+
const step = await this.deps.db.getWorkflowRunStep(stepId);
|
|
1587
|
+
const parked = SystemOneDecisionOutputSchema.safeParse(step?.output);
|
|
1588
|
+
return parked.success && parked.data.review?.status === "pending" ? parked.data : null;
|
|
1589
|
+
}
|
|
1590
|
+
async handOffToReviewer(config, parked, context, meta) {
|
|
1591
|
+
if (!parked || !config.humanReview) {
|
|
1592
|
+
return {
|
|
1593
|
+
status: "failed",
|
|
1594
|
+
error: "The decision this step parked with is missing, so its approval cannot be resumed. Run the node again."
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
const result = await new HumanInTheLoopExecutor(this.deps).run({
|
|
1598
|
+
config: buildReviewCard(meta.nodeId, config, config.humanReview, parked),
|
|
1599
|
+
context,
|
|
1600
|
+
meta
|
|
1601
|
+
});
|
|
1602
|
+
if (result.status === "failed")
|
|
1603
|
+
return { status: "failed", error: result.error };
|
|
1604
|
+
if ("async" in result)
|
|
1605
|
+
return result;
|
|
1606
|
+
const answered = result.output;
|
|
1607
|
+
const outcome = answered ? this.resolveApproval(parked, {
|
|
1608
|
+
requestId: answered.requestId,
|
|
1609
|
+
status: answered.status,
|
|
1610
|
+
responses: answered.responses
|
|
1611
|
+
}) : null;
|
|
1612
|
+
if (!outcome) {
|
|
1613
|
+
return {
|
|
1614
|
+
status: "failed",
|
|
1615
|
+
error: "The approval was answered but its result could not be applied"
|
|
1616
|
+
};
|
|
1617
|
+
}
|
|
1618
|
+
return {
|
|
1619
|
+
status: "success",
|
|
1620
|
+
output: outcome.output,
|
|
1621
|
+
nextPort: outcome.nextPort
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
resolveApproval(parkedOutput, approval) {
|
|
1625
|
+
return resolveReviewedDecision(parkedOutput, approval);
|
|
1626
|
+
}
|
|
1627
|
+
async send(providerId, endpoint, apiKey, body, timeoutMs, maxRetries) {
|
|
1628
|
+
const doFetch = this.options.fetch ?? fetch;
|
|
1629
|
+
const sleep = this.options.sleep ?? defaultSleep;
|
|
1630
|
+
const now = this.options.now ?? Date.now;
|
|
1631
|
+
const random = this.options.random ?? Math.random;
|
|
1632
|
+
const deadlineAt = now() + timeoutMs - DEADLINE_MARGIN_MS;
|
|
1633
|
+
const controller = new AbortController;
|
|
1634
|
+
const timer = setTimeout(() => controller.abort(), Math.max(0, deadlineAt - now()));
|
|
1635
|
+
const timedOut = () => ({
|
|
1636
|
+
ok: false,
|
|
1637
|
+
error: `SystemOne request exceeded its ${timeoutMs}ms time budget`
|
|
1638
|
+
});
|
|
1639
|
+
try {
|
|
1640
|
+
for (let attempt = 0;; attempt++) {
|
|
1641
|
+
const result = await this.attemptOnce(doFetch, providerId, endpoint, apiKey, body, controller.signal, now);
|
|
1642
|
+
if (result.kind === "ok") {
|
|
1643
|
+
return { ok: true, text: result.text, requestId: result.requestId };
|
|
1644
|
+
}
|
|
1645
|
+
if (result.kind === "timeout" || controller.signal.aborted)
|
|
1646
|
+
return timedOut();
|
|
1647
|
+
const attempts = attempt + 1;
|
|
1648
|
+
const suffix = attempts > 1 ? ` after ${attempts} attempts` : "";
|
|
1649
|
+
if (!result.retryable || attempt >= maxRetries) {
|
|
1650
|
+
return { ok: false, error: `${result.message}${suffix}` };
|
|
1651
|
+
}
|
|
1652
|
+
const backoff = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** attempt);
|
|
1653
|
+
const delay = result.retryAfterMs ?? Math.round(backoff * (0.5 + random() * 0.5));
|
|
1654
|
+
if (delay >= deadlineAt - now()) {
|
|
1655
|
+
return {
|
|
1656
|
+
ok: false,
|
|
1657
|
+
error: `${result.message}${suffix}; the ${delay}ms retry delay does not fit the ${timeoutMs}ms time budget`
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
await sleep(delay, controller.signal);
|
|
1661
|
+
if (controller.signal.aborted)
|
|
1662
|
+
return timedOut();
|
|
1663
|
+
}
|
|
1664
|
+
} finally {
|
|
1665
|
+
clearTimeout(timer);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
async attemptOnce(doFetch, providerId, endpoint, apiKey, body, signal, now) {
|
|
1669
|
+
let res;
|
|
1670
|
+
try {
|
|
1671
|
+
res = await doFetch(endpoint, {
|
|
1672
|
+
method: "POST",
|
|
1673
|
+
headers: {
|
|
1674
|
+
...getOpenRouterAttributionHeaders(endpoint, this.providerContext().env),
|
|
1675
|
+
"content-type": "application/json",
|
|
1676
|
+
accept: "application/json",
|
|
1677
|
+
authorization: `Bearer ${apiKey}`
|
|
1678
|
+
},
|
|
1679
|
+
body,
|
|
1680
|
+
redirect: "manual",
|
|
1681
|
+
signal
|
|
1682
|
+
});
|
|
1683
|
+
} catch (err) {
|
|
1684
|
+
if (signal.aborted)
|
|
1685
|
+
return { kind: "timeout" };
|
|
1686
|
+
const code2 = err?.code;
|
|
1687
|
+
const detail2 = typeof code2 === "string" && /^[A-Z0-9_]{3,40}$/.test(code2) ? ` (${code2})` : "";
|
|
1688
|
+
return {
|
|
1689
|
+
kind: "fail",
|
|
1690
|
+
message: `SystemOne request failed: network error${detail2}`,
|
|
1691
|
+
retryable: true
|
|
1692
|
+
};
|
|
1693
|
+
}
|
|
1694
|
+
const requestId = readRequestId(res.headers);
|
|
1695
|
+
const withId = (message) => requestId ? `${message} [request ${requestId}]` : message;
|
|
1696
|
+
if (res.status >= 200 && res.status < 300) {
|
|
1697
|
+
let text;
|
|
1698
|
+
try {
|
|
1699
|
+
text = await res.text();
|
|
1700
|
+
} catch {
|
|
1701
|
+
if (signal.aborted)
|
|
1702
|
+
return { kind: "timeout" };
|
|
1703
|
+
return {
|
|
1704
|
+
kind: "fail",
|
|
1705
|
+
message: withId("SystemOne response body could not be read"),
|
|
1706
|
+
retryable: true
|
|
1707
|
+
};
|
|
1708
|
+
}
|
|
1709
|
+
if (text.length > MAX_RESPONSE_CHARS) {
|
|
1710
|
+
return {
|
|
1711
|
+
kind: "fail",
|
|
1712
|
+
message: withId("SystemOne response exceeded the size limit"),
|
|
1713
|
+
retryable: false
|
|
1714
|
+
};
|
|
1715
|
+
}
|
|
1716
|
+
return { kind: "ok", text, requestId };
|
|
1717
|
+
}
|
|
1718
|
+
if (res.status >= 300 && res.status < 400) {
|
|
1719
|
+
return {
|
|
1720
|
+
kind: "fail",
|
|
1721
|
+
message: withId(`SystemOne request was redirected (HTTP ${res.status}); redirects are refused`),
|
|
1722
|
+
retryable: false
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
let errorText = "";
|
|
1726
|
+
try {
|
|
1727
|
+
errorText = await res.text();
|
|
1728
|
+
} catch {
|
|
1729
|
+
if (signal.aborted)
|
|
1730
|
+
return { kind: "timeout" };
|
|
1731
|
+
}
|
|
1732
|
+
const code = readErrorCode(errorText);
|
|
1733
|
+
if (res.status === 401 || res.status === 403) {
|
|
1734
|
+
return {
|
|
1735
|
+
kind: "fail",
|
|
1736
|
+
message: withId(systemOneKeyRejectedMessage(providerId, res.status, code)),
|
|
1737
|
+
retryable: false
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
const detail = code ? ` (${code})` : "";
|
|
1741
|
+
const retryable = res.status === 408 || res.status === 429 || res.status >= 500;
|
|
1742
|
+
return {
|
|
1743
|
+
kind: "fail",
|
|
1744
|
+
message: withId(`SystemOne API returned HTTP ${res.status}${detail}`),
|
|
1745
|
+
retryable,
|
|
1746
|
+
retryAfterMs: retryable ? parseRetryAfter(res.headers.get("retry-after"), now()) : undefined
|
|
1747
|
+
};
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
// src/workflows/definition.ts
|
|
1752
|
+
function getNextTargets(next) {
|
|
1753
|
+
if (typeof next === "string")
|
|
1754
|
+
return [next];
|
|
1755
|
+
if (Array.isArray(next))
|
|
1756
|
+
return next;
|
|
1757
|
+
return Object.values(next);
|
|
1758
|
+
}
|
|
1759
|
+
function generateEdges(def) {
|
|
1760
|
+
const edges = [];
|
|
1761
|
+
for (const node of def.nodes) {
|
|
1762
|
+
if (!node.next)
|
|
1763
|
+
continue;
|
|
1764
|
+
if (typeof node.next === "string") {
|
|
1765
|
+
edges.push({
|
|
1766
|
+
id: `${node.id}→${node.next}`,
|
|
1767
|
+
source: node.id,
|
|
1768
|
+
target: node.next,
|
|
1769
|
+
sourcePort: "default"
|
|
1770
|
+
});
|
|
1771
|
+
} else if (Array.isArray(node.next)) {
|
|
1772
|
+
for (const targetId of node.next) {
|
|
1773
|
+
edges.push({
|
|
1774
|
+
id: `${node.id}→${targetId}`,
|
|
1775
|
+
source: node.id,
|
|
1776
|
+
target: targetId,
|
|
1777
|
+
sourcePort: "default"
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
} else {
|
|
1781
|
+
for (const [port, targetId] of Object.entries(node.next)) {
|
|
1782
|
+
edges.push({
|
|
1783
|
+
id: `${node.id}→${targetId}:${port}`,
|
|
1784
|
+
source: node.id,
|
|
1785
|
+
target: targetId,
|
|
1786
|
+
sourcePort: port
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return edges;
|
|
1792
|
+
}
|
|
1793
|
+
function findEntryNodes(def) {
|
|
1794
|
+
const targets = new Set;
|
|
1795
|
+
for (const node of def.nodes) {
|
|
1796
|
+
if (!node.next)
|
|
1797
|
+
continue;
|
|
1798
|
+
for (const targetId of getNextTargets(node.next)) {
|
|
1799
|
+
targets.add(targetId);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
return def.nodes.filter((n) => !targets.has(n.id));
|
|
1803
|
+
}
|
|
1804
|
+
function resolveValidationPort(next, executorPort, validationPassed) {
|
|
1805
|
+
if (validationPassed === undefined)
|
|
1806
|
+
return executorPort;
|
|
1807
|
+
if (!next || typeof next !== "object" || Array.isArray(next))
|
|
1808
|
+
return executorPort;
|
|
1809
|
+
if (executorPort && Object.hasOwn(next, executorPort))
|
|
1810
|
+
return executorPort;
|
|
1811
|
+
return validationPassed ? "pass" : "fail";
|
|
1812
|
+
}
|
|
1813
|
+
function getSuccessors(def, nodeId, port) {
|
|
1814
|
+
const node = def.nodes.find((n) => n.id === nodeId);
|
|
1815
|
+
if (!node?.next)
|
|
1816
|
+
return [];
|
|
1817
|
+
const targetIds = [];
|
|
1818
|
+
if (typeof node.next === "string") {
|
|
1819
|
+
targetIds.push(node.next);
|
|
1820
|
+
} else if (Array.isArray(node.next)) {
|
|
1821
|
+
targetIds.push(...node.next);
|
|
1822
|
+
} else {
|
|
1823
|
+
if (port) {
|
|
1824
|
+
const targetId = node.next[port];
|
|
1825
|
+
if (targetId)
|
|
1826
|
+
targetIds.push(targetId);
|
|
1827
|
+
} else {
|
|
1828
|
+
targetIds.push(...Object.values(node.next));
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return targetIds.map((id) => def.nodes.find((n) => n.id === id)).filter((n) => n != null);
|
|
1832
|
+
}
|
|
1833
|
+
function isUpstream(def, sourceId, targetId) {
|
|
1834
|
+
const reverseDeps = new Map;
|
|
1835
|
+
for (const node of def.nodes) {
|
|
1836
|
+
if (!node.next)
|
|
1837
|
+
continue;
|
|
1838
|
+
for (const target of getNextTargets(node.next)) {
|
|
1839
|
+
if (!reverseDeps.has(target))
|
|
1840
|
+
reverseDeps.set(target, []);
|
|
1841
|
+
reverseDeps.get(target).push(node.id);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
const visited = new Set;
|
|
1845
|
+
const queue = [targetId];
|
|
1846
|
+
while (queue.length > 0) {
|
|
1847
|
+
const current = queue.shift();
|
|
1848
|
+
const preds = reverseDeps.get(current) || [];
|
|
1849
|
+
for (const pred of preds) {
|
|
1850
|
+
if (visited.has(pred))
|
|
1851
|
+
continue;
|
|
1852
|
+
visited.add(pred);
|
|
1853
|
+
if (pred === sourceId)
|
|
1854
|
+
return true;
|
|
1855
|
+
queue.push(pred);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
return false;
|
|
1859
|
+
}
|
|
1860
|
+
function containsInterpolationToken(value) {
|
|
1861
|
+
if (typeof value === "string")
|
|
1862
|
+
return value.includes("{{") && value.includes("}}");
|
|
1863
|
+
if (Array.isArray(value))
|
|
1864
|
+
return value.some(containsInterpolationToken);
|
|
1865
|
+
if (value && typeof value === "object") {
|
|
1866
|
+
return Object.values(value).some(containsInterpolationToken);
|
|
1867
|
+
}
|
|
1868
|
+
return false;
|
|
1869
|
+
}
|
|
1870
|
+
function definitionNodeIds(def) {
|
|
1871
|
+
return new Set(def.nodes.map((node) => node.id));
|
|
1872
|
+
}
|
|
1873
|
+
function validateDefinition(def, registry, options = {}) {
|
|
1874
|
+
const errors = [];
|
|
1875
|
+
const nodeIds = new Set(def.nodes.map((n) => n.id));
|
|
1876
|
+
for (const node of def.nodes) {
|
|
1877
|
+
if (node.id.includes("#") && !options.legacyNodeIds?.has(node.id)) {
|
|
1878
|
+
errors.push(`Node "${node.id}" contains reserved character "#"`);
|
|
1879
|
+
}
|
|
1880
|
+
if (node.type === "human-in-the-loop" && typeof node.config.questions === "string" && !/^\{\{[^}]+\}\}$/.test(node.config.questions.trim())) {
|
|
1881
|
+
errors.push(`Node "${node.id}" human-in-the-loop config.questions must be an array or one exact {{interpolation}} token`);
|
|
1882
|
+
}
|
|
1883
|
+
errors.push(...systemOneStaticShapeViolations(node), ...systemOneRetryViolations(node));
|
|
1884
|
+
if (node.type === "foreach") {
|
|
1885
|
+
validateForeachNode(node, errors);
|
|
1886
|
+
if (node.id.includes("#")) {
|
|
1887
|
+
errors.push(`Node "${node.id}" cannot be a foreach: its id contains "#", which is reserved for synthetic child ids`);
|
|
1888
|
+
}
|
|
1889
|
+
if (isUpstream(def, node.id, node.id)) {
|
|
1890
|
+
errors.push("foreach inside a loop is not supported in v1");
|
|
1891
|
+
}
|
|
1892
|
+
const collidingSibling = def.nodes.find((other) => other.id !== node.id && other.id.startsWith(`${node.id}#`));
|
|
1893
|
+
if (collidingSibling) {
|
|
1894
|
+
errors.push(`Node "${node.id}" cannot be a foreach: node "${collidingSibling.id}" collides with its synthetic child id space ("${node.id}#…")`);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
for (const node of def.nodes) {
|
|
1899
|
+
if (!node.next)
|
|
1900
|
+
continue;
|
|
1901
|
+
if (typeof node.next === "string") {
|
|
1902
|
+
if (!nodeIds.has(node.next)) {
|
|
1903
|
+
errors.push(`Node "${node.id}" references non-existent next target "${node.next}"`);
|
|
1904
|
+
}
|
|
1905
|
+
} else if (Array.isArray(node.next)) {
|
|
1906
|
+
for (const targetId of node.next) {
|
|
1907
|
+
if (!nodeIds.has(targetId)) {
|
|
1908
|
+
errors.push(`Node "${node.id}" fan-out references non-existent target "${targetId}"`);
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
} else {
|
|
1912
|
+
for (const [port, targetId] of Object.entries(node.next)) {
|
|
1913
|
+
if (!nodeIds.has(targetId)) {
|
|
1914
|
+
errors.push(`Node "${node.id}" port "${port}" references non-existent target "${targetId}"`);
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
const entryNodes = findEntryNodes(def);
|
|
1920
|
+
if (entryNodes.length === 0) {
|
|
1921
|
+
errors.push("No entry node found (every node is a target of some other node)");
|
|
1922
|
+
} else if (entryNodes.length > 1) {
|
|
1923
|
+
const ids = entryNodes.map((n) => `"${n.id}"`).join(", ");
|
|
1924
|
+
errors.push(`Multiple entry nodes found: ${ids} (expected exactly one)`);
|
|
1925
|
+
}
|
|
1926
|
+
if (entryNodes.length === 1) {
|
|
1927
|
+
const reachable = new Set;
|
|
1928
|
+
const queue = [entryNodes[0].id];
|
|
1929
|
+
while (queue.length > 0) {
|
|
1930
|
+
const current = queue.shift();
|
|
1931
|
+
if (reachable.has(current))
|
|
1932
|
+
continue;
|
|
1933
|
+
reachable.add(current);
|
|
1934
|
+
const node = def.nodes.find((n) => n.id === current);
|
|
1935
|
+
if (!node?.next)
|
|
1936
|
+
continue;
|
|
1937
|
+
for (const targetId of getNextTargets(node.next)) {
|
|
1938
|
+
queue.push(targetId);
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
for (const node of def.nodes) {
|
|
1942
|
+
if (!reachable.has(node.id)) {
|
|
1943
|
+
errors.push(`Node "${node.id}" is unreachable from the entry node`);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
if (registry) {
|
|
1948
|
+
for (const node of def.nodes) {
|
|
1949
|
+
if (!registry.has(node.type)) {
|
|
1950
|
+
errors.push(`Node "${node.id}" uses unregistered executor type "${node.type}"`);
|
|
1951
|
+
continue;
|
|
1952
|
+
}
|
|
1953
|
+
reportStaticConfigIssues(node, registry.get(node.type).configSchema, node.config, errors);
|
|
1954
|
+
if (node.type === "foreach" && registry.has("agent-task")) {
|
|
1955
|
+
const body = node.config.body;
|
|
1956
|
+
const bodyConfig = typeof body === "object" && body !== null && !Array.isArray(body) ? body.config : undefined;
|
|
1957
|
+
if (typeof bodyConfig === "object" && bodyConfig !== null && !Array.isArray(bodyConfig)) {
|
|
1958
|
+
reportStaticConfigIssues(node, registry.get("agent-task").configSchema, bodyConfig, errors, "config.body.config");
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
for (const node of def.nodes) {
|
|
1964
|
+
if (!node.inputs)
|
|
1965
|
+
continue;
|
|
1966
|
+
for (const [localName, sourcePath] of Object.entries(node.inputs)) {
|
|
1967
|
+
const [sourceNodeId] = sourcePath.split(".");
|
|
1968
|
+
if (!sourceNodeId)
|
|
1969
|
+
continue;
|
|
1970
|
+
if (sourceNodeId === "trigger" || sourceNodeId === "input" || sourceNodeId === "run") {
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
if (!nodeIds.has(sourceNodeId)) {
|
|
1974
|
+
errors.push(`Node "${node.id}" input "${localName}" references non-existent node "${sourceNodeId}"`);
|
|
1975
|
+
continue;
|
|
1976
|
+
}
|
|
1977
|
+
if (!isUpstream(def, sourceNodeId, node.id)) {
|
|
1978
|
+
errors.push(`Node "${node.id}" input "${localName}" references "${sourceNodeId}" which is not upstream`);
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
return { valid: errors.length === 0, errors };
|
|
1983
|
+
}
|
|
1984
|
+
function reportStaticConfigIssues(node, schema, config, errors, pathPrefix = "config") {
|
|
1985
|
+
const result = schema.safeParse(config);
|
|
1986
|
+
if (result.success)
|
|
1987
|
+
return;
|
|
1988
|
+
for (const rawIssue of result.error?.issues ?? []) {
|
|
1989
|
+
const issue = rawIssue;
|
|
1990
|
+
const issuePath = issue.path.map(String);
|
|
1991
|
+
const path = [pathPrefix, ...issuePath].join(".");
|
|
1992
|
+
let value = config;
|
|
1993
|
+
for (const segment of issue.path) {
|
|
1994
|
+
if (value === null || typeof value !== "object") {
|
|
1995
|
+
value = undefined;
|
|
1996
|
+
break;
|
|
1997
|
+
}
|
|
1998
|
+
value = value[segment];
|
|
1999
|
+
}
|
|
2000
|
+
if (containsInterpolationToken(value))
|
|
2001
|
+
continue;
|
|
2002
|
+
const renderedValue = value === undefined ? "undefined" : JSON.stringify(value);
|
|
2003
|
+
errors.push(`Node "${node.id}" (${node.type}) ${path} has invalid value ${renderedValue}: ${issue.message}`);
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
function validateForeachNode(node, errors) {
|
|
2007
|
+
const config = node.config;
|
|
2008
|
+
if (Object.hasOwn(config, "concurrency")) {
|
|
2009
|
+
errors.push(`Node "${node.id}" foreach concurrency is not supported in v1`);
|
|
2010
|
+
}
|
|
2011
|
+
if (node.outputSchema !== undefined || node.validation !== undefined) {
|
|
2012
|
+
errors.push(`Node "${node.id}" foreach node-level outputSchema/validation is not supported in v1`);
|
|
2013
|
+
}
|
|
2014
|
+
const over = config.over;
|
|
2015
|
+
const isInterpolatedArray = typeof over === "string" && /^\{\{[^}]+\}\}$/.test(over.trim());
|
|
2016
|
+
if (!Array.isArray(over) && !isInterpolatedArray) {
|
|
2017
|
+
errors.push(`Node "${node.id}" foreach config.over must be an array or one exact {{interpolation}} token`);
|
|
2018
|
+
}
|
|
2019
|
+
if (typeof config.itemKey !== "string" || config.itemKey.length === 0) {
|
|
2020
|
+
errors.push(`Node "${node.id}" foreach config.itemKey must be a non-empty string`);
|
|
2021
|
+
}
|
|
2022
|
+
const body = config.body;
|
|
2023
|
+
if (typeof body !== "object" || body === null || Array.isArray(body)) {
|
|
2024
|
+
errors.push(`Node "${node.id}" foreach config.body must be an object`);
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const bodyRecord = body;
|
|
2028
|
+
if (bodyRecord.type !== "agent-task") {
|
|
2029
|
+
errors.push(`Node "${node.id}" foreach config.body.type must be "agent-task" in v1`);
|
|
2030
|
+
}
|
|
2031
|
+
if (typeof bodyRecord.config !== "object" || bodyRecord.config === null || Array.isArray(bodyRecord.config)) {
|
|
2032
|
+
errors.push(`Node "${node.id}" foreach config.body.config must be an object`);
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
const bodyConfig = bodyRecord.config;
|
|
2036
|
+
if (typeof bodyConfig.template !== "string") {
|
|
2037
|
+
errors.push(`Node "${node.id}" foreach agent-task body.config.template must be a string`);
|
|
2038
|
+
}
|
|
2039
|
+
}
|
|
2040
|
+
function applyDefinitionPatch(def, patch) {
|
|
2041
|
+
const errors = [];
|
|
2042
|
+
let nodes = [...def.nodes];
|
|
2043
|
+
if (patch.delete?.length) {
|
|
2044
|
+
const missing = patch.delete.filter((id) => !nodes.some((n) => n.id === id));
|
|
2045
|
+
if (missing.length > 0) {
|
|
2046
|
+
errors.push(`Cannot delete non-existent nodes: ${missing.join(", ")}`);
|
|
2047
|
+
}
|
|
2048
|
+
const toDelete = new Set(patch.delete);
|
|
2049
|
+
nodes = nodes.filter((n) => !toDelete.has(n.id));
|
|
2050
|
+
}
|
|
2051
|
+
if (patch.create?.length) {
|
|
2052
|
+
const existingIds = new Set(nodes.map((n) => n.id));
|
|
2053
|
+
for (const newNode of patch.create) {
|
|
2054
|
+
if (existingIds.has(newNode.id)) {
|
|
2055
|
+
errors.push(`Cannot create node with duplicate ID: "${newNode.id}"`);
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
2058
|
+
nodes.push(newNode);
|
|
2059
|
+
existingIds.add(newNode.id);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
if (patch.update?.length) {
|
|
2063
|
+
for (const { nodeId, node: partial } of patch.update) {
|
|
2064
|
+
const idx = nodes.findIndex((n) => n.id === nodeId);
|
|
2065
|
+
if (idx === -1) {
|
|
2066
|
+
errors.push(`Cannot update non-existent node: "${nodeId}"`);
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
const defined = {};
|
|
2070
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
2071
|
+
if (v !== undefined)
|
|
2072
|
+
defined[k] = v;
|
|
2073
|
+
}
|
|
2074
|
+
nodes[idx] = { ...nodes[idx], ...defined, id: nodeId };
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
const patchedDef = { ...def, nodes };
|
|
2078
|
+
if (patch.onNodeFailure !== undefined) {
|
|
2079
|
+
patchedDef.onNodeFailure = patch.onNodeFailure;
|
|
2080
|
+
}
|
|
2081
|
+
return { definition: patchedDef, errors };
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
export { BaseExecutor, postApprovalCancellationUpdates, createApprovalFollowUpTask, HumanInTheLoopExecutor, resolveWorkflowLlmConfig, SYSTEM_ONE_DECISION_NODE_TYPE, systemOneRetryViolations, systemOneUnresolvedError, SystemOneDecisionExecutor, getNextTargets, generateEdges, findEntryNodes, resolveValidationPort, getSuccessors, definitionNodeIds, validateDefinition, applyDefinitionPatch };
|