@desplega.ai/agent-swarm 1.52.1 → 1.53.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 +1517 -488
- package/package.json +5 -2
- package/src/be/db.ts +530 -0
- package/src/be/events.ts +322 -0
- package/src/be/migrations/021_events.sql +24 -0
- package/src/be/migrations/022_context_usage.sql +34 -0
- package/src/be/migrations/023_mcp_servers.sql +44 -0
- package/src/commands/runner.ts +348 -1
- package/src/http/context.ts +118 -0
- package/src/http/events.ts +188 -0
- package/src/http/index.ts +6 -0
- package/src/http/mcp-servers.ts +364 -0
- package/src/http/tasks.ts +33 -0
- package/src/linear/outbound.ts +8 -1
- package/src/linear/sync.ts +3 -0
- package/src/oauth/ensure-token.ts +50 -0
- package/src/prompts/base-prompt.ts +7 -0
- package/src/providers/claude-adapter.ts +156 -15
- package/src/providers/pi-mono-adapter.ts +68 -0
- package/src/providers/pi-mono-extension.ts +56 -2
- package/src/providers/pi-mono-mcp-client.ts +10 -1
- package/src/providers/types.ts +14 -1
- package/src/server.ts +19 -0
- package/src/tests/context-window.test.ts +66 -0
- package/src/tests/ensure-token.test.ts +170 -0
- package/src/tests/events-db.test.ts +314 -0
- package/src/tests/events-http.test.ts +267 -0
- package/src/tests/prompt-template-remaining.test.ts +5 -5
- package/src/tests/tool-annotations.test.ts +2 -2
- package/src/tests/vcs-tracking.test.ts +176 -0
- package/src/tests/workflow-executors.test.ts +8 -1
- package/src/tools/mcp-servers/index.ts +7 -0
- package/src/tools/mcp-servers/mcp-server-create.ts +138 -0
- package/src/tools/mcp-servers/mcp-server-delete.ts +72 -0
- package/src/tools/mcp-servers/mcp-server-get.ts +80 -0
- package/src/tools/mcp-servers/mcp-server-install.ts +110 -0
- package/src/tools/mcp-servers/mcp-server-list.ts +67 -0
- package/src/tools/mcp-servers/mcp-server-uninstall.ts +71 -0
- package/src/tools/mcp-servers/mcp-server-update.ts +120 -0
- package/src/tools/tool-config.ts +9 -0
- package/src/types.ts +153 -0
- package/src/utils/context-window.ts +41 -0
- package/src/workflows/executors/base.ts +9 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getOAuthApp, getOAuthTokens, isTokenExpiringSoon } from "../be/db-queries/oauth";
|
|
2
|
+
import { type OAuthProviderConfig, refreshAccessToken } from "./wrapper";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build an OAuthProviderConfig from the oauth_apps table for any provider.
|
|
6
|
+
*/
|
|
7
|
+
function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
8
|
+
const app = getOAuthApp(provider);
|
|
9
|
+
if (!app) return null;
|
|
10
|
+
|
|
11
|
+
const metadata = JSON.parse(app.metadata || "{}");
|
|
12
|
+
return {
|
|
13
|
+
provider,
|
|
14
|
+
clientId: app.clientId,
|
|
15
|
+
clientSecret: app.clientSecret,
|
|
16
|
+
authorizeUrl: app.authorizeUrl,
|
|
17
|
+
tokenUrl: app.tokenUrl,
|
|
18
|
+
redirectUri: app.redirectUri,
|
|
19
|
+
scopes: app.scopes.split(","),
|
|
20
|
+
extraParams: metadata.extraParams ?? (metadata.actor ? { actor: metadata.actor } : undefined),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Ensure a valid OAuth token exists for the given provider.
|
|
26
|
+
* If the token is expiring soon, attempt to refresh it.
|
|
27
|
+
* Call this before any API interaction with an OAuth-protected service.
|
|
28
|
+
*/
|
|
29
|
+
export async function ensureToken(provider: string): Promise<void> {
|
|
30
|
+
if (!isTokenExpiringSoon(provider)) return;
|
|
31
|
+
|
|
32
|
+
const config = getOAuthConfig(provider);
|
|
33
|
+
const tokens = getOAuthTokens(provider);
|
|
34
|
+
if (!config || !tokens?.refreshToken) {
|
|
35
|
+
console.warn(
|
|
36
|
+
`[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
|
|
37
|
+
);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
await refreshAccessToken(config, tokens.refreshToken);
|
|
43
|
+
console.log(`[OAuth] ${provider} token refreshed successfully`);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(
|
|
46
|
+
`[OAuth] Failed to refresh ${provider} token:`,
|
|
47
|
+
err instanceof Error ? err.message : err,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -40,6 +40,8 @@ export type BasePromptArgs = {
|
|
|
40
40
|
};
|
|
41
41
|
/** Pre-fetched skill summaries for the installed skills section */
|
|
42
42
|
skillsSummary?: { name: string; description: string }[];
|
|
43
|
+
/** Pre-fetched MCP server summaries for the installed MCP servers section */
|
|
44
|
+
mcpServersSummary?: string;
|
|
43
45
|
};
|
|
44
46
|
|
|
45
47
|
export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
|
|
@@ -76,6 +78,11 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
|
|
|
76
78
|
prompt += `\n\n## Installed Skills\n\nThe following skills are available. Use the Skill tool to invoke them by name.\n\n${summaries}\n`;
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
// Installed MCP servers section
|
|
82
|
+
if (args.mcpServersSummary) {
|
|
83
|
+
prompt += `\n\n## Installed MCP Servers\n\nThe following MCP servers are configured for your use:\n${args.mcpServersSummary}\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
79
86
|
// Repo context (protected, never truncated)
|
|
80
87
|
if (args.repoContext) {
|
|
81
88
|
prompt += "\n\n## Repository Context\n\n";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { unlink, writeFile } from "node:fs/promises";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
import { computeContextUsed, getContextWindowSize } from "../utils/context-window";
|
|
3
4
|
import { validateClaudeCredentials } from "../utils/credentials";
|
|
4
5
|
import {
|
|
5
6
|
parseStderrForErrors,
|
|
@@ -40,14 +41,83 @@ async function cleanupTaskFile(pid: number): Promise<void> {
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Fetch installed MCP servers from the API and return them as .mcp.json-compatible entries */
|
|
45
|
+
async function fetchInstalledMcpServers(
|
|
46
|
+
apiUrl: string,
|
|
47
|
+
apiKey: string,
|
|
48
|
+
agentId: string,
|
|
49
|
+
): Promise<Record<string, Record<string, unknown>> | null> {
|
|
50
|
+
try {
|
|
51
|
+
const res = await fetch(`${apiUrl}/api/agents/${agentId}/mcp-servers?resolveSecrets=true`, {
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${apiKey}`,
|
|
54
|
+
"X-Agent-ID": agentId,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) return null;
|
|
58
|
+
|
|
59
|
+
const data = (await res.json()) as {
|
|
60
|
+
servers: Array<{
|
|
61
|
+
name: string;
|
|
62
|
+
transport: string;
|
|
63
|
+
isActive: boolean;
|
|
64
|
+
isEnabled: boolean;
|
|
65
|
+
command?: string;
|
|
66
|
+
args?: string;
|
|
67
|
+
url?: string;
|
|
68
|
+
headers?: string;
|
|
69
|
+
resolvedEnv?: Record<string, string>;
|
|
70
|
+
resolvedHeaders?: Record<string, string>;
|
|
71
|
+
}>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const entries: Record<string, Record<string, unknown>> = {};
|
|
75
|
+
for (const srv of data.servers.filter((s) => s.isActive && s.isEnabled)) {
|
|
76
|
+
if (srv.transport === "stdio" && srv.command) {
|
|
77
|
+
let args: string[] = [];
|
|
78
|
+
try {
|
|
79
|
+
args = srv.args ? JSON.parse(srv.args) : [];
|
|
80
|
+
} catch {
|
|
81
|
+
// invalid JSON — use empty args
|
|
82
|
+
}
|
|
83
|
+
entries[srv.name] = {
|
|
84
|
+
command: srv.command,
|
|
85
|
+
args,
|
|
86
|
+
env: srv.resolvedEnv || {},
|
|
87
|
+
};
|
|
88
|
+
} else if ((srv.transport === "http" || srv.transport === "sse") && srv.url) {
|
|
89
|
+
let parsedHeaders: Record<string, string> = {};
|
|
90
|
+
try {
|
|
91
|
+
parsedHeaders = srv.headers ? JSON.parse(srv.headers) : {};
|
|
92
|
+
} catch {
|
|
93
|
+
// invalid JSON — use empty headers
|
|
94
|
+
}
|
|
95
|
+
entries[srv.name] = {
|
|
96
|
+
type: srv.transport,
|
|
97
|
+
url: srv.url,
|
|
98
|
+
headers: { ...parsedHeaders, ...(srv.resolvedHeaders || {}) },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return Object.keys(entries).length > 0 ? entries : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
43
108
|
/**
|
|
44
|
-
* Create a per-session MCP config file with X-Source-Task-Id header injected
|
|
109
|
+
* Create a per-session MCP config file with X-Source-Task-Id header injected
|
|
110
|
+
* and installed MCP servers merged in.
|
|
45
111
|
* Each session gets its own copy to avoid race conditions when multiple concurrent
|
|
46
112
|
* Claude sessions share the same cwd. The session-specific config is passed to
|
|
47
113
|
* Claude CLI via --mcp-config, so the shared .mcp.json is never modified.
|
|
48
114
|
* Returns the path to the per-session config, or null if no config exists.
|
|
49
115
|
*/
|
|
50
|
-
async function createSessionMcpConfig(
|
|
116
|
+
async function createSessionMcpConfig(
|
|
117
|
+
cwd: string,
|
|
118
|
+
taskId: string,
|
|
119
|
+
installedServers?: Record<string, Record<string, unknown>> | null,
|
|
120
|
+
): Promise<string | null> {
|
|
51
121
|
// Walk up from cwd to find .mcp.json (mirrors Claude CLI's project-level config discovery).
|
|
52
122
|
// In Docker, .mcp.json lives at /workspace/.mcp.json but tasks often run with cwd set to
|
|
53
123
|
// a subdirectory like /workspace/repos/<repo>, so a single-directory check misses it.
|
|
@@ -64,24 +134,42 @@ async function createSessionMcpConfig(cwd: string, taskId: string): Promise<stri
|
|
|
64
134
|
searchDir = parent;
|
|
65
135
|
}
|
|
66
136
|
|
|
67
|
-
if (!mcpJsonPath) return null;
|
|
137
|
+
if (!mcpJsonPath && !installedServers) return null;
|
|
68
138
|
|
|
69
139
|
try {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
140
|
+
let config: { mcpServers?: Record<string, unknown> } = { mcpServers: {} };
|
|
141
|
+
if (mcpJsonPath) {
|
|
142
|
+
const file = Bun.file(mcpJsonPath);
|
|
143
|
+
config = await file.json();
|
|
144
|
+
}
|
|
73
145
|
const servers = config?.mcpServers;
|
|
74
|
-
if (!servers) return null;
|
|
146
|
+
if (!servers && !installedServers) return null;
|
|
147
|
+
|
|
148
|
+
if (!config.mcpServers) config.mcpServers = {};
|
|
75
149
|
|
|
76
150
|
// Find the agent-swarm server entry (could be named "agent-swarm" or similar)
|
|
77
|
-
const serverKey = Object.keys(
|
|
78
|
-
(k) =>
|
|
151
|
+
const serverKey = Object.keys(config.mcpServers).find(
|
|
152
|
+
(k) =>
|
|
153
|
+
k === "agent-swarm" ||
|
|
154
|
+
((config.mcpServers![k] as Record<string, unknown>)?.headers &&
|
|
155
|
+
((config.mcpServers![k] as Record<string, Record<string, unknown>>).headers?.[
|
|
156
|
+
"X-Agent-ID"
|
|
157
|
+
] as unknown)),
|
|
79
158
|
);
|
|
80
|
-
if (
|
|
159
|
+
if (serverKey) {
|
|
160
|
+
const server = config.mcpServers[serverKey] as Record<string, unknown>;
|
|
161
|
+
if (!server.headers) server.headers = {};
|
|
162
|
+
(server.headers as Record<string, string>)["X-Source-Task-Id"] = taskId;
|
|
163
|
+
}
|
|
81
164
|
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
|
|
165
|
+
// Merge installed MCP servers (don't overwrite existing entries)
|
|
166
|
+
if (installedServers) {
|
|
167
|
+
for (const [name, serverConfig] of Object.entries(installedServers)) {
|
|
168
|
+
if (!config.mcpServers[name]) {
|
|
169
|
+
config.mcpServers[name] = serverConfig;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
85
173
|
|
|
86
174
|
// Write per-session config to /tmp — no race, each session has its own file
|
|
87
175
|
const sessionConfigPath = `/tmp/mcp-${taskId}.json`;
|
|
@@ -102,6 +190,7 @@ class ClaudeSession implements ProviderSession {
|
|
|
102
190
|
private completionPromise: Promise<ProviderResult>;
|
|
103
191
|
private errorTracker = new SessionErrorTracker();
|
|
104
192
|
private taskFilePid: number;
|
|
193
|
+
private contextWindowSize: number;
|
|
105
194
|
|
|
106
195
|
constructor(
|
|
107
196
|
private config: ProviderSessionConfig,
|
|
@@ -112,6 +201,7 @@ class ClaudeSession implements ProviderSession {
|
|
|
112
201
|
private claudeBinary: string = "claude",
|
|
113
202
|
) {
|
|
114
203
|
this.taskFilePid = taskFilePid;
|
|
204
|
+
this.contextWindowSize = getContextWindowSize(model);
|
|
115
205
|
const cmd = this.buildCommand();
|
|
116
206
|
|
|
117
207
|
console.log(
|
|
@@ -279,6 +369,19 @@ class ClaudeSession implements ProviderSession {
|
|
|
279
369
|
if (json.type === "system" && json.subtype === "init" && json.session_id) {
|
|
280
370
|
this._sessionId = json.session_id;
|
|
281
371
|
this.emit({ type: "session_init", sessionId: json.session_id });
|
|
372
|
+
if (json.model) {
|
|
373
|
+
this.contextWindowSize = getContextWindowSize(json.model);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Compaction detection
|
|
378
|
+
if (json.type === "system" && json.subtype === "compact_boundary" && json.compact_metadata) {
|
|
379
|
+
this.emit({
|
|
380
|
+
type: "compaction",
|
|
381
|
+
preCompactTokens: json.compact_metadata.pre_tokens ?? 0,
|
|
382
|
+
compactTrigger: json.compact_metadata.trigger ?? "auto",
|
|
383
|
+
contextTotalTokens: this.contextWindowSize,
|
|
384
|
+
});
|
|
282
385
|
}
|
|
283
386
|
|
|
284
387
|
// Cost data from result
|
|
@@ -312,6 +415,14 @@ class ClaudeSession implements ProviderSession {
|
|
|
312
415
|
cost,
|
|
313
416
|
isError: json.is_error || false,
|
|
314
417
|
});
|
|
418
|
+
|
|
419
|
+
// Update context window size from modelUsage if available
|
|
420
|
+
if (json.modelUsage) {
|
|
421
|
+
const modelKey = Object.keys(json.modelUsage)[0];
|
|
422
|
+
if (modelKey && json.modelUsage[modelKey]?.contextWindow) {
|
|
423
|
+
this.contextWindowSize = json.modelUsage[modelKey].contextWindow;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
315
426
|
}
|
|
316
427
|
|
|
317
428
|
// Tool use from assistant messages — emit tool_start for auto-progress
|
|
@@ -331,6 +442,21 @@ class ClaudeSession implements ProviderSession {
|
|
|
331
442
|
}
|
|
332
443
|
}
|
|
333
444
|
}
|
|
445
|
+
|
|
446
|
+
// Context usage extraction from assistant message usage
|
|
447
|
+
if (json.message.usage) {
|
|
448
|
+
const usage = json.message.usage;
|
|
449
|
+
const contextUsed = computeContextUsed(usage);
|
|
450
|
+
const contextTotal = this.contextWindowSize;
|
|
451
|
+
|
|
452
|
+
this.emit({
|
|
453
|
+
type: "context_usage",
|
|
454
|
+
contextUsedTokens: contextUsed,
|
|
455
|
+
contextTotalTokens: contextTotal,
|
|
456
|
+
contextPercent: contextTotal > 0 ? (contextUsed / contextTotal) * 100 : 0,
|
|
457
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
334
460
|
}
|
|
335
461
|
|
|
336
462
|
trackErrorFromJson(json, this.errorTracker);
|
|
@@ -435,8 +561,23 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
435
561
|
|
|
436
562
|
console.log(`\x1b[2m[${config.role}]\x1b[0m Task file written: ${taskFilePath}`);
|
|
437
563
|
|
|
438
|
-
//
|
|
439
|
-
const
|
|
564
|
+
// Fetch installed MCP servers from API for this agent
|
|
565
|
+
const installedServers =
|
|
566
|
+
config.apiUrl && config.apiKey && config.agentId
|
|
567
|
+
? await fetchInstalledMcpServers(config.apiUrl, config.apiKey, config.agentId)
|
|
568
|
+
: null;
|
|
569
|
+
if (installedServers) {
|
|
570
|
+
console.log(
|
|
571
|
+
`\x1b[2m[${config.role}]\x1b[0m Merging ${Object.keys(installedServers).length} installed MCP server(s) into session config`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Create per-session MCP config with X-Source-Task-Id header + installed servers (no shared-file race condition)
|
|
576
|
+
const sessionMcpConfig = await createSessionMcpConfig(
|
|
577
|
+
config.cwd,
|
|
578
|
+
config.taskId,
|
|
579
|
+
installedServers,
|
|
580
|
+
);
|
|
440
581
|
|
|
441
582
|
return new ClaudeSession(
|
|
442
583
|
config,
|
|
@@ -389,6 +389,74 @@ export class PiMonoAdapter implements ProviderAdapter {
|
|
|
389
389
|
} catch (err) {
|
|
390
390
|
console.warn(`\x1b[33m[${config.role}] Failed to discover MCP tools: ${err}\x1b[0m`);
|
|
391
391
|
}
|
|
392
|
+
|
|
393
|
+
// 2b. Discover tools from installed MCP servers (HTTP/SSE transport only)
|
|
394
|
+
try {
|
|
395
|
+
const mcpServersRes = await fetch(
|
|
396
|
+
`${config.apiUrl}/api/agents/${config.agentId}/mcp-servers?resolveSecrets=true`,
|
|
397
|
+
{
|
|
398
|
+
headers: {
|
|
399
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
400
|
+
"X-Agent-ID": config.agentId,
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
if (mcpServersRes.ok) {
|
|
405
|
+
const mcpServersData = (await mcpServersRes.json()) as {
|
|
406
|
+
servers: Array<{
|
|
407
|
+
name: string;
|
|
408
|
+
transport: string;
|
|
409
|
+
url?: string;
|
|
410
|
+
headers?: string;
|
|
411
|
+
isActive: boolean;
|
|
412
|
+
isEnabled: boolean;
|
|
413
|
+
resolvedHeaders?: Record<string, string>;
|
|
414
|
+
}>;
|
|
415
|
+
};
|
|
416
|
+
const httpServers = mcpServersData.servers.filter(
|
|
417
|
+
(s) =>
|
|
418
|
+
s.isActive &&
|
|
419
|
+
s.isEnabled &&
|
|
420
|
+
(s.transport === "http" || s.transport === "sse") &&
|
|
421
|
+
s.url,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
for (const srv of httpServers) {
|
|
425
|
+
try {
|
|
426
|
+
const srvClient = new McpHttpClient(srv.url!, "", "");
|
|
427
|
+
srvClient.useRawUrl = true;
|
|
428
|
+
// Build custom headers from static headers + resolved secret headers
|
|
429
|
+
let parsedHeaders: Record<string, string> = {};
|
|
430
|
+
try {
|
|
431
|
+
parsedHeaders = srv.headers ? JSON.parse(srv.headers) : {};
|
|
432
|
+
} catch {
|
|
433
|
+
// invalid JSON
|
|
434
|
+
}
|
|
435
|
+
srvClient.customHeaders = {
|
|
436
|
+
...parsedHeaders,
|
|
437
|
+
...(srv.resolvedHeaders || {}),
|
|
438
|
+
};
|
|
439
|
+
await srvClient.initialize();
|
|
440
|
+
const srvTools = await srvClient.listTools();
|
|
441
|
+
// Prefix tool names with mcp__<server-name>__ to avoid conflicts
|
|
442
|
+
const prefixed = mcpToolsToDefinitions(srvClient, srvTools).map((t) => ({
|
|
443
|
+
...t,
|
|
444
|
+
name: `mcp__${srv.name}__${t.name}`,
|
|
445
|
+
}));
|
|
446
|
+
customTools.push(...prefixed);
|
|
447
|
+
console.log(
|
|
448
|
+
`\x1b[2m[${config.role}]\x1b[0m Discovered ${srvTools.length} tools from MCP server "${srv.name}"`,
|
|
449
|
+
);
|
|
450
|
+
} catch (srvErr) {
|
|
451
|
+
console.warn(
|
|
452
|
+
`\x1b[33m[${config.role}] Failed to discover tools from MCP server "${srv.name}": ${srvErr}\x1b[0m`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
// Non-fatal — installed MCP server tool discovery is optional
|
|
459
|
+
}
|
|
392
460
|
}
|
|
393
461
|
|
|
394
462
|
// 3. Resolve model
|
|
@@ -383,6 +383,8 @@ ${transcript}`;
|
|
|
383
383
|
*/
|
|
384
384
|
export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFactory {
|
|
385
385
|
return (pi) => {
|
|
386
|
+
let lastContextPostTime = 0;
|
|
387
|
+
|
|
386
388
|
// === session_start → SessionStart ===
|
|
387
389
|
pi.on("session_start", async (_event, _ctx) => {
|
|
388
390
|
// Ping server
|
|
@@ -479,7 +481,7 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
|
|
|
479
481
|
});
|
|
480
482
|
|
|
481
483
|
// === tool_result → PostToolUse ===
|
|
482
|
-
pi.on("tool_result", async (event,
|
|
484
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
483
485
|
// Heartbeat (workers only, fire-and-forget)
|
|
484
486
|
if (!config.isLead && config.taskId) {
|
|
485
487
|
fireAndForget(`${config.apiUrl}/api/active-sessions/heartbeat/${config.taskId}`, {
|
|
@@ -494,6 +496,26 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
|
|
|
494
496
|
headers: apiHeaders(config),
|
|
495
497
|
});
|
|
496
498
|
|
|
499
|
+
// Throttled context usage reporting (every 30s)
|
|
500
|
+
const usage = ctx.getContextUsage?.();
|
|
501
|
+
if (config.taskId && usage?.tokens != null) {
|
|
502
|
+
const now = Date.now();
|
|
503
|
+
if (now - lastContextPostTime >= 30_000) {
|
|
504
|
+
lastContextPostTime = now;
|
|
505
|
+
fireAndForget(`${config.apiUrl}/api/tasks/${config.taskId}/context`, {
|
|
506
|
+
method: "POST",
|
|
507
|
+
headers: apiHeaders(config),
|
|
508
|
+
body: JSON.stringify({
|
|
509
|
+
eventType: "progress",
|
|
510
|
+
sessionId: `pi-${config.taskId}`,
|
|
511
|
+
contextUsedTokens: usage.tokens,
|
|
512
|
+
contextTotalTokens: usage.contextWindow,
|
|
513
|
+
contextPercent: usage.percent,
|
|
514
|
+
}),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
497
519
|
// Shared disk write failure detection (Archil only — safety net)
|
|
498
520
|
if (process.env.ARCHIL_MOUNT_TOKEN) {
|
|
499
521
|
// Pi-mono uses lowercase tool names
|
|
@@ -562,7 +584,7 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
|
|
|
562
584
|
// === context → PreCompact ===
|
|
563
585
|
// The context event allows injecting messages before compaction.
|
|
564
586
|
// We log the goal reminder to console (it gets captured in context).
|
|
565
|
-
pi.on("context", async (_event,
|
|
587
|
+
pi.on("context", async (_event, ctx) => {
|
|
566
588
|
if (!config.taskId) return undefined;
|
|
567
589
|
|
|
568
590
|
try {
|
|
@@ -583,6 +605,22 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
|
|
|
583
605
|
/* don't block compaction */
|
|
584
606
|
}
|
|
585
607
|
|
|
608
|
+
// Report context usage as a compaction event
|
|
609
|
+
const usage = ctx.getContextUsage?.();
|
|
610
|
+
if (usage) {
|
|
611
|
+
fireAndForget(`${config.apiUrl}/api/tasks/${config.taskId}/context`, {
|
|
612
|
+
method: "POST",
|
|
613
|
+
headers: apiHeaders(config),
|
|
614
|
+
body: JSON.stringify({
|
|
615
|
+
eventType: "compaction",
|
|
616
|
+
sessionId: `pi-${config.taskId}`,
|
|
617
|
+
contextUsedTokens: usage.tokens ?? undefined,
|
|
618
|
+
contextTotalTokens: usage.contextWindow,
|
|
619
|
+
contextPercent: usage.percent ?? undefined,
|
|
620
|
+
}),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
586
624
|
return undefined;
|
|
587
625
|
});
|
|
588
626
|
|
|
@@ -604,6 +642,22 @@ export function createSwarmHooksExtension(config: SwarmHooksConfig): ExtensionFa
|
|
|
604
642
|
|
|
605
643
|
// === session_shutdown → Stop ===
|
|
606
644
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
645
|
+
// Post final context usage before shutdown
|
|
646
|
+
const usage = ctx.getContextUsage?.();
|
|
647
|
+
if (config.taskId && usage) {
|
|
648
|
+
await fetch(`${config.apiUrl}/api/tasks/${config.taskId}/context`, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: apiHeaders(config),
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
eventType: "completion",
|
|
653
|
+
sessionId: `pi-${config.taskId}`,
|
|
654
|
+
contextTotalTokens: usage.contextWindow,
|
|
655
|
+
contextPercent: usage.percent ?? undefined,
|
|
656
|
+
contextUsedTokens: usage.tokens ?? undefined,
|
|
657
|
+
}),
|
|
658
|
+
}).catch(() => {});
|
|
659
|
+
}
|
|
660
|
+
|
|
607
661
|
// Sync identity files and setup script
|
|
608
662
|
await syncIdentityFilesToServer(config);
|
|
609
663
|
await syncSetupScriptToServer(config);
|
|
@@ -20,6 +20,13 @@ interface McpToolCallResult {
|
|
|
20
20
|
export class McpHttpClient {
|
|
21
21
|
private sessionId: string | null = null;
|
|
22
22
|
private nextId = 1;
|
|
23
|
+
/** Additional headers merged into every request (e.g. for installed MCP servers) */
|
|
24
|
+
public customHeaders: Record<string, string> = {};
|
|
25
|
+
/**
|
|
26
|
+
* When true, baseUrl is used as-is for requests (external MCP servers).
|
|
27
|
+
* When false (default), /mcp is appended (swarm convention).
|
|
28
|
+
*/
|
|
29
|
+
public useRawUrl = false;
|
|
23
30
|
|
|
24
31
|
constructor(
|
|
25
32
|
private baseUrl: string,
|
|
@@ -34,6 +41,7 @@ export class McpHttpClient {
|
|
|
34
41
|
Accept: "application/json, text/event-stream",
|
|
35
42
|
Authorization: `Bearer ${this.apiKey}`,
|
|
36
43
|
"X-Agent-ID": this.agentId,
|
|
44
|
+
...this.customHeaders,
|
|
37
45
|
};
|
|
38
46
|
if (this.taskId) {
|
|
39
47
|
headers["X-Source-Task-Id"] = this.taskId;
|
|
@@ -42,7 +50,8 @@ export class McpHttpClient {
|
|
|
42
50
|
headers["mcp-session-id"] = this.sessionId;
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
const
|
|
53
|
+
const url = this.useRawUrl ? this.baseUrl : `${this.baseUrl}/mcp`;
|
|
54
|
+
const res = await fetch(url, {
|
|
46
55
|
method: "POST",
|
|
47
56
|
headers,
|
|
48
57
|
body: JSON.stringify(body),
|
package/src/providers/types.ts
CHANGED
|
@@ -24,7 +24,20 @@ export type ProviderEvent =
|
|
|
24
24
|
| { type: "error"; message: string; category?: string }
|
|
25
25
|
| { type: "raw_log"; content: string }
|
|
26
26
|
| { type: "raw_stderr"; content: string }
|
|
27
|
-
| { type: "custom"; name: string; data: unknown }
|
|
27
|
+
| { type: "custom"; name: string; data: unknown }
|
|
28
|
+
| {
|
|
29
|
+
type: "context_usage";
|
|
30
|
+
contextUsedTokens: number;
|
|
31
|
+
contextTotalTokens: number;
|
|
32
|
+
contextPercent: number;
|
|
33
|
+
outputTokens: number;
|
|
34
|
+
}
|
|
35
|
+
| {
|
|
36
|
+
type: "compaction";
|
|
37
|
+
preCompactTokens: number;
|
|
38
|
+
compactTrigger: "auto" | "manual";
|
|
39
|
+
contextTotalTokens: number;
|
|
40
|
+
};
|
|
28
41
|
|
|
29
42
|
/** Configuration passed to a provider adapter to create a session. */
|
|
30
43
|
export interface ProviderSessionConfig {
|
package/src/server.ts
CHANGED
|
@@ -25,6 +25,16 @@ import { registerJoinSwarmTool } from "./tools/join-swarm";
|
|
|
25
25
|
// Messaging capability
|
|
26
26
|
import { registerListChannelsTool } from "./tools/list-channels";
|
|
27
27
|
import { registerListServicesTool } from "./tools/list-services";
|
|
28
|
+
// MCP Servers capability
|
|
29
|
+
import {
|
|
30
|
+
registerMcpServerCreateTool,
|
|
31
|
+
registerMcpServerDeleteTool,
|
|
32
|
+
registerMcpServerGetTool,
|
|
33
|
+
registerMcpServerInstallTool,
|
|
34
|
+
registerMcpServerListTool,
|
|
35
|
+
registerMcpServerUninstallTool,
|
|
36
|
+
registerMcpServerUpdateTool,
|
|
37
|
+
} from "./tools/mcp-servers";
|
|
28
38
|
// Memory capability
|
|
29
39
|
import { registerMemoryGetTool } from "./tools/memory-get";
|
|
30
40
|
import { registerMemorySearchTool } from "./tools/memory-search";
|
|
@@ -272,5 +282,14 @@ export function createServer() {
|
|
|
272
282
|
registerSkillSyncRemoteTool(server);
|
|
273
283
|
registerSkillPublishTool(server);
|
|
274
284
|
|
|
285
|
+
// MCP Servers - always registered
|
|
286
|
+
registerMcpServerCreateTool(server);
|
|
287
|
+
registerMcpServerUpdateTool(server);
|
|
288
|
+
registerMcpServerDeleteTool(server);
|
|
289
|
+
registerMcpServerGetTool(server);
|
|
290
|
+
registerMcpServerListTool(server);
|
|
291
|
+
registerMcpServerInstallTool(server);
|
|
292
|
+
registerMcpServerUninstallTool(server);
|
|
293
|
+
|
|
275
294
|
return server;
|
|
276
295
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { computeContextUsed, getContextWindowSize } from "../utils/context-window";
|
|
3
|
+
|
|
4
|
+
describe("getContextWindowSize", () => {
|
|
5
|
+
test("returns 1M for opus models", () => {
|
|
6
|
+
expect(getContextWindowSize("claude-opus-4-6")).toBe(1_000_000);
|
|
7
|
+
expect(getContextWindowSize("opus")).toBe(1_000_000);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("returns 1M for sonnet models", () => {
|
|
11
|
+
expect(getContextWindowSize("claude-sonnet-4-6")).toBe(1_000_000);
|
|
12
|
+
expect(getContextWindowSize("sonnet")).toBe(1_000_000);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("returns 200K for haiku models", () => {
|
|
16
|
+
expect(getContextWindowSize("claude-haiku-4-5")).toBe(200_000);
|
|
17
|
+
expect(getContextWindowSize("haiku")).toBe(200_000);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("returns 200K default for unknown models", () => {
|
|
21
|
+
expect(getContextWindowSize("gpt-5")).toBe(200_000);
|
|
22
|
+
expect(getContextWindowSize("unknown-model")).toBe(200_000);
|
|
23
|
+
expect(getContextWindowSize("")).toBe(200_000);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("returns default entry value", () => {
|
|
27
|
+
expect(getContextWindowSize("default")).toBe(200_000);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("computeContextUsed", () => {
|
|
32
|
+
test("sums all token fields", () => {
|
|
33
|
+
expect(
|
|
34
|
+
computeContextUsed({
|
|
35
|
+
input_tokens: 1000,
|
|
36
|
+
cache_creation_input_tokens: 500,
|
|
37
|
+
cache_read_input_tokens: 200,
|
|
38
|
+
}),
|
|
39
|
+
).toBe(1700);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("handles missing fields as zero", () => {
|
|
43
|
+
expect(computeContextUsed({})).toBe(0);
|
|
44
|
+
expect(computeContextUsed({ input_tokens: 100 })).toBe(100);
|
|
45
|
+
expect(computeContextUsed({ cache_read_input_tokens: 50 })).toBe(50);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("handles null fields as zero", () => {
|
|
49
|
+
expect(
|
|
50
|
+
computeContextUsed({
|
|
51
|
+
input_tokens: null,
|
|
52
|
+
cache_creation_input_tokens: null,
|
|
53
|
+
cache_read_input_tokens: null,
|
|
54
|
+
}),
|
|
55
|
+
).toBe(0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("handles mix of values, nulls, and missing", () => {
|
|
59
|
+
expect(
|
|
60
|
+
computeContextUsed({
|
|
61
|
+
input_tokens: 5000,
|
|
62
|
+
cache_creation_input_tokens: null,
|
|
63
|
+
}),
|
|
64
|
+
).toBe(5000);
|
|
65
|
+
});
|
|
66
|
+
});
|