@desplega.ai/agent-swarm 1.145.0 → 1.147.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/{acp-adapter-580t41c0.js → acp-adapter-9n319wqc.js} +61 -6
- package/dist/{actions-m662tjng.js → actions-2vxqvpr9.js} +9 -8
- package/dist/{app-zb3rndm8.js → app-w066xfy0.js} +6 -5
- package/dist/assistant-kt31dj2h.js +236 -0
- package/dist/{boot-reembed-072x00vn.js → boot-reembed-az5rassp.js} +7 -6
- package/dist/{boot-reembed-65zz2smt.js → boot-reembed-j3mm3rfz.js} +8 -7
- package/dist/{boot-scrub-logs-yz0ams04.js → boot-scrub-logs-b6h54817.js} +4 -4
- package/dist/{claude-adapter-paj270y2.js → claude-adapter-w1z04ann.js} +8 -6
- package/dist/{claude-managed-setup-w619jncn.js → claude-managed-setup-f5pmsyrp.js} +24 -3
- package/dist/{claude-sdk-session-swt80j9z.js → claude-sdk-session-f0cc1kec.js} +8 -6
- package/dist/{cli-dt553ktb.js → cli-0b6y7kxm.js} +1 -1
- package/dist/{cli-bxdhw615.js → cli-12fz972k.js} +4 -4
- package/dist/{cli-s2f20r5w.js → cli-1th1728g.js} +2 -2
- package/dist/{cli-rs6ec3b9.js → cli-3tgwnf43.js} +3 -3
- package/dist/{cli-gxnzc6wh.js → cli-4nr988h6.js} +1 -1
- package/dist/{cli-axs8e6d4.js → cli-53s590z8.js} +1 -1
- package/dist/{cli-ygbjp3z8.js → cli-54wwve05.js} +5 -4
- package/dist/{cli-v0asr5yv.js → cli-719k9j2c.js} +2 -2
- package/dist/{cli-anktweqr.js → cli-89xfd7f9.js} +21 -47
- package/dist/{cli-q34a0e10.js → cli-8v0g1yc8.js} +1 -1
- package/dist/cli-9d8ntetj.js +42 -0
- package/dist/{cli-z9fprcrf.js → cli-atcve9y8.js} +881 -738
- package/dist/{cli-jmb7g47h.js → cli-b5bq9crk.js} +14 -31
- package/dist/cli-d8brzxjb.js +190 -0
- package/dist/{cli-gzky0gkr.js → cli-de14znh8.js} +2 -2
- package/dist/{cli-xytwff7f.js → cli-dttph1bw.js} +2 -2
- package/dist/cli-ep19k3mb.js +19 -0
- package/dist/{cli-m1xa8be7.js → cli-f146mn5s.js} +7 -50
- package/dist/{cli-1fj0chdk.js → cli-f7dsy61y.js} +5 -3
- package/dist/{cli-f567d5c1.js → cli-ftht3zzj.js} +2782 -2589
- package/dist/{cli-a0g1gbt5.js → cli-hbv0kq6w.js} +2 -2
- package/dist/{cli-fhsz3twr.js → cli-jsp14b6c.js} +171 -1
- package/dist/{cli-7fnkj1hj.js → cli-kpv03zhj.js} +21 -17
- package/dist/{cli-m4j6trn4.js → cli-m8yrsg97.js} +2 -2
- package/dist/cli-m9sxbkhm.js +779 -0
- package/dist/cli-mmemxxdc.js +823 -0
- package/dist/{cli-tqtvj5pt.js → cli-n8508nre.js} +1 -1
- package/dist/{cli-z96t0thj.js → cli-p1d5b073.js} +37 -2
- package/dist/{cli-gh8y8f36.js → cli-rw51bq3j.js} +13 -10
- package/dist/{cli-59ej1c07.js → cli-smby5m17.js} +7 -6
- package/dist/{cli-65vqzz3v.js → cli-tzvk9haz.js} +94 -372
- package/dist/{cli-dc9k2ba9.js → cli-w9kb3bk1.js} +1 -1
- package/dist/{cli-kpev657q.js → cli-y6b6fb76.js} +25 -3
- package/dist/{cli-123q2get.js → cli-yz3djrm0.js} +9 -9
- package/dist/{cli-wvp6bvy4.js → cli-zhjmqxzh.js} +1 -1
- package/dist/cli.js +13 -13
- package/dist/{codex-adapter-k98vaw2r.js → codex-adapter-zw4dwsx7.js} +2 -2
- package/dist/{codex-hook-9pf19cks.js → codex-hook-bw8p5nh4.js} +2 -2
- package/dist/{codex-session-runner-tngtphx7.js → codex-session-runner-k981s65v.js} +2 -2
- package/dist/{commands-y8nbr3fv.js → commands-sykrxx7e.js} +4 -4
- package/dist/{db-30p4h2t1.js → db-sfbsc8wt.js} +6 -4
- package/dist/handlers-0aspcejm.js +603 -0
- package/dist/{hook-dcw4jfbj.js → hook-4nvqj2sb.js} +7 -5
- package/dist/{hook-21jexvfx.js → hook-hxccs7m5.js} +24 -49
- package/dist/{http-r4kq5g8d.js → http-y06z517d.js} +230 -94
- package/dist/{index-q3qja6ta.js → index-hssww7kj.js} +15 -14
- package/dist/{index-gay66b9w.js → index-k8znfx0z.js} +13 -12
- package/dist/{index-53zyht4z.js → index-tce1rss4.js} +371 -110
- package/dist/{index-nqvf26za.js → index-x5j894zf.js} +14 -13
- package/dist/{keepalive-xs8fa55y.js → keepalive-w733ax66.js} +6 -6
- package/dist/{lead-d9m2t4s0.js → lead-75bjqa9d.js} +29 -27
- package/dist/{maintenance-3zh82g6z.js → maintenance-w4f1zjck.js} +8 -7
- package/dist/{oauth-refresh-sweep-03snsye5.js → oauth-refresh-sweep-wdetky26.js} +5 -5
- package/dist/{onboard-0cg1925x.js → onboard-bb5net8s.js} +2 -2
- package/dist/{otel-impl-0ab95z27.js → otel-impl-0w7c14ap.js} +1 -1
- package/dist/{pi-mono-adapter-dtkz4e7j.js → pi-mono-adapter-2has4gnp.js} +12 -2
- package/dist/{pricing-refresh-e5jhzz4j.js → pricing-refresh-9nzsrgcq.js} +6 -6
- package/dist/{rbac-roles-p1mf40z3.js → rbac-roles-r3hqjp09.js} +5 -5
- package/dist/{rbac-roles-2w3cmrkc.js → rbac-roles-z1jrffbt.js} +4 -4
- package/dist/{registry-9kdck5ye.js → registry-v5wytg0s.js} +1 -2
- package/dist/{render-v2-xhawry3k.js → render-v2-vbr89fnb.js} +6 -5
- package/dist/{seed-pricing-t223sw91.js → seed-pricing-g62hy1pk.js} +7 -5
- package/dist/{setup-zcs0rce7.js → setup-gnfqnptp.js} +2 -2
- package/dist/{worker-qnp2ka62.js → worker-kznxp4qm.js} +29 -27
- package/openapi.json +263 -12
- package/package.json +1 -1
- package/plugin/commands/start-worker.md +4 -9
- package/plugin/pi-skills/start-worker/SKILL.md +4 -9
- package/src/be/db/agents.ts +556 -0
- package/src/be/db/context-versions.ts +117 -0
- package/src/be/db/runtime.ts +406 -0
- package/src/be/db/tasks/read.ts +1167 -0
- package/src/be/db/tasks/write.ts +953 -0
- package/src/be/db.ts +584 -3429
- package/src/be/migrations/147_scheduled_tasks_parent_task.sql +7 -0
- package/src/be/migrations/148_pages_svg.sql +123 -0
- package/src/be/migrations/149_session_tokens.sql +24 -0
- package/src/be/scripts/typecheck.ts +1 -0
- package/src/be/seed-pricing.ts +13 -8
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
- package/src/be/seed-scripts/catalog/star-history-refresh.ts +101 -0
- package/src/be/seed-scripts/catalog/star-history-renderer.ts +119 -0
- package/src/be/seed-scripts/index.ts +17 -0
- package/src/be/seed-skills/index.ts +14 -0
- package/src/be/swarm-config-guard.ts +5 -2
- package/src/be/task-attachment-store.ts +70 -0
- package/src/be/users.ts +77 -0
- package/src/commands/attachments-section.ts +37 -0
- package/src/commands/claude-managed-setup.ts +45 -3
- package/src/commands/claude-md-session.ts +408 -0
- package/src/commands/context-preamble.ts +15 -3
- package/src/commands/profile-sync.ts +205 -5
- package/src/commands/provider-credentials.ts +60 -27
- package/src/commands/runner.ts +63 -58
- package/src/commands/templates.ts +25 -2
- package/src/hooks/hook.ts +26 -67
- package/src/http/agents.ts +40 -1
- package/src/http/auth.ts +13 -1
- package/src/http/fs.ts +21 -51
- package/src/http/index.ts +2 -0
- package/src/http/mcp.ts +32 -0
- package/src/http/pages-public.ts +20 -4
- package/src/http/pages.ts +11 -3
- package/src/http/poll.ts +4 -0
- package/src/http/repos.ts +3 -3
- package/src/http/sessions.ts +82 -0
- package/src/http/tasks.ts +10 -2
- package/src/http/utils.ts +79 -1
- package/src/prompts/session-templates.ts +12 -8
- package/src/providers/acp-adapter.ts +62 -8
- package/src/providers/pi-mono-adapter.ts +23 -6
- package/src/providers/pricing-sources.md +11 -0
- package/src/scheduler/scheduler.test.ts +49 -2
- package/src/scheduler/scheduler.ts +4 -0
- package/src/scripts-runtime/sdk-allowlist.ts +1 -0
- package/src/scripts-runtime/types/stdlib.d.ts +8 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +8 -0
- package/src/server.ts +2 -0
- package/src/slack/assistant.ts +87 -46
- package/src/slack/handlers.test.ts +1 -3
- package/src/slack/handlers.ts +102 -87
- package/src/slack/inbound-files.ts +466 -0
- package/src/slack/templates.ts +14 -0
- package/src/tasks/task-terminal-effects.ts +158 -0
- package/src/tests/acp-adapter.test.ts +369 -117
- package/src/tests/budget-claim-gate.test.ts +1 -0
- package/src/tests/build-pi-skills.test.ts +5 -7
- package/src/tests/claude-managed-setup.test.ts +52 -4
- package/src/tests/claude-md-session.test.ts +603 -0
- package/src/tests/codex-skill-resolver.test.ts +19 -0
- package/src/tests/context-versioning.test.ts +107 -0
- package/src/tests/cors-allowlist.test.ts +309 -0
- package/src/tests/credential-check.test.ts +157 -14
- package/src/tests/db-agents.test.ts +91 -0
- package/src/tests/db-context-versions.test.ts +37 -0
- package/src/tests/db-extraction-contract.test.ts +45 -0
- package/src/tests/db-runtime.test.ts +70 -0
- package/src/tests/db-task-read-contract.test.ts +624 -0
- package/src/tests/db-task-write-contract.test.ts +653 -0
- package/src/tests/db-tasks-read.test.ts +183 -0
- package/src/tests/db-tasks-write.test.ts +230 -0
- package/src/tests/defer-task.test.ts +389 -0
- package/src/tests/docs-index-footprint.test.js +2 -2
- package/src/tests/e2e-visuals-stage.test.ts +151 -0
- package/src/tests/file-lock.test.ts +144 -0
- package/src/tests/fixtures/file-lock-holder.ts +13 -0
- package/src/tests/fixtures/hold-file-lock.ts +25 -0
- package/src/tests/heartbeat-expiry.test.ts +188 -0
- package/src/tests/http-api-integration.test.ts +78 -1
- package/src/tests/pages-public-html.test.ts +43 -0
- package/src/tests/pages-svg-migration.test.ts +42 -0
- package/src/tests/preload.ts +4 -1
- package/src/tests/profile-sync-warning.test.ts +16 -0
- package/src/tests/profile-sync.test.ts +126 -0
- package/src/tests/prompt-template-session.test.ts +20 -4
- package/src/tests/provider-registration.test.ts +93 -0
- package/src/tests/rbac-e2e-helpers.test.ts +53 -0
- package/src/tests/rbac-e2e-helpers.ts +19 -2
- package/src/tests/reload-config.test.ts +26 -0
- package/src/tests/routing-affinity-inventory.test.ts +1 -1
- package/src/tests/runner-attachments-section.test.ts +22 -2
- package/src/tests/runner-context-preamble.test.ts +30 -0
- package/src/tests/runner-deployment-resume.test.ts +107 -0
- package/src/tests/runner-repo-autostash.test.ts +12 -0
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/seed-scripts.test.ts +2 -2
- package/src/tests/session-tokens.test.ts +279 -0
- package/src/tests/slack-file-tools-attachments.test.ts +408 -0
- package/src/tests/slack-inbound-files.test.ts +847 -0
- package/src/tests/star-history-refresh.test.ts +102 -0
- package/src/tests/swarm-config-reserved-keys.test.ts +21 -0
- package/src/tests/swarm-repos.test.ts +20 -0
- package/src/tests/tool-annotations.test.ts +4 -3
- package/src/tests/ui-logs-parser.test.ts +176 -0
- package/src/tools/create-page.ts +4 -2
- package/src/tools/defer-task.ts +239 -0
- package/src/tools/repos/update-repo.ts +2 -2
- package/src/tools/schedules/create-schedule.ts +1 -0
- package/src/tools/schedules/list-schedules.ts +1 -0
- package/src/tools/schedules/patch-schedule.ts +1 -0
- package/src/tools/schedules/update-schedule.ts +1 -0
- package/src/tools/slack-download-file.ts +60 -29
- package/src/tools/slack-read.ts +73 -32
- package/src/tools/store-progress.ts +16 -146
- package/src/tools/tool-config.ts +1 -0
- package/src/tools/utils.ts +21 -0
- package/src/types.ts +37 -4
- package/src/utils/acp-session-token.ts +56 -0
- package/src/utils/file-lock.ts +111 -0
- package/src/utils/heartbeat-expiry.ts +78 -0
- package/src/utils/request-auth-context.ts +2 -0
- package/src/utils/task-attachment-links.ts +22 -0
- package/templates/skills/code-quality/content.md +8 -5
- package/templates/skills/download-task-attachment/content.md +2 -1
- package/{plugin/pi-skills → templates/skills}/review-offered-task/SKILL.md +5 -5
- package/templates/skills/review-offered-task/config.json +14 -0
- package/{plugin/commands/review-offered-task.md → templates/skills/review-offered-task/content.md} +3 -8
- package/templates/skills/scheduling/content.md +4 -0
- package/templates/skills/slack-interaction/content.md +2 -2
- package/templates/skills/work-on-task/SKILL.md +16 -0
- package/templates/skills/work-on-task/config.json +14 -0
- package/templates/skills/work-on-task/content.md +11 -0
- package/dist/assistant-0qt42nzg.js +0 -212
- package/dist/cli-fs1kkqgr.js +0 -317
- package/dist/cli-jnf5zghe.js +0 -269
- package/dist/cli-n1q8q14d.js +0 -177
- package/dist/handlers-yfp1qn81.js +0 -592
- package/plugin/commands/work-on-task.md +0 -39
- package/plugin/pi-skills/work-on-task/SKILL.md +0 -39
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack files turned into task attachments: the ones a user shares with the
|
|
3
|
+
* bot, and the ones an agent fetches with `slack-download-file` / `slack-read`.
|
|
4
|
+
*
|
|
5
|
+
* The API downloads each file with the bot token, stores it through the active
|
|
6
|
+
* file provider and records a `task_attachments` row, so the worker gets the
|
|
7
|
+
* same fetch recipe as a file uploaded from the UI. Downloads stream to temp
|
|
8
|
+
* files (the size cap holds mid-stream and a batch never sits in memory). The
|
|
9
|
+
* task is created in `draft` while its uploads run (#1240), with its lease
|
|
10
|
+
* renewed so the abandoned-draft sweep leaves it alone: nobody can claim it
|
|
11
|
+
* before its attachments exist. Every file also stays in the task text as a
|
|
12
|
+
* `[File: …]` line, and the user gets a thread reply naming any file that
|
|
13
|
+
* could not be attached — never dropped silently. A download failure is
|
|
14
|
+
* also flagged on that line; a storage failure happens after the text is
|
|
15
|
+
* written, so it shows up only in the reply and in the missing attachment.
|
|
16
|
+
*/
|
|
17
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import type { WebClient } from "@slack/web-api";
|
|
21
|
+
import { getTaskAttachments, getTaskById, promoteDraftTask, refreshDraftTaskLease } from "../be/db";
|
|
22
|
+
import { MAX_TASK_ATTACHMENT_BYTES, recordTaskAttachmentUpload } from "../be/task-attachment-store";
|
|
23
|
+
import { providerPath } from "../fs/provider";
|
|
24
|
+
import { getFileStorageProvider } from "../fs/registry";
|
|
25
|
+
import { resolveTemplate } from "../prompts/resolver";
|
|
26
|
+
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
27
|
+
import type { AgentTask, CreateTaskOptions, TaskAttachment } from "../types";
|
|
28
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
29
|
+
import { taskAttachmentFetchCommand } from "../utils/task-attachment-links";
|
|
30
|
+
import { getFileInfo, type SlackFile } from "./files";
|
|
31
|
+
// Side-effect import: registers all Slack event templates in the in-memory registry
|
|
32
|
+
import "./templates";
|
|
33
|
+
|
|
34
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000;
|
|
35
|
+
const SIGNED_OUT_REASON =
|
|
36
|
+
"Slack returned its sign-in page instead of the file (is the files:read scope granted?)";
|
|
37
|
+
/**
|
|
38
|
+
* Slack can serve its sign-in page at the file's own URL, as `text/html`, at
|
|
39
|
+
* whatever length the file's declared size happens to be — so for a file that
|
|
40
|
+
* really is HTML, neither the MIME type, the redirect, nor the size separates
|
|
41
|
+
* the page from the file. Only the page itself does.
|
|
42
|
+
*/
|
|
43
|
+
const SIGN_IN_MARKERS = [
|
|
44
|
+
/<title>[^<]*sign in to slack/i,
|
|
45
|
+
/id="signin[_-]form"/i,
|
|
46
|
+
/slack\.com\/(workspace-)?signin/i,
|
|
47
|
+
];
|
|
48
|
+
/** Enough of an HTML body to carry the markers above; a sign-in page leads with them. */
|
|
49
|
+
const SIGN_IN_SNIFF_BYTES = 4096;
|
|
50
|
+
/** How often a draft's lease is renewed while its uploads run; the sweep waits 5 minutes. */
|
|
51
|
+
let draftLeaseRefreshMs = 60_000;
|
|
52
|
+
/** A file a user sent the bot, like one uploaded from the dashboard composer. */
|
|
53
|
+
const SHARED_FILE_INTENT = "user-upload";
|
|
54
|
+
/** A file an agent pulled from Slack with `slack-download-file` / `slack-read`. */
|
|
55
|
+
const FETCHED_FILE_INTENT = "slack-file";
|
|
56
|
+
const BUFFERED_REASON = "follow-ups queued by ADDITIVE_SLACK carry the file name only";
|
|
57
|
+
|
|
58
|
+
export type SlackFileFailure = { file: SlackFile; reason: string };
|
|
59
|
+
|
|
60
|
+
export type AttachedSlackFile = { file: SlackFile; attachment: TaskAttachment };
|
|
61
|
+
|
|
62
|
+
/** What happened to one file an agent asked to attach: the attachment and how to fetch it, or why not. */
|
|
63
|
+
export type SlackFileOutcome =
|
|
64
|
+
| { attachment: TaskAttachment; fetchCommand: string }
|
|
65
|
+
| { reason: string };
|
|
66
|
+
|
|
67
|
+
/** A downloaded Slack file, spooled to a temp file so a batch never sits in memory. */
|
|
68
|
+
export type FetchedSlackFile = { file: SlackFile; path: string; sizeBytes: number; sha256: string };
|
|
69
|
+
|
|
70
|
+
export type InboundSlackFiles = {
|
|
71
|
+
/** Every file on the message, with full metadata (resolved via `files.info` when the event omitted it). */
|
|
72
|
+
files: SlackFile[];
|
|
73
|
+
fetched: FetchedSlackFile[];
|
|
74
|
+
failed: SlackFileFailure[];
|
|
75
|
+
/** Deletes the downloaded copies; hold the batch with `await using`. */
|
|
76
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Error text safe to put in a task or a Slack message. */
|
|
80
|
+
function errorText(error: unknown): string {
|
|
81
|
+
return scrubSecrets(error instanceof Error ? error.message : String(error));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Format a file size in bytes to a human-readable string.
|
|
86
|
+
*/
|
|
87
|
+
export function formatFileSize(bytes: number): string {
|
|
88
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
89
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
90
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
91
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a text representation of file attachments for inclusion in messages.
|
|
96
|
+
* Each file is formatted as: [File: filename.ext (mimetype, size) id=FILE_ID],
|
|
97
|
+
* followed by `(not attached: <reason>)` when the file never made it into the
|
|
98
|
+
* task's attachments.
|
|
99
|
+
*/
|
|
100
|
+
export function buildAttachmentText(files: SlackFile[], failed: SlackFileFailure[] = []): string {
|
|
101
|
+
const reasons = new Map(failed.map((f) => [f.file.id, f.reason]));
|
|
102
|
+
return files
|
|
103
|
+
.map((f) => {
|
|
104
|
+
// A file only `files.info` could describe may still lack metadata.
|
|
105
|
+
const size = typeof f.size === "number" ? formatFileSize(f.size) : "unknown size";
|
|
106
|
+
const line = `[File: ${f.name ?? f.id} (${f.mimetype ?? "unknown type"}, ${size}) id=${f.id}]`;
|
|
107
|
+
const reason = reasons.get(f.id);
|
|
108
|
+
return reason ? `${line} (not attached: ${reason})` : line;
|
|
109
|
+
})
|
|
110
|
+
.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Build the effective message text from the original text and any file attachments.
|
|
115
|
+
* - Text only: returns the text as-is
|
|
116
|
+
* - Files only: returns the attachment metadata
|
|
117
|
+
* - Both: returns text followed by attachment metadata
|
|
118
|
+
*/
|
|
119
|
+
export function buildEffectiveText(
|
|
120
|
+
text: string | undefined,
|
|
121
|
+
files?: SlackFile[],
|
|
122
|
+
failed?: SlackFileFailure[],
|
|
123
|
+
): string {
|
|
124
|
+
const hasText = !!text?.trim();
|
|
125
|
+
const hasFiles = files && files.length > 0;
|
|
126
|
+
|
|
127
|
+
if (hasText && hasFiles) {
|
|
128
|
+
return `${text}\n\n${buildAttachmentText(files, failed)}`;
|
|
129
|
+
}
|
|
130
|
+
if (hasFiles) {
|
|
131
|
+
return buildAttachmentText(files, failed);
|
|
132
|
+
}
|
|
133
|
+
return text || "";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The files of a follow-up that `ADDITIVE_SLACK` queues instead of turning into
|
|
138
|
+
* a task right away: the queued text keeps their `[File: …]` lines, but they
|
|
139
|
+
* are not attached, so they're reported like any other unattached file.
|
|
140
|
+
*/
|
|
141
|
+
export function bufferedFileFailures(files: SlackFile[] | undefined): SlackFileFailure[] {
|
|
142
|
+
return (files ?? []).map((file) => ({ file, reason: BUFFERED_REASON }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Download every file on a Slack message with the bot token, each to its own
|
|
147
|
+
* temp file. Never throws: a file that can't be fetched lands in `failed` with
|
|
148
|
+
* a reason the user and the agent can read. Hold the result with `await using`
|
|
149
|
+
* so the temp files go away with it.
|
|
150
|
+
*/
|
|
151
|
+
export async function fetchSlackFiles(
|
|
152
|
+
client: WebClient,
|
|
153
|
+
files: SlackFile[] | undefined,
|
|
154
|
+
): Promise<InboundSlackFiles> {
|
|
155
|
+
let dir: string | undefined;
|
|
156
|
+
const result: InboundSlackFiles = {
|
|
157
|
+
files: [],
|
|
158
|
+
fetched: [],
|
|
159
|
+
failed: [],
|
|
160
|
+
async [Symbol.asyncDispose]() {
|
|
161
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
if (!files || files.length === 0) return result;
|
|
165
|
+
|
|
166
|
+
// Disk use is bounded by the per-file cap times the files on this message.
|
|
167
|
+
dir = await mkdtemp(join(tmpdir(), "slack-files-"));
|
|
168
|
+
const token = client.token ?? process.env.SLACK_BOT_TOKEN;
|
|
169
|
+
for (const [index, eventFile] of files.entries()) {
|
|
170
|
+
// Slack Connect and some file_share events carry only the file id.
|
|
171
|
+
const file = eventFile.url_private_download
|
|
172
|
+
? eventFile
|
|
173
|
+
: ((await getFileInfo(client, eventFile.id)) ?? eventFile);
|
|
174
|
+
result.files.push(file);
|
|
175
|
+
|
|
176
|
+
const outcome = await downloadSlackFile(file, token, join(dir, String(index)));
|
|
177
|
+
if (typeof outcome === "string") {
|
|
178
|
+
console.warn(`[Slack] could not fetch file ${file.id}: ${outcome}`);
|
|
179
|
+
result.failed.push({ file, reason: outcome });
|
|
180
|
+
} else {
|
|
181
|
+
result.fetched.push({ file, ...outcome });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Does the start of an HTML response read like Slack's sign-in page? */
|
|
188
|
+
function looksLikeSignIn(head: Uint8Array[]): boolean {
|
|
189
|
+
const joined = new Uint8Array(head.reduce((total, part) => total + part.byteLength, 0));
|
|
190
|
+
let offset = 0;
|
|
191
|
+
for (const part of head) {
|
|
192
|
+
joined.set(part, offset);
|
|
193
|
+
offset += part.byteLength;
|
|
194
|
+
}
|
|
195
|
+
const text = new TextDecoder().decode(joined);
|
|
196
|
+
return SIGN_IN_MARKERS.some((marker) => marker.test(text));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Stream one file to `path`, enforcing the attachment cap as bytes arrive, or
|
|
201
|
+
* return why it couldn't be fetched. Without `files:read` Slack serves its HTML
|
|
202
|
+
* sign-in page instead of the file, via a redirect or in place. So an HTML
|
|
203
|
+
* response counts as "not the file" when the file isn't HTML, when it came
|
|
204
|
+
* through a redirect off `/files-pri/`, when the body reads like the sign-in
|
|
205
|
+
* page, or when its size isn't the one Slack declared for the file. Non-HTML
|
|
206
|
+
* responses are never second-guessed.
|
|
207
|
+
*/
|
|
208
|
+
async function downloadSlackFile(
|
|
209
|
+
file: SlackFile,
|
|
210
|
+
token: string | undefined,
|
|
211
|
+
path: string,
|
|
212
|
+
): Promise<Omit<FetchedSlackFile, "file"> | string> {
|
|
213
|
+
const limit = `larger than the ${MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)} MB limit`;
|
|
214
|
+
if (file.size > MAX_TASK_ATTACHMENT_BYTES) return limit;
|
|
215
|
+
if (!file.url_private_download) return "Slack gave no download URL";
|
|
216
|
+
if (!token) return "no Slack bot token configured";
|
|
217
|
+
|
|
218
|
+
// Aborting — not just leaving the read loop — closes the connection, so the
|
|
219
|
+
// rest of an unwanted body stops arriving instead of being read and dropped.
|
|
220
|
+
const abort = new AbortController();
|
|
221
|
+
try {
|
|
222
|
+
const response = await fetch(file.url_private_download, {
|
|
223
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
224
|
+
signal: AbortSignal.any([abort.signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]),
|
|
225
|
+
});
|
|
226
|
+
if (!response.ok) return `download failed (HTTP ${response.status})`;
|
|
227
|
+
const isHtml = (response.headers.get("content-type") ?? "").startsWith("text/html");
|
|
228
|
+
const redirectedToHtml =
|
|
229
|
+
isHtml && response.redirected && !new URL(response.url).pathname.includes("/files-pri/");
|
|
230
|
+
if (redirectedToHtml || (isHtml && !file.mimetype?.startsWith("text/html"))) {
|
|
231
|
+
abort.abort();
|
|
232
|
+
return SIGNED_OUT_REASON;
|
|
233
|
+
}
|
|
234
|
+
if (Number(response.headers.get("content-length") ?? 0) > MAX_TASK_ATTACHMENT_BYTES) {
|
|
235
|
+
abort.abort();
|
|
236
|
+
return limit;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const hasher = new Bun.CryptoHasher("sha256");
|
|
240
|
+
const sink = Bun.file(path).writer({ highWaterMark: 1024 * 1024 });
|
|
241
|
+
let sizeBytes = 0;
|
|
242
|
+
let overCap = false;
|
|
243
|
+
const head: Uint8Array[] = [];
|
|
244
|
+
let headBytes = 0;
|
|
245
|
+
try {
|
|
246
|
+
for await (const chunk of response.body ?? []) {
|
|
247
|
+
sizeBytes += chunk.byteLength;
|
|
248
|
+
if (sizeBytes > MAX_TASK_ATTACHMENT_BYTES) {
|
|
249
|
+
overCap = true;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
if (isHtml && headBytes < SIGN_IN_SNIFF_BYTES) {
|
|
253
|
+
const wanted = chunk.subarray(0, SIGN_IN_SNIFF_BYTES - headBytes);
|
|
254
|
+
head.push(wanted);
|
|
255
|
+
headBytes += wanted.byteLength;
|
|
256
|
+
}
|
|
257
|
+
hasher.update(chunk);
|
|
258
|
+
sink.write(chunk);
|
|
259
|
+
}
|
|
260
|
+
} finally {
|
|
261
|
+
await sink.end();
|
|
262
|
+
}
|
|
263
|
+
if (overCap) {
|
|
264
|
+
abort.abort();
|
|
265
|
+
return limit;
|
|
266
|
+
}
|
|
267
|
+
if (isHtml && looksLikeSignIn(head)) return SIGNED_OUT_REASON;
|
|
268
|
+
if (isHtml && file.size > 0 && sizeBytes !== file.size) {
|
|
269
|
+
return `Slack sent ${sizeBytes} bytes of HTML for a ${file.size}-byte file, likely its sign-in page (is the files:read scope granted?)`;
|
|
270
|
+
}
|
|
271
|
+
return { path, sizeBytes, sha256: hasher.digest("hex") };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return `download failed (${errorText(error)})`;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Create a Slack-sourced task and attach the files fetched from its message.
|
|
279
|
+
* With files, the task is created in `draft` and promoted once every upload
|
|
280
|
+
* has settled — success or not — so a worker can't start before its
|
|
281
|
+
* attachments exist. `unattached` lists every file of the message that the
|
|
282
|
+
* task ended up without: the ones that couldn't be downloaded plus the ones
|
|
283
|
+
* that couldn't be stored.
|
|
284
|
+
*/
|
|
285
|
+
export async function createSlackTaskWithFiles(
|
|
286
|
+
description: string,
|
|
287
|
+
options: CreateTaskOptions,
|
|
288
|
+
inbound: InboundSlackFiles,
|
|
289
|
+
): Promise<{ task: AgentTask; unattached: SlackFileFailure[] }> {
|
|
290
|
+
if (inbound.fetched.length === 0) {
|
|
291
|
+
const task = await createTaskWithSiblingAwareness(description, options);
|
|
292
|
+
return { task, unattached: inbound.failed };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const task = await createTaskWithSiblingAwareness(description, { ...options, status: "draft" });
|
|
296
|
+
const unattached = [...inbound.failed];
|
|
297
|
+
// Uploads run one after another and can outlast the abandoned-draft sweep's
|
|
298
|
+
// window; renewing the lease keeps the sweep from promoting a live batch.
|
|
299
|
+
const lease = setInterval(() => {
|
|
300
|
+
void refreshDraftTaskLease(task.id).catch((error) =>
|
|
301
|
+
console.warn(
|
|
302
|
+
`[Slack] could not renew the draft lease of task ${task.id}: ${errorText(error)}`,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}, draftLeaseRefreshMs);
|
|
306
|
+
try {
|
|
307
|
+
unattached.push(...(await attachSlackFilesToTask(task.id, inbound.fetched, null)).unattached);
|
|
308
|
+
} finally {
|
|
309
|
+
clearInterval(lease);
|
|
310
|
+
await promoteDraftTask(task.id);
|
|
311
|
+
}
|
|
312
|
+
return { task, unattached };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Tests shorten the draft lease renewal to observe it; returns the previous interval. */
|
|
316
|
+
export function setDraftLeaseRefreshMsForTests(ms: number): number {
|
|
317
|
+
const previous = draftLeaseRefreshMs;
|
|
318
|
+
draftLeaseRefreshMs = ms;
|
|
319
|
+
return previous;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Store fetched Slack files as attachments of a task. `agentId` is the agent
|
|
324
|
+
* that fetched them, or `null` for files a user sent the bot.
|
|
325
|
+
*
|
|
326
|
+
* A file whose bytes the task already holds is reused instead of uploaded
|
|
327
|
+
* again. Each blob is keyed by its Slack file id, so two different files can
|
|
328
|
+
* never overwrite each other's bytes — not even from parallel tool calls.
|
|
329
|
+
* The attachment name stays the file's own, and gets the Slack file id as a
|
|
330
|
+
* prefix when another attachment already uses it (pasted screenshots are all
|
|
331
|
+
* called "image.png"). That is only for readability: concurrent calls can still
|
|
332
|
+
* pick the same name, and fetch commands never depend on it — each attachment
|
|
333
|
+
* downloads into its own directory.
|
|
334
|
+
*/
|
|
335
|
+
export async function attachSlackFilesToTask(
|
|
336
|
+
taskId: string,
|
|
337
|
+
fetched: InboundSlackFiles["fetched"],
|
|
338
|
+
agentId: string | null,
|
|
339
|
+
): Promise<{ attached: AttachedSlackFile[]; unattached: SlackFileFailure[] }> {
|
|
340
|
+
const existing = await getTaskAttachments(taskId);
|
|
341
|
+
const bySha = new Map(existing.flatMap((a) => (a.sha256 ? [[a.sha256, a] as const] : [])));
|
|
342
|
+
const usedNames = new Set(existing.map((a) => a.name));
|
|
343
|
+
const provider = getFileStorageProvider();
|
|
344
|
+
const attached: AttachedSlackFile[] = [];
|
|
345
|
+
const unattached: SlackFileFailure[] = [];
|
|
346
|
+
|
|
347
|
+
for (const { file, path, sizeBytes, sha256 } of fetched) {
|
|
348
|
+
const known = bySha.get(sha256);
|
|
349
|
+
if (known) {
|
|
350
|
+
attached.push({ file, attachment: known });
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
const name = usedNames.has(file.name) ? `${file.id}-${file.name}` : file.name;
|
|
354
|
+
usedNames.add(name);
|
|
355
|
+
const key = providerPath({ taskId, name: `slack-${file.id}-${file.name}` });
|
|
356
|
+
const scope = { taskId, name, key };
|
|
357
|
+
try {
|
|
358
|
+
const uploaded = await provider.upload(scope, Bun.file(path), {
|
|
359
|
+
contentType: file.mimetype,
|
|
360
|
+
sizeBytes,
|
|
361
|
+
message: `Upload ${name} from Slack for task ${taskId}`,
|
|
362
|
+
});
|
|
363
|
+
const attachment = await recordTaskAttachmentUpload({
|
|
364
|
+
provider,
|
|
365
|
+
scope,
|
|
366
|
+
uploaded,
|
|
367
|
+
sizeBytes,
|
|
368
|
+
sha256,
|
|
369
|
+
contentType: file.mimetype,
|
|
370
|
+
agentId,
|
|
371
|
+
intent: agentId ? FETCHED_FILE_INTENT : SHARED_FILE_INTENT,
|
|
372
|
+
description: agentId
|
|
373
|
+
? `Fetched from Slack (file ${file.id})`
|
|
374
|
+
: `Shared on Slack (file ${file.id})`,
|
|
375
|
+
});
|
|
376
|
+
bySha.set(sha256, attachment);
|
|
377
|
+
attached.push({ file, attachment });
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const reason = `could not be stored (${errorText(error)})`;
|
|
380
|
+
console.warn(`[Slack] file ${file.id} for task ${taskId} ${reason}`);
|
|
381
|
+
unattached.push({ file, reason });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return { attached, unattached };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Download Slack files and attach them to `task` on behalf of `agentId`
|
|
389
|
+
* (`slack-download-file`, `slack-read`), one file at a time so a long thread
|
|
390
|
+
* never holds more than one download. One outcome per Slack file id.
|
|
391
|
+
*/
|
|
392
|
+
export async function attachSlackFilesForAgent(
|
|
393
|
+
client: WebClient,
|
|
394
|
+
task: AgentTask,
|
|
395
|
+
files: SlackFile[],
|
|
396
|
+
agentId: string,
|
|
397
|
+
): Promise<Map<string, SlackFileOutcome>> {
|
|
398
|
+
const outcomes = new Map<string, SlackFileOutcome>();
|
|
399
|
+
for (const file of files) {
|
|
400
|
+
if (outcomes.has(file.id)) continue;
|
|
401
|
+
await using inbound = await fetchSlackFiles(client, [file]);
|
|
402
|
+
const { attached, unattached } = await attachSlackFilesToTask(
|
|
403
|
+
task.id,
|
|
404
|
+
inbound.fetched,
|
|
405
|
+
agentId,
|
|
406
|
+
);
|
|
407
|
+
for (const { file: failed, reason } of [...inbound.failed, ...unattached]) {
|
|
408
|
+
outcomes.set(failed.id, { reason });
|
|
409
|
+
}
|
|
410
|
+
for (const { file: stored, attachment } of attached) {
|
|
411
|
+
outcomes.set(stored.id, {
|
|
412
|
+
attachment,
|
|
413
|
+
fetchCommand: taskAttachmentFetchCommand(task.id, attachment.id, attachment.name),
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return outcomes;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* The task a Slack file tool may attach files to: `taskId` if the calling
|
|
422
|
+
* agent owns or created it, `null` otherwise (or when there is no task).
|
|
423
|
+
*/
|
|
424
|
+
export async function attachableTask(
|
|
425
|
+
agentId: string,
|
|
426
|
+
taskId: string | undefined,
|
|
427
|
+
): Promise<AgentTask | null> {
|
|
428
|
+
if (!taskId) return null;
|
|
429
|
+
const task = await getTaskById(taskId);
|
|
430
|
+
if (!task) return null;
|
|
431
|
+
return task.agentId === agentId || task.creatorAgentId === agentId ? task : null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Tell the user, in the thread, which of their files the agent won't see —
|
|
436
|
+
* one reply listing each file once, even when several tasks failed on it.
|
|
437
|
+
* Best-effort: a failed post is logged, never thrown.
|
|
438
|
+
*/
|
|
439
|
+
export async function notifySlackFileFailures(
|
|
440
|
+
client: WebClient,
|
|
441
|
+
channel: string,
|
|
442
|
+
threadTs: string,
|
|
443
|
+
failures: SlackFileFailure[],
|
|
444
|
+
): Promise<void> {
|
|
445
|
+
if (failures.length === 0) return;
|
|
446
|
+
const byFile = new Map(failures.map((f) => [f.file.id, f]));
|
|
447
|
+
const failedFiles = [...byFile.values()]
|
|
448
|
+
.map((f) => `\`${f.file.name}\` (${f.reason})`)
|
|
449
|
+
.join(", ");
|
|
450
|
+
const { text } = resolveTemplate("slack.message.attachment_failed", {
|
|
451
|
+
failed_files: failedFiles,
|
|
452
|
+
});
|
|
453
|
+
try {
|
|
454
|
+
await client.chat.postMessage({
|
|
455
|
+
channel,
|
|
456
|
+
thread_ts: threadTs,
|
|
457
|
+
text,
|
|
458
|
+
unfurl_links: false,
|
|
459
|
+
unfurl_media: false,
|
|
460
|
+
});
|
|
461
|
+
} catch (error) {
|
|
462
|
+
console.warn(
|
|
463
|
+
`[Slack] could not post the attachment-failure notice in ${channel}/${threadTs}: ${errorText(error)}`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
}
|
package/src/slack/templates.ts
CHANGED
|
@@ -39,6 +39,20 @@ registerTemplate({
|
|
|
39
39
|
category: "system",
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
registerTemplate({
|
|
43
|
+
eventType: "slack.message.attachment_failed",
|
|
44
|
+
header: "",
|
|
45
|
+
defaultBody:
|
|
46
|
+
":warning: Couldn't attach {{failed_files}} to the task. The agent sees the file name, not its contents.",
|
|
47
|
+
variables: [
|
|
48
|
+
{
|
|
49
|
+
name: "failed_files",
|
|
50
|
+
description: "Comma-separated `name` (reason) list of the files that were not attached",
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
category: "system",
|
|
54
|
+
});
|
|
55
|
+
|
|
42
56
|
registerTemplate({
|
|
43
57
|
eventType: "slack.message.thread_context",
|
|
44
58
|
header: "",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-commit side effects of a task reaching a terminal state.
|
|
3
|
+
*
|
|
4
|
+
* Extracted verbatim from `src/tools/store-progress.ts` so a second writer
|
|
5
|
+
* (`src/tools/defer-task.ts`) fires the identical set. Three blocks, all
|
|
6
|
+
* fire-and-forget: none of them may change the task's status or fail the
|
|
7
|
+
* caller's response.
|
|
8
|
+
*
|
|
9
|
+
* 1. task_completion memory write (+ swarm promotion for shared research)
|
|
10
|
+
* 2. server-side memory raters
|
|
11
|
+
* 3. lead follow-up task
|
|
12
|
+
*
|
|
13
|
+
* Call this AFTER the completion transaction commits. Callers own the
|
|
14
|
+
* "should this run at all" guards (idempotent re-calls, forced overwrites);
|
|
15
|
+
* this function assumes the terminal write really happened.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { getSessionLogsByTaskId } from "@/be/db";
|
|
19
|
+
import { getEmbeddingProvider, getMemoryStore } from "@/be/memory";
|
|
20
|
+
import { getRetrievalsForTask } from "@/be/memory/raters/retrieval";
|
|
21
|
+
import { runServerRaters } from "@/be/memory/raters/run-server-raters";
|
|
22
|
+
import { shouldPersistTaskCompletionMemory } from "@/memory/automatic-task-gate";
|
|
23
|
+
import { createWorkerTaskFollowUp } from "@/tasks/worker-follow-up";
|
|
24
|
+
import type { AgentTask } from "@/types";
|
|
25
|
+
import { scrubSecrets } from "@/utils/secret-scrubber";
|
|
26
|
+
|
|
27
|
+
export async function runTaskTerminalEffects(args: {
|
|
28
|
+
task: AgentTask;
|
|
29
|
+
status: "completed" | "failed";
|
|
30
|
+
output?: string;
|
|
31
|
+
failureReason?: string;
|
|
32
|
+
agentId?: string;
|
|
33
|
+
persistMemory?: boolean;
|
|
34
|
+
}): Promise<void> {
|
|
35
|
+
const { task, status, output, failureReason, agentId, persistMemory } = args;
|
|
36
|
+
const taskId = task.id;
|
|
37
|
+
|
|
38
|
+
// Index completed and failed tasks as memory (async, non-blocking).
|
|
39
|
+
// Automatic/recurring tasks are noisy by default; require explicit opt-in.
|
|
40
|
+
if (shouldPersistTaskCompletionMemory(task, persistMemory)) {
|
|
41
|
+
(async () => {
|
|
42
|
+
try {
|
|
43
|
+
const taskContent =
|
|
44
|
+
status === "completed"
|
|
45
|
+
? `Task: ${task.task}\n\nOutput:\n${output || "(no output)"}`
|
|
46
|
+
: `Task: ${task.task}\n\nFailure reason:\n${failureReason || "No reason provided"}\n\nThis task failed. Learn from this to avoid repeating the mistake.`;
|
|
47
|
+
|
|
48
|
+
// Skip indexing if there's truly no content
|
|
49
|
+
if (taskContent.length < 30) return;
|
|
50
|
+
|
|
51
|
+
const store = getMemoryStore();
|
|
52
|
+
const provider = getEmbeddingProvider();
|
|
53
|
+
|
|
54
|
+
const memory = await store.store({
|
|
55
|
+
agentId: agentId ?? null,
|
|
56
|
+
content: taskContent,
|
|
57
|
+
name: `Task: ${task.task.slice(0, 80)}`,
|
|
58
|
+
scope: "agent",
|
|
59
|
+
source: "task_completion",
|
|
60
|
+
sourceTaskId: taskId,
|
|
61
|
+
});
|
|
62
|
+
const embedding = await provider.embed(taskContent);
|
|
63
|
+
if (embedding) {
|
|
64
|
+
await store.updateEmbedding(memory.id, embedding, provider.name);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Auto-promote high-value completions to swarm memory (P3)
|
|
68
|
+
const shouldShareWithSwarm =
|
|
69
|
+
status === "completed" &&
|
|
70
|
+
(task.taskType === "research" ||
|
|
71
|
+
task.tags?.includes("knowledge") ||
|
|
72
|
+
task.tags?.includes("shared"));
|
|
73
|
+
|
|
74
|
+
if (shouldShareWithSwarm) {
|
|
75
|
+
try {
|
|
76
|
+
const swarmMemory = await store.store({
|
|
77
|
+
agentId: agentId ?? null,
|
|
78
|
+
scope: "swarm",
|
|
79
|
+
name: `Shared: ${task.task.slice(0, 80)}`,
|
|
80
|
+
content: `Task completed by agent ${agentId}:\n\n${taskContent}`,
|
|
81
|
+
source: "task_completion",
|
|
82
|
+
sourceTaskId: taskId,
|
|
83
|
+
});
|
|
84
|
+
const swarmEmbedding = await provider.embed(taskContent);
|
|
85
|
+
if (swarmEmbedding) {
|
|
86
|
+
await store.updateEmbedding(swarmMemory.id, swarmEmbedding, provider.name);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// Non-blocking — swarm memory promotion failure is not critical
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} catch {
|
|
93
|
+
// Non-blocking — task completion memory failure should not affect task status
|
|
94
|
+
}
|
|
95
|
+
})().catch((err) =>
|
|
96
|
+
console.error(
|
|
97
|
+
"[task-terminal-effects] task completion memory write failed:",
|
|
98
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
99
|
+
),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Memory rater v1.5 — fire server-side raters on task completion.
|
|
104
|
+
// Plan: thoughts/taras/plans/2026-05-05-memory-rater-v1.5/step-2.md §5
|
|
105
|
+
//
|
|
106
|
+
// Read `memory_retrieval` rows for this task + concatenated session_logs
|
|
107
|
+
// and hand both to `runServerRaters`, which iterates the allow-listed
|
|
108
|
+
// server raters (currently just `implicit-citation`), stamps source,
|
|
109
|
+
// applies the configured weight multiplier, and persists via
|
|
110
|
+
// `applyRating`. The orchestration is extracted so it can be unit-tested
|
|
111
|
+
// with stub raters (see `src/tests/run-server-raters.test.ts`).
|
|
112
|
+
//
|
|
113
|
+
// Fire-and-forget: rater failure must NEVER affect task status.
|
|
114
|
+
(async () => {
|
|
115
|
+
try {
|
|
116
|
+
const retrievals = await getRetrievalsForTask(taskId);
|
|
117
|
+
if (retrievals.length === 0) return;
|
|
118
|
+
|
|
119
|
+
const retrievedMemoryIds = retrievals.map((r) => r.memoryId);
|
|
120
|
+
const logs = await getSessionLogsByTaskId(taskId);
|
|
121
|
+
const evidence = logs.map((l) => l.content).join("\n");
|
|
122
|
+
|
|
123
|
+
await runServerRaters({
|
|
124
|
+
taskId,
|
|
125
|
+
agentId: agentId ?? "",
|
|
126
|
+
retrievedMemoryIds,
|
|
127
|
+
evidence,
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error(
|
|
131
|
+
"[task-terminal-effects] server-rater fire failed:",
|
|
132
|
+
err instanceof Error ? err.message : String(err),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
})().catch((err) =>
|
|
136
|
+
console.error(
|
|
137
|
+
"[task-terminal-effects] server rater run failed:",
|
|
138
|
+
scrubSecrets(err instanceof Error ? err.message : String(err)),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Create follow-up task for the lead when a worker task finishes.
|
|
143
|
+
// This replaces the old poll-based tasks_finished trigger which was unreliable.
|
|
144
|
+
// Skip for workflow-managed tasks — the workflow engine handles sequencing via resume.ts.
|
|
145
|
+
if (!task.workflowRunId) {
|
|
146
|
+
try {
|
|
147
|
+
const followUp = await createWorkerTaskFollowUp({ task, status, output, failureReason });
|
|
148
|
+
if (followUp) {
|
|
149
|
+
console.log(
|
|
150
|
+
`[task-terminal-effects] Created follow-up task ${followUp.id.slice(0, 8)} for ${status} task ${taskId.slice(0, 8)}`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// Non-blocking — follow-up task creation failure should not affect the caller's response
|
|
155
|
+
console.warn(`[task-terminal-effects] Failed to create follow-up task: ${err}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|