@desplega.ai/agent-swarm 2.7.4 → 2.7.5
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/README.md +1 -1
- package/dist/acp-adapter-g3ads55s.js +15 -0
- package/dist/{actions-gfrr9vb7.js → actions-zwfykqqk.js} +18 -18
- package/dist/{amp-adapter-0s58gm46.js → amp-adapter-z3s0ssdt.js} +3 -3
- package/dist/{app-stvq692g.js → app-vmp4fgep.js} +3 -3
- package/dist/{assistant-5s37fr9n.js → assistant-vwde653f.js} +28 -21
- package/dist/{boot-reembed-yzy1841n.js → boot-reembed-375t7tbk.js} +1 -1
- package/dist/{boot-reembed-cv82gz7y.js → boot-reembed-gq9rcpnj.js} +1 -1
- package/dist/{boot-scrub-sweep-8k4q95ap.js → boot-scrub-sweep-nd938kyf.js} +4 -4
- package/dist/{boot-seeds-ftt9htah.js → boot-seeds-aykx51v3.js} +4 -4
- package/dist/{boxd-515v1t8z.js → boxd-k1j26g3h.js} +1 -1
- package/dist/{claude-adapter-t00wrzeh.js → claude-adapter-8j4hm099.js} +7 -7
- package/dist/{claude-managed-adapter-g6vmjccb.js → claude-managed-adapter-dvs2xw5n.js} +2 -2
- package/dist/{claude-managed-setup-afpa82f7.js → claude-managed-setup-0wyynpn1.js} +3 -3
- package/dist/{claude-sdk-session-vj0c2azx.js → claude-sdk-session-q6wj5hmy.js} +9 -9
- package/dist/{cli-wndv9p7g.js → cli-0731hvbw.js} +6 -6
- package/dist/{cli-d2pzqej1.js → cli-0bxqnvq9.js} +719 -7
- package/dist/{cli-jkt52mgt.js → cli-0gey5fzy.js} +2 -2
- package/dist/{cli-1yekbyqm.js → cli-1338796d.js} +205 -48
- package/dist/{cli-1s1dqn1j.js → cli-1ayk1eas.js} +171 -123
- package/dist/{cli-a4btqqft.js → cli-1b7btd38.js} +1 -1
- package/dist/{cli-a2jw6ee5.js → cli-1p89xny0.js} +1 -1
- package/dist/{cli-v726afdm.js → cli-2pnswgh8.js} +1 -1
- package/dist/{cli-kvtc7aez.js → cli-3h4nhjn8.js} +42 -14
- package/dist/{cli-655xm9ms.js → cli-583frbyr.js} +1 -1
- package/dist/{cli-4r12t6w6.js → cli-5c4hjzne.js} +101 -4
- package/dist/{cli-az0bpf8p.js → cli-5pag6rmn.js} +3 -3
- package/dist/{cli-36g23z21.js → cli-6s7vdcta.js} +4 -4
- package/dist/{cli-rtk9g7av.js → cli-73jzwksr.js} +6 -2
- package/dist/{cli-jg0nfda6.js → cli-75gkpjvw.js} +52 -21
- package/dist/{cli-a81qevyg.js → cli-7a42nfy0.js} +20 -36
- package/dist/{cli-0sbkss7h.js → cli-7wcyk2s5.js} +2 -2
- package/dist/{cli-41z8xwj0.js → cli-7wspm5gm.js} +14 -4
- package/dist/{cli-byqfbnjw.js → cli-82ae98pz.js} +9 -9
- package/dist/{cli-bq2pcphn.js → cli-8hs41zy2.js} +1 -1
- package/dist/{cli-vqgnyh76.js → cli-98rw5dt5.js} +15 -3
- package/dist/{cli-9cctx1tn.js → cli-a22h291q.js} +1 -1
- package/dist/{cli-j4kt1mpb.js → cli-a47xy8eq.js} +3 -3
- package/dist/{cli-ycm6180s.js → cli-a7v854rf.js} +5 -3
- package/dist/{cli-98pke4vk.js → cli-ae7mrdf9.js} +2 -2
- package/dist/{cli-ahd90bwt.js → cli-afrrppsw.js} +6 -6
- package/dist/{cli-8yy525ad.js → cli-bhjt02p8.js} +2 -2
- package/dist/{cli-v1yw3wha.js → cli-bnz7a7qf.js} +4 -4
- package/dist/{cli-j4kw3rsx.js → cli-cm5yqn35.js} +18 -0
- package/dist/{cli-h55y2bjc.js → cli-dhar9qay.js} +2 -2
- package/dist/{cli-5gr1g711.js → cli-fqzx7gwa.js} +2 -2
- package/dist/{cli-tzrpv5sc.js → cli-ftyxyspd.js} +1 -1
- package/dist/cli-fw2w0kva.js +47 -0
- package/dist/{cli-2dfbv8qr.js → cli-fy6x31ps.js} +1 -0
- package/dist/{cli-mvckssyq.js → cli-g61qxmge.js} +5 -5
- package/dist/{cli-aq58fk5m.js → cli-g83s9ewh.js} +7 -1
- package/dist/{cli-224epwwf.js → cli-hn9hxmz9.js} +3 -3
- package/dist/{cli-5hjced2v.js → cli-j3jvnpe8.js} +6 -6
- package/dist/{cli-dae4sbgy.js → cli-jm2vfpfd.js} +2 -2
- package/dist/{cli-zn15c46z.js → cli-mjzynrse.js} +1 -1
- package/dist/{cli-a1fhj4n9.js → cli-mmwm7zbf.js} +2 -2
- package/dist/{cli-cq1pf336.js → cli-mry0qzym.js} +5 -5
- package/dist/{cli-bwty35gx.js → cli-mthwvjre.js} +1 -1
- package/dist/{cli-s0exq74r.js → cli-nmbmy64z.js} +3 -3
- package/dist/{cli-wcmnan2g.js → cli-nn4d8dne.js} +5 -5
- package/dist/{cli-jg4p5t36.js → cli-nzxr556s.js} +121 -51
- package/dist/{cli-bcz074zx.js → cli-p599f1qm.js} +1 -1
- package/dist/{cli-k76tgq03.js → cli-pywf3eh5.js} +2 -2
- package/dist/{cli-wk9md5px.js → cli-qxnf8z2z.js} +2 -2
- package/dist/{cli-je8yeyc5.js → cli-rhw40jgb.js} +5 -5
- package/dist/{cli-avgcz4ra.js → cli-rm6hqj3z.js} +406 -35
- package/dist/{cli-0fgr1w9b.js → cli-rpm2sgv0.js} +2 -2
- package/dist/{cli-t282jgab.js → cli-rqkxrmgw.js} +12 -12
- package/dist/{cli-2qpvmmkw.js → cli-s0qnd4wv.js} +1 -1
- package/dist/{cli-pwyevs29.js → cli-spknvxbq.js} +22 -21
- package/dist/{cli-9y354g3j.js → cli-srye3vdt.js} +1 -1
- package/dist/{cli-pe09p6dv.js → cli-tqgg40ek.js} +1 -1
- package/dist/{cli-f255yaqn.js → cli-vbyfwh92.js} +3 -3
- package/dist/{cli-gcbdjt47.js → cli-vfym7wmz.js} +6 -6
- package/dist/{cli-edmm9d9y.js → cli-vqypxzh4.js} +7 -7
- package/dist/{cli-1gm8jcyr.js → cli-wvaawacd.js} +13 -13
- package/dist/{cli-eny54typ.js → cli-wx3rhnm0.js} +1 -1
- package/dist/{cli-b1afgtj3.js → cli-xbkdm710.js} +3 -3
- package/dist/{cli-mc97xy2s.js → cli-xtjhfmpc.js} +16 -4
- package/dist/{cli-eqx6k993.js → cli-y24sc29k.js} +25 -13
- package/dist/{cli-ktmvb20s.js → cli-z6jkx23q.js} +12 -12
- package/dist/{cli-7n5sbw0z.js → cli-z8eh3ex8.js} +5 -5
- package/dist/cli-zh3fp8v2.js +151 -0
- package/dist/{cli-13jvmew3.js → cli-zppwg9p9.js} +7 -7
- package/dist/cli.js +18 -18
- package/dist/{codex-adapter-g0gctw29.js → codex-adapter-q6k3gnyg.js} +5 -5
- package/dist/{codex-hook-vrzz2xhr.js → codex-hook-9efm2x1m.js} +1 -1
- package/dist/{codex-login-0gg46ygn.js → codex-login-y0pmff1c.js} +1 -1
- package/dist/{codex-session-runner-dr2cxe1c.js → codex-session-runner-3mwmk3d9.js} +6 -6
- package/dist/{commands-1p07cvff.js → commands-evrenjn9.js} +2 -2
- package/dist/{controller-z0s7sv6r.js → controller-xjp8vswh.js} +4 -4
- package/dist/{controllers-connect-x9kvvhyk.js → controllers-connect-pp5jnxvc.js} +20 -20
- package/dist/{cursor-adapter-m5b98pvk.js → cursor-adapter-yf47w4t0.js} +2 -2
- package/dist/{db-rsh04kh3.js → db-rgfsh91f.js} +2 -2
- package/dist/{db-capk2raj.js → db-zxpw6aht.js} +3 -3
- package/dist/{devin-adapter-yxm87fe9.js → devin-adapter-r9phbnc9.js} +1 -1
- package/dist/{dispatcher-304q6fwa.js → dispatcher-smasgws7.js} +17 -17
- package/dist/{docker-7rxt0rry.js → docker-rrjhaqt9.js} +2 -2
- package/dist/{dsh-adapter-btewem0y.js → dsh-adapter-ftyy8dnp.js} +2 -2
- package/dist/{e2b-renc0k8q.js → e2b-qcaj675y.js} +2 -2
- package/dist/{e2b-7fc9b9bd.js → e2b-w1p0bnpm.js} +2 -2
- package/dist/{easter-egg-3d-fm1p3n4d.js → easter-egg-3d-yyts7p16.js} +1 -1
- package/dist/{embedding-space-s68v49ct.js → embedding-space-1z0mbg95.js} +6 -6
- package/dist/{embeddings-4ms3604w.js → embeddings-ygeg7tm7.js} +1 -1
- package/dist/{event-bus-dfjx2qdh.js → event-bus-g0ka3pbw.js} +1 -1
- package/dist/{execute-mcdgccjs.js → execute-14qhqp4d.js} +1 -1
- package/dist/{external-channel-9dxwfztn.js → external-channel-ttgrs3sx.js} +2 -2
- package/dist/fx-adapter-gt6pncj2.js +18 -0
- package/dist/{grok-adapter-05ecv4h0.js → grok-adapter-sm4m5k4g.js} +8 -8
- package/dist/handlers-vwmba1m2.js +31 -0
- package/dist/{heartbeat-3n94heh3.js → heartbeat-qahz4p62.js} +20 -20
- package/dist/{hook-zxwje86c.js → hook-4fd5wq5w.js} +4 -4
- package/dist/{hook-aadrn69x.js → hook-kj3d398m.js} +6 -6
- package/dist/{http-xzxk9mst.js → http-1ted0j1k.js} +100 -64
- package/dist/index-5r71rf98.js +32 -0
- package/dist/{index-d34f6r87.js → index-6rq3r5zb.js} +1 -1
- package/dist/{index-9j4zwcht.js → index-7v51awbn.js} +158 -672
- package/dist/index-anmwxs87.js +29 -0
- package/dist/{index-qeb82z37.js → index-q4b917zs.js} +1 -1
- package/dist/index-spp0ra5c.js +36 -0
- package/dist/{keepalive-w4aakbyk.js → keepalive-6cf311t5.js} +4 -4
- package/dist/lead-fjy418cw.js +69 -0
- package/dist/{maintenance-hbptpk6p.js → maintenance-a4bm1bmt.js} +1 -1
- package/dist/{oauth-refresh-sweep-217semrv.js → oauth-refresh-sweep-3h0dfw1k.js} +3 -3
- package/dist/{onboard-vz2s5df3.js → onboard-myn5hraa.js} +3 -3
- package/dist/{opencode-adapter-gtfd7d7v.js → opencode-adapter-p2nkb5am.js} +5 -5
- package/dist/{otel-impl-aefave37.js → otel-impl-a1a0ef6b.js} +2 -2
- package/dist/{pi-mono-adapter-f8h5yseh.js → pi-mono-adapter-rzmpb72a.js} +117 -23
- package/dist/{pricing-refresh-m1yhh9ya.js → pricing-refresh-83drrnxj.js} +3 -3
- package/dist/{rbac-roles-svj72cdf.js → rbac-roles-0fb6e7pj.js} +2 -2
- package/dist/{rbac-roles-rrw8twdz.js → rbac-roles-s8y6ecb5.js} +1 -1
- package/dist/{render-v2-amwbnfxh.js → render-v2-twmbgrz4.js} +3 -3
- package/dist/{resume-9cd6wf6z.js → resume-zfqc36xj.js} +7 -7
- package/dist/{runtime-fp4vrx7m.js → runtime-qwggdpqr.js} +1 -1
- package/dist/{session-access-21t6yjyg.js → session-access-sc9pn9da.js} +4 -4
- package/dist/{session-source-backfill-pxbzcsc6.js → session-source-backfill-qh07xr8g.js} +3 -3
- package/dist/{setup-4592tqz4.js → setup-4fr0m7a1.js} +1 -1
- package/dist/{virtual-modules-gzfnpmnz.js → virtual-modules-fh4ha15b.js} +2 -2
- package/dist/{work-objects-t9tcf174.js → work-objects-0wr8zxct.js} +20 -20
- package/dist/worker-0c5tyzqe.js +69 -0
- package/dist/{worker-follow-up-q32jh4yk.js → worker-follow-up-ktyyazyp.js} +17 -17
- package/dist/{x-b6wxntdt.js → x-vmmye1kb.js} +2 -2
- package/openapi.json +97 -32
- package/package.json +1 -1
- package/src/be/db/session-summaries.ts +8 -2
- package/src/be/db/session-tree.ts +174 -61
- package/src/be/db/session-visibility.ts +5 -4
- package/src/be/db.ts +8 -4
- package/src/be/migrations/259_session_summaries_open_tasks.sql +111 -0
- package/src/be/model-validation.ts +5 -1
- package/src/be/pricing-normalize.ts +7 -2
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed/schedules-seeder.ts +135 -9
- package/src/be/seed/workflows-seeder.ts +65 -1
- package/src/be/seed-pricing.ts +23 -0
- package/src/be/steering.ts +50 -3
- package/src/be/swarm-config-guard.ts +1 -0
- package/src/commands/provider-credentials.ts +29 -2
- package/src/commands/runner.ts +86 -5
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +27 -1
- package/src/extensions/dispatcher.ts +4 -0
- package/src/http/agents.ts +1 -0
- package/src/http/mcp-bridge.ts +4 -0
- package/src/integrations/chat-sdk/inbound.ts +15 -5
- package/src/integrations/chat-sdk/steering.ts +3 -1
- package/src/kv-reserved-namespaces.ts +2 -0
- package/src/prompts/base-prompt.ts +4 -1
- package/src/prompts/session-templates.ts +26 -0
- package/src/providers/acp-adapter.ts +190 -26
- package/src/providers/acp-targets.ts +306 -6
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +9 -44
- package/src/providers/codex-oauth/flow.ts +24 -3
- package/src/providers/codex-oauth/storage.ts +56 -17
- package/src/providers/codex-oauth/subscription.ts +67 -0
- package/src/providers/fx-adapter.ts +211 -0
- package/src/providers/index.ts +5 -1
- package/src/providers/mcp-loopback-proxy.ts +94 -0
- package/src/providers/pi-mono-adapter.ts +98 -13
- package/src/providers/pricing-sources.md +8 -0
- package/src/providers/reasoning-effort.ts +17 -1
- package/src/providers/types.ts +2 -1
- package/src/scheduler/scheduler.ts +36 -3
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +5 -0
- package/src/scripts-runtime/types/swarm-extension.d.ts +33 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +5 -0
- package/src/server.ts +7 -0
- package/src/slack/assistant.ts +17 -1
- package/src/slack/handlers.ts +18 -5
- package/src/slack/left-threads.ts +65 -0
- package/src/slack/steering.ts +3 -0
- package/src/slack/thread-buffer.ts +40 -16
- package/src/tests/acp-adapter.test.ts +79 -0
- package/src/tests/base-prompt.test.ts +12 -0
- package/src/tests/chat-sdk-inbound.test.ts +30 -0
- package/src/tests/chatgpt-subscription-model-groups.test.ts +62 -0
- package/src/tests/chatgpt-subscription-pi.test.ts +170 -0
- package/src/tests/chatgpt-subscription-session-bench.test.ts +198 -0
- package/src/tests/codex-oauth-credential-info.test.ts +40 -0
- package/src/tests/codex-oauth-refresh-egress.test.ts +194 -0
- package/src/tests/context-window.test.ts +7 -0
- package/src/tests/explicit-model-validation.test.ts +99 -0
- package/src/tests/extensions-run-context.test.ts +11 -0
- package/src/tests/extensions-steering.test.ts +353 -0
- package/src/tests/extensions-validate.test.ts +5 -0
- package/src/tests/fixtures/extensions/steering-policy/hooks.ts +39 -0
- package/src/tests/fixtures/extensions/steering-policy/manifest.json +7 -0
- package/src/tests/fixtures/fake-fx.ts +167 -0
- package/src/tests/fixtures/fx/acp-responses-recorded.json +157 -0
- package/src/tests/fixtures/fx/acp-updates-recorded.jsonl +15 -0
- package/src/tests/fx-adapter.test.ts +771 -0
- package/src/tests/grok-adapter.test.ts +36 -0
- package/src/tests/mcp-loopback-proxy.test.ts +193 -0
- package/src/tests/model-tier-resolution.test.ts +3 -3
- package/src/tests/pricing-refresh.test.ts +5 -5
- package/src/tests/prompt-template-session.test.ts +5 -3
- package/src/tests/provider-registration.test.ts +2 -1
- package/src/tests/query-audit-plans.test.ts +3 -2
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/seed-model-profiles.test.ts +2 -2
- package/src/tests/seed-schedules.test.ts +138 -0
- package/src/tests/session-cost-recompute-unit.test.ts +39 -0
- package/src/tests/session-costs-golden.test.ts +26 -0
- package/src/tests/session-summaries-plans.test.ts +120 -0
- package/src/tests/session-summaries.test.ts +72 -12
- package/src/tests/sessions-tree.test.ts +11 -3
- package/src/tests/slack-leave-thread.test.ts +431 -0
- package/src/tests/tool-annotations.test.ts +2 -1
- package/src/tests/workflow-engine-v2.test.ts +42 -0
- package/src/tests/workflow-script-principal.test.ts +207 -7
- package/src/tools/slack-leave-thread.ts +104 -0
- package/src/tools/steer-task.ts +3 -2
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +19 -0
- package/src/utils/codex-auth-failure.ts +30 -0
- package/src/utils/context-window.ts +4 -1
- package/src/utils/credentials.ts +7 -0
- package/src/utils/fx-models.ts +43 -0
- package/src/workflows/cooldown.ts +3 -2
- package/src/workflows/engine.ts +24 -9
- package/templates/schedules/dreaming-daily/config.json +1 -1
- package/templates/schedules/dreaming-daily/content.md +6 -14
- package/templates/skills/swarm-docs/content.md +1 -0
- package/templates/workflows/dreaming/content.md +5 -1
- package/dist/acp-adapter-96dc64m2.js +0 -15
- package/dist/handlers-jg7v74ty.js +0 -31
- package/dist/index-8c1zbz59.js +0 -29
- package/dist/index-8k8tmfxd.js +0 -32
- package/dist/index-hq617xdj.js +0 -36
- package/dist/lead-567kw7x3.js +0 -68
- package/dist/worker-fjdqra6q.js +0 -68
|
@@ -13,14 +13,19 @@ import {
|
|
|
13
13
|
createAgent,
|
|
14
14
|
createScheduledTask,
|
|
15
15
|
createWorkflow,
|
|
16
|
+
createWorkflowRun,
|
|
16
17
|
getDbClient,
|
|
18
|
+
getScheduledTaskByName,
|
|
19
|
+
getWorkflowRun,
|
|
17
20
|
initDb,
|
|
18
21
|
listWorkflows,
|
|
22
|
+
updateWorkflow,
|
|
19
23
|
} from "../be/db";
|
|
20
24
|
import { upsertScriptByName } from "../be/scripts/db";
|
|
21
25
|
import { setScriptEmbeddingProviderForTests } from "../be/scripts/embeddings";
|
|
22
26
|
import * as runCredential from "../be/scripts/run-credential";
|
|
23
27
|
import { runSeeder } from "../be/seed";
|
|
28
|
+
import { createSchedulesSeeder } from "../be/seed/schedules-seeder";
|
|
24
29
|
import { createWorkflowsSeeder } from "../be/seed/workflows-seeder";
|
|
25
30
|
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
26
31
|
import { handleWorkflows } from "../http/workflows";
|
|
@@ -34,6 +39,7 @@ import {
|
|
|
34
39
|
workflowEventBus,
|
|
35
40
|
} from "../workflows";
|
|
36
41
|
import { handleEventTrigger } from "../workflows/triggers";
|
|
42
|
+
import { snapshotAndUpdateWorkflow } from "../workflows/version";
|
|
37
43
|
import { listenOnFreePort } from "./test-net";
|
|
38
44
|
|
|
39
45
|
// A creator-less workflow (the seeded `dreaming`) runs its `swarm-script` nodes as
|
|
@@ -160,9 +166,31 @@ afterAll(async () => {
|
|
|
160
166
|
await removeDbFiles();
|
|
161
167
|
});
|
|
162
168
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
169
|
+
/** Put the seeded dreaming row back after a test edits it. */
|
|
170
|
+
async function restoreDreaming(): Promise<void> {
|
|
171
|
+
await updateWorkflow(dreaming.id, {
|
|
172
|
+
description: dreaming.description,
|
|
173
|
+
definition: dreaming.definition,
|
|
174
|
+
input: dreaming.input,
|
|
175
|
+
cooldown: dreaming.cooldown,
|
|
176
|
+
enabled: dreaming.enabled,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Move every dreaming run start out of its 20-hour cooldown window. */
|
|
181
|
+
async function ageDreamingRuns(): Promise<void> {
|
|
182
|
+
await getDbClient().run(
|
|
183
|
+
`UPDATE workflow_runs SET startedAt = '2000-01-01T00:00:00.000Z',
|
|
184
|
+
finishedAt = CASE WHEN finishedAt IS NULL THEN NULL ELSE '2000-01-01T00:00:00.000Z' END
|
|
185
|
+
WHERE workflowId = ?`,
|
|
186
|
+
[dreaming.id],
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
beforeEach(async () => {
|
|
191
|
+
// Dreaming has a 20-hour cooldown. Age earlier runs (parked on an agent-task)
|
|
192
|
+
// so they do not block the next trigger.
|
|
193
|
+
await ageDreamingRuns();
|
|
166
194
|
calls = [];
|
|
167
195
|
scriptResult = QUIET_GATHER;
|
|
168
196
|
});
|
|
@@ -241,6 +269,179 @@ describe("swarm-script principal for a creator-less workflow", () => {
|
|
|
241
269
|
expect(gatherPrincipals()).toEqual([leadId]);
|
|
242
270
|
});
|
|
243
271
|
|
|
272
|
+
test("a creator-less schedule runs as the lead, resolved at fire time", async () => {
|
|
273
|
+
const schedule = await createScheduledTask({
|
|
274
|
+
name: `principal-seeded-${crypto.randomUUID().slice(0, 8)}`,
|
|
275
|
+
cronExpression: "0 0 1 1 *",
|
|
276
|
+
targetType: "workflow",
|
|
277
|
+
workflowId: dreaming.id,
|
|
278
|
+
targetAgentId: workerId,
|
|
279
|
+
});
|
|
280
|
+
expect(schedule.createdByAgentId).toBeUndefined();
|
|
281
|
+
await dispatchScheduleTarget(schedule);
|
|
282
|
+
expect(gatherPrincipals()).toEqual([leadId]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a creator-less schedule fails its fire when the swarm has no lead", async () => {
|
|
286
|
+
const schedule = await createScheduledTask({
|
|
287
|
+
name: `principal-leadless-${crypto.randomUUID().slice(0, 8)}`,
|
|
288
|
+
cronExpression: "0 0 1 1 *",
|
|
289
|
+
targetType: "workflow",
|
|
290
|
+
workflowId: dreaming.id,
|
|
291
|
+
});
|
|
292
|
+
await getDbClient().run("UPDATE agents SET isLead = 0 WHERE id = ?", [leadId]);
|
|
293
|
+
try {
|
|
294
|
+
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow(
|
|
295
|
+
"has no creator and the swarm has no lead",
|
|
296
|
+
);
|
|
297
|
+
} finally {
|
|
298
|
+
await getDbClient().run("UPDATE agents SET isLead = 1 WHERE id = ?", [leadId]);
|
|
299
|
+
}
|
|
300
|
+
expect(calls).toEqual([]);
|
|
301
|
+
const runs = await getDbClient().query<{ id: string }>(
|
|
302
|
+
"SELECT id FROM workflow_runs WHERE workflowId = ? AND startedAt > '2001-01-01'",
|
|
303
|
+
[dreaming.id],
|
|
304
|
+
);
|
|
305
|
+
expect(runs).toEqual([]);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("a creator-less schedule keeps the lead after the documented input setup and enable", async () => {
|
|
309
|
+
// The guide's setup: update-workflow with every input key, then turn it on.
|
|
310
|
+
// Both snapshot the seeded state first, as the tool and REST paths do.
|
|
311
|
+
await snapshotAndUpdateWorkflow(
|
|
312
|
+
dreaming.id,
|
|
313
|
+
{ input: { ...dreaming.input, leadAgentId: leadId, slackChannel: "C123" } },
|
|
314
|
+
{ changedByAgentId: workerId },
|
|
315
|
+
);
|
|
316
|
+
await snapshotAndUpdateWorkflow(dreaming.id, { enabled: false });
|
|
317
|
+
await snapshotAndUpdateWorkflow(dreaming.id, {
|
|
318
|
+
enabled: true,
|
|
319
|
+
description: "tuned by the operator",
|
|
320
|
+
cooldown: { hours: 22, from: "start" },
|
|
321
|
+
});
|
|
322
|
+
const schedule = await createScheduledTask({
|
|
323
|
+
name: `principal-tuned-${crypto.randomUUID().slice(0, 8)}`,
|
|
324
|
+
cronExpression: "0 0 1 1 *",
|
|
325
|
+
targetType: "workflow",
|
|
326
|
+
workflowId: dreaming.id,
|
|
327
|
+
});
|
|
328
|
+
try {
|
|
329
|
+
await dispatchScheduleTarget(schedule);
|
|
330
|
+
expect(gatherPrincipals()).toEqual([leadId]);
|
|
331
|
+
expect(calls[0]?.args.leadAgentId).toBe(leadId);
|
|
332
|
+
} finally {
|
|
333
|
+
await restoreDreaming();
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("a creator-less schedule refuses the lead once a worker edits the seeded nodes", async () => {
|
|
338
|
+
const schedule = await createScheduledTask({
|
|
339
|
+
name: `principal-edited-${crypto.randomUUID().slice(0, 8)}`,
|
|
340
|
+
cronExpression: "0 0 1 1 *",
|
|
341
|
+
targetType: "workflow",
|
|
342
|
+
workflowId: dreaming.id,
|
|
343
|
+
});
|
|
344
|
+
const [gather, ...rest] = dreaming.definition.nodes;
|
|
345
|
+
await snapshotAndUpdateWorkflow(
|
|
346
|
+
dreaming.id,
|
|
347
|
+
{
|
|
348
|
+
input: { ...dreaming.input, leadAgentId: leadId },
|
|
349
|
+
definition: {
|
|
350
|
+
...dreaming.definition,
|
|
351
|
+
nodes: [{ ...gather!, config: { ...gather!.config, scriptName: "evil" } }, ...rest],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
{ changedByAgentId: workerId },
|
|
355
|
+
);
|
|
356
|
+
try {
|
|
357
|
+
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow(
|
|
358
|
+
'the nodes or params of seeded workflow "dreaming" were edited',
|
|
359
|
+
);
|
|
360
|
+
} finally {
|
|
361
|
+
await restoreDreaming();
|
|
362
|
+
}
|
|
363
|
+
expect(calls).toEqual([]);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("a creator-less schedule refuses the lead for an edit with no seeded snapshot", async () => {
|
|
367
|
+
const schedule = await createScheduledTask({
|
|
368
|
+
name: `principal-unsnapshotted-${crypto.randomUUID().slice(0, 8)}`,
|
|
369
|
+
cronExpression: "0 0 1 1 *",
|
|
370
|
+
targetType: "workflow",
|
|
371
|
+
workflowId: dreaming.id,
|
|
372
|
+
});
|
|
373
|
+
// Delete the history so no snapshot proves the seeded state.
|
|
374
|
+
await getDbClient().run("DELETE FROM workflow_versions WHERE workflowId = ?", [dreaming.id]);
|
|
375
|
+
await updateWorkflow(dreaming.id, { description: "edited without a snapshot" });
|
|
376
|
+
try {
|
|
377
|
+
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow("were edited");
|
|
378
|
+
} finally {
|
|
379
|
+
await restoreDreaming();
|
|
380
|
+
}
|
|
381
|
+
expect(calls).toEqual([]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("a long successful night does not make the next nightly fire skip", async () => {
|
|
385
|
+
const schedule = await createScheduledTask({
|
|
386
|
+
name: `principal-long-${crypto.randomUUID().slice(0, 8)}`,
|
|
387
|
+
cronExpression: "0 0 1 1 *",
|
|
388
|
+
targetType: "workflow",
|
|
389
|
+
workflowId: dreaming.id,
|
|
390
|
+
});
|
|
391
|
+
// Last night started 24h ago and finished 1h ago.
|
|
392
|
+
const runId = crypto.randomUUID();
|
|
393
|
+
await createWorkflowRun({ id: runId, workflowId: dreaming.id });
|
|
394
|
+
await getDbClient().run(
|
|
395
|
+
"UPDATE workflow_runs SET status = 'completed', startedAt = ?, finishedAt = ? WHERE id = ?",
|
|
396
|
+
[
|
|
397
|
+
new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
|
398
|
+
new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
|
399
|
+
runId,
|
|
400
|
+
],
|
|
401
|
+
);
|
|
402
|
+
const fired = await dispatchScheduleTarget(schedule);
|
|
403
|
+
const run = await getWorkflowRun(fired.workflowRunIds![0]!);
|
|
404
|
+
expect(run?.status).not.toBe("skipped");
|
|
405
|
+
expect(gatherPrincipals()).toEqual([leadId]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("control: a creator-less workflow the seeder never wrote keeps running agentless", async () => {
|
|
409
|
+
const copy = await createWorkflow({
|
|
410
|
+
name: `principal-unseeded-${crypto.randomUUID()}`,
|
|
411
|
+
definition: dreaming.definition,
|
|
412
|
+
});
|
|
413
|
+
const schedule = await createScheduledTask({
|
|
414
|
+
name: `principal-unseeded-${crypto.randomUUID().slice(0, 8)}`,
|
|
415
|
+
cronExpression: "0 0 1 1 *",
|
|
416
|
+
targetType: "workflow",
|
|
417
|
+
workflowId: copy.id,
|
|
418
|
+
});
|
|
419
|
+
await dispatchScheduleTarget(schedule);
|
|
420
|
+
expect(gatherPrincipals()).toEqual(["workflow"]);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("end to end: the seeded dreaming-daily schedule fires dreaming as the lead, once a day", async () => {
|
|
424
|
+
const seeded = await runSeeder(createSchedulesSeeder(), { quiet: true });
|
|
425
|
+
expect(seeded.failed).toEqual([]);
|
|
426
|
+
const schedule = await getScheduledTaskByName("dreaming-daily");
|
|
427
|
+
expect(schedule).toMatchObject({ targetType: "workflow", workflowId: dreaming.id });
|
|
428
|
+
expect(schedule?.taskTemplate).toBeUndefined();
|
|
429
|
+
expect(schedule?.createdByAgentId).toBeUndefined();
|
|
430
|
+
|
|
431
|
+
const first = await dispatchScheduleTarget(schedule!);
|
|
432
|
+
expect(first.task).toBeUndefined();
|
|
433
|
+
expect(first.workflowRunIds).toHaveLength(1);
|
|
434
|
+
expect(gatherPrincipals()).toEqual([leadId]);
|
|
435
|
+
const run = await getWorkflowRun(first.workflowRunIds![0]!);
|
|
436
|
+
expect(run?.triggerData).toMatchObject({ scheduleName: "dreaming-daily" });
|
|
437
|
+
|
|
438
|
+
// The cooldown replaces the old task prompt's 20-hour double-run guard.
|
|
439
|
+
const second = await dispatchScheduleTarget(schedule!);
|
|
440
|
+
const skipped = await getWorkflowRun(second.workflowRunIds![0]!);
|
|
441
|
+
expect(skipped).toMatchObject({ status: "skipped", error: "cooldown" });
|
|
442
|
+
expect(gatherPrincipals()).toEqual([leadId]);
|
|
443
|
+
});
|
|
444
|
+
|
|
244
445
|
test("control: a workflow with a creator still runs as its creator", async () => {
|
|
245
446
|
const owned = await createWorkflow({
|
|
246
447
|
name: `principal-owned-${crypto.randomUUID()}`,
|
|
@@ -251,10 +452,9 @@ describe("swarm-script principal for a creator-less workflow", () => {
|
|
|
251
452
|
expect(gatherPrincipals()).toEqual([leadId]);
|
|
252
453
|
});
|
|
253
454
|
|
|
254
|
-
test("control:
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// synthesize and apply-lead target gather's leadAgentId.
|
|
455
|
+
test("control: a worker's manual trigger with no data runs the *-lead nodes on the lead", async () => {
|
|
456
|
+
// A worker that calls trigger-workflow with no triggerData. Scripts run as
|
|
457
|
+
// that worker, while synthesize and apply-lead target gather's leadAgentId.
|
|
258
458
|
scriptResult = {
|
|
259
459
|
ok: true,
|
|
260
460
|
agents: [{ id: workerId, name: "principal-worker" }],
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { getAgentById, getTaskById } from "@/be/db";
|
|
4
|
+
import { can } from "@/rbac";
|
|
5
|
+
import {
|
|
6
|
+
isSlackLeaveThreadDisabled,
|
|
7
|
+
leaveSlackThread,
|
|
8
|
+
SLACK_LEAVE_THREAD_DISABLE,
|
|
9
|
+
} from "@/slack/left-threads";
|
|
10
|
+
import { parseSlackTs } from "@/slack/message-text";
|
|
11
|
+
import { createToolRegistrar, swarmToolOutputSchema, toolErr, toolOk } from "@/tools/utils";
|
|
12
|
+
|
|
13
|
+
export const registerSlackLeaveThreadTool = (server: McpServer) => {
|
|
14
|
+
createToolRegistrar(server)(
|
|
15
|
+
"slack-leave-thread",
|
|
16
|
+
{
|
|
17
|
+
title: "Leave a Slack thread",
|
|
18
|
+
description:
|
|
19
|
+
"Stop following a Slack thread until someone @mentions the bot there again. Messages in the thread that do not @mention the bot are then ignored, with no task and no reply. Posts nothing to Slack. Pass taskId for the thread of your Slack-originated task, or (lead only) channelId and threadTs.",
|
|
20
|
+
annotations: { openWorldHint: true, idempotentHint: true },
|
|
21
|
+
|
|
22
|
+
inputSchema: z.object({
|
|
23
|
+
taskId: z.uuid().optional().describe("A task whose Slack thread to leave."),
|
|
24
|
+
channelId: z.string().min(1).optional().describe("The Slack channel ID of the thread."),
|
|
25
|
+
threadTs: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.optional()
|
|
29
|
+
.describe("Timestamp of the thread root (dotted, 'p' deep-link, or full permalink URL)."),
|
|
30
|
+
}),
|
|
31
|
+
outputSchema: swarmToolOutputSchema({
|
|
32
|
+
channelId: z.string().optional(),
|
|
33
|
+
threadTs: z.string().optional(),
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
async ({ taskId, channelId, threadTs }, requestInfo, _meta) => {
|
|
37
|
+
// Sessions created before the flag was set still list the tool.
|
|
38
|
+
if (isSlackLeaveThreadDisabled()) {
|
|
39
|
+
return toolErr(`slack-leave-thread is disabled by ${SLACK_LEAVE_THREAD_DISABLE}.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!requestInfo.agentId) {
|
|
43
|
+
return toolErr("Agent ID not found.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const agent = await getAgentById(requestInfo.agentId);
|
|
47
|
+
if (!agent) {
|
|
48
|
+
return toolErr("Agent not found.");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Any thread is open to leads, like slack-post. Task Slack context and
|
|
52
|
+
// source can be set by any agent (send-task, POST /api/tasks), so a non-lead
|
|
53
|
+
// may only leave the thread of a task assigned to it that Slack ingress
|
|
54
|
+
// created: only the Slack handlers write slackTriggerMessageTs.
|
|
55
|
+
const mayLeaveAnyThread = can({
|
|
56
|
+
principal: { kind: "agent", agentId: agent.id, isLead: agent.isLead },
|
|
57
|
+
verb: "integration.slack.post",
|
|
58
|
+
resource: { kind: "none" },
|
|
59
|
+
source: "mcp",
|
|
60
|
+
}).allow;
|
|
61
|
+
|
|
62
|
+
let slackChannelId: string | undefined;
|
|
63
|
+
let slackThreadTs: string | undefined;
|
|
64
|
+
|
|
65
|
+
if (taskId) {
|
|
66
|
+
if (channelId || threadTs) {
|
|
67
|
+
return toolErr("Provide either taskId or channelId and threadTs, not both.");
|
|
68
|
+
}
|
|
69
|
+
const task = await getTaskById(taskId);
|
|
70
|
+
if (!task) {
|
|
71
|
+
return toolErr("Task not found.");
|
|
72
|
+
}
|
|
73
|
+
if (
|
|
74
|
+
!mayLeaveAnyThread &&
|
|
75
|
+
(task.agentId !== agent.id || task.source !== "slack" || !task.slackTriggerMessageTs)
|
|
76
|
+
) {
|
|
77
|
+
return toolErr(
|
|
78
|
+
"Only a lead or the agent assigned a Slack-originated task can leave its thread.",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
slackChannelId = task.slackChannelId;
|
|
82
|
+
slackThreadTs = task.slackThreadTs;
|
|
83
|
+
} else if (channelId && threadTs) {
|
|
84
|
+
if (!mayLeaveAnyThread) {
|
|
85
|
+
return toolErr("Leaving a thread by channelId and threadTs requires lead privileges.");
|
|
86
|
+
}
|
|
87
|
+
slackChannelId = channelId;
|
|
88
|
+
slackThreadTs = parseSlackTs(threadTs);
|
|
89
|
+
} else {
|
|
90
|
+
return toolErr("Must provide taskId, or both channelId and threadTs.");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!slackChannelId || !slackThreadTs) {
|
|
94
|
+
return toolErr("No Slack thread context available.");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
await leaveSlackThread(slackChannelId, slackThreadTs, agent.id);
|
|
98
|
+
return toolOk(
|
|
99
|
+
`Left Slack thread ${slackChannelId}/${slackThreadTs}. Messages there are ignored until someone @mentions the bot.`,
|
|
100
|
+
{ data: { channelId: slackChannelId, threadTs: slackThreadTs } },
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
};
|
package/src/tools/steer-task.ts
CHANGED
|
@@ -99,7 +99,8 @@ export async function steerTaskHandler(
|
|
|
99
99
|
message,
|
|
100
100
|
mode,
|
|
101
101
|
onUnsupported,
|
|
102
|
-
source:
|
|
102
|
+
source:
|
|
103
|
+
ctx.kind === "owner" && ctx.requestInfo?.callOrigin === "script-sdk" ? "script" : "mcp",
|
|
103
104
|
createdByKind: ctx.kind === "owner" ? "agent" : "user",
|
|
104
105
|
createdByAgentId: ctx.kind === "owner" ? ctx.agentId : undefined,
|
|
105
106
|
createdByUserId: ctx.kind === "user" ? ctx.userId : undefined,
|
|
@@ -126,7 +127,7 @@ export const registerSteerTaskTool = (server: McpServer) => {
|
|
|
126
127
|
{
|
|
127
128
|
title: "Steer Task",
|
|
128
129
|
description:
|
|
129
|
-
'Send a message to a task that is already running. `mode:"steer"` is honored on pi, claude-managed, codex and
|
|
130
|
+
'Send a message to a task that is already running. `mode:"steer"` is honored on pi, claude-managed, codex, cursor and fx (fx supports steer only); claude, devin, opencode and amp support queue only (amp delivery lands at the next tool result). Pass `onUnsupported:"fail"` to get an error instead of a downgrade.',
|
|
130
131
|
annotations: { destructiveHint: true },
|
|
131
132
|
inputSchema: steerTaskInputSchema,
|
|
132
133
|
outputSchema: steerTaskOutputSchema,
|
package/src/tools/tool-config.ts
CHANGED
|
@@ -96,7 +96,7 @@ export const DEFERRED_TOOLS = new Set([
|
|
|
96
96
|
"read-messages",
|
|
97
97
|
"post-message",
|
|
98
98
|
|
|
99
|
-
// Slack (
|
|
99
|
+
// Slack (13): task-context gated; keep out of CORE_TOOLS
|
|
100
100
|
"slack-reply",
|
|
101
101
|
"slack-read",
|
|
102
102
|
"slack-upload-file",
|
|
@@ -109,6 +109,7 @@ export const DEFERRED_TOOLS = new Set([
|
|
|
109
109
|
"slack-archive-channel",
|
|
110
110
|
"slack-delete",
|
|
111
111
|
"slack-update",
|
|
112
|
+
"slack-leave-thread",
|
|
112
113
|
|
|
113
114
|
// Channel management (2)
|
|
114
115
|
"create-channel",
|
package/src/types.ts
CHANGED
|
@@ -185,6 +185,14 @@ export const DEFAULT_MODEL_TIER_MAP: Record<ProviderName, Record<ModelTier, stri
|
|
|
185
185
|
smart: "grok-4.6",
|
|
186
186
|
ultra: "grok-4.7",
|
|
187
187
|
},
|
|
188
|
+
// fx runs these on its `openrouter` custom connection (OPENROUTER_API_KEY),
|
|
189
|
+
// pi's tested OpenRouter set; each ran under fx 0.0.15 (2026-10-29 probe).
|
|
190
|
+
fx: {
|
|
191
|
+
smol: "openrouter/deepseek/deepseek-v4.1-flash",
|
|
192
|
+
regular: "openrouter/deepseek/deepseek-v4.1-flash",
|
|
193
|
+
smart: "openrouter/deepseek/deepseek-v4-pro-0813",
|
|
194
|
+
ultra: "openrouter/anthropic/claude-opus-5.5",
|
|
195
|
+
},
|
|
188
196
|
// ACP has no portable tier-to-model mapping. Operators may set an explicit
|
|
189
197
|
// MODEL_OVERRIDE, which the adapter applies through an advertised `model`
|
|
190
198
|
// config option with a target-specific startup fallback.
|
|
@@ -436,6 +444,7 @@ export const ProviderNameSchema = z.enum([
|
|
|
436
444
|
"amp",
|
|
437
445
|
"cursor",
|
|
438
446
|
"grok",
|
|
447
|
+
"fx",
|
|
439
448
|
]);
|
|
440
449
|
export type ProviderName = z.infer<typeof ProviderNameSchema>;
|
|
441
450
|
|
|
@@ -596,6 +605,10 @@ export const PROVIDER_STEER_CAPABILITIES: Record<ProviderName, SteerMode[]> = {
|
|
|
596
605
|
// Grok runs on the ACP client: one `session/prompt` turn, interrupted only
|
|
597
606
|
// by `session/cancel`. No Grok surface (ACP or headless) exposes a steer.
|
|
598
607
|
grok: [],
|
|
608
|
+
// `fx acp` steers natively: a `session/prompt` with `_meta.fx.steer` during a
|
|
609
|
+
// running turn lands at the next tool boundary without cancelling it (fx
|
|
610
|
+
// 0.0.15 probe, 2026-10-29). Queue is not measured, so it is not advertised.
|
|
611
|
+
fx: ["steer"],
|
|
599
612
|
};
|
|
600
613
|
|
|
601
614
|
export type DevinProviderMeta = {
|
|
@@ -619,6 +632,7 @@ export type ProviderMetaMap = {
|
|
|
619
632
|
amp: NoProviderMeta;
|
|
620
633
|
cursor: NoProviderMeta;
|
|
621
634
|
grok: NoProviderMeta;
|
|
635
|
+
fx: NoProviderMeta;
|
|
622
636
|
};
|
|
623
637
|
|
|
624
638
|
export const FollowUpConfigSchema = z
|
|
@@ -2606,6 +2620,10 @@ export const CooldownConfigSchema = z
|
|
|
2606
2620
|
hours: z.number().min(0).optional(),
|
|
2607
2621
|
minutes: z.number().min(0).optional(),
|
|
2608
2622
|
seconds: z.number().min(0).optional(),
|
|
2623
|
+
// "finish" (default): also wait out the window after the last successful
|
|
2624
|
+
// run finished. "start": count only from run starts, so a long run never
|
|
2625
|
+
// pushes the next fixed-time trigger out.
|
|
2626
|
+
from: z.enum(["finish", "start"]).optional(),
|
|
2609
2627
|
})
|
|
2610
2628
|
.refine((v) => v.hours !== undefined || v.minutes !== undefined || v.seconds !== undefined, {
|
|
2611
2629
|
message: "At least one of hours, minutes, or seconds is required",
|
|
@@ -3838,6 +3856,7 @@ export const PricingProviderSchema = z.enum([
|
|
|
3838
3856
|
"amp",
|
|
3839
3857
|
"cursor",
|
|
3840
3858
|
"grok",
|
|
3859
|
+
"fx",
|
|
3841
3860
|
]);
|
|
3842
3861
|
export type PricingProvider = z.infer<typeof PricingProviderSchema>;
|
|
3843
3862
|
|
|
@@ -32,3 +32,33 @@ export function isCodexAuthFailureReason(failureReason: string | undefined | nul
|
|
|
32
32
|
const lower = failureReason.toLowerCase();
|
|
33
33
|
return !TRANSIENT_AUTH_ERROR_MARKERS.some((m) => lower.includes(m));
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Turn-level texts of a rejected ChatGPT/Codex credential: a bad API key, or
|
|
38
|
+
* a pool login revoked while its access token is unexpired. That death mode
|
|
39
|
+
* never enters `getValidCodexOAuth` (the token looks fresh), so this error is
|
|
40
|
+
* its only artifact. The adapters tag a match `[auth-error]`.
|
|
41
|
+
*/
|
|
42
|
+
const REJECTED_ACCESS_PATTERNS = [
|
|
43
|
+
"unauthorized",
|
|
44
|
+
"http 401",
|
|
45
|
+
" 401 ",
|
|
46
|
+
"invalid api key",
|
|
47
|
+
"invalid_api_key",
|
|
48
|
+
"missing api key",
|
|
49
|
+
"no api key",
|
|
50
|
+
"authentication failed",
|
|
51
|
+
"failed to refresh token",
|
|
52
|
+
"invalid 'refresh_token'",
|
|
53
|
+
// ChatGPT backend 401 bodies (pi on the subscription surfaces them as-is).
|
|
54
|
+
"token_invalidated",
|
|
55
|
+
"authentication token has been invalidated",
|
|
56
|
+
"authentication token is expired",
|
|
57
|
+
"refresh token was revoked",
|
|
58
|
+
"could not parse your authentication token",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export function isCodexRejectedAccessError(text: string): boolean {
|
|
62
|
+
const lower = text.toLowerCase();
|
|
63
|
+
return REJECTED_ACCESS_PATTERNS.some((p) => lower.includes(p));
|
|
64
|
+
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* deprecated; new code should use `computeContextUsedUnified`.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { buildClaudeShortnameMap } from "@desplega/model-catalog";
|
|
23
|
+
import { buildClaudeShortnameMap, chatgptSubscriptionModelId } from "@desplega/model-catalog";
|
|
24
24
|
import { runtimeCatalogModel, runtimeCatalogSection } from "./runtime-model-catalog";
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -73,6 +73,9 @@ export function getContextWindowSize(model: string): number {
|
|
|
73
73
|
/** {@link getContextWindowSize} without the fallback: undefined when the catalog has no window. */
|
|
74
74
|
export function getKnownContextWindowSize(model: string | undefined): number | undefined {
|
|
75
75
|
if (!model) return undefined;
|
|
76
|
+
// `openai-codex/<id>` (pi on the ChatGPT subscription) is the OpenAI `<id>`.
|
|
77
|
+
const subscriptionId = chatgptSubscriptionModelId(model);
|
|
78
|
+
if (subscriptionId) return catalogContext("openai", subscriptionId);
|
|
76
79
|
const slash = model.indexOf("/");
|
|
77
80
|
if (slash > 0) {
|
|
78
81
|
const scoped = catalogContext(model.slice(0, slash), model.slice(slash + 1));
|
package/src/utils/credentials.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
|
+
fxModelRoute,
|
|
3
4
|
openAICompatibleProviders,
|
|
4
5
|
providerForKeyVar,
|
|
5
6
|
providerForModel,
|
|
@@ -61,6 +62,7 @@ export const PROVIDER_CREDENTIAL_VARS: Record<string, readonly string[]> = {
|
|
|
61
62
|
amp: ["AMP_API_KEY"],
|
|
62
63
|
cursor: ["CURSOR_API_KEY"],
|
|
63
64
|
grok: ["XAI_API_KEY", "OPENROUTER_API_KEY"],
|
|
65
|
+
fx: ["OPENROUTER_API_KEY", "OPENAI_API_KEY", "AI_GATEWAY_API_KEY"],
|
|
64
66
|
opencode: ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
|
65
67
|
};
|
|
66
68
|
|
|
@@ -100,6 +102,11 @@ export function getModelAwareCredentialVars(provider: string, model?: string): r
|
|
|
100
102
|
if (provider === "grok" && model) {
|
|
101
103
|
return model.startsWith("openrouter/") ? ["OPENROUTER_API_KEY"] : ["XAI_API_KEY"];
|
|
102
104
|
}
|
|
105
|
+
// fx routes by namespace (`src/utils/fx-models.ts`); each route reads one key.
|
|
106
|
+
if (provider === "fx" && model) {
|
|
107
|
+
const route = fxModelRoute(model);
|
|
108
|
+
return "error" in route ? base : [route.keyVar];
|
|
109
|
+
}
|
|
103
110
|
if (!SLASH_MODEL_PROVIDERS.has(provider) || !model) return base;
|
|
104
111
|
// OpenAI-compatible registry providers own their key: `nan/<id>` and
|
|
105
112
|
// `helmcode/<id>` never pool an OpenRouter or Anthropic key.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model routing for the `fx` harness. Pure, so the API server can validate a
|
|
3
|
+
* model and the worker can route it without importing the adapter.
|
|
4
|
+
*
|
|
5
|
+
* | Swarm model string | fx provider (`FX_PROVIDER`) | `fx acp --model` | Key |
|
|
6
|
+
* |------------------------------|-----------------------------------|------------------|----------------------|
|
|
7
|
+
* | `openrouter/<vendor>/<id>` | `openrouter` (custom connection) | `<vendor>/<id>` | `OPENROUTER_API_KEY` |
|
|
8
|
+
* | `vercel/<vendor>/<id>` | `gateway` (built in) | `<vendor>/<id>` | `AI_GATEWAY_API_KEY` |
|
|
9
|
+
* | bare `<id>` (no slash) | `openai` (custom connection) | `<id>` | `OPENAI_API_KEY` |
|
|
10
|
+
*
|
|
11
|
+
* Anything else (`nan/…`, `helmcode/…`, `anthropic/…`) is rejected with a reason.
|
|
12
|
+
*/
|
|
13
|
+
import { type FxRoute, fxModelRoute } from "@desplega/model-catalog";
|
|
14
|
+
import { DEFAULT_MODEL_TIER_MAP } from "../types";
|
|
15
|
+
|
|
16
|
+
export type { FxRoute };
|
|
17
|
+
|
|
18
|
+
/** An fx route plus the swarm model string it came from. */
|
|
19
|
+
export type ResolvedFxRoute = FxRoute & { model: string };
|
|
20
|
+
|
|
21
|
+
/** Every key an fx route can read, in route-table order. */
|
|
22
|
+
export const FX_ROUTE_KEY_VARS = [
|
|
23
|
+
"OPENROUTER_API_KEY",
|
|
24
|
+
"OPENAI_API_KEY",
|
|
25
|
+
"AI_GATEWAY_API_KEY",
|
|
26
|
+
] as const;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The fx route for a swarm model string, or the reason it has none. An empty
|
|
30
|
+
* model means the regular tier default.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveFxRoute(model: string | undefined): ResolvedFxRoute | { error: string } {
|
|
33
|
+
const value = model?.trim() || DEFAULT_MODEL_TIER_MAP.fx.regular;
|
|
34
|
+
const route = fxModelRoute(value);
|
|
35
|
+
return "error" in route ? route : { ...route, model: value };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** {@link resolveFxRoute}, throwing the rejection reason. */
|
|
39
|
+
export function requireFxRoute(model: string | undefined): ResolvedFxRoute {
|
|
40
|
+
const route = resolveFxRoute(model);
|
|
41
|
+
if ("error" in route) throw new Error(route.error);
|
|
42
|
+
return route;
|
|
43
|
+
}
|
|
@@ -15,7 +15,8 @@ function cooldownToMs(cooldown: CooldownConfig): number {
|
|
|
15
15
|
/**
|
|
16
16
|
* Check if a workflow should be skipped due to cooldown.
|
|
17
17
|
* Returns true if:
|
|
18
|
-
* - the last successful run finished within the cooldown window
|
|
18
|
+
* - the last successful run finished within the cooldown window (skipped for
|
|
19
|
+
* `from: "start"`), OR
|
|
19
20
|
* - any run (including failed ones) started within the cooldown window.
|
|
20
21
|
*
|
|
21
22
|
* The second condition prevents runaway re-triggering when all runs fail
|
|
@@ -30,7 +31,7 @@ export async function shouldSkipCooldown(
|
|
|
30
31
|
const now = Date.now();
|
|
31
32
|
|
|
32
33
|
// Skip if last successful run finished within the cooldown window
|
|
33
|
-
const lastSuccess = await getLastSuccessfulRun(workflowId);
|
|
34
|
+
const lastSuccess = cooldown.from === "start" ? null : await getLastSuccessfulRun(workflowId);
|
|
34
35
|
if (lastSuccess?.finishedAt) {
|
|
35
36
|
const lastFinished = new Date(lastSuccess.finishedAt).getTime();
|
|
36
37
|
if (now - lastFinished < cooldownMs) return true;
|
package/src/workflows/engine.ts
CHANGED
|
@@ -192,7 +192,7 @@ export async function startWorkflowExecution(
|
|
|
192
192
|
const newRunId = crypto.randomUUID();
|
|
193
193
|
const release = holdWorkflowRun(newRunId);
|
|
194
194
|
try {
|
|
195
|
-
const { runId, created, preflightAlert } = await createRunWithInitialState(
|
|
195
|
+
const { runId, created, preflightAlert, status } = await createRunWithInitialState(
|
|
196
196
|
workflow,
|
|
197
197
|
triggerData,
|
|
198
198
|
options,
|
|
@@ -204,7 +204,7 @@ export async function startWorkflowExecution(
|
|
|
204
204
|
if (preflightAlert) await notifyAutomationPreflightFailure(preflight);
|
|
205
205
|
return runId;
|
|
206
206
|
}
|
|
207
|
-
if (
|
|
207
|
+
if (status === "skipped") return runId;
|
|
208
208
|
telemetry.workflow("started", {
|
|
209
209
|
workflowId: workflow.id,
|
|
210
210
|
nodeCount: workflow.definition.nodes.length,
|
|
@@ -317,7 +317,12 @@ async function createRunWithInitialState(
|
|
|
317
317
|
options: WorkflowExecutionOptions,
|
|
318
318
|
initial: InitialRunState,
|
|
319
319
|
runId: string,
|
|
320
|
-
): Promise<{
|
|
320
|
+
): Promise<{
|
|
321
|
+
runId: string;
|
|
322
|
+
created: boolean;
|
|
323
|
+
preflightAlert?: boolean;
|
|
324
|
+
status?: InitialRunState["status"];
|
|
325
|
+
}> {
|
|
321
326
|
return getDbClient().transaction(async () => {
|
|
322
327
|
const { parentStepId } = options;
|
|
323
328
|
const attempt = options.parentStepAttempt ?? 0;
|
|
@@ -328,6 +333,16 @@ async function createRunWithInitialState(
|
|
|
328
333
|
const existingId = await getChildWorkflowRunId(parentStepId, attempt);
|
|
329
334
|
if (existingId) return { runId: existingId, created: false };
|
|
330
335
|
}
|
|
336
|
+
// The cooldown check before this transaction is only a fast path: two
|
|
337
|
+
// triggers can both pass it before either run exists. The write lock is
|
|
338
|
+
// held from BEGIN, across processes too, so this recheck sees every run
|
|
339
|
+
// committed before it and the losing trigger records a skipped run.
|
|
340
|
+
const state: InitialRunState =
|
|
341
|
+
!initial.status &&
|
|
342
|
+
workflow.cooldown &&
|
|
343
|
+
(await shouldSkipCooldown(workflow.id, workflow.cooldown))
|
|
344
|
+
? { status: "skipped", error: "cooldown", finishedAt: new Date().toISOString() }
|
|
345
|
+
: initial;
|
|
331
346
|
await createWorkflowRun({
|
|
332
347
|
id: runId,
|
|
333
348
|
workflowId: workflow.id,
|
|
@@ -340,16 +355,16 @@ async function createRunWithInitialState(
|
|
|
340
355
|
});
|
|
341
356
|
await updateWorkflowRun(
|
|
342
357
|
runId,
|
|
343
|
-
|
|
344
|
-
? { status:
|
|
345
|
-
: { context:
|
|
358
|
+
state.status
|
|
359
|
+
? { status: state.status, error: state.error, finishedAt: state.finishedAt }
|
|
360
|
+
: { context: state.context },
|
|
346
361
|
);
|
|
347
362
|
const preflightAlert =
|
|
348
|
-
|
|
363
|
+
state.status === "failed" && state.preflight
|
|
349
364
|
? await isFirstWorkflowPreflightFailureToday(workflow.id, runId)
|
|
350
365
|
: undefined;
|
|
351
|
-
await options.accept?.({ runId, skipped:
|
|
352
|
-
return { runId, created: true, preflightAlert };
|
|
366
|
+
await options.accept?.({ runId, skipped: state.status === "skipped" });
|
|
367
|
+
return { runId, created: true, preflightAlert, status: state.status };
|
|
353
368
|
});
|
|
354
369
|
}
|
|
355
370
|
|