@desplega.ai/agent-swarm 1.84.1 → 1.85.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/openapi.json +1 -1
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +33 -0
- package/src/be/db.ts +7 -1
- package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
- package/src/commands/runner.ts +59 -6
- package/src/http/index.ts +11 -3
- package/src/http/tasks.ts +17 -0
- package/src/http/utils.ts +17 -0
- package/src/oauth/ensure-token.ts +97 -11
- package/src/providers/pi-mono-adapter.ts +44 -25
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +82 -0
- package/src/tests/agents-list-model-display.test.ts +13 -1
- package/src/tests/db-queries-oauth.test.ts +27 -0
- package/src/tests/ensure-token.test.ts +71 -0
- package/src/tests/http-log-scrubbing.test.ts +24 -0
- package/src/tests/list-endpoint-slimming.test.ts +22 -1
- package/src/tests/oauth-access-token-tool.test.ts +138 -0
- package/src/tests/pi-mono-adapter.test.ts +37 -1
- package/src/tests/runner-fallback-output.test.ts +118 -39
- package/src/tests/task-completion-idempotency.test.ts +89 -0
- package/src/tools/oauth-access-token.ts +118 -0
- package/src/tools/store-progress.ts +12 -77
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +5 -0
- package/src/utils/secret-scrubber.ts +23 -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.85.0",
|
|
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": [
|
package/package.json
CHANGED
|
@@ -180,3 +180,36 @@ export function isTokenExpiringSoon(provider: string, bufferMs = 5 * 60 * 1000):
|
|
|
180
180
|
const expiresAt = new Date(tokens.expiresAt).getTime();
|
|
181
181
|
return expiresAt - Date.now() < bufferMs;
|
|
182
182
|
}
|
|
183
|
+
|
|
184
|
+
// ── OAuth Refresh Locks ──
|
|
185
|
+
|
|
186
|
+
export function acquireOAuthRefreshLock(provider: string, ttlMs: number): string | null {
|
|
187
|
+
const owner = crypto.randomUUID();
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const expiresAt = new Date(now + ttlMs).toISOString();
|
|
190
|
+
const nowIso = new Date(now).toISOString();
|
|
191
|
+
|
|
192
|
+
getDb()
|
|
193
|
+
.query(
|
|
194
|
+
`INSERT INTO oauth_refresh_locks (provider, owner, expiresAt, createdAt, updatedAt)
|
|
195
|
+
VALUES (?, ?, ?, ?, ?)
|
|
196
|
+
ON CONFLICT(provider) DO UPDATE SET
|
|
197
|
+
owner = excluded.owner,
|
|
198
|
+
expiresAt = excluded.expiresAt,
|
|
199
|
+
updatedAt = excluded.updatedAt
|
|
200
|
+
WHERE oauth_refresh_locks.expiresAt <= ?`,
|
|
201
|
+
)
|
|
202
|
+
.run(provider, owner, expiresAt, nowIso, nowIso, nowIso);
|
|
203
|
+
|
|
204
|
+
const row = getDb()
|
|
205
|
+
.query("SELECT owner FROM oauth_refresh_locks WHERE provider = ?")
|
|
206
|
+
.get(provider) as { owner: string } | null;
|
|
207
|
+
|
|
208
|
+
return row?.owner === owner ? owner : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function releaseOAuthRefreshLock(provider: string, owner: string): void {
|
|
212
|
+
getDb()
|
|
213
|
+
.query("DELETE FROM oauth_refresh_locks WHERE provider = ? AND owner = ?")
|
|
214
|
+
.run(provider, owner);
|
|
215
|
+
}
|
package/src/be/db.ts
CHANGED
|
@@ -1012,6 +1012,7 @@ type AgentTaskRow = {
|
|
|
1012
1012
|
swarmVersion: string | null;
|
|
1013
1013
|
provider: string | null;
|
|
1014
1014
|
providerMeta: string | null;
|
|
1015
|
+
totalCostUsd?: number | null;
|
|
1015
1016
|
};
|
|
1016
1017
|
|
|
1017
1018
|
function rowToAgentTask(row: AgentTaskRow): AgentTask {
|
|
@@ -1075,6 +1076,7 @@ function rowToAgentTask(row: AgentTaskRow): AgentTask {
|
|
|
1075
1076
|
swarmVersion: row.swarmVersion ?? undefined,
|
|
1076
1077
|
provider: (row.provider as ProviderName | null) ?? undefined,
|
|
1077
1078
|
providerMeta: parseProviderMeta(row.provider as ProviderName | null, row.providerMeta),
|
|
1079
|
+
totalCostUsd: row.totalCostUsd ?? undefined,
|
|
1078
1080
|
};
|
|
1079
1081
|
}
|
|
1080
1082
|
|
|
@@ -1110,6 +1112,7 @@ function rowToAgentTaskSummary(row: AgentTaskRow): AgentTaskSummary {
|
|
|
1110
1112
|
lastUpdatedAt: t.lastUpdatedAt,
|
|
1111
1113
|
finishedAt: t.finishedAt,
|
|
1112
1114
|
peakContextPercent: t.peakContextPercent,
|
|
1115
|
+
totalCostUsd: t.totalCostUsd,
|
|
1113
1116
|
};
|
|
1114
1117
|
}
|
|
1115
1118
|
|
|
@@ -1504,7 +1507,10 @@ export function getAllTasks(
|
|
|
1504
1507
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
1505
1508
|
const limit = filters?.limit ?? 25;
|
|
1506
1509
|
const offset = filters?.offset ?? 0;
|
|
1507
|
-
const query = `SELECT
|
|
1510
|
+
const query = `SELECT agent_tasks.*,
|
|
1511
|
+
(SELECT SUM(totalCostUsd) FROM session_costs WHERE session_costs.taskId = agent_tasks.id) AS totalCostUsd
|
|
1512
|
+
FROM agent_tasks ${whereClause}
|
|
1513
|
+
ORDER BY lastUpdatedAt DESC, priority DESC LIMIT ${limit} OFFSET ${offset}`;
|
|
1508
1514
|
|
|
1509
1515
|
const rows = getDb()
|
|
1510
1516
|
.prepare<AgentTaskRow, (string | AgentTaskStatus)[]>(query)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
-- Cross-process mutex for OAuth refresh-token rotation.
|
|
2
|
+
CREATE TABLE IF NOT EXISTS oauth_refresh_locks (
|
|
3
|
+
provider TEXT PRIMARY KEY,
|
|
4
|
+
owner TEXT NOT NULL,
|
|
5
|
+
expiresAt TEXT NOT NULL,
|
|
6
|
+
createdAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
7
|
+
updatedAt TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
8
|
+
);
|
package/src/commands/runner.ts
CHANGED
|
@@ -48,6 +48,7 @@ import { prettyPrintLine, prettyPrintStderr } from "../utils/pretty-print.ts";
|
|
|
48
48
|
import { scrubSecrets } from "../utils/secret-scrubber.ts";
|
|
49
49
|
import { refreshSkillsIfChanged } from "../utils/skills-refresh.ts";
|
|
50
50
|
import { detectVcsProvider } from "../vcs/index.ts";
|
|
51
|
+
import { validateJsonSchema } from "../workflows/json-schema-validator.ts";
|
|
51
52
|
import { interpolate } from "../workflows/template.ts";
|
|
52
53
|
import { buildContextPreamble } from "./context-preamble.ts";
|
|
53
54
|
import { awaitCredentials, BootMaxWaitExceededError, EX_CONFIG } from "./credential-wait.ts";
|
|
@@ -704,6 +705,56 @@ Extract the structured data from the progress updates above. Return ONLY valid J
|
|
|
704
705
|
}
|
|
705
706
|
}
|
|
706
707
|
|
|
708
|
+
async function validateProviderOutputIfNeeded(
|
|
709
|
+
config: ApiConfig,
|
|
710
|
+
taskId: string,
|
|
711
|
+
providerOutput: string,
|
|
712
|
+
): Promise<{ ok: true } | { ok: false; failReason: string }> {
|
|
713
|
+
const headers: Record<string, string> = {
|
|
714
|
+
"Content-Type": "application/json",
|
|
715
|
+
};
|
|
716
|
+
if (config.apiKey) {
|
|
717
|
+
headers.Authorization = `Bearer ${config.apiKey}`;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
try {
|
|
721
|
+
const taskRes = await fetch(`${config.apiUrl}/api/tasks/${taskId}`, { headers });
|
|
722
|
+
if (!taskRes.ok) {
|
|
723
|
+
return { ok: true };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const taskData = (await taskRes.json()) as {
|
|
727
|
+
outputSchema?: Record<string, unknown>;
|
|
728
|
+
};
|
|
729
|
+
if (!taskData.outputSchema || typeof taskData.outputSchema !== "object") {
|
|
730
|
+
return { ok: true };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
let parsed: unknown;
|
|
734
|
+
try {
|
|
735
|
+
parsed = JSON.parse(providerOutput);
|
|
736
|
+
} catch {
|
|
737
|
+
return {
|
|
738
|
+
ok: false,
|
|
739
|
+
failReason:
|
|
740
|
+
"Structured output required by outputSchema but provider output was not valid JSON",
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const validationErrors = validateJsonSchema(taskData.outputSchema, parsed);
|
|
745
|
+
if (validationErrors.length > 0) {
|
|
746
|
+
return {
|
|
747
|
+
ok: false,
|
|
748
|
+
failReason: `Structured output did not match outputSchema: ${validationErrors.join("; ")}`,
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
} catch {
|
|
752
|
+
return { ok: true };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return { ok: true };
|
|
756
|
+
}
|
|
757
|
+
|
|
707
758
|
export async function ensureTaskFinished(
|
|
708
759
|
config: ApiConfig,
|
|
709
760
|
role: string,
|
|
@@ -734,12 +785,14 @@ export async function ensureTaskFinished(
|
|
|
734
785
|
if (status === "failed") {
|
|
735
786
|
body.failureReason = failureReason || `Claude process exited with code ${exitCode}`;
|
|
736
787
|
} else if (providerOutput) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
788
|
+
const validation = await validateProviderOutputIfNeeded(config, taskId, providerOutput);
|
|
789
|
+
if (validation.ok) {
|
|
790
|
+
body.output = providerOutput;
|
|
791
|
+
} else {
|
|
792
|
+
status = "failed";
|
|
793
|
+
body.status = "failed";
|
|
794
|
+
body.failureReason = validation.failReason;
|
|
795
|
+
}
|
|
743
796
|
} else {
|
|
744
797
|
// Try structured output fallback if the task has an outputSchema
|
|
745
798
|
const adapterType = provider ?? process.env.HARNESS_PROVIDER ?? "claude";
|
package/src/http/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { startSlackApp, stopSlackApp } from "../slack";
|
|
25
25
|
import { initTelemetry, telemetry } from "../telemetry";
|
|
26
26
|
import { getApiKey } from "../utils/api-key";
|
|
27
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
27
28
|
import { initWorkflows } from "../workflows";
|
|
28
29
|
import { handleActiveSessions } from "./active-sessions";
|
|
29
30
|
import { handleAgentRegister, handleAgentsRest } from "./agents";
|
|
@@ -68,6 +69,7 @@ import {
|
|
|
68
69
|
getPathSegments,
|
|
69
70
|
httpServerSemconvAttributes,
|
|
70
71
|
parseQueryParams,
|
|
72
|
+
safeRequestUrlForLog,
|
|
71
73
|
setCorsHeaders,
|
|
72
74
|
} from "./utils";
|
|
73
75
|
import { handleWebhooks } from "./webhooks";
|
|
@@ -124,7 +126,9 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
124
126
|
const logRequest = () => {
|
|
125
127
|
const elapsed = (performance.now() - startTime).toFixed(1);
|
|
126
128
|
const statusEmoji = statusCode >= 400 ? "⚠️" : "✓";
|
|
127
|
-
console.log(
|
|
129
|
+
console.log(
|
|
130
|
+
`[HTTP] ${statusEmoji} ${req.method} ${safeRequestUrlForLog(req.url)} → ${statusCode} (${elapsed}ms)`,
|
|
131
|
+
);
|
|
128
132
|
};
|
|
129
133
|
|
|
130
134
|
// Ensure we log on response finish
|
|
@@ -132,7 +136,9 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
132
136
|
|
|
133
137
|
// Log errors
|
|
134
138
|
res.on("error", (err) => {
|
|
135
|
-
console.error(
|
|
139
|
+
console.error(
|
|
140
|
+
`[HTTP] ❌ ${req.method} ${safeRequestUrlForLog(req.url)} → Error: ${scrubSecrets(err.message)}`,
|
|
141
|
+
);
|
|
136
142
|
});
|
|
137
143
|
|
|
138
144
|
await withRemoteContext(req.headers as Record<string, unknown>, async () => {
|
|
@@ -257,7 +263,9 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
257
263
|
span.setStatus({ code: 2, message: err instanceof Error ? err.message : String(err) });
|
|
258
264
|
}
|
|
259
265
|
const message = err instanceof Error ? err.message : String(err);
|
|
260
|
-
console.error(
|
|
266
|
+
console.error(
|
|
267
|
+
`[HTTP] ❌ ${req.method} ${safeRequestUrlForLog(req.url)} → ${scrubSecrets(message)}`,
|
|
268
|
+
);
|
|
261
269
|
if (!res.headersSent) {
|
|
262
270
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
263
271
|
res.end(JSON.stringify({ error: message }));
|
package/src/http/tasks.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
updateTaskVcs,
|
|
23
23
|
} from "../be/db";
|
|
24
24
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
25
|
+
import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
|
|
25
26
|
import { telemetry } from "../telemetry";
|
|
26
27
|
import {
|
|
27
28
|
type AgentTaskSource,
|
|
@@ -635,6 +636,22 @@ export async function handleTasks(
|
|
|
635
636
|
filter: ({}, ctx) => ctx.deps.length > 0,
|
|
636
637
|
conditions: [{ timeout_ms: 3_600_000 }], // 1 hour: task running time
|
|
637
638
|
});
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
const followUp = createWorkerTaskFollowUp({
|
|
642
|
+
task: result.task,
|
|
643
|
+
status: parsed.body.status,
|
|
644
|
+
output: parsed.body.output,
|
|
645
|
+
failureReason: parsed.body.failureReason,
|
|
646
|
+
});
|
|
647
|
+
if (followUp) {
|
|
648
|
+
console.log(
|
|
649
|
+
`[tasks.finish] Created follow-up task ${followUp.id.slice(0, 8)} for ${parsed.body.status} task ${parsed.params.id.slice(0, 8)}`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
} catch (err) {
|
|
653
|
+
console.warn(`[tasks.finish] Failed to create follow-up task: ${err}`);
|
|
654
|
+
}
|
|
638
655
|
}
|
|
639
656
|
|
|
640
657
|
json(res, {
|
package/src/http/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { getActiveTaskCount } from "../be/db";
|
|
3
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
3
4
|
|
|
4
5
|
export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
|
|
5
6
|
// Echo the request Origin (rather than emitting `*`) so credentialed fetches
|
|
@@ -46,6 +47,22 @@ export function getPathSegments(url: string): string[] {
|
|
|
46
47
|
return path.split("/").filter(Boolean);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
export function safeRequestUrlForLog(rawUrl: string | undefined): string {
|
|
51
|
+
if (!rawUrl) return "";
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(rawUrl, "http://localhost");
|
|
55
|
+
const params = Array.from(url.searchParams.keys());
|
|
56
|
+
if (params.length === 0) return url.pathname;
|
|
57
|
+
|
|
58
|
+
const redactedQuery = params.map((key) => `${key}=[REDACTED]`).join("&");
|
|
59
|
+
return `${url.pathname}?${redactedQuery}`;
|
|
60
|
+
} catch {
|
|
61
|
+
const pathOnly = rawUrl.split("?")[0] || rawUrl;
|
|
62
|
+
return scrubSecrets(pathOnly);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
/** Add capacity info to agent response */
|
|
50
67
|
export function agentWithCapacity<T extends { id: string; maxTasks?: number }>(
|
|
51
68
|
agent: T,
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
acquireOAuthRefreshLock,
|
|
3
|
+
getOAuthApp,
|
|
4
|
+
getOAuthTokens,
|
|
5
|
+
isTokenExpiringSoon,
|
|
6
|
+
releaseOAuthRefreshLock,
|
|
7
|
+
} from "../be/db-queries/oauth";
|
|
8
|
+
import type { OAuthTokens } from "../tracker/types";
|
|
2
9
|
import { type OAuthProviderConfig, refreshAccessToken } from "./wrapper";
|
|
3
10
|
|
|
11
|
+
const refreshLocks = new Map<string, Promise<void>>();
|
|
12
|
+
const REFRESH_LOCK_TTL_MS = 2 * 60 * 1000;
|
|
13
|
+
const REFRESH_LOCK_WAIT_MS = 30 * 1000;
|
|
14
|
+
const REFRESH_LOCK_POLL_MS = 250;
|
|
15
|
+
|
|
4
16
|
/**
|
|
5
17
|
* Build an OAuthProviderConfig from the oauth_apps table for any provider.
|
|
6
18
|
*/
|
|
@@ -22,6 +34,41 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
|
22
34
|
};
|
|
23
35
|
}
|
|
24
36
|
|
|
37
|
+
async function withProviderRefreshLock<T>(provider: string, fn: () => Promise<T>): Promise<T> {
|
|
38
|
+
const previous = refreshLocks.get(provider) ?? Promise.resolve();
|
|
39
|
+
let release!: () => void;
|
|
40
|
+
const current = new Promise<void>((resolve) => {
|
|
41
|
+
release = resolve;
|
|
42
|
+
});
|
|
43
|
+
const next = previous.catch(() => undefined).then(() => current);
|
|
44
|
+
refreshLocks.set(provider, next);
|
|
45
|
+
|
|
46
|
+
await previous.catch(() => undefined);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return await fn();
|
|
50
|
+
} finally {
|
|
51
|
+
release();
|
|
52
|
+
if (refreshLocks.get(provider) === next) {
|
|
53
|
+
refreshLocks.delete(provider);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sleep(ms: number): Promise<void> {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tokenRowChanged(current: OAuthTokens | null, observed: OAuthTokens | null): boolean {
|
|
63
|
+
if (!observed) return current !== null;
|
|
64
|
+
if (!current) return true;
|
|
65
|
+
return (
|
|
66
|
+
current.accessToken !== observed.accessToken ||
|
|
67
|
+
current.refreshToken !== observed.refreshToken ||
|
|
68
|
+
current.expiresAt !== observed.expiresAt
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
/**
|
|
26
73
|
* Ensure a valid OAuth token exists for the given provider.
|
|
27
74
|
* If the token is expiring soon, attempt to refresh it.
|
|
@@ -57,16 +104,55 @@ export async function ensureToken(provider: string, bufferMs?: number): Promise<
|
|
|
57
104
|
*/
|
|
58
105
|
export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
|
|
59
106
|
if (!isTokenExpiringSoon(provider, bufferMs)) return;
|
|
107
|
+
const observedTokens = getOAuthTokens(provider);
|
|
60
108
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
109
|
+
await withProviderRefreshLock(provider, async () => {
|
|
110
|
+
const waitStartedAt = Date.now();
|
|
111
|
+
|
|
112
|
+
while (isTokenExpiringSoon(provider, bufferMs)) {
|
|
113
|
+
const tokens = getOAuthTokens(provider);
|
|
114
|
+
if (tokenRowChanged(tokens, observedTokens)) return;
|
|
115
|
+
|
|
116
|
+
const config = getOAuthConfig(provider);
|
|
117
|
+
if (!config || !tokens?.refreshToken) {
|
|
118
|
+
console.warn(
|
|
119
|
+
`[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const lockOwner = acquireOAuthRefreshLock(provider, REFRESH_LOCK_TTL_MS);
|
|
125
|
+
if (!lockOwner) {
|
|
126
|
+
if (Date.now() - waitStartedAt > REFRESH_LOCK_WAIT_MS) {
|
|
127
|
+
throw new Error(`Timed out waiting for ${provider} OAuth token refresh lock`);
|
|
128
|
+
}
|
|
129
|
+
await sleep(REFRESH_LOCK_POLL_MS);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const lockedTokens = getOAuthTokens(provider);
|
|
135
|
+
if (
|
|
136
|
+
!isTokenExpiringSoon(provider, bufferMs) ||
|
|
137
|
+
tokenRowChanged(lockedTokens, observedTokens)
|
|
138
|
+
) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lockedConfig = getOAuthConfig(provider);
|
|
143
|
+
if (!lockedConfig || !lockedTokens?.refreshToken) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
69
149
|
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
await refreshAccessToken(lockedConfig, lockedTokens.refreshToken);
|
|
151
|
+
console.log(`[OAuth] ${provider} token refreshed successfully`);
|
|
152
|
+
return;
|
|
153
|
+
} finally {
|
|
154
|
+
releaseOAuthRefreshLock(provider, lockOwner);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
72
158
|
}
|
|
@@ -316,6 +316,26 @@ function cleanupAgentsMdSymlink(cwd: string): void {
|
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
function extractTextContent(content: unknown): string {
|
|
320
|
+
if (typeof content === "string") return content.trim();
|
|
321
|
+
if (!Array.isArray(content)) return "";
|
|
322
|
+
return content
|
|
323
|
+
.filter(
|
|
324
|
+
(c): c is { type?: string; text?: string } =>
|
|
325
|
+
typeof c === "object" && c !== null && (c as { type?: string }).type === "text",
|
|
326
|
+
)
|
|
327
|
+
.map((c) => c.text || "")
|
|
328
|
+
.join("")
|
|
329
|
+
.trim();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function extractPiAssistantText(message: unknown): string {
|
|
333
|
+
if (!message || typeof message !== "object") return "";
|
|
334
|
+
const msg = message as { role?: string; content?: unknown };
|
|
335
|
+
if (msg.role !== "assistant") return "";
|
|
336
|
+
return extractTextContent(msg.content);
|
|
337
|
+
}
|
|
338
|
+
|
|
319
339
|
export class PiMonoSession implements ProviderSession {
|
|
320
340
|
private listeners: Array<(event: ProviderEvent) => void> = [];
|
|
321
341
|
private eventQueue: ProviderEvent[] = [];
|
|
@@ -327,6 +347,8 @@ export class PiMonoSession implements ProviderSession {
|
|
|
327
347
|
private logFileHandle: ReturnType<ReturnType<typeof Bun.file>["writer"]>;
|
|
328
348
|
/** Track last emitted message text to avoid duplicates across turns */
|
|
329
349
|
private lastEmittedMessage = "";
|
|
350
|
+
/** Last assistant text surfaced by pi-mono; used as runner fallback output. */
|
|
351
|
+
private lastAssistantText = "";
|
|
330
352
|
/** Phase 7: wallclock start so we can populate `durationMs` on the cost row. */
|
|
331
353
|
private sessionStartedAt: number = Date.now();
|
|
332
354
|
/**
|
|
@@ -391,31 +413,27 @@ export class PiMonoSession implements ProviderSession {
|
|
|
391
413
|
private handleAgentEvent(event: AgentSessionEvent): void {
|
|
392
414
|
switch (event.type) {
|
|
393
415
|
case "message_end": {
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
:
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}),
|
|
416
|
-
});
|
|
417
|
-
this.lastEmittedMessage = text;
|
|
418
|
-
}
|
|
416
|
+
// Pi emits message_end for user, assistant, and tool-result messages.
|
|
417
|
+
// Only assistant text should be printed or used as fallback output.
|
|
418
|
+
const text = extractPiAssistantText(event.message);
|
|
419
|
+
if (text) {
|
|
420
|
+
this.lastAssistantText = text;
|
|
421
|
+
}
|
|
422
|
+
if (text && text !== this.lastEmittedMessage) {
|
|
423
|
+
const model = this.reportedModel();
|
|
424
|
+
this.emit({
|
|
425
|
+
type: "raw_log",
|
|
426
|
+
content: JSON.stringify({
|
|
427
|
+
type: "assistant",
|
|
428
|
+
message: {
|
|
429
|
+
role: "assistant",
|
|
430
|
+
content: [{ type: "text", text }],
|
|
431
|
+
model,
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
});
|
|
435
|
+
this.emit({ type: "message", role: "assistant", content: text });
|
|
436
|
+
this.lastEmittedMessage = text;
|
|
419
437
|
}
|
|
420
438
|
// Emit context_usage for dashboard tracking.
|
|
421
439
|
// Phase 7: derive `outputTokens` from `SessionStats` delta (pi-ai's
|
|
@@ -522,6 +540,7 @@ export class PiMonoSession implements ProviderSession {
|
|
|
522
540
|
exitCode: 0,
|
|
523
541
|
sessionId: this._sessionId,
|
|
524
542
|
cost,
|
|
543
|
+
output: this.lastAssistantText || undefined,
|
|
525
544
|
isError: false,
|
|
526
545
|
};
|
|
527
546
|
} catch (err) {
|
package/src/server.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { registerMemoryGetTool } from "./tools/memory-get";
|
|
|
43
43
|
import { registerMemoryRateTool } from "./tools/memory-rate";
|
|
44
44
|
import { registerMemorySearchTool } from "./tools/memory-search";
|
|
45
45
|
import { registerMyAgentInfoTool } from "./tools/my-agent-info";
|
|
46
|
+
import { registerGetOauthAccessTokenTool } from "./tools/oauth-access-token";
|
|
46
47
|
import { registerPollTaskTool } from "./tools/poll-task";
|
|
47
48
|
import { registerPostMessageTool } from "./tools/post-message";
|
|
48
49
|
// Prompt template tools
|
|
@@ -199,6 +200,7 @@ export function createServer() {
|
|
|
199
200
|
|
|
200
201
|
// Debug tools - always registered (self-guards with lead check)
|
|
201
202
|
registerDbQueryTool(server);
|
|
203
|
+
registerGetOauthAccessTokenTool(server);
|
|
202
204
|
|
|
203
205
|
// Swarm config tools - always registered (config management is fundamental)
|
|
204
206
|
registerSetConfigTool(server);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createTaskExtended, getAgentById, getLeadAgent, getTaskAttachments } from "../be/db";
|
|
2
|
+
import { resolveTemplate } from "../prompts/resolver";
|
|
3
|
+
import type { AgentTask, TaskAttachment } from "../types";
|
|
4
|
+
// Side-effect import: registers task lifecycle templates in the in-memory registry.
|
|
5
|
+
import "../tools/templates";
|
|
6
|
+
|
|
7
|
+
function attachmentPointer(a: TaskAttachment): string {
|
|
8
|
+
switch (a.kind) {
|
|
9
|
+
case "url":
|
|
10
|
+
return a.url ?? "";
|
|
11
|
+
case "page":
|
|
12
|
+
return `page:${a.pageId ?? ""}`;
|
|
13
|
+
case "agent-fs":
|
|
14
|
+
return `agent-fs:${a.path ?? ""}`;
|
|
15
|
+
case "shared-fs":
|
|
16
|
+
return `shared-fs:${a.path ?? ""}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatAttachmentsBlock(attachments: TaskAttachment[]): string {
|
|
21
|
+
if (attachments.length === 0) return "";
|
|
22
|
+
const lines = attachments.map((a) => {
|
|
23
|
+
const tag = a.isPrimary ? "[primary] " : "";
|
|
24
|
+
const intent = a.intent ? ` (intent: ${a.intent})` : "";
|
|
25
|
+
return `- ${tag}${a.name} - ${attachmentPointer(a)}${intent}`;
|
|
26
|
+
});
|
|
27
|
+
return `\n\nAttachments (${attachments.length}):\n${lines.join("\n")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createWorkerTaskFollowUp(args: {
|
|
31
|
+
task: AgentTask;
|
|
32
|
+
status: "completed" | "failed";
|
|
33
|
+
output?: string;
|
|
34
|
+
failureReason?: string;
|
|
35
|
+
}): AgentTask | null {
|
|
36
|
+
const { task, status, output, failureReason } = args;
|
|
37
|
+
|
|
38
|
+
if (task.workflowRunId) return null;
|
|
39
|
+
|
|
40
|
+
const taskAgent = getAgentById(task.agentId ?? "");
|
|
41
|
+
if (!taskAgent || taskAgent.isLead) return null;
|
|
42
|
+
|
|
43
|
+
const leadAgent = getLeadAgent();
|
|
44
|
+
if (!leadAgent) return null;
|
|
45
|
+
|
|
46
|
+
const agentName = taskAgent.name || task.agentId?.slice(0, 8) || "Unknown";
|
|
47
|
+
const taskDesc = task.task.slice(0, 200);
|
|
48
|
+
|
|
49
|
+
let followUpDescription: string;
|
|
50
|
+
if (status === "completed") {
|
|
51
|
+
const attachmentsBlock = formatAttachmentsBlock(getTaskAttachments(task.id));
|
|
52
|
+
const outputSummary = output
|
|
53
|
+
? `${output.slice(0, 500)}${output.length > 500 ? "..." : ""}${attachmentsBlock}`
|
|
54
|
+
: `(no output)${attachmentsBlock}`;
|
|
55
|
+
const completedResult = resolveTemplate("task.worker.completed", {
|
|
56
|
+
agent_name: agentName,
|
|
57
|
+
task_desc: taskDesc,
|
|
58
|
+
output_summary: outputSummary,
|
|
59
|
+
task_id: task.id,
|
|
60
|
+
});
|
|
61
|
+
followUpDescription = completedResult.text;
|
|
62
|
+
} else {
|
|
63
|
+
const reason = failureReason || "(no reason given)";
|
|
64
|
+
const failedResult = resolveTemplate("task.worker.failed", {
|
|
65
|
+
agent_name: agentName,
|
|
66
|
+
task_desc: taskDesc,
|
|
67
|
+
failure_reason: reason,
|
|
68
|
+
task_id: task.id,
|
|
69
|
+
});
|
|
70
|
+
followUpDescription = failedResult.text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return createTaskExtended(followUpDescription, {
|
|
74
|
+
agentId: leadAgent.id,
|
|
75
|
+
source: "system",
|
|
76
|
+
taskType: "follow-up",
|
|
77
|
+
parentTaskId: task.id,
|
|
78
|
+
slackChannelId: task.slackChannelId,
|
|
79
|
+
slackThreadTs: task.slackThreadTs,
|
|
80
|
+
slackUserId: task.slackUserId,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getAgentModelDisplay,
|
|
4
|
+
getAgentModelPresentation,
|
|
5
|
+
} from "../../ui/src/lib/agents-list-model-display";
|
|
3
6
|
|
|
4
7
|
describe("agents list model display", () => {
|
|
5
8
|
test("shows configured and last-used models when they diverge", () => {
|
|
@@ -30,4 +33,13 @@ describe("agents list model display", () => {
|
|
|
30
33
|
expect(display.primary).toBe("claude-opus-4-7");
|
|
31
34
|
expect(display.diverged).toBe(false);
|
|
32
35
|
});
|
|
36
|
+
|
|
37
|
+
test("presents known provider-prefixed model ids as readable labels", () => {
|
|
38
|
+
expect(getAgentModelPresentation("openrouter/deepseek/deepseek-v4-flash")).toEqual({
|
|
39
|
+
raw: "openrouter/deepseek/deepseek-v4-flash",
|
|
40
|
+
label: "DeepSeek V4 Flash",
|
|
41
|
+
provider: "OpenRouter",
|
|
42
|
+
providerId: "openrouter",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
33
45
|
});
|