@desplega.ai/agent-swarm 1.87.0 → 1.88.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 +13 -1
- package/package.json +5 -5
- package/src/be/db.ts +49 -7
- package/src/be/migrations/080_skill_system_defaults.sql +8 -0
- package/src/be/modelsdev-cache.json +1123 -1034
- package/src/be/seed/registry.ts +3 -2
- package/src/be/seed-skills/index.ts +172 -0
- package/src/cli.tsx +33 -4
- package/src/commands/e2b-stack-wizard.tsx +394 -0
- package/src/commands/e2b.ts +1352 -53
- package/src/commands/onboard/dashboard-url.ts +29 -0
- package/src/commands/onboard/steps/post-dashboard.tsx +3 -1
- package/src/commands/onboard.tsx +3 -1
- package/src/commands/runner.ts +1 -0
- package/src/e2b/dispatch.ts +234 -18
- package/src/http/memory.ts +13 -1
- package/src/http/skills.ts +53 -0
- package/src/http/webhooks.ts +75 -0
- package/src/integrations/kapso/client.ts +82 -0
- package/src/memory/automatic-task-gate.ts +47 -0
- package/src/prompts/base-prompt.ts +16 -1
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/claude-adapter.ts +19 -0
- package/src/providers/codex-adapter.ts +22 -0
- package/src/providers/ctx-mode-env.ts +10 -0
- package/src/providers/opencode-adapter.ts +50 -1
- package/src/slack/blocks.ts +12 -4
- package/src/slack/watcher.ts +3 -3
- package/src/telemetry.ts +14 -1
- package/src/templates.d.ts +4 -0
- package/src/tests/base-prompt.test.ts +41 -0
- package/src/tests/claude-adapter.test.ts +86 -1
- package/src/tests/codex-adapter.test.ts +89 -0
- package/src/tests/e2b-dispatch.test.ts +603 -11
- package/src/tests/http-api-integration.test.ts +113 -0
- package/src/tests/kapso-client.test.ts +74 -1
- package/src/tests/kapso-inbound.test.ts +60 -2
- package/src/tests/opencode-adapter.test.ts +95 -0
- package/src/tests/prompt-template-session.test.ts +4 -2
- package/src/tests/self-improvement.test.ts +89 -0
- package/src/tests/skill-update-scope.test.ts +88 -1
- package/src/tests/slack-blocks.test.ts +15 -0
- package/src/tests/system-default-skills.test.ts +119 -0
- package/src/tests/telemetry-init.test.ts +86 -0
- package/src/tools/skills/skill-delete.ts +14 -0
- package/src/tools/skills/skill-update.ts +14 -0
- package/src/tools/store-progress.ts +19 -5
- package/src/types.ts +1 -0
- package/templates/skills/artifacts/config.json +1 -0
- package/templates/skills/kv-storage/config.json +1 -0
- package/templates/skills/pages/config.json +1 -0
- package/templates/skills/scheduled-task-resilience/config.json +1 -0
- package/templates/skills/swarm-scripts/SKILL.md +91 -0
- package/templates/skills/swarm-scripts/config.json +14 -0
- package/templates/skills/swarm-scripts/content.md +86 -0
- package/templates/skills/workflow-iterate/config.json +1 -0
- package/templates/skills/workflow-structured-output/config.json +1 -0
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the swarm-dashboard deep-link the SPA reads after a local onboard.
|
|
3
|
+
*
|
|
4
|
+
* IMPORTANT: the dashboard SPA reads **camelCase** `apiUrl` / `apiKey` query
|
|
5
|
+
* params (see ui/src/hooks/use-config.ts → extractUrlParams) and silently
|
|
6
|
+
* ignores snake_case. An earlier version of these builders emitted snake_case
|
|
7
|
+
* `api_url` / `api_key`, so the auto-connect deep-link never worked. Keep these
|
|
8
|
+
* camelCase.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DASHBOARD_BASE = "https://app.agent-swarm.dev";
|
|
12
|
+
|
|
13
|
+
export type DashboardUrlParts = {
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
/** Optional connection name shown in the dashboard (camelCase `name`). */
|
|
17
|
+
name?: string;
|
|
18
|
+
/** Override the dashboard base (defaults to the production app). */
|
|
19
|
+
base?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function buildOnboardDashboardUrl(parts: DashboardUrlParts): string {
|
|
23
|
+
const params = new URLSearchParams();
|
|
24
|
+
params.set("apiUrl", parts.apiUrl);
|
|
25
|
+
if (parts.apiKey) params.set("apiKey", parts.apiKey);
|
|
26
|
+
if (parts.name) params.set("name", parts.name);
|
|
27
|
+
const base = (parts.base ?? DEFAULT_DASHBOARD_BASE).replace(/\/+$/, "");
|
|
28
|
+
return `${base}?${params.toString()}`;
|
|
29
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Select } from "@inkjs/ui";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { buildOnboardDashboardUrl } from "../dashboard-url.ts";
|
|
3
4
|
import type { StepProps } from "../types.ts";
|
|
4
5
|
|
|
5
6
|
export function PostDashboardStep({ state, addLog, goToNext }: StepProps) {
|
|
6
7
|
const apiUrl = `http://localhost:${state.apiPort || 3013}`;
|
|
7
|
-
|
|
8
|
+
// camelCase params — the SPA ignores snake_case (see dashboard-url.ts).
|
|
9
|
+
const dashboardUrl = buildOnboardDashboardUrl({ apiUrl, apiKey: state.apiKey });
|
|
8
10
|
|
|
9
11
|
return (
|
|
10
12
|
<Box flexDirection="column" padding={1}>
|
package/src/commands/onboard.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { Box, Text, useApp, useInput } from "ink";
|
|
|
4
4
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
5
5
|
import pkg from "../../package.json";
|
|
6
6
|
import { getApiKey } from "../utils/api-key.ts";
|
|
7
|
+
import { buildOnboardDashboardUrl } from "./onboard/dashboard-url.ts";
|
|
7
8
|
import { getAgentSummary, getPresetById, PRESETS } from "./onboard/presets.ts";
|
|
8
9
|
import { CoreCredentialsStep } from "./onboard/steps/core-credentials.tsx";
|
|
9
10
|
import { CustomTemplatesStep } from "./onboard/steps/custom-templates.tsx";
|
|
@@ -244,7 +245,8 @@ export function Onboard({ dryRun = false, yes = false, preset }: OnboardProps) {
|
|
|
244
245
|
|
|
245
246
|
if (state.step === "done") {
|
|
246
247
|
const apiUrl = `http://localhost:${state.apiPort || 3013}`;
|
|
247
|
-
|
|
248
|
+
// camelCase params — the SPA ignores snake_case (see dashboard-url.ts).
|
|
249
|
+
const dashUrl = buildOnboardDashboardUrl({ apiUrl, apiKey: state.apiKey });
|
|
248
250
|
const agentCount = state.services.reduce((sum, s) => sum + s.count, 0);
|
|
249
251
|
|
|
250
252
|
return (
|
package/src/commands/runner.ts
CHANGED
|
@@ -3295,6 +3295,7 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3295
3295
|
swarmUrl,
|
|
3296
3296
|
capabilities,
|
|
3297
3297
|
traits,
|
|
3298
|
+
provider: adapter.name as ProviderName,
|
|
3298
3299
|
name: agentProfileName,
|
|
3299
3300
|
description: agentDescription,
|
|
3300
3301
|
...(traits.hasLocalEnvironment && {
|
package/src/e2b/dispatch.ts
CHANGED
|
@@ -14,6 +14,26 @@ export type E2BSandboxInfo = {
|
|
|
14
14
|
startedAt?: string;
|
|
15
15
|
endAt?: string;
|
|
16
16
|
metadata?: Record<string, string>;
|
|
17
|
+
// Client-side fallback for the sandbox expiry. The raw `POST /sandboxes`
|
|
18
|
+
// create response uses E2B's `Sandbox` schema, which (unlike `ListedSandbox`
|
|
19
|
+
// / `SandboxDetail`) does NOT include `endAt`. We populate this from
|
|
20
|
+
// `now + timeoutSec*1000` at create time so `ttlRemaining` can report expiry
|
|
21
|
+
// immediately after a launch without an extra round-trip. `endAt` (when
|
|
22
|
+
// present, e.g. from `listSandboxes`) is always authoritative over this.
|
|
23
|
+
expiresAt?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type TtlRemaining = {
|
|
27
|
+
expiresAt?: string;
|
|
28
|
+
secondsLeft?: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type SetSandboxTimeoutOptions = {
|
|
32
|
+
sandboxId: string;
|
|
33
|
+
apiKey: string;
|
|
34
|
+
apiBase?: string;
|
|
35
|
+
e2bEnv?: EnvMap;
|
|
36
|
+
timeoutMs: number;
|
|
17
37
|
};
|
|
18
38
|
|
|
19
39
|
export type E2BCommandResult = {
|
|
@@ -80,6 +100,25 @@ export type StartDetachedOptions = {
|
|
|
80
100
|
cwd?: string;
|
|
81
101
|
};
|
|
82
102
|
|
|
103
|
+
export type StreamSandboxLogOptions = {
|
|
104
|
+
sandboxId: string;
|
|
105
|
+
role: E2BRole;
|
|
106
|
+
apiKey: string;
|
|
107
|
+
apiBase?: string;
|
|
108
|
+
e2bEnv?: EnvMap;
|
|
109
|
+
/** Number of trailing history lines to emit before following (default 200). */
|
|
110
|
+
tailLines?: number;
|
|
111
|
+
/** When true, keep streaming new output (`tail -f`) until the caller aborts. */
|
|
112
|
+
follow?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* Egress sink for each chunk. The caller MUST scrub here — log output is
|
|
115
|
+
* untrusted entrypoint stdout and can embed tokens/secrets.
|
|
116
|
+
*/
|
|
117
|
+
onChunk: (chunk: string) => void;
|
|
118
|
+
/** Abort signal to stop a `--follow` stream (e.g. on SIGINT). */
|
|
119
|
+
signal?: AbortSignal;
|
|
120
|
+
};
|
|
121
|
+
|
|
83
122
|
type E2BSdkConnectionOptions = {
|
|
84
123
|
apiKey: string;
|
|
85
124
|
apiUrl?: string;
|
|
@@ -98,15 +137,24 @@ function e2bHeaders(apiKey: string): Record<string, string> {
|
|
|
98
137
|
};
|
|
99
138
|
}
|
|
100
139
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
140
|
+
/**
|
|
141
|
+
* Build the shell payload for the envd-tracked entrypoint launch. Phase 5: the
|
|
142
|
+
* entrypoint is no longer detached via `nohup … >file &` (a grandchild envd
|
|
143
|
+
* never sees). Instead it runs as the SDK background command itself (envd owns
|
|
144
|
+
* and streams it; it survives client disconnect). We still `tee` to a
|
|
145
|
+
* deterministic file so `swarms logs` can retrieve FULL history later: the SDK's
|
|
146
|
+
* `commands.connect(pid)` only streams output going forward from the connect
|
|
147
|
+
* instant — it does NOT replay stdout produced while disconnected (verified
|
|
148
|
+
* against the e2b SDK types + docs) — so the file copy is the only reliable
|
|
149
|
+
* full-history source.
|
|
150
|
+
*
|
|
151
|
+
* `set -o pipefail` makes the pipeline's exit code reflect the ENTRYPOINT rather
|
|
152
|
+
* than `tee` (tee exits 0 on EOF even if the entrypoint crashed), so the early
|
|
153
|
+
* `exitCode` poll in {@link startDetachedProcess} can detect a launch failure.
|
|
154
|
+
* Invoked via `bash -lc` (both the api + worker images ship bash) for pipefail.
|
|
155
|
+
*/
|
|
156
|
+
export function buildTrackedShell(command: string, logPath: string): string {
|
|
157
|
+
return `set -o pipefail; ${command} 2>&1 | tee ${logPath}`;
|
|
110
158
|
}
|
|
111
159
|
|
|
112
160
|
export function e2bSdkConnectionOptions(
|
|
@@ -184,7 +232,10 @@ export async function e2bFetchJson<T>(
|
|
|
184
232
|
}
|
|
185
233
|
|
|
186
234
|
export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSandboxInfo> {
|
|
187
|
-
|
|
235
|
+
// Capture the wall-clock create instant BEFORE the request so the client-side
|
|
236
|
+
// expiry fallback reflects when the TTL countdown begins.
|
|
237
|
+
const createdAt = Date.now();
|
|
238
|
+
const sandbox = await e2bFetchJson<E2BSandboxInfo>(
|
|
188
239
|
"/sandboxes",
|
|
189
240
|
opts.apiKey,
|
|
190
241
|
{
|
|
@@ -200,6 +251,61 @@ export async function createSandbox(opts: CreateSandboxOptions): Promise<E2BSand
|
|
|
200
251
|
},
|
|
201
252
|
opts.apiBase,
|
|
202
253
|
);
|
|
254
|
+
// Pre-flight check (resolved against node_modules/e2b types): the create
|
|
255
|
+
// response is E2B's `Sandbox` schema, which omits `endAt`. Compute a
|
|
256
|
+
// client-side expiry fallback so `ttlRemaining` works right after launch.
|
|
257
|
+
if (!sandbox.endAt && !sandbox.expiresAt) {
|
|
258
|
+
sandbox.expiresAt = new Date(createdAt + opts.timeoutSec * 1000).toISOString();
|
|
259
|
+
}
|
|
260
|
+
return sandbox;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Compute the remaining time-to-live for a sandbox. Prefers the authoritative
|
|
265
|
+
* `endAt` (present on listed/detail responses); falls back to the client-side
|
|
266
|
+
* `expiresAt` stamped by `createSandbox`. Returns an empty object when neither
|
|
267
|
+
* is available (e.g. a dry-run fake sandbox). `secondsLeft` is clamped at 0 so
|
|
268
|
+
* an already-expired sandbox never reports negative time.
|
|
269
|
+
*/
|
|
270
|
+
export function ttlRemaining(sandbox: E2BSandboxInfo): TtlRemaining {
|
|
271
|
+
const expiresAt = sandbox.endAt ?? sandbox.expiresAt;
|
|
272
|
+
if (!expiresAt) return {};
|
|
273
|
+
const expiryMs = Date.parse(expiresAt);
|
|
274
|
+
if (Number.isNaN(expiryMs)) return {};
|
|
275
|
+
const secondsLeft = Math.max(0, Math.round((expiryMs - Date.now()) / 1000));
|
|
276
|
+
return { expiresAt, secondsLeft };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Extend (or reduce) a live sandbox's TTL via the SDK and read back the actual
|
|
281
|
+
* `endAt` E2B applied (the server clamps to the tier max, so the requested
|
|
282
|
+
* timeout is not always honored verbatim). Connecting to a dead/expired sandbox
|
|
283
|
+
* throws; we translate that into a redacted "not found / already expired"
|
|
284
|
+
* error so a stale sandbox ID never leaks the controller key into logs.
|
|
285
|
+
*/
|
|
286
|
+
export async function setSandboxTimeout(opts: SetSandboxTimeoutOptions): Promise<TtlRemaining> {
|
|
287
|
+
const { Sandbox } = await import("e2b");
|
|
288
|
+
let sandbox: Awaited<ReturnType<typeof Sandbox.connect>>;
|
|
289
|
+
try {
|
|
290
|
+
sandbox = await Sandbox.connect(
|
|
291
|
+
opts.sandboxId,
|
|
292
|
+
e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
|
|
293
|
+
);
|
|
294
|
+
} catch {
|
|
295
|
+
// Do not surface the underlying error verbatim — it can embed the
|
|
296
|
+
// controller API key / connection URL. Emit a fixed redacted message.
|
|
297
|
+
throw new Error(`sandbox ${opts.sandboxId} not found / already expired`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
await sandbox.setTimeout(opts.timeoutMs);
|
|
301
|
+
// `setTimeout` returns void; re-read the info to learn the clamped expiry.
|
|
302
|
+
const info = await sandbox.getInfo();
|
|
303
|
+
const expiresAt = info.endAt instanceof Date ? info.endAt.toISOString() : String(info.endAt);
|
|
304
|
+
return ttlRemaining({
|
|
305
|
+
sandboxID: opts.sandboxId,
|
|
306
|
+
templateID: info.templateId,
|
|
307
|
+
endAt: expiresAt,
|
|
308
|
+
});
|
|
203
309
|
}
|
|
204
310
|
|
|
205
311
|
export async function killSandbox(
|
|
@@ -222,27 +328,137 @@ export async function listSandboxes(
|
|
|
222
328
|
return e2bFetchJson<E2BSandboxInfo[]>("/sandboxes", apiKey, {}, apiBase);
|
|
223
329
|
}
|
|
224
330
|
|
|
331
|
+
/**
|
|
332
|
+
* The deterministic per-role log path the entrypoint tees to. `swarms logs`
|
|
333
|
+
* recomputes it from the role alone (no PID bookkeeping needed) to `tail`/`cat`
|
|
334
|
+
* full history or `tail -f` for `--follow`.
|
|
335
|
+
*/
|
|
336
|
+
export function sandboxLogPath(role: E2BRole): string {
|
|
337
|
+
return `/tmp/agent-swarm-e2b-${role}.log`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Launch the entrypoint as an envd-tracked BACKGROUND command (Phase 5). Returns
|
|
342
|
+
* the PID immediately. Replaces the old `nohup … >file & sleep 2; kill -0` hack:
|
|
343
|
+
* the SDK's background handle exposes `exitCode` (undefined while running), so we
|
|
344
|
+
* poll it once after a short grace period — a non-zero exit by then means the
|
|
345
|
+
* entrypoint died at launch, which we surface as a launch failure (reading the
|
|
346
|
+
* tee'd log for context). The `tee` preserves a file copy for full-history
|
|
347
|
+
* retrieval regardless of envd stdout-replay semantics.
|
|
348
|
+
*/
|
|
225
349
|
export async function startDetachedProcess(opts: StartDetachedOptions): Promise<string> {
|
|
226
|
-
const logPath =
|
|
227
|
-
const
|
|
228
|
-
const shell = buildDetachedShell(opts.command, logPath, pidPath);
|
|
350
|
+
const logPath = sandboxLogPath(opts.role);
|
|
351
|
+
const shell = buildTrackedShell(opts.command, logPath);
|
|
229
352
|
|
|
230
353
|
const { Sandbox } = await import("e2b");
|
|
231
354
|
const sandbox = await Sandbox.connect(
|
|
232
355
|
opts.sandbox.sandboxID,
|
|
233
356
|
e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
|
|
234
357
|
);
|
|
235
|
-
|
|
358
|
+
// `bash -lc` (not `sh`) so `set -o pipefail` is honored on both images.
|
|
359
|
+
const handle = await sandbox.commands.run(`bash -lc ${shellQuote(shell)}`, {
|
|
236
360
|
user: opts.user ?? "root",
|
|
237
361
|
cwd: opts.cwd ?? "/",
|
|
238
362
|
envs: opts.env,
|
|
239
|
-
|
|
363
|
+
background: true,
|
|
240
364
|
});
|
|
241
365
|
|
|
242
|
-
|
|
243
|
-
|
|
366
|
+
// Early liveness poll: give the entrypoint a moment to fault, then check the
|
|
367
|
+
// handle's exit code. `undefined` = still running (the expected happy path).
|
|
368
|
+
await Bun.sleep(2_000);
|
|
369
|
+
if (typeof handle.exitCode === "number" && handle.exitCode !== 0) {
|
|
370
|
+
// The pipeline already exited non-zero — surface stderr/stdout (redacted, as
|
|
371
|
+
// entrypoint output can embed tokens) as a launch failure.
|
|
372
|
+
const detail = redactWithEnv(`${handle.stdout}\n${handle.stderr}`.trim(), opts.env);
|
|
373
|
+
throw new Error(`E2B start command exited ${handle.exitCode} at launch: ${detail}`);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return String(handle.pid);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Single-quote a string for safe embedding in a `bash -lc '<...>'` invocation. */
|
|
380
|
+
function shellQuote(value: string): string {
|
|
381
|
+
return `'${value.split("'").join(`'\\''`)}'`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Stream a sandbox's tee'd entrypoint log to the caller's `onChunk` sink.
|
|
386
|
+
*
|
|
387
|
+
* Design (Phase 5): we read from the deterministic per-role {@link sandboxLogPath}
|
|
388
|
+
* the entrypoint tees to — NOT from a tracked PID — so no PID bookkeeping is
|
|
389
|
+
* needed and history survives reconnect / a fresh CLI process. The SDK's
|
|
390
|
+
* `commands.connect(pid)` only streams forward from connect (no historical
|
|
391
|
+
* replay, verified against the SDK), so the file is the source of truth for
|
|
392
|
+
* full history.
|
|
393
|
+
*
|
|
394
|
+
* - History (no `--follow`): `tail -n <N> <logPath>` once (a CommandResult).
|
|
395
|
+
* - Follow: `tail -n <N> -F <logPath>` as a BACKGROUND command, piping each
|
|
396
|
+
* `onStdout`/`onStderr` chunk to `onChunk` until the abort signal fires
|
|
397
|
+
* (`-F` keeps following across truncation/rotation; tolerates a not-yet-created
|
|
398
|
+
* file). The caller scrubs inside `onChunk`.
|
|
399
|
+
*
|
|
400
|
+
* Output is emitted RAW here; the caller is responsible for scrubbing in
|
|
401
|
+
* `onChunk` (it sees both this function's stdout and stderr).
|
|
402
|
+
*/
|
|
403
|
+
export async function streamSandboxLog(opts: StreamSandboxLogOptions): Promise<void> {
|
|
404
|
+
const logPath = sandboxLogPath(opts.role);
|
|
405
|
+
const tailLines = opts.tailLines ?? 200;
|
|
406
|
+
|
|
407
|
+
const { Sandbox } = await import("e2b");
|
|
408
|
+
const sandbox = await Sandbox.connect(
|
|
409
|
+
opts.sandboxId,
|
|
410
|
+
e2bSdkConnectionOptions(opts.apiKey, opts.e2bEnv ?? {}, opts.apiBase),
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
if (!opts.follow) {
|
|
414
|
+
// History only: a single `tail`. If the file does not exist yet (entrypoint
|
|
415
|
+
// hasn't written), `tail` exits non-zero with a message on stderr — we emit
|
|
416
|
+
// that to the sink rather than throwing, so a freshly-launched swarm reads as
|
|
417
|
+
// "no logs yet" instead of a hard error.
|
|
418
|
+
const result = await sandbox.commands.run(
|
|
419
|
+
`bash -lc ${shellQuote(`tail -n ${tailLines} ${logPath} 2>&1 || true`)}`,
|
|
420
|
+
{ user: "root", timeoutMs: 30_000 },
|
|
421
|
+
);
|
|
422
|
+
if (result.stdout) opts.onChunk(result.stdout);
|
|
423
|
+
if (result.stderr) opts.onChunk(result.stderr);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Follow: background `tail -F` streaming forward. `-F` (vs `-f`) re-opens the
|
|
428
|
+
// file if it is rotated/recreated and waits for a not-yet-existing file.
|
|
429
|
+
const handle = await sandbox.commands.run(
|
|
430
|
+
`bash -lc ${shellQuote(`tail -n ${tailLines} -F ${logPath}`)}`,
|
|
431
|
+
{
|
|
432
|
+
user: "root",
|
|
433
|
+
background: true,
|
|
434
|
+
onStdout: (data) => opts.onChunk(data),
|
|
435
|
+
onStderr: (data) => opts.onChunk(data),
|
|
436
|
+
},
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
const stop = async () => {
|
|
440
|
+
try {
|
|
441
|
+
await handle.kill();
|
|
442
|
+
} catch {
|
|
443
|
+
// The command may already be gone (sandbox killed/expired); ignore.
|
|
444
|
+
}
|
|
445
|
+
};
|
|
446
|
+
if (opts.signal) {
|
|
447
|
+
if (opts.signal.aborted) {
|
|
448
|
+
await stop();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
opts.signal.addEventListener("abort", stop, { once: true });
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
// `wait()` resolves when the stream ends (sandbox death) or the handle is
|
|
456
|
+
// killed by the abort listener above. `tail -F` otherwise runs indefinitely.
|
|
457
|
+
await handle.wait();
|
|
458
|
+
} catch {
|
|
459
|
+
// A kill / disconnect surfaces as a rejected wait — that is the expected exit
|
|
460
|
+
// path for `--follow`, not an error to propagate.
|
|
244
461
|
}
|
|
245
|
-
return result.stdout.trim();
|
|
246
462
|
}
|
|
247
463
|
|
|
248
464
|
export async function waitForAgentRegistration(
|
package/src/http/memory.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { chunkContent } from "../be/chunking";
|
|
4
|
+
import { getTaskById } from "../be/db";
|
|
4
5
|
import { getEmbeddingProvider, getMemoryStore } from "../be/memory";
|
|
5
6
|
import { CANDIDATE_SET_MULTIPLIER } from "../be/memory/constants";
|
|
6
7
|
import { listEdgesForAgent } from "../be/memory/edges-store";
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
} from "../be/memory/raters/types";
|
|
14
15
|
import { rerank } from "../be/memory/reranker";
|
|
15
16
|
import { getRetrievalsForAgent, hasRetrievalForTask } from "../be/memory/retrieval-store";
|
|
17
|
+
import { shouldPersistAutomaticTaskMemory } from "../memory/automatic-task-gate";
|
|
16
18
|
import { AgentMemoryScopeSchema, AgentMemorySourceSchema } from "../types";
|
|
17
19
|
import { route } from "./route-def";
|
|
18
20
|
import { json, jsonError, parseQueryParams } from "./utils";
|
|
@@ -34,6 +36,7 @@ const indexMemory = route({
|
|
|
34
36
|
sourceTaskId: z.string().uuid().optional(),
|
|
35
37
|
sourcePath: z.string().optional(),
|
|
36
38
|
tags: z.array(z.string()).optional(),
|
|
39
|
+
persistMemory: z.boolean().optional(),
|
|
37
40
|
}),
|
|
38
41
|
responses: {
|
|
39
42
|
202: { description: "Content queued for embedding" },
|
|
@@ -249,7 +252,16 @@ export async function handleMemory(
|
|
|
249
252
|
const parsed = await indexMemory.parse(req, res, pathSegments, new URLSearchParams());
|
|
250
253
|
if (!parsed) return true;
|
|
251
254
|
|
|
252
|
-
const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags } =
|
|
255
|
+
const { agentId, content, name, scope, source, sourceTaskId, sourcePath, tags, persistMemory } =
|
|
256
|
+
parsed.body;
|
|
257
|
+
|
|
258
|
+
if (source === "session_summary" && sourceTaskId) {
|
|
259
|
+
const sourceTask = getTaskById(sourceTaskId);
|
|
260
|
+
if (sourceTask && !shouldPersistAutomaticTaskMemory(sourceTask, persistMemory)) {
|
|
261
|
+
json(res, { queued: false, memoryIds: [], skipped: "automatic_task_memory_disabled" }, 202);
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
253
265
|
|
|
254
266
|
// Chunk content and create memories
|
|
255
267
|
const contentChunks = chunkContent(content);
|
package/src/http/skills.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { computeAgentSkillsSignature, syncSkillsToFilesystem } from "../be/skill
|
|
|
16
16
|
import { route } from "./route-def";
|
|
17
17
|
import { json, jsonError } from "./utils";
|
|
18
18
|
|
|
19
|
+
const SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE =
|
|
20
|
+
"This skill is system-managed and cannot be edited from the UI; it is re-seeded on each start. Fork it under a new name to customize.";
|
|
21
|
+
|
|
19
22
|
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
20
23
|
|
|
21
24
|
const listSkillsRoute = route({
|
|
@@ -67,6 +70,7 @@ const createSkillRoute = route({
|
|
|
67
70
|
type: z.string().optional(),
|
|
68
71
|
scope: z.string().optional(),
|
|
69
72
|
ownerAgentId: z.string().optional(),
|
|
73
|
+
systemDefault: z.boolean().optional(),
|
|
70
74
|
}),
|
|
71
75
|
responses: {
|
|
72
76
|
201: { description: "Skill created" },
|
|
@@ -85,6 +89,7 @@ const updateSkillRoute = route({
|
|
|
85
89
|
body: z.record(z.string(), z.unknown()),
|
|
86
90
|
responses: {
|
|
87
91
|
200: { description: "Skill updated" },
|
|
92
|
+
403: { description: "System-managed skills cannot be edited" },
|
|
88
93
|
404: { description: "Skill not found" },
|
|
89
94
|
},
|
|
90
95
|
});
|
|
@@ -99,6 +104,7 @@ const deleteSkillRoute = route({
|
|
|
99
104
|
params: z.object({ id: z.string() }),
|
|
100
105
|
responses: {
|
|
101
106
|
200: { description: "Skill deleted" },
|
|
107
|
+
403: { description: "System-managed skills cannot be deleted" },
|
|
102
108
|
404: { description: "Skill not found" },
|
|
103
109
|
},
|
|
104
110
|
});
|
|
@@ -452,6 +458,7 @@ export async function handleSkills(
|
|
|
452
458
|
agent: pm.agent,
|
|
453
459
|
disableModelInvocation: pm.disableModelInvocation,
|
|
454
460
|
userInvocable: pm.userInvocable,
|
|
461
|
+
systemDefault: parsed.body.systemDefault,
|
|
455
462
|
});
|
|
456
463
|
json(res, { skill }, 201);
|
|
457
464
|
} catch (err) {
|
|
@@ -465,6 +472,42 @@ export async function handleSkills(
|
|
|
465
472
|
const parsed = await updateSkillRoute.parse(req, res, pathSegments, queryParams);
|
|
466
473
|
if (!parsed) return true;
|
|
467
474
|
|
|
475
|
+
const existing = getSkillById(parsed.params.id);
|
|
476
|
+
if (!existing) {
|
|
477
|
+
jsonError(res, "Skill not found", 404);
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const protectedSystemDefaultFields = [
|
|
482
|
+
"content",
|
|
483
|
+
"name",
|
|
484
|
+
"description",
|
|
485
|
+
"type",
|
|
486
|
+
"scope",
|
|
487
|
+
"ownerAgentId",
|
|
488
|
+
"sourceUrl",
|
|
489
|
+
"sourceRepo",
|
|
490
|
+
"sourcePath",
|
|
491
|
+
"sourceBranch",
|
|
492
|
+
"sourceHash",
|
|
493
|
+
"isComplex",
|
|
494
|
+
"allowedTools",
|
|
495
|
+
"model",
|
|
496
|
+
"effort",
|
|
497
|
+
"context",
|
|
498
|
+
"agent",
|
|
499
|
+
"disableModelInvocation",
|
|
500
|
+
"userInvocable",
|
|
501
|
+
"systemDefault",
|
|
502
|
+
];
|
|
503
|
+
if (
|
|
504
|
+
existing.systemDefault &&
|
|
505
|
+
protectedSystemDefaultFields.some((field) => Object.hasOwn(parsed.body, field))
|
|
506
|
+
) {
|
|
507
|
+
jsonError(res, SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE, 403);
|
|
508
|
+
return true;
|
|
509
|
+
}
|
|
510
|
+
|
|
468
511
|
const updates: Record<string, unknown> = {};
|
|
469
512
|
for (const [key, value] of Object.entries(parsed.body)) {
|
|
470
513
|
updates[key] = value;
|
|
@@ -498,6 +541,16 @@ export async function handleSkills(
|
|
|
498
541
|
const parsed = await deleteSkillRoute.parse(req, res, pathSegments, queryParams);
|
|
499
542
|
if (!parsed) return true;
|
|
500
543
|
|
|
544
|
+
const existing = getSkillById(parsed.params.id);
|
|
545
|
+
if (!existing) {
|
|
546
|
+
jsonError(res, "Skill not found", 404);
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
if (existing.systemDefault) {
|
|
550
|
+
jsonError(res, SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE, 403);
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
|
|
501
554
|
const deleted = deleteSkill(parsed.params.id);
|
|
502
555
|
if (!deleted) {
|
|
503
556
|
jsonError(res, "Skill not found", 404);
|
package/src/http/webhooks.ts
CHANGED
|
@@ -41,7 +41,14 @@ import {
|
|
|
41
41
|
isGitLabEnabled,
|
|
42
42
|
verifyGitLabWebhook,
|
|
43
43
|
} from "../gitlab";
|
|
44
|
+
import {
|
|
45
|
+
type KapsoMessageActionResult,
|
|
46
|
+
markKapsoMessageRead,
|
|
47
|
+
sendKapsoReaction,
|
|
48
|
+
} from "../integrations/kapso/client";
|
|
49
|
+
import type { KapsoConfig } from "../integrations/kapso/config";
|
|
44
50
|
import { getKapsoConfig } from "../integrations/kapso/config";
|
|
51
|
+
import type { KapsoWebhookPayload } from "../integrations/kapso/inbound";
|
|
45
52
|
import { routeKapsoInbound } from "../integrations/kapso/inbound";
|
|
46
53
|
import { getExecutorRegistry } from "../workflows";
|
|
47
54
|
import { workflowEventBus } from "../workflows/event-bus";
|
|
@@ -108,6 +115,72 @@ const kapsoWebhook = route({
|
|
|
108
115
|
|
|
109
116
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
110
117
|
|
|
118
|
+
function logKapsoAckResult(action: string, result: KapsoMessageActionResult): void {
|
|
119
|
+
if (result.ok) return;
|
|
120
|
+
console.warn(
|
|
121
|
+
`[Kapso] Inbound acknowledgement ${action} failed: ${
|
|
122
|
+
result.errorMessage ?? `status ${result.status}`
|
|
123
|
+
}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function acknowledgeKapsoInboundMessage(
|
|
128
|
+
payload: KapsoWebhookPayload,
|
|
129
|
+
config: KapsoConfig,
|
|
130
|
+
): Promise<void> {
|
|
131
|
+
const message = payload.message;
|
|
132
|
+
const phoneNumberId = payload.phone_number_id;
|
|
133
|
+
const messageId = message?.id;
|
|
134
|
+
const to = message?.from ?? payload.conversation?.phone_number;
|
|
135
|
+
|
|
136
|
+
if (payload.test || message?.kapso?.direction !== "inbound" || !phoneNumberId || !messageId) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!config.apiKey) {
|
|
141
|
+
console.warn("[Kapso] Cannot acknowledge inbound message: KAPSO_API_KEY is not configured");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const markRead = markKapsoMessageRead({
|
|
146
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
147
|
+
apiKey: config.apiKey,
|
|
148
|
+
phoneNumberId,
|
|
149
|
+
messageId,
|
|
150
|
+
typingIndicatorType: "text",
|
|
151
|
+
});
|
|
152
|
+
const react =
|
|
153
|
+
to && to.length > 0
|
|
154
|
+
? sendKapsoReaction({
|
|
155
|
+
apiBaseUrl: config.apiBaseUrl,
|
|
156
|
+
apiKey: config.apiKey,
|
|
157
|
+
phoneNumberId,
|
|
158
|
+
to,
|
|
159
|
+
messageId,
|
|
160
|
+
emoji: "👀",
|
|
161
|
+
})
|
|
162
|
+
: Promise.resolve<KapsoMessageActionResult>({
|
|
163
|
+
ok: false,
|
|
164
|
+
status: 0,
|
|
165
|
+
raw: null,
|
|
166
|
+
errorMessage: "missing sender phone",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const [readResult, reactionResult] = await Promise.allSettled([markRead, react]);
|
|
170
|
+
if (readResult.status === "fulfilled") {
|
|
171
|
+
logKapsoAckResult("mark-as-read/typing", readResult.value);
|
|
172
|
+
} else {
|
|
173
|
+
console.warn(
|
|
174
|
+
`[Kapso] Inbound acknowledgement mark-as-read/typing failed: ${readResult.reason}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (reactionResult.status === "fulfilled") {
|
|
178
|
+
logKapsoAckResult("reaction", reactionResult.value);
|
|
179
|
+
} else {
|
|
180
|
+
console.warn(`[Kapso] Inbound acknowledgement reaction failed: ${reactionResult.reason}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
111
184
|
export async function handleWebhooks(
|
|
112
185
|
req: IncomingMessage,
|
|
113
186
|
res: ServerResponse,
|
|
@@ -494,6 +567,8 @@ export async function handleWebhooks(
|
|
|
494
567
|
return true;
|
|
495
568
|
}
|
|
496
569
|
|
|
570
|
+
void acknowledgeKapsoInboundMessage(payload, config);
|
|
571
|
+
|
|
497
572
|
try {
|
|
498
573
|
const routing = routeKapsoInbound(payload);
|
|
499
574
|
switch (routing.kind) {
|