@desplega.ai/agent-swarm 1.90.0 → 1.92.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/openapi.json +803 -150
- package/package.json +5 -5
- package/src/artifact-sdk/server.ts +2 -1
- package/src/be/db.ts +337 -1
- package/src/be/memory/providers/sqlite-store.ts +6 -1
- package/src/be/memory/types.ts +1 -0
- package/src/be/migrations/083_script_workflows.sql +51 -0
- package/src/be/modelsdev-cache.json +42352 -38595
- package/src/be/scripts/typecheck.ts +181 -1
- package/src/be/seed-scripts/catalog/compound-insights.ts +398 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +911 -0
- package/src/be/seed-scripts/catalog/schedule-health.ts +73 -0
- package/src/be/seed-scripts/catalog/smart-recall.ts +65 -0
- package/src/be/seed-scripts/catalog/task-context-gathering.ts +92 -0
- package/src/be/seed-scripts/catalog/tool-usage.ts +59 -0
- package/src/be/seed-scripts/index.ts +54 -0
- package/src/be/seed-skills/index.ts +7 -0
- package/src/be/swarm-config-guard.ts +17 -0
- package/src/commands/artifact.ts +3 -2
- package/src/commands/profile-sync.ts +310 -0
- package/src/commands/runner.ts +134 -3
- package/src/hooks/hook.ts +32 -9
- package/src/http/db-query.ts +20 -5
- package/src/http/index.ts +57 -0
- package/src/http/integrations.ts +6 -1
- package/src/http/mcp-bridge.ts +117 -0
- package/src/http/mcp-oauth.ts +97 -39
- package/src/http/memory.ts +5 -2
- package/src/http/openapi.ts +2 -2
- package/src/http/pages-public.ts +10 -11
- package/src/http/pages.ts +7 -11
- package/src/http/script-runs.ts +555 -0
- package/src/http/scripts.ts +24 -1
- package/src/http/utils.ts +11 -4
- package/src/jira/app.ts +2 -3
- package/src/jira/webhook-lifecycle.ts +2 -1
- package/src/linear/app.ts +2 -3
- package/src/prompts/session-templates.ts +24 -4
- package/src/providers/claude-adapter.ts +86 -13
- package/src/script-workflows/executor.ts +110 -0
- package/src/script-workflows/harness.ts +73 -0
- package/src/script-workflows/label-lint.ts +51 -0
- package/src/script-workflows/limits.ts +22 -0
- package/src/script-workflows/supervisor.ts +139 -0
- package/src/script-workflows/workflow-ctx.ts +205 -0
- package/src/scripts-runtime/executors/native.ts +1 -0
- package/src/scripts-runtime/sdk-allowlist.ts +124 -0
- package/src/scripts-runtime/swarm-sdk.ts +198 -3
- package/src/scripts-runtime/types/stdlib.d.ts +287 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +287 -0
- package/src/server.ts +2 -0
- package/src/slack/handlers.ts +11 -4
- package/src/slack/message-text.ts +98 -0
- package/src/slack/thread-buffer.ts +5 -3
- package/src/tests/claude-adapter-binary.test.ts +147 -4
- package/src/tests/claude-adapter-otel.test.ts +85 -1
- package/src/tests/db-query.test.ts +28 -0
- package/src/tests/error-tracker.test.ts +121 -0
- package/src/tests/harness-provider-resolution.test.ts +33 -0
- package/src/tests/hook-registration-nudge.test.ts +69 -0
- package/src/tests/mcp-oauth-manual-client.test.ts +213 -0
- package/src/tests/mcp-tools.test.ts +6 -0
- package/src/tests/pages-public-html.test.ts +41 -0
- package/src/tests/pages-public-json-redirect.test.ts +37 -2
- package/src/tests/profile-sync.test.ts +282 -0
- package/src/tests/prompt-template-session.test.ts +34 -5
- package/src/tests/script-runs-http.test.ts +278 -0
- package/src/tests/script-workflows-label-lint.test.ts +43 -0
- package/src/tests/script-workflows-runtime-e2e.test.ts +170 -0
- package/src/tests/scripts-mcp-e2e.test.ts +49 -2
- package/src/tests/scripts-runtime.test.ts +33 -0
- package/src/tests/seed-scripts.test.ts +347 -2
- package/src/tests/slack-message-text.test.ts +250 -0
- package/src/tests/system-default-skills.test.ts +40 -0
- package/src/tools/create-metric.ts +2 -3
- package/src/tools/create-page.ts +3 -6
- package/src/tools/db-query.ts +16 -6
- package/src/tools/memory-rate.ts +2 -1
- package/src/tools/memory-search.ts +1 -0
- package/src/tools/register-kapso-number.ts +2 -4
- package/src/tools/request-human-input.ts +2 -1
- package/src/tools/script-common.ts +2 -4
- package/src/tools/script-run.ts +7 -0
- package/src/tools/script-runs.ts +123 -0
- package/src/tools/slack-read.ts +12 -3
- package/src/tools/tool-config.ts +4 -1
- package/src/types.ts +52 -0
- package/src/utils/constants.ts +58 -8
- package/src/utils/error-tracker.ts +40 -1
- package/src/utils/internal-ai/complete-structured.ts +10 -4
- package/src/workflows/executors/raw-llm.ts +76 -59
- package/templates/skills/pages/content.md +205 -55
- package/templates/skills/script-workflows/config.json +14 -0
- package/templates/skills/script-workflows/content.md +68 -0
- package/templates/skills/swarm-scripts/content.md +45 -7
|
@@ -378,11 +378,9 @@ registerTemplate({
|
|
|
378
378
|
|
|
379
379
|
You have access to the \`context-mode\` MCP tools (\`batch_execute\`, \`execute\`, \`execute_file\`, \`search\`, \`fetch_and_index\`, \`index\`) which compress tool output to save context window space. For data-heavy operations (web fetches, large file reads, CLI output processing), prefer these over raw Bash/WebFetch to avoid flooding your context window with raw output.
|
|
380
380
|
|
|
381
|
-
When a tool returns more than a few dozen lines — JSON payloads, log tails, search results, API responses — route it through \`ctx_execute\` or \`ctx_batch_execute\` so only the derived answer enters your conversation. This is especially important for tasks that make many Bash/Read/MCP calls in sequence; each raw response compounds context pressure.
|
|
382
|
-
|
|
383
381
|
### Agent Scripts — for bulk, repetitive, or data-heavy work
|
|
384
382
|
|
|
385
|
-
Use **scripts** (\`script-upsert\` + \`script-run\`) when a task involves repetitive SDK calls, large data processing, or deterministic multi-step pipelines. Scripts run out-of-process and return only their final result
|
|
383
|
+
Use **scripts** (\`script-upsert\` + \`script-run\`) when a task involves repetitive SDK calls, large data processing, or deterministic multi-step pipelines. Scripts run out-of-process and return only their final result.
|
|
386
384
|
|
|
387
385
|
**Decision rubric — when to use scripts vs. other approaches:**
|
|
388
386
|
|
|
@@ -394,7 +392,6 @@ Use **scripts** (\`script-upsert\` + \`script-run\`) when a task involves repeti
|
|
|
394
392
|
| Single expensive web fetch | \`ctx_fetch_and_index\` (context-mode) |
|
|
395
393
|
| Multi-agent fan-out, parallel work, deterministic pipeline | **Workflow** |
|
|
396
394
|
| One-off bash/TS with no reuse needed | \`code-mode run\` (Bash) |
|
|
397
|
-
| Same logic needed across sessions/agents | **Named script** (\`script-upsert\` + reuse) |
|
|
398
395
|
|
|
399
396
|
The 5 script tools (\`script-search\`, \`script-run\`, \`script-upsert\`, \`script-delete\`, \`script-query-types\`) are deferred tools. Call ToolSearch to load \`script-upsert\`, \`script-run\`, and \`script-query-types\` before using them.
|
|
400
397
|
|
|
@@ -407,6 +404,27 @@ The 5 script tools (\`script-search\`, \`script-run\`, \`script-upsert\`, \`scri
|
|
|
407
404
|
category: "system",
|
|
408
405
|
});
|
|
409
406
|
|
|
407
|
+
registerTemplate({
|
|
408
|
+
eventType: "system.agent.seed_scripts",
|
|
409
|
+
header: "",
|
|
410
|
+
defaultBody: `
|
|
411
|
+
### Pre-built Seed Scripts
|
|
412
|
+
|
|
413
|
+
The swarm ships named scripts at global scope — each replaces a multi-step tool chain.
|
|
414
|
+
See the \`swarm-scripts\` skill for the full catalog, signatures, and usage patterns.
|
|
415
|
+
|
|
416
|
+
**At every task start (do this FIRST):** Run \`task-context-gathering\` with the task ID and 2-4 queries from the task description. Returns a slimmed task projection + deduped memories in one call — replaces task_get + memory_search loops.
|
|
417
|
+
|
|
418
|
+
**At task start when you only need memory:** Run \`smart-recall\` with 2-4 search angles from the task description.
|
|
419
|
+
|
|
420
|
+
**For any other repetitive multi-tool pattern:** Call \`script-search\` with a natural-language description. If a script exists, use it.
|
|
421
|
+
|
|
422
|
+
Script tools are deferred — load via \`ToolSearch("select:script-search,script-run")\` before first use.
|
|
423
|
+
`,
|
|
424
|
+
variables: [],
|
|
425
|
+
category: "system",
|
|
426
|
+
});
|
|
427
|
+
|
|
410
428
|
// system.agent.guidelines removed — its content (communication etiquette, todos)
|
|
411
429
|
// is covered by worker/lead templates and filesystem template respectively.
|
|
412
430
|
|
|
@@ -602,6 +620,7 @@ registerTemplate({
|
|
|
602
620
|
{{@template[system.agent.filesystem]}}
|
|
603
621
|
{{@template[system.agent.self_awareness]}}
|
|
604
622
|
{{@template[system.agent.context_mode]}}
|
|
623
|
+
{{@template[system.agent.seed_scripts]}}
|
|
605
624
|
|
|
606
625
|
{{@template[system.agent.system]}}
|
|
607
626
|
{{@template[system.agent.share_urls]}}
|
|
@@ -623,6 +642,7 @@ registerTemplate({
|
|
|
623
642
|
{{@template[system.agent.filesystem]}}
|
|
624
643
|
{{@template[system.agent.self_awareness]}}
|
|
625
644
|
{{@template[system.agent.context_mode]}}
|
|
645
|
+
{{@template[system.agent.seed_scripts]}}
|
|
626
646
|
|
|
627
647
|
{{@template[system.agent.system]}}
|
|
628
648
|
{{@template[system.agent.share_urls]}}
|
|
@@ -108,6 +108,45 @@ export function resolveClaudeBinary(
|
|
|
108
108
|
return candidate || "claude";
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
const CLAUDE_BRIDGE_BINARY = "claude-bridge";
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse a boolean env toggle. Only true/1 enable and false/0 disable; unset
|
|
115
|
+
* and invalid values are treated as disabled.
|
|
116
|
+
*
|
|
117
|
+
* Exported for unit testing.
|
|
118
|
+
*/
|
|
119
|
+
export function parseClaudeBridgeEnabled(raw: string | undefined): boolean {
|
|
120
|
+
const normalized = raw?.trim().toLowerCase();
|
|
121
|
+
return normalized === "true" || normalized === "1";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Resolve the reloadable claude-bridge toggle from the same resolved-env
|
|
126
|
+
* overlay used for `CLAUDE_BINARY`.
|
|
127
|
+
*
|
|
128
|
+
* Exported for unit testing.
|
|
129
|
+
*/
|
|
130
|
+
export function resolveClaudeBridgeEnabled(
|
|
131
|
+
resolvedEnv: Record<string, string | undefined>,
|
|
132
|
+
fallbackEnv: Record<string, string | undefined> = process.env,
|
|
133
|
+
): boolean {
|
|
134
|
+
const candidate =
|
|
135
|
+
resolvedEnv.SWARM_USE_CLAUDE_BRIDGE?.trim() || fallbackEnv.SWARM_USE_CLAUDE_BRIDGE?.trim();
|
|
136
|
+
return parseClaudeBridgeEnabled(candidate);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function resolveClaudeBinaryArgv(
|
|
140
|
+
resolvedEnv: Record<string, string | undefined>,
|
|
141
|
+
fallbackEnv: Record<string, string | undefined> = process.env,
|
|
142
|
+
): { raw: string; argv: string[]; useClaudeBridge: boolean } {
|
|
143
|
+
const useClaudeBridge = resolveClaudeBridgeEnabled(resolvedEnv, fallbackEnv);
|
|
144
|
+
const raw = useClaudeBridge
|
|
145
|
+
? CLAUDE_BRIDGE_BINARY
|
|
146
|
+
: resolveClaudeBinary(resolvedEnv, fallbackEnv);
|
|
147
|
+
return { raw, argv: parseClaudeBinary(raw), useClaudeBridge };
|
|
148
|
+
}
|
|
149
|
+
|
|
111
150
|
/**
|
|
112
151
|
* Pre-seed `~/.claude.json` so the per-project trust-dialog ("Quick safety
|
|
113
152
|
* check: Is this a project you trust?") doesn't block on first run.
|
|
@@ -336,6 +375,30 @@ export function buildClaudeCodeOtelEnv(
|
|
|
336
375
|
return otelEnv;
|
|
337
376
|
}
|
|
338
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Claude Code runtime defaults for ephemeral swarm harness sessions.
|
|
380
|
+
*
|
|
381
|
+
* These are plain subprocess env vars, not prompt content. They are injected
|
|
382
|
+
* after the resolved swarm config so the worker enforces the memory/privacy
|
|
383
|
+
* guardrails consistently per spawn. Statsig/DNT opt-out is intentionally
|
|
384
|
+
* separate from our Claude Code OTel export path, which is controlled by
|
|
385
|
+
* buildClaudeCodeOtelEnv.
|
|
386
|
+
*/
|
|
387
|
+
export function buildClaudeCodeRuntimeEnv(
|
|
388
|
+
_sourceEnv: Record<string, string | undefined>,
|
|
389
|
+
): Record<string, string> {
|
|
390
|
+
return {
|
|
391
|
+
ENABLE_TOOL_SEARCH: "true",
|
|
392
|
+
CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING: "1",
|
|
393
|
+
CLAUDE_CODE_SKIP_PROMPT_HISTORY: "1",
|
|
394
|
+
CLAUDE_CODE_DISABLE_ATTACHMENTS: "1",
|
|
395
|
+
DISABLE_TELEMETRY: "1",
|
|
396
|
+
DO_NOT_TRACK: "1",
|
|
397
|
+
DISABLE_FEEDBACK_COMMAND: "1",
|
|
398
|
+
DISABLE_BUG_COMMAND: "1",
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
339
402
|
/**
|
|
340
403
|
* Resolve the path at which the per-task system prompt is staged on disk.
|
|
341
404
|
*
|
|
@@ -398,11 +461,13 @@ class ClaudeSession implements ProviderSession {
|
|
|
398
461
|
// so the freshly-computed TRACEPARENT wins over any stale value the
|
|
399
462
|
// container env might carry.
|
|
400
463
|
const otelEnv = buildClaudeCodeOtelEnv(sourceEnv);
|
|
464
|
+
const runtimeEnv = buildClaudeCodeRuntimeEnv(sourceEnv);
|
|
401
465
|
this.proc = Bun.spawn(cmd, {
|
|
402
466
|
cwd: this.config.cwd,
|
|
403
467
|
env: {
|
|
404
468
|
ENABLE_PROMPT_CACHING_1H: "1",
|
|
405
469
|
...sourceEnv,
|
|
470
|
+
...runtimeEnv,
|
|
406
471
|
...otelEnv,
|
|
407
472
|
TASK_FILE: taskFilePath,
|
|
408
473
|
// Belt-and-braces: TASK_FILE on disk can disappear mid-session (race
|
|
@@ -771,12 +836,9 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
771
836
|
console.log(`\x1b[2m[claude]\x1b[0m Using credential: ${credType}`);
|
|
772
837
|
|
|
773
838
|
// Resolve the argv prefix. Same flags (`-p`, `--model`, ...) work across
|
|
774
|
-
// alternates; only argv[0..n] changes.
|
|
775
|
-
//
|
|
776
|
-
//
|
|
777
|
-
// Setting it to anything containing `shannon` opts into the dexhorthy/shannon
|
|
778
|
-
// variant, which drives `claude` interactively in tmux to stay on the
|
|
779
|
-
// subscription credit pool after the 2026-06-15 programmatic-credit split.
|
|
839
|
+
// alternates; only argv[0..n] changes. Prefer SWARM_USE_CLAUDE_BRIDGE=true
|
|
840
|
+
// for the Desplega-owned bridge. CLAUDE_BINARY remains as the low-level
|
|
841
|
+
// override for custom binaries and the deprecated shannon path.
|
|
780
842
|
//
|
|
781
843
|
// `config.env` carries the swarm_config overlay (resolved repo > agent > global
|
|
782
844
|
// by `fetchResolvedEnv` in src/commands/runner.ts), so operators can flip
|
|
@@ -785,19 +847,30 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
785
847
|
//
|
|
786
848
|
// See `docs-site/.../shannon-experimental.mdx` for the user-facing guide
|
|
787
849
|
// and `runbooks/harness-providers.md` for engineering notes.
|
|
788
|
-
const
|
|
789
|
-
|
|
850
|
+
const {
|
|
851
|
+
raw: claudeBinaryRaw,
|
|
852
|
+
argv: claudeBinaryArgv,
|
|
853
|
+
useClaudeBridge,
|
|
854
|
+
} = resolveClaudeBinaryArgv(config.env || process.env);
|
|
790
855
|
const isShannon = claudeBinaryRaw.toLowerCase().includes("shannon");
|
|
856
|
+
const configuredClaudeBinaryRaw = resolveClaudeBinary(config.env || process.env);
|
|
857
|
+
if (configuredClaudeBinaryRaw.toLowerCase().includes("shannon")) {
|
|
858
|
+
console.warn(
|
|
859
|
+
"\x1b[33m[claude]\x1b[0m CLAUDE_BINARY=shannon is deprecated; set SWARM_USE_CLAUDE_BRIDGE=true to use @desplega.ai/claude-bridge.",
|
|
860
|
+
);
|
|
861
|
+
}
|
|
791
862
|
|
|
792
863
|
console.log(
|
|
793
|
-
`\x1b[2m[${config.role}]\x1b[0m Resolved
|
|
864
|
+
`\x1b[2m[${config.role}]\x1b[0m Resolved claude binary: ${claudeBinaryArgv.join(" ")} (useClaudeBridge: ${useClaudeBridge}, isShannon: ${isShannon})`,
|
|
794
865
|
);
|
|
795
866
|
|
|
796
|
-
// Fail fast: shannon
|
|
797
|
-
// clear error here rather than letting
|
|
798
|
-
|
|
867
|
+
// Fail fast: shannon and claude-bridge both shell out to tmux. If it's
|
|
868
|
+
// missing, surface a clear error here rather than letting startup fail
|
|
869
|
+
// opaquely.
|
|
870
|
+
if ((isShannon || useClaudeBridge) && !Bun.which("tmux")) {
|
|
871
|
+
const label = useClaudeBridge ? "SWARM_USE_CLAUDE_BRIDGE=true" : "CLAUDE_BINARY=shannon";
|
|
799
872
|
throw new Error(
|
|
800
|
-
|
|
873
|
+
`${label} requires 'tmux' on PATH (install via apt/brew). See runbooks/harness-providers.md.`,
|
|
801
874
|
);
|
|
802
875
|
}
|
|
803
876
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import type { ScriptRun } from "../types";
|
|
5
|
+
|
|
6
|
+
export type ScriptExecutionResult = {
|
|
7
|
+
exitCode: number | null;
|
|
8
|
+
stderr: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type ScriptExecutionHandle = {
|
|
12
|
+
pid: number | null;
|
|
13
|
+
tmpdir: string;
|
|
14
|
+
startedAtMs: number;
|
|
15
|
+
exited: Promise<ScriptExecutionResult>;
|
|
16
|
+
terminate(signal?: NodeJS.Signals): void;
|
|
17
|
+
cleanup(): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type StartScriptExecutionInput = {
|
|
21
|
+
run: ScriptRun;
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
apiKey: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface ScriptExecutor {
|
|
27
|
+
start(input: StartScriptExecutionInput): Promise<ScriptExecutionHandle>;
|
|
28
|
+
isRunning(pid: number): boolean;
|
|
29
|
+
terminatePid(pid: number, signal?: NodeJS.Signals): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getScriptWorkflowHarnessPath(): string {
|
|
33
|
+
const runtimeDir = process.env.SCRIPT_WORKFLOW_RUNTIME_DIR;
|
|
34
|
+
if (!runtimeDir) return new URL("./harness.ts", import.meta.url).pathname;
|
|
35
|
+
|
|
36
|
+
const bundledHarness = `${resolve(runtimeDir)}/harness.bundle.js`;
|
|
37
|
+
if (!existsSync(bundledHarness)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Script workflow harness bundle not found at ${bundledHarness}. ` +
|
|
40
|
+
"Build/copy harness.bundle.js and set SCRIPT_WORKFLOW_RUNTIME_DIR to its directory.",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return bundledHarness;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class LocalProcessScriptExecutor implements ScriptExecutor {
|
|
47
|
+
async start(input: StartScriptExecutionInput): Promise<ScriptExecutionHandle> {
|
|
48
|
+
const { run, baseUrl, apiKey } = input;
|
|
49
|
+
const tmpdir = `${process.env.TMPDIR ?? "/tmp"}/script-workflow-${run.id}`;
|
|
50
|
+
await mkdir(tmpdir, { recursive: true });
|
|
51
|
+
const sourceFile = `${tmpdir}/source.ts`;
|
|
52
|
+
const argsFile = `${tmpdir}/args.json`;
|
|
53
|
+
await Bun.write(sourceFile, run.source);
|
|
54
|
+
await Bun.write(argsFile, JSON.stringify(run.args ?? null));
|
|
55
|
+
|
|
56
|
+
const proc = Bun.spawn(["bun", "run", getScriptWorkflowHarnessPath()], {
|
|
57
|
+
cwd: tmpdir,
|
|
58
|
+
stdin: "ignore",
|
|
59
|
+
stdout: "ignore",
|
|
60
|
+
stderr: "pipe",
|
|
61
|
+
env: {
|
|
62
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
63
|
+
HOME: process.env.HOME ?? "/tmp",
|
|
64
|
+
LANG: process.env.LANG ?? "C.UTF-8",
|
|
65
|
+
LC_ALL: process.env.LC_ALL ?? "C.UTF-8",
|
|
66
|
+
TMPDIR: tmpdir,
|
|
67
|
+
AGENT_SWARM_API_KEY: apiKey,
|
|
68
|
+
MCP_BASE_URL: baseUrl,
|
|
69
|
+
SCRIPT_RUN_ID: run.id,
|
|
70
|
+
SCRIPT_RUN_AGENT_ID: run.agentId,
|
|
71
|
+
SCRIPT_RUN_TMPDIR: tmpdir,
|
|
72
|
+
SCRIPT_RUN_SOURCE_FILE: sourceFile,
|
|
73
|
+
SCRIPT_RUN_ARGS_FILE: argsFile,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const stderrPromise = new Response(proc.stderr).text().catch(() => "");
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
pid: proc.pid,
|
|
81
|
+
tmpdir,
|
|
82
|
+
startedAtMs: Date.now(),
|
|
83
|
+
exited: proc.exited.then(async (exitCode) => ({
|
|
84
|
+
exitCode,
|
|
85
|
+
stderr: await stderrPromise,
|
|
86
|
+
})),
|
|
87
|
+
terminate: (signal = "SIGTERM") => {
|
|
88
|
+
proc.kill(signal);
|
|
89
|
+
},
|
|
90
|
+
cleanup: async () => {
|
|
91
|
+
await rm(tmpdir, { recursive: true, force: true });
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
isRunning(pid: number): boolean {
|
|
97
|
+
try {
|
|
98
|
+
process.kill(pid, 0);
|
|
99
|
+
return true;
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
terminatePid(pid: number, signal: NodeJS.Signals = "SIGTERM"): void {
|
|
106
|
+
process.kill(pid, signal);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const localProcessScriptExecutor = new LocalProcessScriptExecutor();
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { buildWorkflowCtx } from "./workflow-ctx";
|
|
2
|
+
|
|
3
|
+
function requiredEnv(name: string): string {
|
|
4
|
+
const value = process.env[name];
|
|
5
|
+
if (!value) throw new Error(`Missing required env ${name}`);
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async function postStatus(
|
|
10
|
+
runId: string,
|
|
11
|
+
baseUrl: string,
|
|
12
|
+
agentId: string,
|
|
13
|
+
apiKey: string,
|
|
14
|
+
body: Record<string, unknown>,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const res = await fetch(`${baseUrl}/api/internal/script-runs/${runId}/status`, {
|
|
17
|
+
method: "POST",
|
|
18
|
+
headers: {
|
|
19
|
+
Authorization: `Bearer ${apiKey}`,
|
|
20
|
+
"X-Agent-ID": agentId,
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
},
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
throw new Error(`status callback failed with ${res.status}: ${await res.text()}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const runId = requiredEnv("SCRIPT_RUN_ID");
|
|
31
|
+
const agentId = requiredEnv("SCRIPT_RUN_AGENT_ID");
|
|
32
|
+
const apiKey = requiredEnv("AGENT_SWARM_API_KEY");
|
|
33
|
+
const baseUrl = requiredEnv("MCP_BASE_URL").replace(/\/$/, "");
|
|
34
|
+
const sourceFile = requiredEnv("SCRIPT_RUN_SOURCE_FILE");
|
|
35
|
+
const argsFile = requiredEnv("SCRIPT_RUN_ARGS_FILE");
|
|
36
|
+
const userModulePath = `${requiredEnv("SCRIPT_RUN_TMPDIR")}/user-script.ts`;
|
|
37
|
+
|
|
38
|
+
const heartbeat = setInterval(() => {
|
|
39
|
+
fetch(`${baseUrl}/api/internal/script-runs/${runId}/heartbeat`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: `Bearer ${apiKey}`,
|
|
43
|
+
"X-Agent-ID": agentId,
|
|
44
|
+
},
|
|
45
|
+
}).catch(() => {});
|
|
46
|
+
}, 10_000);
|
|
47
|
+
heartbeat.unref?.();
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const source = await Bun.file(sourceFile).text();
|
|
51
|
+
const args = JSON.parse(await Bun.file(argsFile).text());
|
|
52
|
+
await Bun.write(userModulePath, source);
|
|
53
|
+
const mod = await import(userModulePath);
|
|
54
|
+
if (typeof mod.default !== "function") {
|
|
55
|
+
throw new Error("Script workflow must export a default function");
|
|
56
|
+
}
|
|
57
|
+
const ctx = buildWorkflowCtx({ runId, agentId, apiKey, baseUrl, args });
|
|
58
|
+
const output = await mod.default(args, ctx);
|
|
59
|
+
await postStatus(runId, baseUrl, agentId, apiKey, {
|
|
60
|
+
status: "completed",
|
|
61
|
+
output: output ?? null,
|
|
62
|
+
});
|
|
63
|
+
process.exit(0);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(err instanceof Error ? err.stack || err.message : String(err));
|
|
66
|
+
await postStatus(runId, baseUrl, agentId, apiKey, {
|
|
67
|
+
status: "failed",
|
|
68
|
+
error: err instanceof Error ? err.message : String(err),
|
|
69
|
+
});
|
|
70
|
+
process.exit(1);
|
|
71
|
+
} finally {
|
|
72
|
+
clearInterval(heartbeat);
|
|
73
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type LabelLintError = {
|
|
2
|
+
label: string;
|
|
3
|
+
lineNumber: number | null;
|
|
4
|
+
detail: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type LabelLintResult = { ok: true } | { ok: false; errors: LabelLintError[] };
|
|
8
|
+
|
|
9
|
+
const CTX_STEP_LITERAL_LABEL_PATTERN = /ctx\.step\.\w+\(\s*"([^"]+)"/g;
|
|
10
|
+
const LOOP_PATTERNS = [
|
|
11
|
+
/\bfor\s*\(/,
|
|
12
|
+
/\bwhile\s*\(/,
|
|
13
|
+
/\.map\s*\(/,
|
|
14
|
+
/\.forEach\s*\(/,
|
|
15
|
+
/\.reduce\s*\(/,
|
|
16
|
+
/\.flatMap\s*\(/,
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function lintWorkflowLabels(source: string): LabelLintResult {
|
|
20
|
+
const errors: LabelLintError[] = [];
|
|
21
|
+
const lines = source.split("\n");
|
|
22
|
+
|
|
23
|
+
for (let i = 0; i < lines.length; i++) {
|
|
24
|
+
const line = lines[i] ?? "";
|
|
25
|
+
CTX_STEP_LITERAL_LABEL_PATTERN.lastIndex = 0;
|
|
26
|
+
let match = CTX_STEP_LITERAL_LABEL_PATTERN.exec(line);
|
|
27
|
+
while (match !== null) {
|
|
28
|
+
const label = match[1];
|
|
29
|
+
if (!label) {
|
|
30
|
+
match = CTX_STEP_LITERAL_LABEL_PATTERN.exec(line);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const windowStart = Math.max(0, i - 10);
|
|
34
|
+
const context = lines.slice(windowStart, i + 1).join("\n");
|
|
35
|
+
if (!LOOP_PATTERNS.some((pattern) => pattern.test(context))) {
|
|
36
|
+
match = CTX_STEP_LITERAL_LABEL_PATTERN.exec(line);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
errors.push({
|
|
40
|
+
label,
|
|
41
|
+
lineNumber: i + 1,
|
|
42
|
+
detail:
|
|
43
|
+
`Literal string label "${label}" at line ${i + 1} appears inside a loop. ` +
|
|
44
|
+
"Labels must be unique per run; use a template literal that includes the loop variable.",
|
|
45
|
+
});
|
|
46
|
+
match = CTX_STEP_LITERAL_LABEL_PATTERN.exec(line);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return errors.length > 0 ? { ok: false, errors } : { ok: true };
|
|
51
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const DEFAULT_MAX_STEPS = 1000;
|
|
2
|
+
const DEFAULT_MAX_WALL_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
const DEFAULT_MAX_AGENT_TASKS = 50;
|
|
4
|
+
|
|
5
|
+
function positiveIntEnv(name: string, fallback: number): number {
|
|
6
|
+
const raw = process.env[name];
|
|
7
|
+
if (!raw) return fallback;
|
|
8
|
+
const parsed = Number(raw);
|
|
9
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function scriptRunMaxSteps(): number {
|
|
13
|
+
return positiveIntEnv("SCRIPT_RUN_MAX_STEPS", DEFAULT_MAX_STEPS);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function scriptRunMaxWallMs(): number {
|
|
17
|
+
return positiveIntEnv("SCRIPT_RUN_MAX_WALL_MS", DEFAULT_MAX_WALL_MS);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function scriptRunMaxAgentTasks(): number {
|
|
21
|
+
return positiveIntEnv("SCRIPT_RUN_MAX_AGENT_TASKS", DEFAULT_MAX_AGENT_TASKS);
|
|
22
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { getRunningScriptRuns, getScriptRun, updateScriptRun } from "../be/db";
|
|
2
|
+
import type { ScriptRun } from "../types";
|
|
3
|
+
import { getApiKey } from "../utils/api-key";
|
|
4
|
+
import {
|
|
5
|
+
localProcessScriptExecutor,
|
|
6
|
+
type ScriptExecutionHandle,
|
|
7
|
+
type ScriptExecutor,
|
|
8
|
+
} from "./executor";
|
|
9
|
+
import { scriptRunMaxWallMs } from "./limits";
|
|
10
|
+
|
|
11
|
+
type ManagedRun = {
|
|
12
|
+
execution: ScriptExecutionHandle;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const managed = new Map<string, ManagedRun>();
|
|
16
|
+
let reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
|
17
|
+
let scriptExecutor: ScriptExecutor = localProcessScriptExecutor;
|
|
18
|
+
|
|
19
|
+
function supervisorDisabled(): boolean {
|
|
20
|
+
return process.env.SCRIPT_RUN_SUPERVISOR_DISABLE === "true";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setScriptRunExecutor(executor: ScriptExecutor): void {
|
|
24
|
+
scriptExecutor = executor;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function startScriptRunProcess(
|
|
28
|
+
run: ScriptRun,
|
|
29
|
+
baseUrl: string,
|
|
30
|
+
apiKeyOverride?: string,
|
|
31
|
+
): Promise<void> {
|
|
32
|
+
if (supervisorDisabled()) return;
|
|
33
|
+
if (managed.has(run.id)) return;
|
|
34
|
+
const apiKey = apiKeyOverride ?? getApiKey();
|
|
35
|
+
if (!apiKey) throw new Error("AGENT_SWARM_API_KEY is required to spawn script runs");
|
|
36
|
+
if (process.env.SCRIPT_WORKFLOW_DEBUG === "true") {
|
|
37
|
+
console.error(
|
|
38
|
+
`[script-workflows] spawning ${run.id} auth override=${apiKeyOverride ? "yes" : "no"} len=${apiKey.length}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const execution = await scriptExecutor.start({ run, baseUrl, apiKey });
|
|
43
|
+
managed.set(run.id, { execution });
|
|
44
|
+
updateScriptRun(run.id, {
|
|
45
|
+
status: "running",
|
|
46
|
+
pid: execution.pid,
|
|
47
|
+
lastHeartbeatAt: new Date().toISOString(),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
execution.exited
|
|
51
|
+
.then(async ({ exitCode, stderr }) => {
|
|
52
|
+
const current = getScriptRun(run.id);
|
|
53
|
+
if (current && current.status === "running") {
|
|
54
|
+
if (exitCode !== 0) {
|
|
55
|
+
console.error(
|
|
56
|
+
`[script-workflows] run ${run.id} subprocess exited ${exitCode}: ${stderr.trim() || "(no stderr)"}`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
updateScriptRun(run.id, {
|
|
60
|
+
status: exitCode === 0 ? "completed" : "failed",
|
|
61
|
+
pid: null,
|
|
62
|
+
finishedAt: new Date().toISOString(),
|
|
63
|
+
error:
|
|
64
|
+
exitCode === 0
|
|
65
|
+
? null
|
|
66
|
+
: stderr.trim() || `Script workflow subprocess exited ${exitCode}`,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.finally(async () => {
|
|
71
|
+
managed.delete(run.id);
|
|
72
|
+
await execution.cleanup();
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function terminateScriptRunProcess(runId: string): boolean {
|
|
77
|
+
const managedRun = managed.get(runId);
|
|
78
|
+
const run = getScriptRun(runId);
|
|
79
|
+
if (managedRun) {
|
|
80
|
+
managedRun.execution.terminate("SIGTERM");
|
|
81
|
+
managed.delete(runId);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
if (run?.pid && scriptExecutor.isRunning(run.pid)) {
|
|
85
|
+
scriptExecutor.terminatePid(run.pid, "SIGTERM");
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function pauseScriptRunProcess(runId: string): void {
|
|
92
|
+
terminateScriptRunProcess(runId);
|
|
93
|
+
updateScriptRun(runId, { status: "paused", pid: null });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function abortScriptRunLimit(runId: string, reason: string): void {
|
|
97
|
+
terminateScriptRunProcess(runId);
|
|
98
|
+
updateScriptRun(runId, {
|
|
99
|
+
status: "aborted_limit",
|
|
100
|
+
pid: null,
|
|
101
|
+
finishedAt: new Date().toISOString(),
|
|
102
|
+
error: reason,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function reconcileScriptRuns(baseUrl: string): void {
|
|
107
|
+
if (supervisorDisabled()) return;
|
|
108
|
+
for (const run of getRunningScriptRuns()) {
|
|
109
|
+
if (run.status === "paused") continue;
|
|
110
|
+
const current = managed.get(run.id);
|
|
111
|
+
if (current && Date.now() - current.execution.startedAtMs > scriptRunMaxWallMs()) {
|
|
112
|
+
abortScriptRunLimit(run.id, `SCRIPT_RUN_MAX_WALL_MS exceeded (${scriptRunMaxWallMs()})`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!current && (!run.pid || !scriptExecutor.isRunning(run.pid))) {
|
|
116
|
+
startScriptRunProcess(run, baseUrl).catch((err) => {
|
|
117
|
+
updateScriptRun(run.id, {
|
|
118
|
+
status: "failed",
|
|
119
|
+
pid: null,
|
|
120
|
+
finishedAt: new Date().toISOString(),
|
|
121
|
+
error: err instanceof Error ? err.message : String(err),
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function startScriptRunSupervisor(baseUrl: string): void {
|
|
129
|
+
if (supervisorDisabled() || reconcileTimer) return;
|
|
130
|
+
reconcileScriptRuns(baseUrl);
|
|
131
|
+
reconcileTimer = setInterval(() => reconcileScriptRuns(baseUrl), 15_000);
|
|
132
|
+
reconcileTimer.unref?.();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function stopScriptRunSupervisor(): void {
|
|
136
|
+
if (reconcileTimer) clearInterval(reconcileTimer);
|
|
137
|
+
reconcileTimer = null;
|
|
138
|
+
for (const runId of [...managed.keys()]) terminateScriptRunProcess(runId);
|
|
139
|
+
}
|