@desplega.ai/agent-swarm 2.5.2 → 2.5.4
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-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
- package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
- package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
- package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
- package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
- package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
- package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
- package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
- package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
- package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
- package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
- package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
- package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
- package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
- package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
- package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
- package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
- package/dist/cli-2655k16q.js +140 -0
- package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
- package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
- package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
- package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
- package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
- package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
- package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
- package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
- package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
- package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
- package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
- package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
- package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
- package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
- package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
- package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
- package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
- package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
- package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
- package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
- package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
- package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
- package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
- package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
- package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
- package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
- package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
- package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
- package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
- package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
- package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
- package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
- package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
- package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
- package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
- package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
- package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
- package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
- package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
- package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
- package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
- package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
- package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
- package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
- package/dist/cli.js +20 -20
- package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
- package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
- package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
- package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
- package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
- package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
- package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
- package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
- package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
- package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
- package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
- package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
- package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
- package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
- package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
- package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
- package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
- package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
- package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
- package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
- package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
- package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
- package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
- package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
- package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
- package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
- package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
- package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
- package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
- package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
- package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
- package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
- package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
- package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
- package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
- package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
- package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
- package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
- package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
- package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
- package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
- package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
- package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
- package/openapi.json +3078 -2424
- package/package.json +7 -7
- package/plugin/commands/create-pr.md +2 -2
- package/plugin/pi-skills/create-pr/SKILL.md +2 -2
- package/src/be/approval-config-question.ts +389 -0
- package/src/be/automation-preflight.ts +37 -3
- package/src/be/db/approvals.ts +48 -0
- package/src/be/db/session-tree.ts +73 -15
- package/src/be/db/skill-search.ts +87 -0
- package/src/be/db/tasks/read.ts +29 -2
- package/src/be/db/tasks/write.ts +15 -5
- package/src/be/db/workflow-idempotency.ts +183 -0
- package/src/be/db/workflow-runs.ts +366 -8
- package/src/be/db.ts +92 -22
- package/src/be/migrations/239_skills_fts.sql +40 -0
- package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
- package/src/be/migrations/241_skills_meta_fts.sql +39 -0
- package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
- package/src/be/sealed-json.ts +1 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
- package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
- package/src/be/seed-skills/bundled-files.generated.json +1 -1
- package/src/be/sql-connections.ts +7 -1
- package/src/be/swarm-config-guard.ts +25 -0
- package/src/be/users.ts +9 -0
- package/src/commands/controller.ts +33 -0
- package/src/commands/runner.ts +6 -2
- package/src/extensions/apply-task-create.ts +57 -11
- package/src/extensions/catalog.generated.json +4 -4
- package/src/extensions/contract-types.generated.ts +1 -1
- package/src/extensions/contract.ts +25 -0
- package/src/extensions/dispatcher.ts +4 -0
- package/src/extensions/post-bridge.ts +8 -0
- package/src/hooks/context-bound-note.ts +196 -0
- package/src/hooks/hook.ts +12 -0
- package/src/http/approval-requests.ts +209 -38
- package/src/http/config.ts +70 -53
- package/src/http/idempotency-key.ts +117 -0
- package/src/http/sessions.ts +27 -2
- package/src/http/workflows.ts +129 -24
- package/src/integrations/chat-sdk/inbound.ts +5 -4
- package/src/prompts/context-bound-note.ts +48 -0
- package/src/prompts/session-templates.ts +25 -1
- package/src/providers/amp-adapter.ts +30 -8
- package/src/providers/claude-adapter.ts +29 -4
- package/src/providers/claude-managed-adapter.ts +26 -0
- package/src/providers/claude-sdk-session.ts +10 -0
- package/src/providers/claude-session-events.ts +13 -0
- package/src/providers/codex-adapter.ts +12 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/opencode-adapter.ts +18 -0
- package/src/providers/pi-mono-adapter.ts +35 -0
- package/src/runtime-providers/docker.ts +6 -2
- package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
- package/src/tests/amp-adapter.test.ts +97 -0
- package/src/tests/approval-config-question.test.ts +702 -0
- package/src/tests/approval-requests.test.ts +10 -7
- package/src/tests/approval-sweeps.test.ts +12 -6
- package/src/tests/base-prompt.test.ts +1 -1
- package/src/tests/chat-sdk-inbound.test.ts +20 -0
- package/src/tests/claude-hook-output.test.ts +79 -1
- package/src/tests/claude-managed-adapter.test.ts +275 -0
- package/src/tests/codex-adapter.test.ts +163 -0
- package/src/tests/context-bound-note.test.ts +269 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
- package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
- package/src/tests/extensions-pre-task.test.ts +273 -0
- package/src/tests/fetch-resolved-env.test.ts +17 -0
- package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
- package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
- package/src/tests/fixtures/fake-amp.ts +22 -8
- package/src/tests/fixtures/held-template-render.ts +42 -0
- package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
- package/src/tests/list-endpoint-slimming.test.ts +33 -0
- package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
- package/src/tests/openapi-route-headers.test.ts +2 -0
- package/src/tests/opencode-adapter.test.ts +134 -0
- package/src/tests/pi-mono-adapter.test.ts +105 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/runner-push-delivery.test.ts +25 -0
- package/src/tests/runtime-controller-liveness.test.ts +133 -0
- package/src/tests/runtime-providers-docker.test.ts +2 -0
- package/src/tests/script-connections.test.ts +9 -0
- package/src/tests/script-run-config-scope.test.ts +91 -3
- package/src/tests/session-logs.test.ts +70 -0
- package/src/tests/sessions-tree.test.ts +205 -1
- package/src/tests/skill-search-long-content.test.ts +193 -0
- package/src/tests/skill-search.test.ts +173 -22
- package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
- package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
- package/src/tests/workflow-idempotency.test.ts +915 -0
- package/src/tests/workflow-sub-workflow.test.ts +538 -1
- package/src/tools/request-human-input.ts +27 -5
- package/src/tools/workflows/cancel-workflow-run.ts +1 -1
- package/src/types.ts +7 -0
- package/src/utils/context-window.ts +8 -6
- package/src/workflows/approval-cancel.ts +15 -13
- package/src/workflows/approval-notifications.ts +15 -4
- package/src/workflows/checkpoint.ts +72 -35
- package/src/workflows/engine.ts +262 -93
- package/src/workflows/executors/base.ts +6 -0
- package/src/workflows/executors/foreach.ts +2 -2
- package/src/workflows/executors/human-in-the-loop.ts +25 -1
- package/src/workflows/executors/sub-workflow.ts +45 -16
- package/src/workflows/foreach-driver.ts +10 -3
- package/src/workflows/foreach-join.ts +29 -6
- package/src/workflows/recovery.ts +68 -1
- package/src/workflows/resume.ts +228 -77
- package/src/workflows/retry-poller.ts +74 -7
- package/src/workflows/task-step-routing.ts +29 -7
- package/templates/ai-toolbox.manifest.json +8 -8
- package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
- package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
- package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
- package/templates/extensions/comms-adherence-gate/README.md +38 -13
- package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
- package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
- package/templates/skills/agent-browser/SKILL.md +4 -4
- package/templates/skills/agent-browser/content.md +4 -4
- package/templates/skills/code-quality/config.json +1 -1
- package/templates/skills/code-quality/content.md +2 -2
- package/templates/skills/delegate-work/content.md +29 -25
- package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
- package/templates/skills/qa/content.md +2 -2
- package/templates/skills/researching/content.md +2 -0
- package/templates/skills/turso-interaction/content.md +1 -1
- package/templates/skills/work-on-task/SKILL.md +1 -1
- package/templates/skills/work-on-task/content.md +1 -1
- package/templates/skills/workflow-iterate/content.md +8 -1
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
"manifest": {
|
|
5
5
|
"$schema": "../manifest.schema.json",
|
|
6
6
|
"name": "comms-adherence-gate",
|
|
7
|
-
"description": "Judges every Slack-facing store-progress completion against the
|
|
8
|
-
"version": "1.
|
|
7
|
+
"description": "Judges every Slack-facing store-progress completion and agent Slack post against the comms preferences of the requester, or of the person it @-mentions, their latest request and a plain-language rubric with Jev (TypeSafe or OpenRouter), and in enforce blocks a failing call once per task",
|
|
8
|
+
"version": "1.7.0",
|
|
9
9
|
"runtime": "api",
|
|
10
10
|
"assets": {
|
|
11
11
|
"hooks": "hooks.ts"
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"files": {
|
|
15
|
-
"hooks.ts": "import { type ApiCtx, block, type SwarmExtension } from \"swarm-extension\";\nimport { z } from \"zod\";\n\n/**\n * comms-adherence-gate\n *\n * When an agent calls `store-progress` with status \"completed\" on a task that\n * reaches a human in Slack, judge the `output` against the requesting user's\n * stored comms preferences (users.metadata.$.comms) with Jev, and against what\n * that user actually asked for in their latest message. An explicit request\n * (\"tell me a long joke\", \"explain in detail\") outranks the stored default, so\n * a requested long answer is not a verbosity failure.\n *\n * Transport (config.provider): Jev is served by TypeSafe directly and by\n * OpenRouter's Decisions endpoint. Both take the same {model, state, questions}\n * body and return the same `answers` shape. `auto` uses TYPESAFE_API_KEY when\n * set, else OPENROUTER_API_KEY.\n *\n * modes (config.mode):\n * off -> never evaluate\n * shadow -> evaluate + record, never block (default)\n * enforce -> record, and block the completion up to maxBlocksPerTask times\n * per task with actionable feedback\n *\n * Fails OPEN on every error: a judge outage must never wall the swarm.\n * Every verdict lands in KV namespace `ext:comms-gate`.\n */\n\nexport const config = z.object({\n mode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n /** A dimension scoring below this fails. */\n threshold: z.number().min(0).max(1).default(0.5),\n timeoutMs: z.number().int().positive().default(4000),\n maxBlocksPerTask: z.number().int().min(0).default(1),\n provider: z.enum([\"auto\", \"typesafe\", \"openrouter\"]).default(\"auto\"),\n /** Jev model on the TypeSafe transport. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on the OpenRouter transport. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n /** Explicit TypeSafe key; otherwise TYPESAFE_API_KEY from the API process env. */\n apiKey: z.string().optional(),\n});\n\ntype Cfg = z.infer<typeof config>;\ntype Swarm = ApiCtx[\"swarm\"];\n\nconst NS = \"ext:comms-gate\";\nconst TYPESAFE_ENDPOINT = \"https://api.typesafe.ai/v1/systemone\";\nconst OPENROUTER_ENDPOINT = \"https://openrouter.ai/api/alpha/decisions\";\nconst UUID_RE = /^[0-9a-fA-F-]{36}$/;\n// The dispatcher cancels a handler after 5 s; leave room for the DB read and KV writes.\nconst MAX_TIMEOUT_MS = 4000;\n\nexport const cfgOf = (raw: unknown): Cfg => {\n const parsed = config.safeParse(raw ?? {});\n return parsed.success ? parsed.data : config.parse({});\n};\n\n// kv keys accept [a-zA-Z0-9._:/%-]\nconst safeKey = (raw: string | undefined | null): string =>\n (raw && raw.length > 0 ? raw : \"unknown\").replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 120);\n\n/** SDK envelopes: {success, data:{...}} or the bare payload. */\ntype Envelope = {\n data?: { rows?: unknown[]; value?: unknown };\n rows?: unknown[];\n value?: unknown;\n} | null;\n\nconst cell = (row: unknown, i: number, key: string): unknown =>\n Array.isArray(row) ? row[i] : (row as Record<string, unknown>)?.[key];\n\n/**\n * The newest human message sits at the END of a Slack task: the\n * `<thread_context>` blob comes first and can run to thousands of chars, so a\n * leading slice of `agent_tasks.task` shows the judge everything except the\n * actual request. Take the tail, then the part after the last `</thread_context>`.\n */\nconst CTX_END = \"</thread_context>\";\nexport const latestAsk = (tail: string): string => {\n const end = tail.lastIndexOf(CTX_END);\n let out = (end >= 0 ? tail.slice(end + CTX_END.length) : tail)\n .replace(/\\[Thread follow-up[^\\]]*\\]/g, \"\")\n .trim();\n // Some tasks end with the context block; fall back to the last messages in it.\n if (out.length < 8) out = (end >= 0 ? tail.slice(0, end) : tail).trim().slice(-800);\n return out.slice(0, 900);\n};\n\n// Preferences are the DEFAULT. A direct request outranks them.\nconst JUDGING_RULES = [\n \"The requester's explicit instruction in latest_request OUTRANKS requester_preferences. Preferences describe the DEFAULT; they never override a direct request.\",\n \"If latest_request asks for something long, detailed, creative, a story, a joke, a full explanation or a document, then length and structure serving that request are CORRECT and must NOT be penalized.\",\n \"Judge whether agent_message serves what latest_request actually asked for, written in the requester's manner. Do not judge it against the default preference when the request overrides it.\",\n];\n\ntype Prefs = { verbosity?: unknown; tone?: unknown; language?: unknown } & Record<string, unknown>;\n\n/** One `noul` question per stated preference the gate knows how to judge. */\nexport const buildQuestions = (prefs: Prefs): Record<string, unknown> => {\n const questions: Record<string, unknown> = {};\n if (prefs.verbosity) {\n questions.verbosity = {\n type: \"noul\",\n instructions:\n \"Does agent_message respect the requester's verbosity preference, AS OVERRIDDEN by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"Length and structure fit what latest_request asked for (or the stated preference when the request does not override it), and it leads with the result.\",\n false:\n \"Too long for what was asked, or it opens with preamble, recaps the request, narrates the process, or ends with an offer to elaborate. Length explicitly requested in latest_request is NOT a failure.\",\n },\n };\n }\n if (prefs.tone) {\n questions.tone = {\n type: \"noul\",\n instructions:\n \"Does agent_message match the requester's stated tone preference, as overridden by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"The tone matches what the requester asked for.\",\n false:\n \"The tone conflicts with it (for example hedging, flattery or filler when the requester asked for direct and plain). A voice appropriate to an explicitly requested creative or narrative form is NOT a failure.\",\n },\n };\n }\n if (prefs.language) {\n questions.language = {\n type: \"noul\",\n instructions: \"Is agent_message written in the requester's stated language?\",\n criteria: {\n true: \"Written in the stated language.\",\n false: \"Written in a different language.\",\n },\n };\n }\n return questions;\n};\n\nconst secretCache = new Map<string, { value: string; at: number }>();\nconst KEY_TTL_MS = 5 * 60 * 1000;\nconst usable = (v: string): boolean => Boolean(v) && !v.startsWith(\"[REDACTED\");\nconst envOf = (name: string): string => String(process.env[name] ?? \"\");\n\n/**\n * Resolve a secret: explicit value, then the API process env. The API process loads\n * global swarm_config rows into process.env at boot and on config reload.\n * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to \"[REDACTED:<name>]\".\n */\nconst secret = (name: string, explicit?: string): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = [String(explicit ?? \"\"), envOf(name)].find(usable) ?? \"\";\n if (usable(value)) secretCache.set(name, { value, at: Date.now() });\n return value;\n};\n\n/** Test hook: forget cached secrets. */\nexport const resetKeyCache = (): void => {\n secretCache.clear();\n};\n\nexport type JevTransport = {\n provider: \"typesafe\" | \"openrouter\";\n endpoint: string;\n key: string;\n model: string;\n};\n\n/**\n * Pick the Jev transport. `auto` prefers TypeSafe and falls back to OpenRouter.\n * Throws when the chosen provider has no usable key; the handler fails open.\n */\nexport const resolveTransport = (cfg: Cfg): JevTransport => {\n const typesafe = (): JevTransport | null => {\n const key = secret(\"TYPESAFE_API_KEY\", cfg.apiKey);\n return usable(key)\n ? { provider: \"typesafe\", endpoint: TYPESAFE_ENDPOINT, key, model: cfg.model }\n : null;\n };\n const openrouter = (): JevTransport | null => {\n const key = secret(\"OPENROUTER_API_KEY\");\n return usable(key)\n ? { provider: \"openrouter\", endpoint: OPENROUTER_ENDPOINT, key, model: cfg.openrouterModel }\n : null;\n };\n const transport =\n cfg.provider === \"typesafe\"\n ? typesafe()\n : cfg.provider === \"openrouter\"\n ? openrouter()\n : (typesafe() ?? openrouter());\n if (transport) return transport;\n throw new Error(\n cfg.provider === \"typesafe\"\n ? \"no TYPESAFE_API_KEY\"\n : cfg.provider === \"openrouter\"\n ? \"no OPENROUTER_API_KEY\"\n : \"no TYPESAFE_API_KEY or OPENROUTER_API_KEY\",\n );\n};\n\n/** One Jev call; returns the `noul` score per question. Throws on any failure. */\nexport const askJev = async (\n state: Record<string, unknown>,\n questions: Record<string, unknown>,\n transport: JevTransport,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<{ scores: Record<string, number>; latencyMs: number }> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS));\n const onOuterAbort = () => controller.abort();\n outer?.addEventListener(\"abort\", onOuterAbort);\n const t0 = Date.now();\n try {\n const res = await fetch(transport.endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", Authorization: `Bearer ${transport.key}` },\n body: JSON.stringify({ state, model: transport.model, questions }),\n signal: controller.signal,\n });\n if (!res.ok) throw new Error(`${transport.provider} ${res.status}`);\n const body = (await res.json()) as { answers?: Record<string, { noul?: unknown }> } | null;\n const answers = body?.answers;\n if (!answers || typeof answers !== \"object\") throw new Error(\"no answers in response\");\n const scores: Record<string, number> = {};\n for (const [id, a] of Object.entries(answers)) {\n if (typeof a?.noul === \"number\") scores[id] = a.noul;\n }\n if (Object.keys(scores).length === 0) throw new Error(\"no scores in response\");\n return { scores, latencyMs: Date.now() - t0 };\n } finally {\n clearTimeout(timer);\n outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\nconst SELFTEST = \"comms-gate-selftest\";\nconst SELFTEST_PREFS = {\n tone: \"direct, plain, no hedging\",\n language: \"English\",\n verbosity:\n \"very terse - answer in the first sentence, ~400 chars max. No preamble, no recap, no closing offer.\",\n};\nconst SELFTEST_BAD =\n \"Great question! Let me walk you through what I did here. First I looked at the task, then I reviewed the relevant files, and after careful consideration I decided to go with option B. I hope this helps - let me know if you would like me to elaborate further on any of the above!\";\nconst SELFTEST_GOOD =\n \"Went with option B: it is the only one that keeps the migration reversible. PR #123.\";\n\n/**\n * Run the judge on a known-bad (positive control, must score below threshold) and a\n * known-good (negative control, must score at or above it) message and record both\n * under `selftest:last`. Proves key, egress and KV from inside the API runtime\n * without touching real traffic.\n */\nconst selfTest = async (swarm: Swarm, cfg: Cfg, signal?: AbortSignal): Promise<void> => {\n const started = Date.now();\n try {\n const transport = resolveTransport(cfg);\n const questions = { verbosity: buildQuestions(SELFTEST_PREFS).verbosity };\n const judge = (message: string) =>\n askJev(\n { requester_preferences: SELFTEST_PREFS, agent_message: message },\n questions,\n transport,\n cfg,\n signal,\n ).then((r) => r.scores.verbosity ?? null);\n const [bad, good] = await Promise.all([judge(SELFTEST_BAD), judge(SELFTEST_GOOD)]);\n await swarm.kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n provider: transport.provider,\n threshold: cfg.threshold,\n positiveControlBadScore: bad,\n negativeControlGoodScore: good,\n controlsOk:\n typeof bad === \"number\" &&\n typeof good === \"number\" &&\n bad < cfg.threshold &&\n good >= cfg.threshold,\n latencyMs: Date.now() - started,\n },\n });\n } catch (err) {\n await swarm\n .kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n error: String(err).slice(0, 400),\n latencyMs: Date.now() - started,\n },\n })\n .catch(() => {});\n }\n};\n\nconst TASK_SQL = `SELECT t.source, t.slackThreadTs, t.agentId, t.requestedByUserId,\n json_extract(u.metadata, '$.comms') AS comms, u.name,\n substr(t.task, 1, 400) AS askHead,\n substr(t.task, -1400) AS askTail\nFROM agent_tasks t\nLEFT JOIN users u ON u.id = t.requestedByUserId\nWHERE t.id = ?`;\n\nconst LEAD_SQL = \"SELECT isLead FROM agents WHERE id = ?\";\n\n/**\n * Mirrors the `task.progress.write` rule store-progress enforces\n * (src/rbac/legacy-policy.ts): the caller owns the task, the task is\n * unassigned, or the caller is a lead. Lead status comes from the caller's\n * agents row, never from tool args. Extension callers return before this.\n */\nconst mayWriteProgress = async (\n swarm: Swarm,\n callerId: string | undefined,\n taskAgentId: string,\n): Promise<boolean> => {\n if (!callerId) return false;\n if (!taskAgentId || taskAgentId === callerId) return true;\n const q = (await swarm.db_query({ sql: LEAD_SQL, params: [callerId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n return Number(cell(row, 0, \"isLead\") ?? 0) === 1;\n};\n\nconst extension: SwarmExtension = (api) => {\n api.on(\"pre.tool.call\", async (event, ctx) => {\n if (event.tool !== \"store-progress\") return;\n if (event.requestInfo?.callOrigin === \"extension\") return;\n\n const cfg = cfgOf(ctx.config);\n if (cfg.mode === \"off\") return;\n\n const args = (event.args ?? {}) as Record<string, unknown>;\n\n // Self-test hatch: a progress call whose text starts with the sentinel runs\n // the controls and records them under `selftest:last`.\n if (args.status !== \"completed\") {\n if (typeof args.progress === \"string\" && args.progress.startsWith(SELFTEST)) {\n await selfTest(ctx.swarm, cfg, ctx.signal);\n }\n return;\n }\n\n const output = typeof args.output === \"string\" ? args.output.trim() : \"\";\n if (!output) return;\n\n const taskId = String(args.taskId ?? event.requestInfo?.sourceTaskId ?? \"\");\n if (!UUID_RE.test(taskId)) return;\n\n try {\n const q = (await ctx.swarm.db_query({ sql: TASK_SQL, params: [taskId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n if (!row) return;\n\n const source = String(cell(row, 0, \"source\") ?? \"\");\n const slackThreadTs = cell(row, 1, \"slackThreadTs\");\n const agentId = String(cell(row, 2, \"agentId\") ?? \"\");\n const requesterId = String(cell(row, 3, \"requestedByUserId\") ?? \"\");\n const commsRaw = cell(row, 4, \"comms\");\n const requesterName = String(cell(row, 5, \"name\") ?? requesterId);\n const threadOpening = String(cell(row, 6, \"askHead\") ?? \"\");\n const ask = latestAsk(String(cell(row, 7, \"askTail\") ?? \"\"));\n\n // Judge only a completion store-progress will accept, so no agent can ship\n // another task's thread and preferences to the external judge.\n if (!(await mayWriteProgress(ctx.swarm, event.requestInfo?.agentId, agentId))) return;\n\n // Only where the text actually reaches a human in Slack.\n if (source !== \"slack\" && !slackThreadTs) return;\n if (!commsRaw) return; // no stated preferences -> nothing to judge against\n const prefs = (typeof commsRaw === \"string\" ? JSON.parse(commsRaw) : commsRaw) as Prefs;\n if (!prefs || typeof prefs !== \"object\") return;\n\n const questions = buildQuestions(prefs);\n if (Object.keys(questions).length === 0) return;\n\n const transport = resolveTransport(cfg);\n const { scores, latencyMs } = await askJev(\n {\n role: \"You audit an AI agent's final message to the human who requested the work.\",\n requester: requesterName,\n requester_preferences: prefs,\n latest_request: ask,\n thread_opening: threadOpening,\n judging_rules: JUDGING_RULES,\n agent_message: output,\n },\n questions,\n transport,\n cfg,\n ctx.signal,\n );\n\n const failed = Object.entries(scores)\n .filter(([, v]) => v < cfg.threshold)\n .map(([k]) => k);\n\n const verdict = {\n at: new Date().toISOString(),\n taskId,\n agentId,\n source,\n requester: requesterName,\n requesterId,\n provider: transport.provider,\n scores,\n failed,\n threshold: cfg.threshold,\n mode: cfg.mode,\n latencyMs,\n preferences: prefs,\n askPreview: ask.slice(0, 400),\n outputPreview: output.slice(0, 700),\n };\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"total\" });\n await ctx.swarm.kv_set({ namespace: NS, key: \"last\", value: verdict });\n\n if (failed.length === 0) {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"pass\" });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"fail\" });\n for (const dim of failed) {\n await ctx.swarm.kv_incr({ namespace: NS, key: `dim:${safeKey(dim)}` });\n }\n\n const blocksKey = `blocks:${safeKey(taskId)}`;\n // kv_get answers {success, data:{value}}, and {success:false} when absent.\n const prior = (await ctx.swarm.kv_get({ namespace: NS, key: blocksKey })) as Envelope;\n const priorBlocks = Number(prior?.data?.value ?? prior?.value ?? 0) || 0;\n const willBlock = cfg.mode === \"enforce\" && priorBlocks < cfg.maxBlocksPerTask;\n\n await ctx.swarm.kv_set({\n namespace: NS,\n key: `fail:${Date.now()}:${safeKey(taskId)}`,\n value: { ...verdict, action: willBlock ? \"blocked\" : \"recorded\" },\n });\n\n if (!willBlock) {\n await ctx.swarm.kv_incr({\n namespace: NS,\n key: cfg.mode === \"enforce\" ? \"escaped\" : \"shadowed\",\n });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"blocked\" });\n await ctx.swarm.kv_incr({ namespace: NS, key: blocksKey });\n\n const detail = failed\n .map(\n (d) =>\n `${d} (${scores[d]!.toFixed(2)} vs ${cfg.threshold}) — requested: ${String(prefs[d] ?? \"\").slice(0, 220)}`,\n )\n .join(\"\\n\");\n\n return block(\n [\n `Comms gate: this output does not match ${requesterName}'s stated preferences.`,\n detail,\n \"Rewrite the output to match and call store-progress again. Do not change the facts, only how they are said.\",\n ].join(\"\\n\"),\n );\n } catch (err) {\n // Fail open, always.\n try {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"error\" });\n await ctx.swarm.kv_set({\n namespace: NS,\n key: \"error:last\",\n value: { at: new Date().toISOString(), taskId, error: String(err).slice(0, 400) },\n });\n } catch {\n /* ignore */\n }\n ctx.log.warn(\"comms-adherence-gate: failing open\", { error: String(err) });\n return;\n }\n });\n};\n\nexport default extension;\n"
|
|
15
|
+
"hooks.ts": "import { type ApiCtx, block, type SwarmExtension } from \"swarm-extension\";\nimport { z } from \"zod\";\n\n/**\n * comms-adherence-gate\n *\n * When an agent calls `store-progress` with status \"completed\" on a task that\n * reaches a human in Slack, or posts to Slack itself (`slack-reply`,\n * `slack-post`, `slack-start-thread`, `slack-update`), judge the text with Jev:\n * against the requesting user's stored comms preferences\n * (users.metadata.$.comms) and what that user actually asked for in their\n * latest message, and against a plain-language rubric that applies to every\n * message whatever preferences exist. An explicit request (\"tell me a long\n * joke\", \"explain in detail\") outranks the stored default, so a requested long\n * answer is not a verbosity failure.\n *\n * Threads with several people: a message that @-mentions someone is judged\n * against the first mentioned person with stored preferences, skipping a\n * requester without preferences; otherwise against the requester. `plain` applies to everyone.\n *\n * Transport (config.provider): Jev is served by TypeSafe directly and by\n * OpenRouter's Decisions endpoint. Both take the same {model, state, questions}\n * body and return the same `answers` shape. `auto` uses TYPESAFE_API_KEY when\n * set, else OPENROUTER_API_KEY.\n *\n * modes (config.mode for the preference dimensions, config.plainMode for `plain`):\n * off -> never evaluate\n * shadow -> evaluate + record, never block (default)\n * enforce -> record, and block the call up to maxBlocksPerTask times per task\n * with actionable feedback. The budget is shared by both modes and\n * by every gated tool.\n *\n * Fails OPEN on every error: a judge outage must never wall the swarm.\n * Every verdict lands in KV namespace `ext:comms-gate`.\n */\n\nexport const config = z.object({\n mode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n /** A preference dimension scoring below this fails. */\n threshold: z.number().min(0).max(1).default(0.5),\n /** The `plain` dimension, judged on every message. Independent of `mode`. */\n plainMode: z.enum([\"off\", \"shadow\", \"enforce\"]).default(\"shadow\"),\n /**\n * `plain` scoring below this fails. Jev scores dense status text low and\n * plain text high with little in between, so this sits far below `threshold`.\n */\n plainThreshold: z.number().min(0).max(1).default(0.25),\n timeoutMs: z.number().int().positive().default(4000),\n maxBlocksPerTask: z.number().int().min(0).default(1),\n provider: z.enum([\"auto\", \"typesafe\", \"openrouter\"]).default(\"auto\"),\n /** Jev model on the TypeSafe transport. */\n model: z.string().default(\"jev-latest\"),\n /** Jev model on the OpenRouter transport. */\n openrouterModel: z.string().default(\"typesafe/jev-1.13\"),\n /** Explicit TypeSafe key; otherwise TYPESAFE_API_KEY from the API process env. */\n apiKey: z.string().optional(),\n});\n\ntype Cfg = z.infer<typeof config>;\ntype Swarm = ApiCtx[\"swarm\"];\n\nconst NS = \"ext:comms-gate\";\nconst TYPESAFE_ENDPOINT = \"https://api.typesafe.ai/v1/systemone\";\nconst OPENROUTER_ENDPOINT = \"https://openrouter.ai/api/alpha/decisions\";\nconst UUID_RE = /^[0-9a-fA-F-]{36}$/;\n/** MCP tools that post agent-authored text to Slack, all with a `message` arg. */\nexport const SLACK_TOOLS = new Set([\n \"slack-reply\",\n \"slack-post\",\n \"slack-start-thread\",\n \"slack-update\",\n]);\n// The dispatcher cancels a handler after 5 s; leave room for the DB read and KV writes.\nconst MAX_TIMEOUT_MS = 4000;\n\nexport const cfgOf = (raw: unknown): Cfg => {\n const parsed = config.safeParse(raw ?? {});\n return parsed.success ? parsed.data : config.parse({});\n};\n\n// kv keys accept [a-zA-Z0-9._:/%-]\nconst safeKey = (raw: string | undefined | null): string =>\n (raw && raw.length > 0 ? raw : \"unknown\").replace(/[^a-zA-Z0-9._-]/g, \"_\").slice(0, 120);\n\n/** SDK envelopes: {success, data:{...}} or the bare payload. */\ntype Envelope = {\n data?: { rows?: unknown[]; value?: unknown };\n rows?: unknown[];\n value?: unknown;\n} | null;\n\nconst cell = (row: unknown, i: number, key: string): unknown =>\n Array.isArray(row) ? row[i] : (row as Record<string, unknown>)?.[key];\n\n/**\n * The newest human message sits at the END of a Slack task: the\n * `<thread_context>` blob comes first and can run to thousands of chars, so a\n * leading slice of `agent_tasks.task` shows the judge everything except the\n * actual request. Take the tail, then the part after the last `</thread_context>`.\n */\nconst CTX_END = \"</thread_context>\";\nexport const latestAsk = (tail: string): string => {\n const end = tail.lastIndexOf(CTX_END);\n let out = (end >= 0 ? tail.slice(end + CTX_END.length) : tail)\n .replace(/\\[Thread follow-up[^\\]]*\\]/g, \"\")\n .trim();\n // Some tasks end with the context block; fall back to the last messages in it.\n if (out.length < 8) out = (end >= 0 ? tail.slice(0, end) : tail).trim().slice(-800);\n return out.slice(0, 900);\n};\n\n// Preferences are the DEFAULT. A direct request outranks them.\nconst JUDGING_RULES = [\n \"The requester's explicit instruction in latest_request OUTRANKS requester_preferences. Preferences describe the DEFAULT; they never override a direct request.\",\n \"If latest_request asks for something long, detailed, creative, a story, a joke, a full explanation or a document, then length and structure serving that request are CORRECT and must NOT be penalized.\",\n \"Judge whether agent_message serves what latest_request actually asked for, written in the requester's manner. Do not judge it against the default preference when the request overrides it.\",\n];\n\ntype Prefs = { verbosity?: unknown; tone?: unknown; language?: unknown } & Record<string, unknown>;\n\n/**\n * The `plain` rubric, from the `comms` skill (Simpler and Precise modes). Jev\n * separates dense from plain text reliably only with contrasting examples; a\n * prose-only rubric scored a plain rewrite as low as the dense original.\n */\nexport const PLAIN_RULES = [\n \"Examples of NOT plain: 'p50 Δ +120ms vs baseline (n=37)', 'error rate 2.1% → 0.4% (cohort B, ±0.3 at 95%)', 'cost $-42/day', 'first-call hit ratio 0.61', 'drift unmeasured; 2 flaky'.\",\n \"Examples of plain: 'Pages load 120 ms slower than last week, across 37 runs', 'Errors dropped from 2 in 100 requests to 4 in 1,000', 'We spend $42 a day less', 'PR #123 keeps the migration reversible', 'Lead saves about $75 a week'.\",\n \"Code identifiers, task ids, PR numbers, links, file paths and version numbers are fine as they are.\",\n];\n\nexport const PLAIN_QUESTION = {\n type: \"noul\",\n instructions:\n \"Is agent_message written like the plain examples in plain_language_rules rather than like the NOT plain ones? Ignore length and tone.\",\n criteria: {\n true: \"Written like the plain examples: everyday words and numbers with units and direction.\",\n false:\n \"Written like the NOT plain examples: statistics shorthand, abbreviations, signed or cryptic numbers.\",\n },\n};\n\n/** Structured output (an outputSchema answer) is for machines; `plain` skips it. */\nexport const looksLikeJson = (text: string): boolean => {\n if (!/^[[{]/.test(text)) return false;\n try {\n JSON.parse(text);\n return true;\n } catch {\n return false;\n }\n};\n\n/** One `noul` question per stated preference the gate knows how to judge. */\nexport const buildQuestions = (prefs: Prefs): Record<string, unknown> => {\n const questions: Record<string, unknown> = {};\n if (prefs.verbosity) {\n questions.verbosity = {\n type: \"noul\",\n instructions:\n \"Does agent_message respect the requester's verbosity preference, AS OVERRIDDEN by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"Length and structure fit what latest_request asked for (or the stated preference when the request does not override it), and it leads with the result.\",\n false:\n \"Too long for what was asked, or it opens with preamble, recaps the request, narrates the process, or ends with an offer to elaborate. Length explicitly requested in latest_request is NOT a failure.\",\n },\n };\n }\n if (prefs.tone) {\n questions.tone = {\n type: \"noul\",\n instructions:\n \"Does agent_message match the requester's stated tone preference, as overridden by any explicit instruction in latest_request? Apply judging_rules.\",\n criteria: {\n true: \"The tone matches what the requester asked for.\",\n false:\n \"The tone conflicts with it (for example hedging, flattery or filler when the requester asked for direct and plain). A voice appropriate to an explicitly requested creative or narrative form is NOT a failure.\",\n },\n };\n }\n if (prefs.language) {\n questions.language = {\n type: \"noul\",\n instructions: \"Is agent_message written in the requester's stated language?\",\n criteria: {\n true: \"Written in the stated language.\",\n false: \"Written in a different language.\",\n },\n };\n }\n return questions;\n};\n\nconst secretCache = new Map<string, { value: string; at: number }>();\nconst KEY_TTL_MS = 5 * 60 * 1000;\nconst usable = (v: string): boolean => Boolean(v) && !v.startsWith(\"[REDACTED\");\nconst envOf = (name: string): string => String(process.env[name] ?? \"\");\n\n/**\n * Resolve a secret: explicit value, then the API process env. The API process loads\n * global swarm_config rows into process.env at boot and on config reload.\n * ctx.swarm.config_get cannot serve it: the SDK scrubs secrets to \"[REDACTED:<name>]\".\n */\nconst secret = (name: string, explicit?: string): string => {\n const cached = secretCache.get(name);\n if (cached && Date.now() - cached.at < KEY_TTL_MS) return cached.value;\n const value = [String(explicit ?? \"\"), envOf(name)].find(usable) ?? \"\";\n if (usable(value)) secretCache.set(name, { value, at: Date.now() });\n return value;\n};\n\n/** Test hook: forget cached secrets. */\nexport const resetKeyCache = (): void => {\n secretCache.clear();\n};\n\nexport type JevTransport = {\n provider: \"typesafe\" | \"openrouter\";\n endpoint: string;\n key: string;\n model: string;\n};\n\n/**\n * Pick the Jev transport. `auto` prefers TypeSafe and falls back to OpenRouter.\n * Throws when the chosen provider has no usable key; the handler fails open.\n */\nexport const resolveTransport = (cfg: Cfg): JevTransport => {\n const typesafe = (): JevTransport | null => {\n const key = secret(\"TYPESAFE_API_KEY\", cfg.apiKey);\n return usable(key)\n ? { provider: \"typesafe\", endpoint: TYPESAFE_ENDPOINT, key, model: cfg.model }\n : null;\n };\n const openrouter = (): JevTransport | null => {\n const key = secret(\"OPENROUTER_API_KEY\");\n return usable(key)\n ? { provider: \"openrouter\", endpoint: OPENROUTER_ENDPOINT, key, model: cfg.openrouterModel }\n : null;\n };\n const transport =\n cfg.provider === \"typesafe\"\n ? typesafe()\n : cfg.provider === \"openrouter\"\n ? openrouter()\n : (typesafe() ?? openrouter());\n if (transport) return transport;\n throw new Error(\n cfg.provider === \"typesafe\"\n ? \"no TYPESAFE_API_KEY\"\n : cfg.provider === \"openrouter\"\n ? \"no OPENROUTER_API_KEY\"\n : \"no TYPESAFE_API_KEY or OPENROUTER_API_KEY\",\n );\n};\n\n/** One Jev call; returns the `noul` score per question. Throws on any failure. */\nexport const askJev = async (\n state: Record<string, unknown>,\n questions: Record<string, unknown>,\n transport: JevTransport,\n cfg: Cfg,\n outer?: AbortSignal,\n): Promise<{ scores: Record<string, number>; latencyMs: number }> => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), Math.min(cfg.timeoutMs, MAX_TIMEOUT_MS));\n const onOuterAbort = () => controller.abort();\n outer?.addEventListener(\"abort\", onOuterAbort);\n const t0 = Date.now();\n try {\n const res = await fetch(transport.endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", Authorization: `Bearer ${transport.key}` },\n body: JSON.stringify({ state, model: transport.model, questions }),\n signal: controller.signal,\n });\n if (!res.ok) throw new Error(`${transport.provider} ${res.status}`);\n const body = (await res.json()) as { answers?: Record<string, { noul?: unknown }> } | null;\n const answers = body?.answers;\n if (!answers || typeof answers !== \"object\") throw new Error(\"no answers in response\");\n const scores: Record<string, number> = {};\n for (const [id, a] of Object.entries(answers)) {\n if (typeof a?.noul === \"number\") scores[id] = a.noul;\n }\n if (Object.keys(scores).length === 0) throw new Error(\"no scores in response\");\n return { scores, latencyMs: Date.now() - t0 };\n } finally {\n clearTimeout(timer);\n outer?.removeEventListener(\"abort\", onOuterAbort);\n }\n};\n\nconst SELFTEST = \"comms-gate-selftest\";\nconst SELFTEST_PREFS = {\n tone: \"direct, plain, no hedging\",\n language: \"English\",\n verbosity:\n \"very terse - answer in the first sentence, ~400 chars max. No preamble, no recap, no closing offer.\",\n};\nconst SELFTEST_BAD =\n \"Great question! Let me walk you through what I did here. First I looked at the task, then I reviewed the relevant files, and after careful consideration I decided to go with option B. I hope this helps - let me know if you would like me to elaborate further on any of the above!\";\nconst SELFTEST_GOOD =\n \"Went with option B: it is the only one that keeps the migration reversible. PR #123.\";\n// A real dense monitor post a requester had to ask to have rewritten, and that rewrite.\nconst SELFTEST_PLAIN_BAD =\n \"Prompt cache monitor, day 7 verdict for v2.0.0: partial. Lead first-call read median 12.4k (baseline v1.173.0, n 410) → 19.1k tokens (n 2234; expected 25.0k-40.0k). First-call saving about $75/week on Lead vs v1.173.0 (expected $129; sampling noise ±$24 at 95%, one baseline version, day-to-day drift not measured); session $ change $-167/week; fleet first-call saving $-7/week.\";\nconst SELFTEST_PLAIN_GOOD =\n \"Cheaper, but half what we hoped: Lead saves about $75 a week, not $129. Whole Lead tasks still cost $167 a week more, probably from other releases and heavier work, not this change.\";\n\nconst below = (bad: unknown, good: unknown, threshold: number): boolean =>\n typeof bad === \"number\" && typeof good === \"number\" && bad < threshold && good >= threshold;\n\n/**\n * Run the judge on known-bad (positive control, must score below threshold) and\n * known-good (negative control, must score at or above it) messages and record\n * them under `selftest:last`: one pair for verbosity, one for `plain`. Proves key,\n * egress and KV from inside the API runtime without touching real traffic.\n */\nconst selfTest = async (swarm: Swarm, cfg: Cfg, signal?: AbortSignal): Promise<void> => {\n const started = Date.now();\n try {\n const transport = resolveTransport(cfg);\n const judge = (message: string, dim: \"verbosity\" | \"plain\") =>\n askJev(\n dim === \"plain\"\n ? { plain_language_rules: PLAIN_RULES, agent_message: message }\n : { requester_preferences: SELFTEST_PREFS, agent_message: message },\n { [dim]: dim === \"plain\" ? PLAIN_QUESTION : buildQuestions(SELFTEST_PREFS).verbosity },\n transport,\n cfg,\n signal,\n ).then((r) => r.scores[dim] ?? null);\n const [bad, good, plainBad, plainGood] = await Promise.all([\n judge(SELFTEST_BAD, \"verbosity\"),\n judge(SELFTEST_GOOD, \"verbosity\"),\n judge(SELFTEST_PLAIN_BAD, \"plain\"),\n judge(SELFTEST_PLAIN_GOOD, \"plain\"),\n ]);\n await swarm.kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n provider: transport.provider,\n threshold: cfg.threshold,\n positiveControlBadScore: bad,\n negativeControlGoodScore: good,\n controlsOk: below(bad, good, cfg.threshold),\n plainThreshold: cfg.plainThreshold,\n plainPositiveControlBadScore: plainBad,\n plainNegativeControlGoodScore: plainGood,\n plainControlsOk: below(plainBad, plainGood, cfg.plainThreshold),\n latencyMs: Date.now() - started,\n },\n });\n } catch (err) {\n await swarm\n .kv_set({\n namespace: NS,\n key: \"selftest:last\",\n value: {\n at: new Date().toISOString(),\n error: String(err).slice(0, 400),\n latencyMs: Date.now() - started,\n },\n })\n .catch(() => {});\n }\n};\n\nconst TASK_SQL = `SELECT t.source, t.slackThreadTs, t.agentId, t.requestedByUserId,\n json_extract(u.metadata, '$.comms') AS comms, u.name,\n substr(t.task, 1, 400) AS askHead,\n substr(t.task, -1400) AS askTail,\n t.creatorAgentId\nFROM agent_tasks t\nLEFT JOIN users u ON u.id = t.requestedByUserId\nWHERE t.id = ?`;\n\nconst LEAD_SQL = \"SELECT isLead FROM agents WHERE id = ?\";\n\n/** Slack user mentions in agent text: `<@U123>` or `<@U123|name>`. */\nconst MENTION_RE = /<@([UW][A-Z0-9]+)(?:\\|[^>]*)?>/g;\nconst MAX_MENTIONS = 5;\n\n/** Mentioned Slack user ids in order of first appearance, capped. */\nexport const mentionedSlackIds = (text: string): string[] => {\n const ids: string[] = [];\n for (const m of text.matchAll(MENTION_RE)) {\n if (!ids.includes(m[1]!)) ids.push(m[1]!);\n if (ids.length >= MAX_MENTIONS) break;\n }\n return ids;\n};\n\nconst addresseeSql = (n: number) => `SELECT x.externalId, u.id, u.name,\n json_extract(u.metadata, '$.comms') AS comms\nFROM user_external_ids x\nJOIN users u ON u.id = x.userId\nWHERE x.kind = 'slack' AND x.externalId IN (${Array(n).fill(\"?\").join(\", \")})`;\n\nconst parsePrefs = (raw: unknown): Prefs | null => {\n if (!raw) return null;\n const parsed = typeof raw === \"string\" ? JSON.parse(raw) : raw;\n return parsed && typeof parsed === \"object\" && Object.keys(parsed).length > 0\n ? (parsed as Prefs)\n : null;\n};\n\ntype Audience = { id: string; name: string; prefs: Prefs; via: \"mention\" | \"requester\" };\n\n/**\n * Whose preferences a message is judged against. In a thread with several\n * people, the message is for whoever it @-mentions: the first mentioned person\n * with stored preferences wins, including the requester only if they have prefs.\n * Without such a mention, the task requester (the\n * person whose message created this task). One SQL read, only when the text\n * mentions someone; no judge call.\n */\nconst resolveAudience = async (\n swarm: Swarm,\n text: string,\n requester: Audience,\n): Promise<Audience> => {\n const ids = mentionedSlackIds(text);\n if (ids.length === 0) return requester;\n const q = (await swarm.db_query({ sql: addresseeSql(ids.length), params: ids })) as Envelope;\n const rows = q?.data?.rows ?? q?.rows ?? [];\n for (const slackId of ids) {\n const row = rows.find((r) => String(cell(r, 0, \"externalId\")) === slackId);\n if (!row) continue;\n const userId = String(cell(row, 1, \"id\") ?? \"\");\n // A requester with stored preferences keeps their own task context.\n if (userId && userId === requester.id) {\n if (Object.keys(requester.prefs).length > 0) return requester;\n continue;\n }\n const prefs = parsePrefs(cell(row, 3, \"comms\"));\n if (!prefs) continue;\n return { id: userId, name: String(cell(row, 2, \"name\") ?? userId), prefs, via: \"mention\" };\n }\n return requester;\n};\n\n/**\n * Mirrors the `task.progress.write` rule store-progress enforces\n * (src/rbac/legacy-policy.ts): the caller owns the task, the task is\n * unassigned, or the caller is a lead. Lead status comes from the caller's\n * agents row, never from tool args. Extension callers return before this.\n */\nconst mayWriteProgress = async (\n swarm: Swarm,\n callerId: string | undefined,\n taskAgentId: string,\n): Promise<boolean> => {\n if (!callerId) return false;\n if (!taskAgentId || taskAgentId === callerId) return true;\n const q = (await swarm.db_query({ sql: LEAD_SQL, params: [callerId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n return Number(cell(row, 0, \"isLead\") ?? 0) === 1;\n};\n\n/** The text a gated call would put in front of a human, and the task it belongs to. */\nconst gatedText = (\n tool: string,\n args: Record<string, unknown>,\n sourceTaskId: string | undefined,\n): { text: string; taskId: string } | null => {\n if (tool === \"store-progress\") {\n if (args.status !== \"completed\") return null;\n return {\n text: typeof args.output === \"string\" ? args.output.trim() : \"\",\n taskId: String(args.taskId ?? sourceTaskId ?? \"\"),\n };\n }\n if (SLACK_TOOLS.has(tool)) {\n // slack-reply names its task; the other tools post from the caller's task context.\n const named = tool === \"slack-reply\" ? args.taskId : undefined;\n return {\n text: typeof args.message === \"string\" ? args.message.trim() : \"\",\n taskId: String(named ?? sourceTaskId ?? \"\"),\n };\n }\n return null;\n};\n\nconst extension: SwarmExtension = (api) => {\n api.on(\"pre.tool.call\", async (event, ctx) => {\n if (event.tool !== \"store-progress\" && !SLACK_TOOLS.has(event.tool)) return;\n const origin = event.requestInfo?.callOrigin;\n if (origin === \"extension\") return;\n\n const cfg = cfgOf(ctx.config);\n if (cfg.mode === \"off\" && cfg.plainMode === \"off\") return;\n\n const args = (event.args ?? {}) as Record<string, unknown>;\n\n // Self-test hatch: a progress call whose text starts with the sentinel runs\n // the controls and records them under `selftest:last`.\n if (\n event.tool === \"store-progress\" &&\n args.status !== \"completed\" &&\n typeof args.progress === \"string\" &&\n args.progress.startsWith(SELFTEST)\n ) {\n await selfTest(ctx.swarm, cfg, ctx.signal);\n return;\n }\n\n const gated = gatedText(event.tool, args, event.requestInfo?.sourceTaskId);\n if (!gated?.text) return;\n const { text, taskId } = gated;\n if (!UUID_RE.test(taskId)) return;\n\n try {\n const q = (await ctx.swarm.db_query({ sql: TASK_SQL, params: [taskId] })) as Envelope;\n const row = (q?.data?.rows ?? q?.rows ?? [])[0];\n if (!row) return;\n\n const source = String(cell(row, 0, \"source\") ?? \"\");\n const slackThreadTs = cell(row, 1, \"slackThreadTs\");\n const agentId = String(cell(row, 2, \"agentId\") ?? \"\");\n const requesterId = String(cell(row, 3, \"requestedByUserId\") ?? \"\");\n const commsRaw = cell(row, 4, \"comms\");\n const requesterName = String(cell(row, 5, \"name\") ?? (requesterId || \"the requester\"));\n const threadOpening = String(cell(row, 6, \"askHead\") ?? \"\");\n const ask = latestAsk(String(cell(row, 7, \"askTail\") ?? \"\"));\n\n // Judge only a call the tool itself will accept, so no agent can ship another\n // task's thread and preferences to the external judge. store-progress follows\n // its write rule; the Slack tools follow slack-reply's: the caller is the\n // task's assignee or its creator.\n const callerId = event.requestInfo?.agentId;\n const creatorAgentId = String(cell(row, 8, \"creatorAgentId\") ?? \"\");\n const allowed =\n event.tool === \"store-progress\"\n ? await mayWriteProgress(ctx.swarm, callerId, agentId)\n : Boolean(callerId) && (callerId === agentId || callerId === creatorAgentId);\n if (!allowed) return;\n\n // A completion reaches a human only on a Slack task; a Slack tool always does.\n if (event.tool === \"store-progress\" && source !== \"slack\" && !slackThreadTs) return;\n\n const requester: Audience = {\n id: requesterId,\n name: requesterName,\n prefs: parsePrefs(commsRaw) ?? {},\n via: \"requester\",\n };\n // `plain` alone judges no one's preferences, so it skips the lookup.\n const audience =\n cfg.mode === \"off\" ? requester : await resolveAudience(ctx.swarm, text, requester);\n const { prefs, name: audienceName } = audience;\n\n const questions: Record<string, unknown> = cfg.mode === \"off\" ? {} : buildQuestions(prefs);\n // Only a store-progress outputSchema answer is machine JSON; a Slack post is\n // always read by a human, JSON or not.\n if (cfg.plainMode !== \"off\" && (event.tool !== \"store-progress\" || !looksLikeJson(text)))\n questions.plain = PLAIN_QUESTION;\n if (Object.keys(questions).length === 0) return;\n\n const transport = resolveTransport(cfg);\n const { scores, latencyMs } = await askJev(\n {\n role: \"You audit an AI agent's message to the human who requested the work.\",\n requester: audienceName,\n requester_preferences: prefs,\n latest_request: ask,\n thread_opening: threadOpening,\n judging_rules: JUDGING_RULES,\n plain_language_rules: PLAIN_RULES,\n agent_message: text,\n },\n questions,\n transport,\n cfg,\n ctx.signal,\n );\n\n const limitOf = (dim: string) => (dim === \"plain\" ? cfg.plainThreshold : cfg.threshold);\n const failed = Object.entries(scores)\n .filter(([k, v]) => v < limitOf(k))\n .map(([k]) => k);\n // Script SDK calls never reach pre.tool.call today; if one does, record it only.\n const blocking =\n origin === \"script-sdk\"\n ? []\n : failed.filter((d) => (d === \"plain\" ? cfg.plainMode : cfg.mode) === \"enforce\");\n\n const verdict = {\n at: new Date().toISOString(),\n tool: event.tool,\n taskId,\n agentId,\n source,\n requester: requesterName,\n requesterId,\n judgedFor: audience.via,\n audienceId: audience.id,\n audience: audienceName,\n provider: transport.provider,\n scores,\n failed,\n threshold: cfg.threshold,\n plainThreshold: cfg.plainThreshold,\n mode: cfg.mode,\n plainMode: cfg.plainMode,\n latencyMs,\n preferences: prefs,\n askPreview: ask.slice(0, 400),\n outputPreview: text.slice(0, 700),\n };\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"total\" });\n await ctx.swarm.kv_set({ namespace: NS, key: \"last\", value: verdict });\n\n if (failed.length === 0) {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"pass\" });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"fail\" });\n for (const dim of failed) {\n await ctx.swarm.kv_incr({ namespace: NS, key: `dim:${safeKey(dim)}` });\n }\n\n const blocksKey = `blocks:${safeKey(taskId)}`;\n // kv_get answers {success, data:{value}}, and {success:false} when absent.\n const prior = (await ctx.swarm.kv_get({ namespace: NS, key: blocksKey })) as Envelope;\n const priorBlocks = Number(prior?.data?.value ?? prior?.value ?? 0) || 0;\n const willBlock = blocking.length > 0 && priorBlocks < cfg.maxBlocksPerTask;\n\n await ctx.swarm.kv_set({\n namespace: NS,\n key: `fail:${Date.now()}:${safeKey(taskId)}`,\n value: { ...verdict, action: willBlock ? \"blocked\" : \"recorded\" },\n });\n\n if (!willBlock) {\n await ctx.swarm.kv_incr({\n namespace: NS,\n key: blocking.length > 0 ? \"escaped\" : \"shadowed\",\n });\n return;\n }\n\n await ctx.swarm.kv_incr({ namespace: NS, key: \"blocked\" });\n await ctx.swarm.kv_incr({ namespace: NS, key: blocksKey });\n\n const lines = [\n blocking.includes(\"plain\")\n ? `Comms gate: this message is not in plain words for ${audienceName}.`\n : `Comms gate: this output does not match ${audienceName}'s stated preferences.`,\n ];\n for (const d of blocking) {\n const score = `${d} (${scores[d]!.toFixed(2)} vs ${limitOf(d)})`;\n lines.push(\n d === \"plain\"\n ? `${score}: a reader outside the swarm cannot follow it on first read.`\n : `${score}: requested ${String(prefs[d] ?? \"\").slice(0, 220)}`,\n );\n }\n lines.push(\n blocking.includes(\"plain\")\n ? `Rewrite the message with the \\`comms\\` skill (Simpler mode): common words, one idea per sentence, every number with a unit and a direction word, shorthand explained or replaced. Keep every number, name, link and hedge verbatim, then call ${event.tool} again.`\n : `Rewrite the message to match and call ${event.tool} again. Do not change the facts, only how they are said.`,\n );\n return block(lines.join(\"\\n\"));\n } catch (err) {\n // Fail open, always.\n try {\n await ctx.swarm.kv_incr({ namespace: NS, key: \"error\" });\n await ctx.swarm.kv_set({\n namespace: NS,\n key: \"error:last\",\n value: {\n at: new Date().toISOString(),\n tool: event.tool,\n taskId,\n error: String(err).slice(0, 400),\n },\n });\n } catch {\n /* ignore */\n }\n ctx.log.warn(\"comms-adherence-gate: failing open\", { error: String(err) });\n return;\n }\n });\n};\n\nexport default extension;\n"
|
|
16
16
|
},
|
|
17
|
-
"readme": "# comms-adherence-gate\n\nAgents finish Slack tasks with a final message a human reads. This extension judges that
|
|
17
|
+
"readme": "# comms-adherence-gate\n\nAgents finish Slack tasks with a final message a human reads, and post to Slack threads directly. This extension judges that text with Jev, [TypeSafe](https://typesafe.ai)'s decision model: against the requester's stated comms preferences and their latest request, and against a plain-language rubric that applies to every message. In `enforce` it blocks a failing call once and tells the agent what to fix. A `plain` failure points the agent at the `comms` skill.\n\nInstall config:\n\n```json\n{ \"mode\": \"shadow\", \"plainMode\": \"shadow\" }\n```\n\nStart in `shadow`, read the verdicts in KV for a few days, then switch to `enforce`. `mode` and `plainMode` move independently.\n\n## Modes\n\n| `mode` | Effect |\n|---|---|\n| `off` | Never evaluates. |\n| `shadow` (default) | Asks Jev and records the verdict. Never blocks. |\n| `enforce` | Asks Jev, records the verdict, and blocks the call when a dimension fails, at most `maxBlocksPerTask` times per task. The agent gets the failed dimensions, their scores and the stated preference, and calls again. |\n\n`mode` governs the preference dimensions (`verbosity`, `tone`, `language`). `plainMode` governs `plain`. A dimension blocks only when its own mode is `enforce`. Both share one block budget per task, across every gated tool.\n\n## What it gates\n\nHook: `pre.tool.call` on:\n\n- `store-progress` with `status: \"completed\"` and a non-empty `output`, on a task that came from Slack (`source = \"slack\"` or a `slackThreadTs`). Other completions pass without a Jev call.\n- `slack-reply`, `slack-post`, `slack-start-thread` and `slack-update`, judged on `message`. The task is `slack-reply`'s `taskId`, else the caller's task context. A post with no task context passes.\n\nOnly a call the tool itself accepts is judged, so no other task's text reaches Jev. For `store-progress` the caller must own the task, the task must be unassigned, or the caller must be a lead. For the Slack tools the caller must be the task's assignee or its creator, the rule `slack-reply` enforces. Calls made by extensions are skipped. Script SDK calls never reach `pre.tool.call` (the tool registrar dispatches it only for MCP calls); if one ever does, the gate records it and never blocks.\n\nA requester with no stated preferences in `users.metadata.comms` still gets the `plain` question. Set preferences with the `manage-user` tool.\n\nIn a thread with several people, the requester is the person whose message created the task. A message that @-mentions someone (`<@U123>`) is judged against the first mentioned person with stored preferences, resolved through their Slack identity in `user_external_ids`; otherwise against the requester. The lookup is one SQL read, only when the text mentions someone and `mode` is not `off`. The verdict records who was judged in `judgedFor` (`mention` or `requester`) and `audience`.\n\n## Decision\n\nThe gate reads the task row and asks one `noul` question per stated preference it knows: `verbosity`, `tone`, `language`. Any other key in `comms` is context only.\n\nJev sees the preferences, the requester's latest message (the text after the last `</thread_context>`, from the last 1,400 characters of the task), the first 400 characters of the task, and the output. Its judging rules say an explicit request outranks the stored default. A requested long answer, story or document is not a verbosity failure.\n\nIt also asks `plain` on every message unless `plainMode` is `off` or the text is a JSON document (structured output for an `outputSchema`). The rubric comes from the `comms` skill: everyday words, one idea per sentence, every number with a unit and a direction word, and no unexplained shorthand (n, median, p90, cohort, baseline, delta, `±X at 95%`). Code identifiers, task ids, PR numbers, links and versions are fine. Jev gets contrasting plain and dense examples in `plain_language_rules`; a prose-only rubric scored plain rewrites as low as the dense originals.\n\nAll questions go in one Jev request. A preference dimension fails below `threshold`; `plain` fails below `plainThreshold`. Any failed dimension fails the message.\n\n### Calibration of `plain`\n\nJev scores dense status text near 0 and plain text near 0.8 to 0.9, with a wide middle of compact but readable engineering notes. Measured on 400 Slack-facing completions over 7 days, at `threshold` 0.75 for preferences:\n\n| `plainThreshold` | Newly blocked (of messages that pass today) |\n|---|---|\n| 0.75 | 81% to 83% |\n| 0.3 | 9% to 13% |\n| 0.25 (default) | 5% to 7% |\n\nAt 0.25 the dense prompt-cache-monitor post scores about 0.15 and its plain rewrite about 0.81.\n\n## Jev transport\n\nJev is reachable two ways. Both take the same request and return the same answer.\n\n| `provider` | Endpoint | Key | Model |\n|---|---|---|---|\n| `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |\n| `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |\n| `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |\n\nEach verdict row records the `provider` that answered.\n\n## Fail open\n\nEvery error lets the completion through: a missing key, an HTTP error, a network error, a timeout, a malformed answer, or a failed DB read. The handler never throws, so an outage cannot auto-disable the extension. Each failure bumps the `error` counter and overwrites `error:last`.\n\n## Self-test\n\nCall `store-progress` with a non-completed status and `progress` starting with `comms-gate-selftest`. The gate judges a known-bad and a known-good message against a terse preference, and a dense and a plain message on `plain`, then writes `selftest:last`: every score, both thresholds, the provider, `controlsOk` (verbosity: bad below `threshold`, good at or above it) and `plainControlsOk` (the same against `plainThreshold`). Real traffic is untouched.\n\n## Config\n\n| Key | Default | Notes |\n|---|---|---|\n| `mode` | `shadow` | `off`, `shadow`, or `enforce` |\n| `threshold` | `0.5` | A preference dimension scoring below this fails |\n| `plainMode` | `shadow` | `off`, `shadow`, or `enforce` for the `plain` dimension |\n| `plainThreshold` | `0.25` | `plain` scoring below this fails. See [Calibration](#calibration-of-plain) |\n| `timeoutMs` | `4000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |\n| `maxBlocksPerTask` | `1` | Blocks per task in `enforce`; after that the gate records and lets it through |\n| `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |\n| `model` | `jev-latest` | Jev model on TypeSafe |\n| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |\n| `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |\n\n## Required secrets\n\nRead from the API process environment at call time. Set them as global `swarm_config` secrets: the API process loads global rows into `process.env` at boot and on config reload.\n\n- `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`: one is required for any evaluation. Under `auto`, TypeSafe wins when both are set. Without either, every completion fails open.\n\nNo key is written to config, KV, logs, or a block reason.\n\n## KV namespace `ext:comms-gate`\n\n- `last`: the latest verdict, with `tool`, `scores`, `failed`, `mode`, `plainMode`, `provider`, `latencyMs`, the preferences, a 400-character `askPreview` and a 700-character `outputPreview`.\n- `fail:<epochMs>:<taskId>`: one row per failed verdict, with `action` (`blocked` or `recorded`).\n- `blocks:<taskId>`: blocks issued for that task.\n- `error:last`, `selftest:last`.\n- Counters: `total`, `pass`, `fail`, `blocked`, `escaped` (a blocking dimension failed, block budget spent), `shadowed` (only non-blocking dimensions failed), `error`, and `dim:<dimension>`.\n\n## Known limits\n\n- Only the last 1,400 characters of the task are searched for the latest request. A very long final message is cut to its last 900 characters.\n- The block budget is per task, so a retried call after a block goes through once the budget is spent, even if it still fails.\n- Each gated Slack post waits for one Jev call (about 0.2 s measured, capped by `timeoutMs`).\n"
|
|
18
18
|
},
|
|
19
19
|
"deploy-awareness": {
|
|
20
20
|
"manifestFile": "manifest.json",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
// Generated by scripts/bundle-extension-types.ts. Do not edit.
|
|
2
2
|
// biome-ignore-all lint/suspicious/noTemplateCurlyInString: Type declarations contain literal template placeholders.
|
|
3
3
|
export const EXTENSION_TYPE_DEFINITIONS =
|
|
4
|
-
'// biome-ignore-all lint/suspicious/noConfusingVoidType: Extension handlers may explicitly return no result.\ndeclare module "swarm-extension" {\n export type CreateTaskOptions = {\n key?: string | undefined;\n agentId?: string | null | undefined;\n creatorAgentId?: string | undefined;\n source?:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "bitbucket"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat"\n | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n taskType?: string | undefined;\n tags?: string[] | undefined;\n priority?: number | undefined;\n dependsOn?: string[] | undefined;\n offeredTo?: string | undefined;\n status?: "draft" | "backlog" | "unassigned" | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n overrideSlackContext?: boolean | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | undefined;\n workflowRunStepId?: string | undefined;\n sourceTaskId?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n inheritParentOutputSchema?: boolean | undefined;\n inheritParentRequester?: boolean | undefined;\n inheritParentRoutingAffinity?: boolean | undefined;\n inheritParentFollowUpConfig?: boolean | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n requestedByUserId?: string | undefined;\n contextKey?: string | undefined;\n bypassTrackerContextDedup?: boolean | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n };\n\n export type AgentTask = {\n id: string;\n key: string;\n agentId: string | null;\n task: string;\n status:\n | "draft"\n | "backlog"\n | "unassigned"\n | "offered"\n | "reviewing"\n | "pending"\n | "in_progress"\n | "paused"\n | "completed"\n | "failed"\n | "cancelled"\n | "superseded";\n source:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "bitbucket"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat";\n tags: string[];\n priority: number;\n dependsOn: string[];\n createdAt: string;\n lastUpdatedAt: string;\n slackReplySent: boolean;\n wasPaused: boolean;\n creatorAgentId?: string | undefined;\n title?: string | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n poolWakeAt?: string | undefined;\n taskType?: string | undefined;\n offeredTo?: string | undefined;\n offeredAt?: string | undefined;\n acceptedAt?: string | undefined;\n rejectionReason?: string | undefined;\n finishedAt?: string | undefined;\n notifiedAt?: string | undefined;\n failureReason?: string | undefined;\n output?: string | undefined;\n progress?: string | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n slackProgressMessageTs?: string | undefined;\n slackTreeRootMessageTs?: string | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n claudeSessionId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n resolvedModel?: string | undefined;\n modelSource?: string | undefined;\n modelAlias?: string | undefined;\n resolvedEffort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n resolvedProfile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | null | undefined;\n workflowRunStepId?: string | null | undefined;\n contextKey?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n deferredAt?: string | undefined;\n compactionCount?: number | undefined;\n peakContextPercent?: number | undefined;\n peakContextTokens?: number | undefined;\n contextWindowSize?: number | undefined;\n credentialKeySuffix?: string | undefined;\n credentialKeyType?: string | undefined;\n requestedByUserId?: string | undefined;\n swarmVersion?: string | undefined;\n provider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n providerMeta?: Record<string, unknown> | undefined;\n harnessVariant?: string | undefined;\n harnessVariantMeta?: Record<string, unknown> | undefined;\n totalCostUsd?: number | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n routingAffinityInvalid?: boolean | undefined;\n };\n\n export type ActiveSession = {\n id: string;\n agentId: string;\n taskId: string | null;\n triggerType: string;\n inboxMessageId: string | null;\n taskDescription: string | null;\n runnerSessionId: string | null;\n providerSessionId: string | null;\n startedAt: string;\n lastHeartbeatAt: string;\n runtimeInstanceId?: string | null | undefined;\n };\n\n export type RequestInfo = {\n sessionId: string | undefined;\n agentId: string | undefined;\n runtimeInstanceId: string | undefined;\n sourceTaskId: string | undefined;\n contextKey: string | undefined;\n callOrigin: "mcp" | "extension" | "script-sdk";\n };\n\n import type { SwarmSdk } from "swarm-sdk";\n import type { z } from "zod";\n export type Runtime = "api" | "worker";\n export interface ExtensionScriptAsset {\n name: string;\n file: string;\n description: string;\n intent?: string;\n }\n export interface ExtensionScheduleAsset {\n name: string;\n description?: string;\n script: string;\n cronExpression?: string;\n intervalMs?: number;\n timezone?: string;\n args?: Record<string, unknown>;\n }\n export interface ExtensionWorkflowAsset {\n file: string;\n }\n export interface ExtensionSkillAsset {\n dir: string;\n }\n export interface ExtensionManifest {\n $schema?: string;\n name: string;\n description: string;\n version: string;\n runtime: Runtime;\n assets: {\n hooks: string;\n scripts?: readonly ExtensionScriptAsset[];\n schedules?: readonly ExtensionScheduleAsset[];\n workflows?: readonly ExtensionWorkflowAsset[];\n skills?: readonly ExtensionSkillAsset[];\n };\n homepage?: string;\n author?: string;\n }\n export interface HooksModule {\n default: SwarmExtension;\n config?: z.ZodTypeAny;\n }\n export interface ExtensionState {\n get<T = unknown>(key: string): Promise<T | null>;\n set<T>(key: string, value: T): Promise<void>;\n incr(key: string, by?: number): Promise<number>;\n del(key: string): Promise<void>;\n }\n export interface ExtensionLogger {\n debug(message: string, data?: unknown): void;\n info(message: string, data?: unknown): void;\n warn(message: string, data?: unknown): void;\n error(message: string, data?: unknown): void;\n }\n type ConfigFor<M extends ExtensionManifest> = M extends {\n config: infer C extends z.ZodTypeAny;\n }\n ? z.infer<C>\n : Record<string, unknown>;\n export interface ApiCtx<Config = Record<string, unknown>> {\n swarm: SwarmSdk;\n state: ExtensionState;\n config: Config;\n log: ExtensionLogger;\n signal: AbortSignal;\n event: {\n name: keyof SwarmEventMap;\n at: string;\n extension: {\n id: string;\n version: number;\n };\n };\n }\n export interface WorkerCtx<Config = Record<string, unknown>>\n extends Omit<ApiCtx<Config>, "swarm"> {\n swarm: SwarmSdk;\n worker: {\n agentId: string;\n taskId: string;\n harness: string;\n };\n }\n export type CtxFor<M extends ExtensionManifest> = M["runtime"] extends "api"\n ? ApiCtx<ConfigFor<M>>\n : WorkerCtx<ConfigFor<M>>;\n export type PreResult<Modify> =\n | void\n | {\n action: "continue";\n }\n | {\n action: "modify";\n data: Modify;\n }\n | {\n action: "block";\n reason: string;\n };\n export type TaskCreateOrigin =\n | "rest"\n | "app"\n | "mcp"\n | "slack"\n | "schedule"\n | "workflow"\n | "webhook"\n | "followUp"\n | `extension:${string}`;\n export interface TaskCreateEvent {\n options: CreateTaskOptions;\n description: string;\n origin: TaskCreateOrigin;\n requestInfo?: RequestInfo;\n }\n export type TaskCreateModify = Partial<\n Pick<\n CreateTaskOptions,\n | "agentId"\n | "creatorAgentId"\n | "source"\n | "taskType"\n | "tags"\n | "priority"\n | "dependsOn"\n | "offeredTo"\n | "vcsProvider"\n | "vcsRepo"\n | "vcsEventType"\n | "vcsNumber"\n | "vcsCommentId"\n | "vcsAuthor"\n | "vcsUrl"\n | "vcsInstallationId"\n | "vcsNodeId"\n | "agentmailInboxId"\n | "agentmailMessageId"\n | "agentmailThreadId"\n | "mentionMessageId"\n | "mentionChannelId"\n | "dir"\n | "model"\n | "modelTier"\n | "effort"\n | "outputSchema"\n | "followUpConfig"\n | "bypassTrackerContextDedup"\n >\n > & {\n description?: string;\n };\n export interface TaskFollowUpEvent {\n completedTask: AgentTask;\n status: "completed" | "failed";\n output?: string;\n failureReason?: string;\n workerAgentId: string;\n leadAgentId: string;\n summary: string;\n }\n export type TaskFollowUpModify = Partial<\n Pick<CreateTaskOptions, "agentId" | "priority" | "followUpConfig">\n > & {\n description?: string;\n };\n export interface SlackRouteEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n botMentioned: boolean;\n threadContext?: {\n channelId: string;\n threadTs: string;\n };\n }\n export interface SlackRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n };\n }\n /** A Chat SDK message after its thread buffer flushed, before routing. */\n export interface ChatRouteEvent {\n /** Adapter name, such as `telegram` or `slack`. */\n adapter: string;\n channelId: string;\n /** The Chat SDK thread id, prefixed with the adapter name. */\n threadId: string;\n /** The first buffered sender. */\n userId: string;\n userName?: string;\n /** The buffered messages, joined. */\n text: string;\n /** True for an @mention and for a reply to one of the bot\'s messages. */\n botMentioned: boolean;\n isDirectMessage: boolean;\n /** Platform chat type, such as Telegram `private`, `group`, `supergroup` or `channel`. */\n chatType?: string;\n /** Set for a Telegram supergroup with topics. */\n isForum?: boolean;\n /** A buffered message replies to one of the bot\'s own messages. */\n replyToBot: boolean;\n /** Set when earlier swarm tasks exist in this thread. */\n threadContext?: {\n adapter: string;\n threadId: string;\n };\n }\n export interface ChatRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n }\n | {\n kind: "workflow";\n workflowId: string;\n };\n }\n export type HeartbeatClassification = "no-session" | "stale-session" | "fresh-stalled";\n export type HeartbeatAction = "supersede-resume" | "fail" | "record";\n export interface HeartbeatRemediateEvent {\n task: AgentTask;\n session?: ActiveSession;\n classification: HeartbeatClassification;\n proposedAction: HeartbeatAction;\n reason: string;\n taskAgeMs: number;\n sessionHeartbeatAgeMs?: number;\n }\n export interface HeartbeatRemediateModify {\n proposedAction: HeartbeatAction;\n }\n export interface ToolCallEvent {\n tool: string;\n args: unknown;\n requestInfo: RequestInfo;\n }\n export interface ToolCallModify {\n args: unknown;\n }\n export interface TaskEvent {\n task: AgentTask;\n }\n export interface TaskCompletedEvent extends TaskEvent {\n output: string;\n }\n export interface TaskFailedEvent extends TaskEvent {\n failureReason: string;\n }\n export interface TaskSupersededEvent extends TaskEvent {\n supersededBy: string;\n }\n export interface TaskProgressEvent extends TaskEvent {\n progress: string;\n }\n export interface SlackMessageEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n taskId?: string;\n }\n /** A Chat SDK message the swarm received or sent. */\n export interface ChatMessageEvent {\n adapter: string;\n channelId: string;\n threadId: string;\n /** The sender, for an inbound message. */\n userId?: string;\n text: string;\n direction: "inbound" | "outbound";\n taskId?: string;\n messageId?: string;\n }\n /**\n * A person answered an approval request. Fires for workflow and standalone\n * requests. Timeouts and cancellations do not emit it today.\n */\n export interface ApprovalResolvedEvent {\n requestId: string;\n status: "approved" | "rejected" | "timeout" | "cancelled";\n responses: Record<string, unknown> | null;\n /** Set when a workflow human-in-the-loop step asked. Absent for a standalone request. */\n workflowRunId?: string;\n workflowRunStepId?: string;\n /** The task whose agent asked, for a standalone request. */\n sourceTaskId?: string;\n }\n /** A task was refused because a daily spend budget is used up. */\n export interface TaskBudgetRefusedEvent extends TaskEvent {\n /** The agent the task was refused for. */\n agentId: string;\n /** Which budget refused it. The matching spend and budget fields are set. */\n cause: "agent" | "global" | "user";\n agentSpendUsd?: number;\n agentBudgetUsd?: number;\n globalSpendUsd?: number;\n globalBudgetUsd?: number;\n userSpendUsd?: number;\n userBudgetUsd?: number;\n /** ISO 8601 time the daily budget resets. */\n resetAt: string;\n }\n /** An inbound AgentMail message. */\n export interface EmailReceivedEvent {\n inboxId: string;\n /** The From header as sent. */\n from: string;\n subject: string;\n /** The text body, or the HTML when there is no text, cut to 500 characters with `...` appended. */\n body: string;\n threadId: string;\n messageId: string;\n }\n /** An inbound Kapso (WhatsApp) message. */\n export interface KapsoMessageEvent {\n phoneNumberId: string;\n conversationId?: string;\n messageId: string;\n from?: string;\n /** Kapso message type, such as `text` or `image`. */\n type?: string;\n /** The text, or a placeholder naming the type for a non-text message. */\n text: string;\n }\n export type VcsProvider = "github" | "gitlab" | "azure-devops" | "bitbucket";\n /**\n * The provider object the event is about, as the provider names it. GitLab\n * calls a pull request a merge request, and a comment a `note`.\n */\n export type VcsEventKind =\n | "pull_request"\n | "merge_request"\n | "issue"\n | "issue_comment"\n | "note"\n | "pull_request_review"\n | "pipeline";\n /**\n * A webhook event from a connected code host. `action` is the provider\'s own\n * value: `opened`, `closed`, `synchronize`, `merge`, `created`, `commented`, or,\n * for a pipeline, its status such as `success` or `failed`. Fields a kind does\n * not carry are absent.\n */\n export interface VcsEvent {\n provider: VcsProvider;\n kind: VcsEventKind;\n action: string;\n /** `owner/name` on GitHub and GitLab, `workspace/repo_slug` on Bitbucket, the repository URL on Azure DevOps. */\n repo: string;\n /** The PR, merge request, or issue number. Absent for a pipeline outside a merge request. */\n number?: number;\n title?: string;\n body?: string | null;\n /** Login of the user behind the event. Set for pull requests, merge requests, and Azure DevOps comments. */\n author?: string;\n url?: string;\n merged?: boolean;\n changedFiles?: number;\n /** The review verdict, on a pull request review. */\n reviewState?: string;\n }\n export interface ToolCallCompletedEvent extends ToolCallEvent {\n result: unknown;\n durationMs: number;\n }\n export interface SwarmEventMap {\n "pre.task.create": {\n event: TaskCreateEvent;\n modify: TaskCreateModify;\n result: PreResult<TaskCreateModify>;\n };\n "pre.task.followUp": {\n event: TaskFollowUpEvent;\n modify: TaskFollowUpModify;\n result: PreResult<TaskFollowUpModify>;\n };\n "pre.slack.route": {\n event: SlackRouteEvent;\n modify: SlackRouteModify;\n result: PreResult<SlackRouteModify>;\n };\n "pre.chat.route": {\n event: ChatRouteEvent;\n modify: ChatRouteModify;\n result: PreResult<ChatRouteModify>;\n };\n "pre.heartbeat.remediate": {\n event: HeartbeatRemediateEvent;\n modify: HeartbeatRemediateModify;\n result: PreResult<HeartbeatRemediateModify>;\n };\n "pre.tool.call": {\n event: ToolCallEvent;\n modify: ToolCallModify;\n result: PreResult<ToolCallModify>;\n };\n "post.task.created": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.completed": {\n event: TaskCompletedEvent;\n modify: never;\n result: void;\n };\n "post.task.failed": {\n event: TaskFailedEvent;\n modify: never;\n result: void;\n };\n "post.task.cancelled": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.superseded": {\n event: TaskSupersededEvent;\n modify: never;\n result: void;\n };\n "post.task.progress": {\n event: TaskProgressEvent;\n modify: never;\n result: void;\n };\n "post.slack.message": {\n event: SlackMessageEvent;\n modify: never;\n result: void;\n };\n "post.chat.message": {\n event: ChatMessageEvent;\n modify: never;\n result: void;\n };\n "post.approval.resolved": {\n event: ApprovalResolvedEvent;\n modify: never;\n result: void;\n };\n "post.task.budgetRefused": {\n event: TaskBudgetRefusedEvent;\n modify: never;\n result: void;\n };\n "post.email.received": {\n event: EmailReceivedEvent;\n modify: never;\n result: void;\n };\n "post.kapso.message": {\n event: KapsoMessageEvent;\n modify: never;\n result: void;\n };\n "post.vcs.event": {\n event: VcsEvent;\n modify: never;\n result: void;\n };\n "post.tool.call": {\n event: ToolCallCompletedEvent;\n modify: never;\n result: void;\n };\n }\n export interface ExtensionApi<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > {\n on<E extends keyof SwarmEventMap>(\n event: E,\n handler: (\n event: SwarmEventMap[E]["event"],\n ctx: CtxFor<M>,\n ) => Promise<SwarmEventMap[E]["result"]> | SwarmEventMap[E]["result"],\n opts?: {\n priority?: number;\n },\n ): void;\n }\n export type SwarmExtension<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > = (api: ExtensionApi<M>) => void;\n export { block, modify } from "./contract-runtime";\n}\n';
|
|
4
|
+
'// biome-ignore-all lint/suspicious/noConfusingVoidType: Extension handlers may explicitly return no result.\ndeclare module "swarm-extension" {\n export type CreateTaskOptions = {\n key?: string | undefined;\n agentId?: string | null | undefined;\n creatorAgentId?: string | undefined;\n source?:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "bitbucket"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat"\n | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n taskType?: string | undefined;\n tags?: string[] | undefined;\n priority?: number | undefined;\n dependsOn?: string[] | undefined;\n offeredTo?: string | undefined;\n status?: "draft" | "backlog" | "unassigned" | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n overrideSlackContext?: boolean | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | undefined;\n workflowRunStepId?: string | undefined;\n sourceTaskId?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n inheritParentOutputSchema?: boolean | undefined;\n inheritParentRequester?: boolean | undefined;\n inheritParentRoutingAffinity?: boolean | undefined;\n inheritParentFollowUpConfig?: boolean | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n requestedByUserId?: string | undefined;\n contextKey?: string | undefined;\n bypassTrackerContextDedup?: boolean | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n };\n\n export type AgentTask = {\n id: string;\n key: string;\n agentId: string | null;\n task: string;\n status:\n | "draft"\n | "backlog"\n | "unassigned"\n | "offered"\n | "reviewing"\n | "pending"\n | "in_progress"\n | "paused"\n | "completed"\n | "failed"\n | "cancelled"\n | "superseded";\n source:\n | "workflow"\n | "schedule"\n | "slack"\n | "agentmail"\n | "mcp"\n | "api"\n | "ui"\n | "github"\n | "gitlab"\n | "azure-devops"\n | "bitbucket"\n | "system"\n | "linear"\n | "jira"\n | "comb"\n | "chat";\n tags: string[];\n priority: number;\n dependsOn: string[];\n createdAt: string;\n lastUpdatedAt: string;\n slackReplySent: boolean;\n wasPaused: boolean;\n creatorAgentId?: string | undefined;\n title?: string | undefined;\n routingReason?:\n | "skill"\n | "continuity"\n | "overflow"\n | "human_pinned"\n | "reroute_fault"\n | undefined;\n routingSource?: "declared" | "engine_default" | undefined;\n routingNote?: string | undefined;\n poolWakeAt?: string | undefined;\n taskType?: string | undefined;\n offeredTo?: string | undefined;\n offeredAt?: string | undefined;\n acceptedAt?: string | undefined;\n rejectionReason?: string | undefined;\n finishedAt?: string | undefined;\n notifiedAt?: string | undefined;\n failureReason?: string | undefined;\n output?: string | undefined;\n progress?: string | undefined;\n slackChannelId?: string | undefined;\n slackThreadTs?: string | undefined;\n slackTriggerMessageTs?: string | undefined;\n slackUserId?: string | undefined;\n slackProgressMessageTs?: string | undefined;\n slackTreeRootMessageTs?: string | undefined;\n vcsProvider?: "github" | "gitlab" | "azure-devops" | "bitbucket" | undefined;\n vcsRepo?: string | undefined;\n vcsEventType?: string | undefined;\n vcsNumber?: number | undefined;\n vcsCommentId?: number | undefined;\n vcsAuthor?: string | undefined;\n vcsUrl?: string | undefined;\n vcsInstallationId?: number | undefined;\n vcsNodeId?: string | undefined;\n agentmailInboxId?: string | undefined;\n agentmailMessageId?: string | undefined;\n agentmailThreadId?: string | undefined;\n mentionMessageId?: string | undefined;\n mentionChannelId?: string | undefined;\n dir?: string | undefined;\n parentTaskId?: string | undefined;\n claudeSessionId?: string | undefined;\n model?: string | undefined;\n modelTier?: "smol" | "regular" | "smart" | "ultra" | undefined;\n effort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n resolvedModel?: string | undefined;\n modelSource?: string | undefined;\n modelAlias?: string | undefined;\n resolvedEffort?: "off" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;\n profile?: string | undefined;\n resolvedProfile?: string | undefined;\n scheduleId?: string | undefined;\n workflowRunId?: string | null | undefined;\n workflowRunStepId?: string | null | undefined;\n contextKey?: string | undefined;\n outputSchema?: Record<string, unknown> | undefined;\n followUpConfig?:\n | {\n disabled?: boolean | undefined;\n onCompleted?: string | undefined;\n onFailed?: string | undefined;\n }\n | undefined;\n deferredAt?: string | undefined;\n compactionCount?: number | undefined;\n peakContextPercent?: number | undefined;\n peakContextTokens?: number | undefined;\n contextWindowSize?: number | undefined;\n credentialKeySuffix?: string | undefined;\n credentialKeyType?: string | undefined;\n requestedByUserId?: string | undefined;\n swarmVersion?: string | undefined;\n provider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n providerMeta?: Record<string, unknown> | undefined;\n harnessVariant?: string | undefined;\n harnessVariantMeta?: Record<string, unknown> | undefined;\n totalCostUsd?: number | undefined;\n routingAffinity?:\n | {\n capabilities: string[];\n sourceAgentId?: string | undefined;\n role?: string | undefined;\n harnessProvider?:\n | "claude"\n | "codex"\n | "pi"\n | "devin"\n | "claude-managed"\n | "opencode"\n | "acp"\n | "dsh"\n | "amp"\n | "cursor"\n | "grok"\n | undefined;\n leadOnly?: boolean | undefined;\n }\n | undefined;\n routingAffinityInvalid?: boolean | undefined;\n };\n\n export type ActiveSession = {\n id: string;\n agentId: string;\n taskId: string | null;\n triggerType: string;\n inboxMessageId: string | null;\n taskDescription: string | null;\n runnerSessionId: string | null;\n providerSessionId: string | null;\n startedAt: string;\n lastHeartbeatAt: string;\n runtimeInstanceId?: string | null | undefined;\n };\n\n export type RequestInfo = {\n sessionId: string | undefined;\n agentId: string | undefined;\n runtimeInstanceId: string | undefined;\n sourceTaskId: string | undefined;\n contextKey: string | undefined;\n callOrigin: "mcp" | "extension" | "script-sdk";\n };\n\n import type { SwarmSdk } from "swarm-sdk";\n import type { z } from "zod";\n export type Runtime = "api" | "worker";\n export interface ExtensionScriptAsset {\n name: string;\n file: string;\n description: string;\n intent?: string;\n }\n export interface ExtensionScheduleAsset {\n name: string;\n description?: string;\n script: string;\n cronExpression?: string;\n intervalMs?: number;\n timezone?: string;\n args?: Record<string, unknown>;\n }\n export interface ExtensionWorkflowAsset {\n file: string;\n }\n export interface ExtensionSkillAsset {\n dir: string;\n }\n export interface ExtensionManifest {\n $schema?: string;\n name: string;\n description: string;\n version: string;\n runtime: Runtime;\n assets: {\n hooks: string;\n scripts?: readonly ExtensionScriptAsset[];\n schedules?: readonly ExtensionScheduleAsset[];\n workflows?: readonly ExtensionWorkflowAsset[];\n skills?: readonly ExtensionSkillAsset[];\n };\n homepage?: string;\n author?: string;\n }\n export interface HooksModule {\n default: SwarmExtension;\n config?: z.ZodTypeAny;\n }\n export interface ExtensionState {\n get<T = unknown>(key: string): Promise<T | null>;\n set<T>(key: string, value: T): Promise<void>;\n incr(key: string, by?: number): Promise<number>;\n del(key: string): Promise<void>;\n }\n export interface ExtensionLogger {\n debug(message: string, data?: unknown): void;\n info(message: string, data?: unknown): void;\n warn(message: string, data?: unknown): void;\n error(message: string, data?: unknown): void;\n }\n type ConfigFor<M extends ExtensionManifest> = M extends {\n config: infer C extends z.ZodTypeAny;\n }\n ? z.infer<C>\n : Record<string, unknown>;\n export interface ApiCtx<Config = Record<string, unknown>> {\n swarm: SwarmSdk;\n state: ExtensionState;\n config: Config;\n log: ExtensionLogger;\n signal: AbortSignal;\n event: {\n name: keyof SwarmEventMap;\n at: string;\n extension: {\n id: string;\n version: number;\n };\n };\n }\n export interface WorkerCtx<Config = Record<string, unknown>>\n extends Omit<ApiCtx<Config>, "swarm"> {\n swarm: SwarmSdk;\n worker: {\n agentId: string;\n taskId: string;\n harness: string;\n };\n }\n export type CtxFor<M extends ExtensionManifest> = M["runtime"] extends "api"\n ? ApiCtx<ConfigFor<M>>\n : WorkerCtx<ConfigFor<M>>;\n export type PreResult<Modify> =\n | void\n | {\n action: "continue";\n }\n | {\n action: "modify";\n data: Modify;\n }\n | {\n action: "block";\n reason: string;\n };\n export type TaskCreateOrigin =\n | "rest"\n | "app"\n | "mcp"\n | "slack"\n | "schedule"\n | "workflow"\n | "webhook"\n | "followUp"\n | `extension:${string}`;\n export interface TaskCreateEvent {\n options: CreateTaskOptions;\n description: string;\n origin: TaskCreateOrigin;\n requestInfo?: RequestInfo;\n }\n /** A pre.task.create hook blocked a task before it was created. */\n export interface TaskBlockedEvent {\n /** The extension that blocked, or whose model/agent rewrite was rejected. */\n extension: {\n id: string;\n name: string;\n };\n /** The scrubbed block reason. */\n reason: string;\n origin: TaskCreateOrigin;\n source?: AgentTask["source"];\n requester: {\n userId?: string;\n agentId?: string;\n slackUserId?: string;\n };\n context?: {\n contextKey?: string;\n scheduleId?: string;\n workflowRunId?: string;\n workflowRunStepId?: string;\n parentTaskId?: string;\n vcsRepo?: string;\n vcsNumber?: number;\n slackChannelId?: string;\n slackThreadTs?: string;\n };\n /** The original task description, scrubbed and cut to 500 characters. */\n description: string;\n }\n export type TaskCreateModify = Partial<\n Pick<\n CreateTaskOptions,\n | "agentId"\n | "creatorAgentId"\n | "source"\n | "taskType"\n | "tags"\n | "priority"\n | "dependsOn"\n | "offeredTo"\n | "vcsProvider"\n | "vcsRepo"\n | "vcsEventType"\n | "vcsNumber"\n | "vcsCommentId"\n | "vcsAuthor"\n | "vcsUrl"\n | "vcsInstallationId"\n | "vcsNodeId"\n | "agentmailInboxId"\n | "agentmailMessageId"\n | "agentmailThreadId"\n | "mentionMessageId"\n | "mentionChannelId"\n | "dir"\n | "model"\n | "modelTier"\n | "effort"\n | "outputSchema"\n | "followUpConfig"\n | "bypassTrackerContextDedup"\n >\n > & {\n description?: string;\n };\n export interface TaskFollowUpEvent {\n completedTask: AgentTask;\n status: "completed" | "failed";\n output?: string;\n failureReason?: string;\n workerAgentId: string;\n leadAgentId: string;\n summary: string;\n }\n export type TaskFollowUpModify = Partial<\n Pick<CreateTaskOptions, "agentId" | "priority" | "followUpConfig">\n > & {\n description?: string;\n };\n export interface SlackRouteEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n botMentioned: boolean;\n threadContext?: {\n channelId: string;\n threadTs: string;\n };\n }\n export interface SlackRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n };\n }\n /** A Chat SDK message after its thread buffer flushed, before routing. */\n export interface ChatRouteEvent {\n /** Adapter name, such as `telegram` or `slack`. */\n adapter: string;\n channelId: string;\n /** The Chat SDK thread id, prefixed with the adapter name. */\n threadId: string;\n /** The first buffered sender. */\n userId: string;\n userName?: string;\n /** The buffered messages, joined. */\n text: string;\n /** True for an @mention and for a reply to one of the bot\'s messages. */\n botMentioned: boolean;\n isDirectMessage: boolean;\n /** Platform chat type, such as Telegram `private`, `group`, `supergroup` or `channel`. */\n chatType?: string;\n /** Set for a Telegram supergroup with topics. */\n isForum?: boolean;\n /** A buffered message replies to one of the bot\'s own messages. */\n replyToBot: boolean;\n /** Set when earlier swarm tasks exist in this thread. */\n threadContext?: {\n adapter: string;\n threadId: string;\n };\n }\n export interface ChatRouteModify {\n target:\n | {\n kind: "agent";\n agentId: string;\n }\n | {\n kind: "lead";\n }\n | {\n kind: "broadcast";\n }\n | {\n kind: "workflow";\n workflowId: string;\n };\n }\n export type HeartbeatClassification = "no-session" | "stale-session" | "fresh-stalled";\n export type HeartbeatAction = "supersede-resume" | "fail" | "record";\n export interface HeartbeatRemediateEvent {\n task: AgentTask;\n session?: ActiveSession;\n classification: HeartbeatClassification;\n proposedAction: HeartbeatAction;\n reason: string;\n taskAgeMs: number;\n sessionHeartbeatAgeMs?: number;\n }\n export interface HeartbeatRemediateModify {\n proposedAction: HeartbeatAction;\n }\n export interface ToolCallEvent {\n tool: string;\n args: unknown;\n requestInfo: RequestInfo;\n }\n export interface ToolCallModify {\n args: unknown;\n }\n export interface TaskEvent {\n task: AgentTask;\n }\n export interface TaskCompletedEvent extends TaskEvent {\n output: string;\n }\n export interface TaskFailedEvent extends TaskEvent {\n failureReason: string;\n }\n export interface TaskSupersededEvent extends TaskEvent {\n supersededBy: string;\n }\n export interface TaskProgressEvent extends TaskEvent {\n progress: string;\n }\n export interface SlackMessageEvent {\n channelId: string;\n userId: string;\n text: string;\n threadTs?: string;\n taskId?: string;\n }\n /** A Chat SDK message the swarm received or sent. */\n export interface ChatMessageEvent {\n adapter: string;\n channelId: string;\n threadId: string;\n /** The sender, for an inbound message. */\n userId?: string;\n text: string;\n direction: "inbound" | "outbound";\n taskId?: string;\n messageId?: string;\n }\n /**\n * A person answered an approval request. Fires for workflow and standalone\n * requests. Timeouts and cancellations do not emit it today.\n */\n export interface ApprovalResolvedEvent {\n requestId: string;\n status: "approved" | "rejected" | "timeout" | "cancelled";\n responses: Record<string, unknown> | null;\n /** Set when a workflow human-in-the-loop step asked. Absent for a standalone request. */\n workflowRunId?: string;\n workflowRunStepId?: string;\n /** The task whose agent asked, for a standalone request. */\n sourceTaskId?: string;\n }\n /** A task was refused because a daily spend budget is used up. */\n export interface TaskBudgetRefusedEvent extends TaskEvent {\n /** The agent the task was refused for. */\n agentId: string;\n /** Which budget refused it. The matching spend and budget fields are set. */\n cause: "agent" | "global" | "user";\n agentSpendUsd?: number;\n agentBudgetUsd?: number;\n globalSpendUsd?: number;\n globalBudgetUsd?: number;\n userSpendUsd?: number;\n userBudgetUsd?: number;\n /** ISO 8601 time the daily budget resets. */\n resetAt: string;\n }\n /** An inbound AgentMail message. */\n export interface EmailReceivedEvent {\n inboxId: string;\n /** The From header as sent. */\n from: string;\n subject: string;\n /** The text body, or the HTML when there is no text, cut to 500 characters with `...` appended. */\n body: string;\n threadId: string;\n messageId: string;\n }\n /** An inbound Kapso (WhatsApp) message. */\n export interface KapsoMessageEvent {\n phoneNumberId: string;\n conversationId?: string;\n messageId: string;\n from?: string;\n /** Kapso message type, such as `text` or `image`. */\n type?: string;\n /** The text, or a placeholder naming the type for a non-text message. */\n text: string;\n }\n export type VcsProvider = "github" | "gitlab" | "azure-devops" | "bitbucket";\n /**\n * The provider object the event is about, as the provider names it. GitLab\n * calls a pull request a merge request, and a comment a `note`.\n */\n export type VcsEventKind =\n | "pull_request"\n | "merge_request"\n | "issue"\n | "issue_comment"\n | "note"\n | "pull_request_review"\n | "pipeline";\n /**\n * A webhook event from a connected code host. `action` is the provider\'s own\n * value: `opened`, `closed`, `synchronize`, `merge`, `created`, `commented`, or,\n * for a pipeline, its status such as `success` or `failed`. Fields a kind does\n * not carry are absent.\n */\n export interface VcsEvent {\n provider: VcsProvider;\n kind: VcsEventKind;\n action: string;\n /** `owner/name` on GitHub and GitLab, `workspace/repo_slug` on Bitbucket, the repository URL on Azure DevOps. */\n repo: string;\n /** The PR, merge request, or issue number. Absent for a pipeline outside a merge request. */\n number?: number;\n title?: string;\n body?: string | null;\n /** Login of the user behind the event. Set for pull requests, merge requests, and Azure DevOps comments. */\n author?: string;\n url?: string;\n merged?: boolean;\n changedFiles?: number;\n /** The review verdict, on a pull request review. */\n reviewState?: string;\n }\n export interface ToolCallCompletedEvent extends ToolCallEvent {\n result: unknown;\n durationMs: number;\n }\n export interface SwarmEventMap {\n "pre.task.create": {\n event: TaskCreateEvent;\n modify: TaskCreateModify;\n result: PreResult<TaskCreateModify>;\n };\n "pre.task.followUp": {\n event: TaskFollowUpEvent;\n modify: TaskFollowUpModify;\n result: PreResult<TaskFollowUpModify>;\n };\n "pre.slack.route": {\n event: SlackRouteEvent;\n modify: SlackRouteModify;\n result: PreResult<SlackRouteModify>;\n };\n "pre.chat.route": {\n event: ChatRouteEvent;\n modify: ChatRouteModify;\n result: PreResult<ChatRouteModify>;\n };\n "pre.heartbeat.remediate": {\n event: HeartbeatRemediateEvent;\n modify: HeartbeatRemediateModify;\n result: PreResult<HeartbeatRemediateModify>;\n };\n "pre.tool.call": {\n event: ToolCallEvent;\n modify: ToolCallModify;\n result: PreResult<ToolCallModify>;\n };\n "post.task.created": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.blocked": {\n event: TaskBlockedEvent;\n modify: never;\n result: void;\n };\n "post.task.completed": {\n event: TaskCompletedEvent;\n modify: never;\n result: void;\n };\n "post.task.failed": {\n event: TaskFailedEvent;\n modify: never;\n result: void;\n };\n "post.task.cancelled": {\n event: TaskEvent;\n modify: never;\n result: void;\n };\n "post.task.superseded": {\n event: TaskSupersededEvent;\n modify: never;\n result: void;\n };\n "post.task.progress": {\n event: TaskProgressEvent;\n modify: never;\n result: void;\n };\n "post.slack.message": {\n event: SlackMessageEvent;\n modify: never;\n result: void;\n };\n "post.chat.message": {\n event: ChatMessageEvent;\n modify: never;\n result: void;\n };\n "post.approval.resolved": {\n event: ApprovalResolvedEvent;\n modify: never;\n result: void;\n };\n "post.task.budgetRefused": {\n event: TaskBudgetRefusedEvent;\n modify: never;\n result: void;\n };\n "post.email.received": {\n event: EmailReceivedEvent;\n modify: never;\n result: void;\n };\n "post.kapso.message": {\n event: KapsoMessageEvent;\n modify: never;\n result: void;\n };\n "post.vcs.event": {\n event: VcsEvent;\n modify: never;\n result: void;\n };\n "post.tool.call": {\n event: ToolCallCompletedEvent;\n modify: never;\n result: void;\n };\n }\n export interface ExtensionApi<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > {\n on<E extends keyof SwarmEventMap>(\n event: E,\n handler: (\n event: SwarmEventMap[E]["event"],\n ctx: CtxFor<M>,\n ) => Promise<SwarmEventMap[E]["result"]> | SwarmEventMap[E]["result"],\n opts?: {\n priority?: number;\n },\n ): void;\n }\n export type SwarmExtension<\n M extends ExtensionManifest = ExtensionManifest & {\n runtime: "api";\n },\n > = (api: ExtensionApi<M>) => void;\n export { block, modify } from "./contract-runtime";\n}\n';
|
|
@@ -119,6 +119,30 @@ export interface TaskCreateEvent {
|
|
|
119
119
|
requestInfo?: RequestInfo;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/** A pre.task.create hook blocked a task before it was created. */
|
|
123
|
+
export interface TaskBlockedEvent {
|
|
124
|
+
/** The extension that blocked, or whose model/agent rewrite was rejected. */
|
|
125
|
+
extension: { id: string; name: string };
|
|
126
|
+
/** The scrubbed block reason. */
|
|
127
|
+
reason: string;
|
|
128
|
+
origin: TaskCreateOrigin;
|
|
129
|
+
source?: AgentTask["source"];
|
|
130
|
+
requester: { userId?: string; agentId?: string; slackUserId?: string };
|
|
131
|
+
context?: {
|
|
132
|
+
contextKey?: string;
|
|
133
|
+
scheduleId?: string;
|
|
134
|
+
workflowRunId?: string;
|
|
135
|
+
workflowRunStepId?: string;
|
|
136
|
+
parentTaskId?: string;
|
|
137
|
+
vcsRepo?: string;
|
|
138
|
+
vcsNumber?: number;
|
|
139
|
+
slackChannelId?: string;
|
|
140
|
+
slackThreadTs?: string;
|
|
141
|
+
};
|
|
142
|
+
/** The original task description, scrubbed and cut to 500 characters. */
|
|
143
|
+
description: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
122
146
|
export type TaskCreateModify = Partial<
|
|
123
147
|
Pick<
|
|
124
148
|
CreateTaskOptions,
|
|
@@ -414,6 +438,7 @@ export interface SwarmEventMap {
|
|
|
414
438
|
result: PreResult<ToolCallModify>;
|
|
415
439
|
};
|
|
416
440
|
"post.task.created": { event: TaskEvent; modify: never; result: void };
|
|
441
|
+
"post.task.blocked": { event: TaskBlockedEvent; modify: never; result: void };
|
|
417
442
|
"post.task.completed": { event: TaskCompletedEvent; modify: never; result: void };
|
|
418
443
|
"post.task.failed": { event: TaskFailedEvent; modify: never; result: void };
|
|
419
444
|
"post.task.cancelled": { event: TaskEvent; modify: never; result: void };
|
|
@@ -148,6 +148,10 @@ export function runContext(event: keyof SwarmEventMap, payload: unknown): RunCon
|
|
|
148
148
|
agentId = requestAgent ?? stringField(payload, "options", "agentId");
|
|
149
149
|
subject = `${stringField(payload, "origin") ?? "?"}: ${snippet(field(payload, "description"))}`;
|
|
150
150
|
break;
|
|
151
|
+
case "post.task.blocked":
|
|
152
|
+
agentId = stringField(payload, "requester", "agentId");
|
|
153
|
+
subject = `${stringField(payload, "origin") ?? "?"}: ${stringField(payload, "extension", "name") ?? "?"}`;
|
|
154
|
+
break;
|
|
151
155
|
case "pre.task.followUp":
|
|
152
156
|
agentId = stringField(payload, "workerAgentId");
|
|
153
157
|
subject =
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
ApprovalResolvedEvent,
|
|
6
6
|
ChatMessageEvent,
|
|
7
7
|
KapsoMessageEvent,
|
|
8
|
+
TaskBlockedEvent,
|
|
8
9
|
TaskBudgetRefusedEvent,
|
|
9
10
|
VcsEvent,
|
|
10
11
|
VcsEventKind,
|
|
@@ -44,6 +45,11 @@ const onTaskCreated = observed("task.created", async (data) => {
|
|
|
44
45
|
);
|
|
45
46
|
});
|
|
46
47
|
|
|
48
|
+
const onTaskBlocked = observed("task.create_blocked", async (data) => {
|
|
49
|
+
const { skipExtensionId, ...event } = data as TaskBlockedEvent & { skipExtensionId?: string };
|
|
50
|
+
await dispatchPost("post.task.blocked", event, { skipExtensionId });
|
|
51
|
+
});
|
|
52
|
+
|
|
47
53
|
const onTaskCompleted = observed("task.completed", async (data) => {
|
|
48
54
|
const task = await taskFrom(data);
|
|
49
55
|
if (!task) return;
|
|
@@ -358,6 +364,7 @@ export function initExtensionPostBridge(): void {
|
|
|
358
364
|
if (subscribed) return;
|
|
359
365
|
subscribed = true;
|
|
360
366
|
workflowEventBus.on("task.created", onTaskCreated);
|
|
367
|
+
workflowEventBus.on("task.create_blocked", onTaskBlocked);
|
|
361
368
|
workflowEventBus.on("task.completed", onTaskCompleted);
|
|
362
369
|
workflowEventBus.on("task.failed", onTaskFailed);
|
|
363
370
|
workflowEventBus.on("task.cancelled", onTaskCancelled);
|
|
@@ -377,6 +384,7 @@ export function teardownExtensionPostBridge(): void {
|
|
|
377
384
|
if (!subscribed) return;
|
|
378
385
|
subscribed = false;
|
|
379
386
|
workflowEventBus.off("task.created", onTaskCreated);
|
|
387
|
+
workflowEventBus.off("task.create_blocked", onTaskBlocked);
|
|
380
388
|
workflowEventBus.off("task.completed", onTaskCompleted);
|
|
381
389
|
workflowEventBus.off("task.failed", onTaskFailed);
|
|
382
390
|
workflowEventBus.off("task.cancelled", onTaskCancelled);
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { renderContextBoundNote } from "../prompts/context-bound-note";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One-time "keep this turn bounded" note for long worker sessions, on every
|
|
7
|
+
* harness.
|
|
8
|
+
*
|
|
9
|
+
* This module owns the threshold, the once-per-task claim and the rendering.
|
|
10
|
+
* A harness supplies only two things: the prompt size of its latest
|
|
11
|
+
* main-thread request (input + cache read + cache write tokens) and a way to
|
|
12
|
+
* put text in front of the model mid-session.
|
|
13
|
+
*
|
|
14
|
+
* - Claude: the session writes the size next to the task file
|
|
15
|
+
* (`recordContextState`); the PostToolUse hook claims a marker file
|
|
16
|
+
* (`claimContextBoundNote`) and returns the note as tool context.
|
|
17
|
+
* - In-process harnesses (codex, pi, opencode, amp, claude-managed): the session
|
|
18
|
+
* feeds each request's size to `createContextBoundNote`, which injects the
|
|
19
|
+
* note through the harness's own mid-turn input, and only while that input
|
|
20
|
+
* still lands inside the current turn.
|
|
21
|
+
*
|
|
22
|
+
* Every failure path means "no note": nothing here throws into a session or
|
|
23
|
+
* blocks a tool call.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Threshold from `CONTEXT_BOUND_NOTE_TOKENS`. Off by default: unset, empty,
|
|
28
|
+
* 0 or invalid returns 0 (off); a positive integer turns the note on at that
|
|
29
|
+
* many tokens.
|
|
30
|
+
*/
|
|
31
|
+
export function contextBoundNoteThreshold(
|
|
32
|
+
env: Record<string, string | undefined> = process.env,
|
|
33
|
+
): number {
|
|
34
|
+
const raw = env.CONTEXT_BOUND_NOTE_TOKENS?.trim();
|
|
35
|
+
if (!raw || !/^\d+$/.test(raw)) return 0;
|
|
36
|
+
const parsed = Number(raw);
|
|
37
|
+
return Number.isSafeInteger(parsed) ? parsed : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** True when a valid token count reaches an enabled (> 0) threshold. */
|
|
41
|
+
function reachesThreshold(contextTokens: unknown, threshold: number): contextTokens is number {
|
|
42
|
+
return (
|
|
43
|
+
threshold > 0 &&
|
|
44
|
+
typeof contextTokens === "number" &&
|
|
45
|
+
Number.isFinite(contextTokens) &&
|
|
46
|
+
contextTokens >= threshold
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Render the note and hand it to `inject`. Fails open. */
|
|
51
|
+
async function deliverContextBoundNote(
|
|
52
|
+
contextTokens: number,
|
|
53
|
+
model: string | undefined,
|
|
54
|
+
inject: (note: string) => unknown,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
try {
|
|
57
|
+
const note = await renderContextBoundNote(contextTokens, model);
|
|
58
|
+
if (note) await inject(note);
|
|
59
|
+
} catch {
|
|
60
|
+
// Fail open.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function contextStatePath(taskFilePath: string): string {
|
|
65
|
+
return `${taskFilePath}.context.json`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function contextBoundNoteMarkerPath(taskFilePath: string): string {
|
|
69
|
+
return `${taskFilePath}.bound-note`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What the session records for the hook: the latest request's size and the session's model. */
|
|
73
|
+
export interface ContextState {
|
|
74
|
+
contextTokens: number;
|
|
75
|
+
model?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Record the latest request's context size (and the session's model, for the
|
|
80
|
+
* window share) for the hook. Synchronous so
|
|
81
|
+
* writes land in stream order; tmp + rename so the hook never reads a partial
|
|
82
|
+
* file. Errors are swallowed: a missing state file only means no note.
|
|
83
|
+
*/
|
|
84
|
+
export function recordContextState(
|
|
85
|
+
taskFilePath: string,
|
|
86
|
+
contextTokens: number,
|
|
87
|
+
model?: string,
|
|
88
|
+
): void {
|
|
89
|
+
const path = contextStatePath(taskFilePath);
|
|
90
|
+
const tmp = `${path}.tmp`;
|
|
91
|
+
try {
|
|
92
|
+
writeFileSync(
|
|
93
|
+
tmp,
|
|
94
|
+
JSON.stringify({ contextTokens, model, updatedAt: new Date().toISOString() }),
|
|
95
|
+
);
|
|
96
|
+
renameSync(tmp, path);
|
|
97
|
+
} catch {
|
|
98
|
+
// Fail open.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Returns the recorded state when the note should fire now, else null. Fires at
|
|
104
|
+
* most once per task file: the marker is created with `wx`, so a second call
|
|
105
|
+
* (or a concurrent hook) loses the race and gets null.
|
|
106
|
+
*/
|
|
107
|
+
export async function claimContextBoundNote(
|
|
108
|
+
taskFilePath: string | undefined,
|
|
109
|
+
threshold: number,
|
|
110
|
+
): Promise<ContextState | null> {
|
|
111
|
+
if (!taskFilePath || threshold <= 0) return null;
|
|
112
|
+
let contextTokens: unknown;
|
|
113
|
+
let model: unknown;
|
|
114
|
+
try {
|
|
115
|
+
const state = JSON.parse(await readFile(contextStatePath(taskFilePath), "utf8")) as {
|
|
116
|
+
contextTokens?: unknown;
|
|
117
|
+
model?: unknown;
|
|
118
|
+
};
|
|
119
|
+
contextTokens = state?.contextTokens;
|
|
120
|
+
model = state?.model;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!reachesThreshold(contextTokens, threshold)) return null;
|
|
125
|
+
try {
|
|
126
|
+
await writeFile(contextBoundNoteMarkerPath(taskFilePath), new Date().toISOString(), {
|
|
127
|
+
flag: "wx",
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return { contextTokens, model: typeof model === "string" && model ? model : undefined };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Claude PostToolUse path: claim from the task file, then hand the note to `inject`. */
|
|
136
|
+
export async function injectClaimedContextBoundNote(
|
|
137
|
+
taskFilePath: string | undefined,
|
|
138
|
+
threshold: number,
|
|
139
|
+
inject: (note: string) => unknown,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
const state = await claimContextBoundNote(taskFilePath, threshold).catch(() => null);
|
|
142
|
+
if (state) await deliverContextBoundNote(state.contextTokens, state.model, inject);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* In-process path. Returns an observer the session calls with each
|
|
147
|
+
* main-thread request's prompt size. The first size at or above `threshold`
|
|
148
|
+
* claims the note for this session (one session runs one task) and renders
|
|
149
|
+
* it; later sizes are ignored. The returned promise never rejects.
|
|
150
|
+
*
|
|
151
|
+
* Rendering is async (HTTP on workers), so the session may have moved on by
|
|
152
|
+
* the time the note is ready. `canDeliver` decides then: true injects it;
|
|
153
|
+
* false holds it, and each later call retries the held note instead of
|
|
154
|
+
* rendering a new one. A session that settles, aborts or ends its last turn
|
|
155
|
+
* first never gets it.
|
|
156
|
+
*/
|
|
157
|
+
export function createContextBoundNote(options: {
|
|
158
|
+
threshold: number;
|
|
159
|
+
/** The session's model, read when the note renders, for the window share. */
|
|
160
|
+
model?: () => string | undefined;
|
|
161
|
+
inject: (note: string) => unknown;
|
|
162
|
+
/** True while a note injected now reaches the model inside the current turn. */
|
|
163
|
+
canDeliver: () => boolean;
|
|
164
|
+
}): (contextTokens: number | undefined) => Promise<void> {
|
|
165
|
+
let state: "idle" | "rendering" | "held" | "done" = "idle";
|
|
166
|
+
let held = "";
|
|
167
|
+
const tryDeliver = async (note: string): Promise<void> => {
|
|
168
|
+
try {
|
|
169
|
+
if (!options.canDeliver()) {
|
|
170
|
+
held = note;
|
|
171
|
+
state = "held";
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
state = "done";
|
|
175
|
+
await options.inject(note);
|
|
176
|
+
} catch {
|
|
177
|
+
// Fail open.
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
return async (contextTokens) => {
|
|
181
|
+
if (state === "held") return tryDeliver(held);
|
|
182
|
+
if (state !== "idle" || !reachesThreshold(contextTokens, options.threshold)) return;
|
|
183
|
+
state = "rendering";
|
|
184
|
+
let note = "";
|
|
185
|
+
try {
|
|
186
|
+
note = await renderContextBoundNote(contextTokens, options.model?.());
|
|
187
|
+
} catch {
|
|
188
|
+
// Fail open.
|
|
189
|
+
}
|
|
190
|
+
if (!note) {
|
|
191
|
+
state = "done";
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
await tryDeliver(note);
|
|
195
|
+
};
|
|
196
|
+
}
|
package/src/hooks/hook.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { getMcpBaseUrl, MAX_PROFILE_FILE_LENGTH } from "../utils/constants";
|
|
|
24
24
|
import { isSessionSummarySkipped } from "../utils/env-flag";
|
|
25
25
|
import { summarizeSession as runSummarize } from "../utils/internal-ai";
|
|
26
26
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
27
|
+
import { contextBoundNoteThreshold, injectClaimedContextBoundNote } from "./context-bound-note";
|
|
27
28
|
import { guardGhPrBody } from "./pr-body-guard";
|
|
28
29
|
import { guardGhPrRoom } from "./pr-room-guard";
|
|
29
30
|
import { checkToolLoop, clearToolHistory } from "./tool-loop-detection";
|
|
@@ -78,6 +79,8 @@ interface HookMessage {
|
|
|
78
79
|
tool_input?: Record<string, unknown>;
|
|
79
80
|
tool_response?: Record<string, unknown>;
|
|
80
81
|
tool_use_id?: string;
|
|
82
|
+
/** Set only when the hook fires inside a subagent. */
|
|
83
|
+
agent_id?: string;
|
|
81
84
|
prompt?: string;
|
|
82
85
|
stop_hook_active?: boolean;
|
|
83
86
|
}
|
|
@@ -1339,6 +1342,15 @@ export async function handleHook(): Promise<void> {
|
|
|
1339
1342
|
`Task sent successfully.${maybeTaskId ? ` Task ID: ${maybeTaskId}.` : ""} Monitor progress using the get-task-details tool periodically.`,
|
|
1340
1343
|
);
|
|
1341
1344
|
}
|
|
1345
|
+
} else if (!msg.agent_id) {
|
|
1346
|
+
// One-time nudge once the session's context crosses the threshold.
|
|
1347
|
+
// Main thread only: a subagent call must not consume the marker.
|
|
1348
|
+
// Any failure means no note; it never blocks the tool call.
|
|
1349
|
+
await injectClaimedContextBoundNote(
|
|
1350
|
+
process.env.TASK_FILE,
|
|
1351
|
+
contextBoundNoteThreshold(),
|
|
1352
|
+
(note) => outputToolContext("PostToolUse", note),
|
|
1353
|
+
);
|
|
1342
1354
|
}
|
|
1343
1355
|
}
|
|
1344
1356
|
break;
|