@desplega.ai/agent-swarm 1.82.0 → 1.83.1
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/openapi.json +43 -5
- package/package.json +6 -6
- package/src/be/db.ts +16 -3
- package/src/be/migrations/073_task_attachments_agent_fs_ids.sql +15 -0
- package/src/be/schedules/validate.ts +21 -0
- package/src/be/skill-sync.ts +65 -15
- package/src/commands/provider-credentials.ts +11 -0
- package/src/commands/runner.ts +41 -54
- package/src/http/schedules.ts +34 -9
- package/src/http/skills.ts +27 -2
- package/src/http/tasks.ts +7 -3
- package/src/providers/pi-mono-adapter.ts +20 -3
- package/src/providers/types.ts +5 -1
- package/src/slack/blocks.ts +132 -1
- package/src/slack/responses.ts +15 -5
- package/src/slack/watcher.ts +12 -0
- package/src/tests/credential-check.test.ts +47 -0
- package/src/tests/http-api-integration.test.ts +36 -0
- package/src/tests/rest-api.test.ts +51 -1
- package/src/tests/runner-skills-refresh.test.ts +200 -0
- package/src/tests/schedule-validation-helper.test.ts +51 -0
- package/src/tests/skill-sync.test.ts +73 -9
- package/src/tests/skills-signature.test.ts +141 -0
- package/src/tests/slack-attachments-block.test.ts +240 -0
- package/src/tests/slack-blocks.test.ts +162 -0
- package/src/tests/slack-watcher.test.ts +83 -0
- package/src/tests/store-progress-attachments-handler.test.ts +480 -0
- package/src/tests/store-progress-attachments.test.ts +41 -0
- package/src/tests/update-schedule-mcp-tool.test.ts +161 -0
- package/src/tools/schedules/update-schedule.ts +48 -8
- package/src/tools/slack-upload-file.ts +17 -5
- package/src/tools/store-progress.ts +55 -19
- package/src/types.ts +21 -1
- package/src/utils/constants.ts +58 -0
- package/src/utils/skills-refresh.ts +123 -0
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.83.1",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
|
@@ -836,6 +836,7 @@
|
|
|
836
836
|
"env",
|
|
837
837
|
"file",
|
|
838
838
|
"side-effect-pending",
|
|
839
|
+
"sdk-delegated",
|
|
839
840
|
null
|
|
840
841
|
],
|
|
841
842
|
"default": null
|
|
@@ -6928,10 +6929,17 @@
|
|
|
6928
6929
|
"type": "string"
|
|
6929
6930
|
},
|
|
6930
6931
|
"cronExpression": {
|
|
6931
|
-
"type":
|
|
6932
|
+
"type": [
|
|
6933
|
+
"string",
|
|
6934
|
+
"null"
|
|
6935
|
+
]
|
|
6932
6936
|
},
|
|
6933
6937
|
"intervalMs": {
|
|
6934
|
-
"type":
|
|
6938
|
+
"type": [
|
|
6939
|
+
"integer",
|
|
6940
|
+
"null"
|
|
6941
|
+
],
|
|
6942
|
+
"exclusiveMinimum": 0
|
|
6935
6943
|
},
|
|
6936
6944
|
"taskTemplate": {
|
|
6937
6945
|
"type": "string"
|
|
@@ -7895,6 +7903,35 @@
|
|
|
7895
7903
|
}
|
|
7896
7904
|
}
|
|
7897
7905
|
},
|
|
7906
|
+
"/api/agents/{id}/skills/signature": {
|
|
7907
|
+
"get": {
|
|
7908
|
+
"summary": "Compute a stable signature over an agent's installed skills",
|
|
7909
|
+
"description": "Returns a sha256 hash over per-row mutation fields of the agent's active+enabled skill set. Workers poll this to detect skill changes cheaply without fetching the full list.",
|
|
7910
|
+
"tags": [
|
|
7911
|
+
"Skills"
|
|
7912
|
+
],
|
|
7913
|
+
"security": [
|
|
7914
|
+
{
|
|
7915
|
+
"bearerAuth": []
|
|
7916
|
+
}
|
|
7917
|
+
],
|
|
7918
|
+
"parameters": [
|
|
7919
|
+
{
|
|
7920
|
+
"schema": {
|
|
7921
|
+
"type": "string"
|
|
7922
|
+
},
|
|
7923
|
+
"required": true,
|
|
7924
|
+
"name": "id",
|
|
7925
|
+
"in": "path"
|
|
7926
|
+
}
|
|
7927
|
+
],
|
|
7928
|
+
"responses": {
|
|
7929
|
+
"200": {
|
|
7930
|
+
"description": "Skills signature"
|
|
7931
|
+
}
|
|
7932
|
+
}
|
|
7933
|
+
}
|
|
7934
|
+
},
|
|
7898
7935
|
"/api/scripts/upsert": {
|
|
7899
7936
|
"post": {
|
|
7900
7937
|
"operationId": "scripts_upsert",
|
|
@@ -9795,7 +9832,8 @@
|
|
|
9795
9832
|
},
|
|
9796
9833
|
"/api/tasks/{id}": {
|
|
9797
9834
|
"get": {
|
|
9798
|
-
"summary": "Get task details with logs",
|
|
9835
|
+
"summary": "Get task details with logs and attachments",
|
|
9836
|
+
"description": "Returns the full `AgentTask` row decorated with `logs` (capped by `logsLimit`) and `attachments` (pointer-based artifacts stored on the task, ordered by `created_at`).",
|
|
9799
9837
|
"tags": [
|
|
9800
9838
|
"Tasks"
|
|
9801
9839
|
],
|
|
@@ -9826,7 +9864,7 @@
|
|
|
9826
9864
|
],
|
|
9827
9865
|
"responses": {
|
|
9828
9866
|
"200": {
|
|
9829
|
-
"description": "Task with logs"
|
|
9867
|
+
"description": "Task with logs and attachments"
|
|
9830
9868
|
},
|
|
9831
9869
|
"404": {
|
|
9832
9870
|
"description": "Task not found"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desplega.ai/agent-swarm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.83.1",
|
|
4
4
|
"description": "Multi-agent orchestration for Claude Code, Codex, Gemini CLI, and other AI coding assistants",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "desplega.sh <contact@desplega.sh>",
|
|
@@ -108,12 +108,12 @@
|
|
|
108
108
|
"@desplega.ai/localtunnel": "^2.2.0",
|
|
109
109
|
"@inkjs/ui": "^2.0.0",
|
|
110
110
|
"@linear/sdk": "^77.0.0",
|
|
111
|
-
"@earendil-works/pi-agent-core": "^0.75.
|
|
112
|
-
"@earendil-works/pi-ai": "^0.75.
|
|
113
|
-
"@earendil-works/pi-coding-agent": "^0.75.
|
|
111
|
+
"@earendil-works/pi-agent-core": "^0.75.5",
|
|
112
|
+
"@earendil-works/pi-ai": "^0.75.5",
|
|
113
|
+
"@earendil-works/pi-coding-agent": "^0.75.5",
|
|
114
114
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
|
115
|
-
"@openai/codex-sdk": "^0.
|
|
116
|
-
"@opencode-ai/sdk": "^1.15.
|
|
115
|
+
"@openai/codex-sdk": "^0.133.0",
|
|
116
|
+
"@opencode-ai/sdk": "^1.15.10",
|
|
117
117
|
"@openfort/openfort-node": "^0.9.1",
|
|
118
118
|
"@opentelemetry/api": "^1.9.1",
|
|
119
119
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
package/src/be/db.ts
CHANGED
|
@@ -2146,6 +2146,8 @@ type TaskAttachmentRow = {
|
|
|
2146
2146
|
url: string | null;
|
|
2147
2147
|
path: string | null;
|
|
2148
2148
|
page_id: string | null;
|
|
2149
|
+
agent_fs_org_id: string | null;
|
|
2150
|
+
agent_fs_drive_id: string | null;
|
|
2149
2151
|
mime_type: string | null;
|
|
2150
2152
|
size_bytes: number | null;
|
|
2151
2153
|
sha256: string | null;
|
|
@@ -2165,6 +2167,8 @@ function rowToTaskAttachment(row: TaskAttachmentRow): TaskAttachment {
|
|
|
2165
2167
|
url: row.url ?? undefined,
|
|
2166
2168
|
path: row.path ?? undefined,
|
|
2167
2169
|
pageId: row.page_id ?? undefined,
|
|
2170
|
+
orgId: row.agent_fs_org_id ?? undefined,
|
|
2171
|
+
driveId: row.agent_fs_drive_id ?? undefined,
|
|
2168
2172
|
mimeType: row.mime_type ?? undefined,
|
|
2169
2173
|
sizeBytes: row.size_bytes ?? undefined,
|
|
2170
2174
|
sha256: row.sha256 ?? undefined,
|
|
@@ -2183,6 +2187,10 @@ export interface InsertTaskAttachmentInput {
|
|
|
2183
2187
|
url?: string;
|
|
2184
2188
|
path?: string;
|
|
2185
2189
|
pageId?: string;
|
|
2190
|
+
/** agent-fs only — paired with `driveId` to build a public live-host URL. */
|
|
2191
|
+
orgId?: string;
|
|
2192
|
+
/** agent-fs only — paired with `orgId` to build a public live-host URL. */
|
|
2193
|
+
driveId?: string;
|
|
2186
2194
|
mimeType?: string;
|
|
2187
2195
|
sizeBytes?: number;
|
|
2188
2196
|
sha256?: string;
|
|
@@ -2247,6 +2255,8 @@ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAtta
|
|
|
2247
2255
|
string | null,
|
|
2248
2256
|
string | null,
|
|
2249
2257
|
string | null,
|
|
2258
|
+
string | null,
|
|
2259
|
+
string | null,
|
|
2250
2260
|
number | null,
|
|
2251
2261
|
string | null,
|
|
2252
2262
|
string | null,
|
|
@@ -2256,8 +2266,9 @@ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAtta
|
|
|
2256
2266
|
>(
|
|
2257
2267
|
`INSERT INTO task_attachments
|
|
2258
2268
|
(id, task_id, agent_id, name, kind, url, path, page_id,
|
|
2269
|
+
agent_fs_org_id, agent_fs_drive_id,
|
|
2259
2270
|
mime_type, size_bytes, sha256, intent, description, is_primary)
|
|
2260
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2271
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2261
2272
|
RETURNING *`,
|
|
2262
2273
|
)
|
|
2263
2274
|
.get(
|
|
@@ -2269,6 +2280,8 @@ export function insertTaskAttachment(input: InsertTaskAttachmentInput): TaskAtta
|
|
|
2269
2280
|
input.url ?? null,
|
|
2270
2281
|
input.path ?? null,
|
|
2271
2282
|
input.pageId ?? null,
|
|
2283
|
+
input.orgId ?? null,
|
|
2284
|
+
input.driveId ?? null,
|
|
2272
2285
|
input.mimeType ?? null,
|
|
2273
2286
|
input.sizeBytes ?? null,
|
|
2274
2287
|
input.sha256 ?? null,
|
|
@@ -4893,8 +4906,8 @@ export function createScheduledTask(data: CreateScheduledTaskData): ScheduledTas
|
|
|
4893
4906
|
export interface UpdateScheduledTaskData {
|
|
4894
4907
|
name?: string;
|
|
4895
4908
|
description?: string;
|
|
4896
|
-
cronExpression?: string;
|
|
4897
|
-
intervalMs?: number;
|
|
4909
|
+
cronExpression?: string | null;
|
|
4910
|
+
intervalMs?: number | null;
|
|
4898
4911
|
taskTemplate?: string;
|
|
4899
4912
|
taskType?: string;
|
|
4900
4913
|
tags?: string[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- 073_task_attachments_agent_fs_ids.sql
|
|
2
|
+
-- Phase 2a follow-up: agent-fs `<org_id>/<drive_id>` columns on task_attachments.
|
|
3
|
+
--
|
|
4
|
+
-- The Phase 1 schema stores only the agent-fs `path`, so the Slack/UI renderers
|
|
5
|
+
-- have no way to build a public live-host URL like
|
|
6
|
+
-- ${AGENT_FS_LIVE_URL}/file/~/<org_id>/<drive_id>/<path>
|
|
7
|
+
-- and fall back to the opaque `agent-fs:<path>` string instead. This adds the
|
|
8
|
+
-- two missing columns so the renderers can emit clickable links.
|
|
9
|
+
--
|
|
10
|
+
-- Both columns are NULLABLE so existing rows stay valid and so non-agent-fs
|
|
11
|
+
-- kinds (`url`, `shared-fs`, `page`) are unaffected. No index — these columns
|
|
12
|
+
-- are always paired with a `task_id` filter (the resolver runs after the row
|
|
13
|
+
-- has already been located).
|
|
14
|
+
ALTER TABLE task_attachments ADD COLUMN agent_fs_org_id TEXT;
|
|
15
|
+
ALTER TABLE task_attachments ADD COLUMN agent_fs_drive_id TEXT;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type MergedScheduleTiming = { mergedCron: string | null; mergedInterval: number | null };
|
|
2
|
+
export type ScheduleTimingPatch = { cronExpression?: string | null; intervalMs?: number | null };
|
|
3
|
+
|
|
4
|
+
export function mergeScheduleTiming(
|
|
5
|
+
existing: { cronExpression: string | null; intervalMs: number | null },
|
|
6
|
+
patch: ScheduleTimingPatch,
|
|
7
|
+
): MergedScheduleTiming {
|
|
8
|
+
return {
|
|
9
|
+
mergedCron: patch.cronExpression !== undefined ? patch.cronExpression : existing.cronExpression,
|
|
10
|
+
mergedInterval: patch.intervalMs !== undefined ? patch.intervalMs : existing.intervalMs,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type RecurringTimingError = { kind: "both-null" } | null;
|
|
15
|
+
|
|
16
|
+
export function validateRecurringTiming(merged: MergedScheduleTiming): RecurringTimingError {
|
|
17
|
+
if (merged.mergedCron === null && merged.mergedInterval === null) {
|
|
18
|
+
return { kind: "both-null" };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
package/src/be/skill-sync.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Filesystem sync for skills.
|
|
3
3
|
*
|
|
4
|
-
* Writes installed skills to ~/.claude/skills/<name>/SKILL.md
|
|
5
|
-
* ~/.pi/agent/skills/<name>/SKILL.md
|
|
6
|
-
* natively.
|
|
4
|
+
* Writes installed skills to ~/.claude/skills/<name>/SKILL.md,
|
|
5
|
+
* ~/.pi/agent/skills/<name>/SKILL.md, and ~/.codex/skills/<name>/SKILL.md
|
|
6
|
+
* so Claude Code, Pi, and Codex discover them natively.
|
|
7
7
|
*
|
|
8
8
|
* This runs on the API side — workers call it via POST /api/skills/sync-filesystem.
|
|
9
9
|
*/
|
|
@@ -19,6 +19,16 @@ export interface SkillSyncResult {
|
|
|
19
19
|
errors: string[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Marker file written into every swarm-managed skill directory. Cleanup
|
|
24
|
+
* only ever removes directories that contain this marker, so unrelated
|
|
25
|
+
* personal skills the user installed via the harness's own tooling (e.g.
|
|
26
|
+
* `codex skills add ...` writing into `~/.codex/skills/<name>/`) are left
|
|
27
|
+
* untouched even when the API server shares a HOME with the worker (local
|
|
28
|
+
* dev). See `~/.codex/skills` blast-radius note in PR #555.
|
|
29
|
+
*/
|
|
30
|
+
const SWARM_MARKER_FILE = ".swarm-managed";
|
|
31
|
+
|
|
22
32
|
/**
|
|
23
33
|
* Sync agent's installed skills to the filesystem.
|
|
24
34
|
*
|
|
@@ -27,7 +37,7 @@ export interface SkillSyncResult {
|
|
|
27
37
|
*/
|
|
28
38
|
export function syncSkillsToFilesystem(
|
|
29
39
|
agentId: string,
|
|
30
|
-
harnessType: "claude" | "pi" | "
|
|
40
|
+
harnessType: "claude" | "pi" | "codex" | "all" = "all",
|
|
31
41
|
homeOverride?: string,
|
|
32
42
|
): SkillSyncResult {
|
|
33
43
|
const skills = getAgentSkills(agentId);
|
|
@@ -37,12 +47,15 @@ export function syncSkillsToFilesystem(
|
|
|
37
47
|
|
|
38
48
|
// Directories to write to
|
|
39
49
|
const skillDirs: string[] = [];
|
|
40
|
-
if (harnessType === "claude" || harnessType === "
|
|
50
|
+
if (harnessType === "claude" || harnessType === "all") {
|
|
41
51
|
skillDirs.push(join(home, ".claude", "skills"));
|
|
42
52
|
}
|
|
43
|
-
if (harnessType === "pi" || harnessType === "
|
|
53
|
+
if (harnessType === "pi" || harnessType === "all") {
|
|
44
54
|
skillDirs.push(join(home, ".pi", "agent", "skills"));
|
|
45
55
|
}
|
|
56
|
+
if (harnessType === "codex" || harnessType === "all") {
|
|
57
|
+
skillDirs.push(join(home, ".codex", "skills"));
|
|
58
|
+
}
|
|
46
59
|
|
|
47
60
|
// Ensure base dirs exist
|
|
48
61
|
for (const dir of skillDirs) {
|
|
@@ -66,10 +79,12 @@ export function syncSkillsToFilesystem(
|
|
|
66
79
|
for (const baseDir of skillDirs) {
|
|
67
80
|
const skillDir = join(baseDir, safeName);
|
|
68
81
|
const skillFile = join(skillDir, "SKILL.md");
|
|
82
|
+
const markerFile = join(skillDir, SWARM_MARKER_FILE);
|
|
69
83
|
|
|
70
84
|
try {
|
|
71
85
|
mkdirSync(skillDir, { recursive: true });
|
|
72
86
|
writeFileSync(skillFile, skill.content, "utf-8");
|
|
87
|
+
writeFileSync(markerFile, "", "utf-8");
|
|
73
88
|
synced++;
|
|
74
89
|
} catch (err) {
|
|
75
90
|
errors.push(
|
|
@@ -79,7 +94,10 @@ export function syncSkillsToFilesystem(
|
|
|
79
94
|
}
|
|
80
95
|
}
|
|
81
96
|
|
|
82
|
-
// Cleanup: remove
|
|
97
|
+
// Cleanup: only remove directories WE previously created (marker file
|
|
98
|
+
// present). Leaves user-installed personal skills alone — important on
|
|
99
|
+
// local dev where ~/.codex/skills holds skills the user installed
|
|
100
|
+
// outside the swarm.
|
|
83
101
|
let removed = 0;
|
|
84
102
|
for (const baseDir of skillDirs) {
|
|
85
103
|
if (!existsSync(baseDir)) continue;
|
|
@@ -87,14 +105,15 @@ export function syncSkillsToFilesystem(
|
|
|
87
105
|
try {
|
|
88
106
|
const existing = readdirSync(baseDir, { withFileTypes: true });
|
|
89
107
|
for (const entry of existing) {
|
|
90
|
-
if (entry.isDirectory()
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
108
|
+
if (!entry.isDirectory()) continue;
|
|
109
|
+
if (writtenNames.has(entry.name)) continue;
|
|
110
|
+
const skillDir = join(baseDir, entry.name);
|
|
111
|
+
if (!existsSync(join(skillDir, SWARM_MARKER_FILE))) continue;
|
|
112
|
+
try {
|
|
113
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
114
|
+
removed++;
|
|
115
|
+
} catch {
|
|
116
|
+
// Non-fatal — skip cleanup errors
|
|
98
117
|
}
|
|
99
118
|
}
|
|
100
119
|
} catch {
|
|
@@ -104,3 +123,34 @@ export function syncSkillsToFilesystem(
|
|
|
104
123
|
|
|
105
124
|
return { synced, removed, errors };
|
|
106
125
|
}
|
|
126
|
+
|
|
127
|
+
export interface SkillsSignature {
|
|
128
|
+
hash: string;
|
|
129
|
+
count: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Compute a stable signature over an agent's installed-and-enabled skill set.
|
|
134
|
+
*
|
|
135
|
+
* Hash inputs are the per-row mutation-tracking fields — any install,
|
|
136
|
+
* uninstall, toggle, or skill-update mutates at least one of them. Output is
|
|
137
|
+
* deterministic and contains no timestamps beyond per-row mutation fields.
|
|
138
|
+
*/
|
|
139
|
+
export function computeAgentSkillsSignature(agentId: string): SkillsSignature {
|
|
140
|
+
const skills = getAgentSkills(agentId);
|
|
141
|
+
const sorted = [...skills].sort((a, b) => a.id.localeCompare(b.id));
|
|
142
|
+
const canonical = JSON.stringify(
|
|
143
|
+
sorted.map((s) => [
|
|
144
|
+
s.id,
|
|
145
|
+
s.name,
|
|
146
|
+
s.version,
|
|
147
|
+
s.isEnabled,
|
|
148
|
+
s.isActive,
|
|
149
|
+
s.lastUpdatedAt,
|
|
150
|
+
s.sourceHash ?? "",
|
|
151
|
+
s.installedAt,
|
|
152
|
+
]),
|
|
153
|
+
);
|
|
154
|
+
const hash = new Bun.CryptoHasher("sha256").update(canonical).digest("hex");
|
|
155
|
+
return { hash, count: sorted.length };
|
|
156
|
+
}
|
|
@@ -238,6 +238,7 @@ function parseCodexOAuthAccess(blob: string | undefined): string | null {
|
|
|
238
238
|
* | `codex` | `~/.codex/auth.json` (file) → `CODEX_OAUTH` (env OAuth) → `OPENAI_API_KEY` | OpenAI `/v1/models` (api-key path only) |
|
|
239
239
|
* | `opencode` | `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` (pi-style) | matching provider's `/v1/models` |
|
|
240
240
|
* | `pi` | `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `OPENAI_API_KEY` | matching provider's `/v1/models` |
|
|
241
|
+
* | `pi` (bedrock) | `MODEL_OVERRIDE=amazon-bedrock/*` → AWS SDK default credential chain | presence-only (validated at first inference call) |
|
|
241
242
|
* | `devin` | `DEVIN_API_KEY` (+ `DEVIN_API_BASE_URL` override) | `${baseUrl}/v1/sessions?limit=1` |
|
|
242
243
|
*
|
|
243
244
|
* Returns `{ok: true, latency_ms}` on 2xx, `{ok: false, error, latency_ms}`
|
|
@@ -296,6 +297,16 @@ export async function validateProviderCredentials(provider: string): Promise<Liv
|
|
|
296
297
|
}
|
|
297
298
|
case "pi":
|
|
298
299
|
case "opencode": {
|
|
300
|
+
// pi-mono with MODEL_OVERRIDE=amazon-bedrock/* delegates credential
|
|
301
|
+
// resolution to the AWS SDK default chain (env, ~/.aws/*, SSO, IMDS,
|
|
302
|
+
// assume-role, …). pi-ai exposes no Bedrock-specific check we could
|
|
303
|
+
// call here, and the SDK chain may issue slow IMDS network calls on
|
|
304
|
+
// non-EC2 hosts — so the live test is a presence check, mirroring the
|
|
305
|
+
// codex-OAuth pattern above. Real validation happens at the first
|
|
306
|
+
// Bedrock inference call.
|
|
307
|
+
if (provider === "pi" && env.MODEL_OVERRIDE?.toLowerCase().startsWith("amazon-bedrock/")) {
|
|
308
|
+
return presenceCheckOk();
|
|
309
|
+
}
|
|
299
310
|
// Both pi-mono and opencode resolve credentials in the same order:
|
|
300
311
|
// OPENROUTER → ANTHROPIC → OPENAI. Live-test against the matching
|
|
301
312
|
// provider's models endpoint.
|
package/src/commands/runner.ts
CHANGED
|
@@ -42,6 +42,7 @@ import { parseRateLimitResetTime } from "../utils/error-tracker.ts";
|
|
|
42
42
|
import { resolveHarnessProvider } from "../utils/harness-provider.ts";
|
|
43
43
|
import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
|
|
44
44
|
import { scrubSecrets } from "../utils/secret-scrubber.ts";
|
|
45
|
+
import { refreshSkillsIfChanged } from "../utils/skills-refresh.ts";
|
|
45
46
|
import { detectVcsProvider } from "../vcs/index.ts";
|
|
46
47
|
import { interpolate } from "../workflows/template.ts";
|
|
47
48
|
import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
|
|
@@ -3511,34 +3512,6 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3511
3512
|
}
|
|
3512
3513
|
}
|
|
3513
3514
|
|
|
3514
|
-
// Fetch installed skills for system prompt
|
|
3515
|
-
try {
|
|
3516
|
-
const skillsResp = await fetch(`${apiUrl}/api/agents/${agentId}/skills`, {
|
|
3517
|
-
headers: {
|
|
3518
|
-
Authorization: `Bearer ${apiKey}`,
|
|
3519
|
-
"X-Agent-ID": agentId,
|
|
3520
|
-
},
|
|
3521
|
-
});
|
|
3522
|
-
if (skillsResp.ok) {
|
|
3523
|
-
const skillsData = (await skillsResp.json()) as {
|
|
3524
|
-
skills: {
|
|
3525
|
-
name: string;
|
|
3526
|
-
description: string;
|
|
3527
|
-
isActive: boolean;
|
|
3528
|
-
isEnabled: boolean;
|
|
3529
|
-
}[];
|
|
3530
|
-
};
|
|
3531
|
-
agentSkillsSummary = skillsData.skills
|
|
3532
|
-
.filter((s) => s.isActive && s.isEnabled)
|
|
3533
|
-
.map((s) => ({ name: s.name, description: s.description }));
|
|
3534
|
-
if (agentSkillsSummary.length > 0) {
|
|
3535
|
-
console.log(`[${role}] Loaded ${agentSkillsSummary.length} skills for system prompt`);
|
|
3536
|
-
}
|
|
3537
|
-
}
|
|
3538
|
-
} catch {
|
|
3539
|
-
// Non-fatal — skills are optional
|
|
3540
|
-
}
|
|
3541
|
-
|
|
3542
3515
|
// Fetch installed MCP servers for system prompt
|
|
3543
3516
|
try {
|
|
3544
3517
|
const mcpServersResp = await fetch(`${apiUrl}/api/agents/${agentId}/mcp-servers`, {
|
|
@@ -3649,35 +3622,32 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
3649
3622
|
}
|
|
3650
3623
|
}
|
|
3651
3624
|
|
|
3652
|
-
// ==========
|
|
3653
|
-
|
|
3625
|
+
// ========== Boot-time skill load (signature-gated, replaces the standalone
|
|
3626
|
+
// skill-fetch + FS sync blocks). The polling loop below calls the same
|
|
3627
|
+
// helper per task to hot-reload skills mid-flight. Skipped for
|
|
3628
|
+
// `claude-managed` (cloud sandbox owns skill delivery).
|
|
3629
|
+
const lastSkillHash: { current: string | null } = { current: null };
|
|
3630
|
+
if (state.harnessProvider !== "claude-managed") {
|
|
3654
3631
|
console.log(`[${role}] Syncing skills to filesystem...`);
|
|
3655
|
-
const
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
if (
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
});
|
|
3664
|
-
if (syncRes.ok) {
|
|
3665
|
-
const syncResult = (await syncRes.json()) as {
|
|
3666
|
-
synced: number;
|
|
3667
|
-
removed: number;
|
|
3668
|
-
errors: string[];
|
|
3669
|
-
};
|
|
3670
|
-
console.log(
|
|
3671
|
-
`[${role}] Skills synced: ${syncResult.synced} written, ${syncResult.removed} removed`,
|
|
3672
|
-
);
|
|
3673
|
-
if (syncResult.errors.length > 0) {
|
|
3674
|
-
console.warn(`[${role}] Skill sync errors: ${syncResult.errors.join(", ")}`);
|
|
3632
|
+
const skillResult = await refreshSkillsIfChanged(
|
|
3633
|
+
{ apiUrl, swarmUrl, apiKey, agentId, role },
|
|
3634
|
+
lastSkillHash,
|
|
3635
|
+
);
|
|
3636
|
+
if (skillResult.changed && skillResult.summary) {
|
|
3637
|
+
agentSkillsSummary = skillResult.summary;
|
|
3638
|
+
if (agentSkillsSummary.length > 0) {
|
|
3639
|
+
console.log(`[${role}] Loaded ${agentSkillsSummary.length} skills for system prompt`);
|
|
3675
3640
|
}
|
|
3676
|
-
|
|
3677
|
-
|
|
3641
|
+
// Rebuild base prompt now that we have skills.
|
|
3642
|
+
basePrompt = await buildSystemPrompt();
|
|
3643
|
+
resolvedSystemPrompt = additionalSystemPrompt
|
|
3644
|
+
? `${basePrompt}\n\n${additionalSystemPrompt}`
|
|
3645
|
+
: basePrompt;
|
|
3678
3646
|
}
|
|
3679
|
-
}
|
|
3680
|
-
console.
|
|
3647
|
+
} else {
|
|
3648
|
+
console.log(
|
|
3649
|
+
`[${role}] Skipping skill sync (claude-managed reads skills from agent definition)`,
|
|
3650
|
+
);
|
|
3681
3651
|
}
|
|
3682
3652
|
|
|
3683
3653
|
// ========== Resume paused tasks with PRIORITY ==========
|
|
@@ -4168,6 +4138,23 @@ export async function runAgent(config: RunnerConfig, opts: RunnerOptions) {
|
|
|
4168
4138
|
cwdWarning = `\n\nNote: The task requested working directory "${taskDir}" but it does not exist. Falling back to default directory.`;
|
|
4169
4139
|
}
|
|
4170
4140
|
|
|
4141
|
+
// Per-task skill hot-reload. Reuses the boot-time helper; signature
|
|
4142
|
+
// probe short-circuits when nothing changed. Skipped for
|
|
4143
|
+
// `claude-managed`. Read state.harnessProvider live so an adapter
|
|
4144
|
+
// swap mid-loop honors the new provider.
|
|
4145
|
+
if (state.harnessProvider !== "claude-managed") {
|
|
4146
|
+
const skillResult = await refreshSkillsIfChanged(
|
|
4147
|
+
{ apiUrl, swarmUrl, apiKey, agentId, role },
|
|
4148
|
+
lastSkillHash,
|
|
4149
|
+
);
|
|
4150
|
+
if (skillResult.changed && skillResult.summary) {
|
|
4151
|
+
agentSkillsSummary = skillResult.summary;
|
|
4152
|
+
console.log(
|
|
4153
|
+
`[${role}] Skills changed — refreshing system prompt (${agentSkillsSummary.length} skills)`,
|
|
4154
|
+
);
|
|
4155
|
+
}
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4171
4158
|
// Rebuild system prompt with per-task repo context
|
|
4172
4159
|
const taskBasePrompt = await buildSystemPrompt();
|
|
4173
4160
|
const taskSystemPrompt =
|
package/src/http/schedules.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getScheduledTasks,
|
|
12
12
|
updateScheduledTask,
|
|
13
13
|
} from "../be/db";
|
|
14
|
+
import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
|
|
14
15
|
import { calculateNextRun } from "../scheduler/scheduler";
|
|
15
16
|
import { scheduleContextKey } from "../tasks/context-key";
|
|
16
17
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
@@ -115,8 +116,8 @@ const updateSchedule = route({
|
|
|
115
116
|
body: z.object({
|
|
116
117
|
name: z.string().optional(),
|
|
117
118
|
description: z.string().optional(),
|
|
118
|
-
cronExpression: z.string().optional(),
|
|
119
|
-
intervalMs: z.number().int().optional(),
|
|
119
|
+
cronExpression: z.string().nullable().optional(),
|
|
120
|
+
intervalMs: z.number().int().positive().nullable().optional(),
|
|
120
121
|
taskTemplate: z.string().optional(),
|
|
121
122
|
taskType: z.string().optional(),
|
|
122
123
|
tags: z.array(z.string()).optional(),
|
|
@@ -400,6 +401,22 @@ export async function handleSchedules(
|
|
|
400
401
|
return true;
|
|
401
402
|
}
|
|
402
403
|
|
|
404
|
+
// Validate merged timing state — catches cases where one side is null in the DB
|
|
405
|
+
// and the patch nulls the other, which the schema-level check cannot see.
|
|
406
|
+
if (existing.scheduleType !== "one_time") {
|
|
407
|
+
const timing = mergeScheduleTiming(
|
|
408
|
+
{
|
|
409
|
+
cronExpression: existing.cronExpression ?? null,
|
|
410
|
+
intervalMs: existing.intervalMs ?? null,
|
|
411
|
+
},
|
|
412
|
+
{ cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
|
|
413
|
+
);
|
|
414
|
+
if (validateRecurringTiming(timing)) {
|
|
415
|
+
jsonError(res, "At least one of intervalMs or cronExpression must be set", 400);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
403
420
|
if (parsed.body.cronExpression) {
|
|
404
421
|
try {
|
|
405
422
|
CronExpressionParser.parse(parsed.body.cronExpression);
|
|
@@ -439,14 +456,22 @@ export async function handleSchedules(
|
|
|
439
456
|
parsed.body.intervalMs !== undefined ||
|
|
440
457
|
(parsed.body.enabled === true && !existing.enabled)
|
|
441
458
|
) {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
459
|
+
const timing = mergeScheduleTiming(
|
|
460
|
+
{
|
|
461
|
+
cronExpression: existing.cronExpression ?? null,
|
|
462
|
+
intervalMs: existing.intervalMs ?? null,
|
|
463
|
+
},
|
|
464
|
+
{ cronExpression: parsed.body.cronExpression, intervalMs: parsed.body.intervalMs },
|
|
465
|
+
);
|
|
466
|
+
const mergedTimezone =
|
|
467
|
+
parsed.body.timezone !== undefined ? parsed.body.timezone : existing.timezone;
|
|
468
|
+
if (timing.mergedCron || timing.mergedInterval) {
|
|
448
469
|
// biome-ignore lint/suspicious/noExplicitAny: need partial ScheduledTask for calculateNextRun
|
|
449
|
-
body.nextRunAt = calculateNextRun(
|
|
470
|
+
body.nextRunAt = calculateNextRun({
|
|
471
|
+
cronExpression: timing.mergedCron,
|
|
472
|
+
intervalMs: timing.mergedInterval,
|
|
473
|
+
timezone: mergedTimezone,
|
|
474
|
+
} as any);
|
|
450
475
|
}
|
|
451
476
|
}
|
|
452
477
|
}
|
package/src/http/skills.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
updateSkill,
|
|
13
13
|
} from "../be/db";
|
|
14
14
|
import { parseSkillContent } from "../be/skill-parser";
|
|
15
|
-
import { syncSkillsToFilesystem } from "../be/skill-sync";
|
|
15
|
+
import { computeAgentSkillsSignature, syncSkillsToFilesystem } from "../be/skill-sync";
|
|
16
16
|
import { route } from "./route-def";
|
|
17
17
|
import { json, jsonError } from "./utils";
|
|
18
18
|
|
|
@@ -193,6 +193,21 @@ const getAgentSkillsRoute = route({
|
|
|
193
193
|
},
|
|
194
194
|
});
|
|
195
195
|
|
|
196
|
+
const getAgentSkillsSignatureRoute = route({
|
|
197
|
+
method: "get",
|
|
198
|
+
path: "/api/agents/{id}/skills/signature",
|
|
199
|
+
pattern: ["api", "agents", null, "skills", "signature"],
|
|
200
|
+
summary: "Compute a stable signature over an agent's installed skills",
|
|
201
|
+
description:
|
|
202
|
+
"Returns a sha256 hash over per-row mutation fields of the agent's active+enabled skill set. Workers poll this to detect skill changes cheaply without fetching the full list.",
|
|
203
|
+
tags: ["Skills"],
|
|
204
|
+
auth: { apiKey: true },
|
|
205
|
+
params: z.object({ id: z.string() }),
|
|
206
|
+
responses: {
|
|
207
|
+
200: { description: "Skills signature" },
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
196
211
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
197
212
|
|
|
198
213
|
export async function handleSkills(
|
|
@@ -202,12 +217,22 @@ export async function handleSkills(
|
|
|
202
217
|
queryParams: URLSearchParams,
|
|
203
218
|
myAgentId: string | undefined,
|
|
204
219
|
): Promise<boolean> {
|
|
220
|
+
// GET /api/agents/:id/skills/signature (must come before the shorter pattern)
|
|
221
|
+
if (getAgentSkillsSignatureRoute.match(req.method, pathSegments)) {
|
|
222
|
+
const parsed = await getAgentSkillsSignatureRoute.parse(req, res, pathSegments, queryParams);
|
|
223
|
+
if (!parsed) return true;
|
|
224
|
+
const sig = computeAgentSkillsSignature(parsed.params.id);
|
|
225
|
+
json(res, { hash: sig.hash, count: sig.count, generatedAt: new Date().toISOString() });
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
|
|
205
229
|
// GET /api/agents/:id/skills (must be before /api/skills routes)
|
|
206
230
|
if (getAgentSkillsRoute.match(req.method, pathSegments)) {
|
|
207
231
|
const parsed = await getAgentSkillsRoute.parse(req, res, pathSegments, queryParams);
|
|
208
232
|
if (!parsed) return true;
|
|
209
233
|
const skills = getAgentSkills(parsed.params.id);
|
|
210
|
-
|
|
234
|
+
const signature = computeAgentSkillsSignature(parsed.params.id).hash;
|
|
235
|
+
json(res, { skills, total: skills.length, signature });
|
|
211
236
|
return true;
|
|
212
237
|
}
|
|
213
238
|
|