@desplega.ai/agent-swarm 1.77.3 → 1.78.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/commands/runner.ts +8 -2
- package/src/providers/codex-adapter.ts +78 -21
- package/src/providers/pi-mono-adapter.ts +77 -20
- package/src/server.ts +6 -5
- package/src/tests/codex-adapter.test.ts +82 -0
- package/src/tests/credential-check.test.ts +23 -2
- package/src/tests/pi-mono-adapter.test.ts +63 -1
- package/src/tests/workflow-executors.test.ts +15 -6
- package/src/tests/workflow-wait-event.test.ts +5 -2
- package/src/workflows/engine.ts +5 -0
- package/src/workflows/executors/script.ts +13 -3
- package/src/x402/README.md +67 -0
- package/src/x402/index.ts +7 -1
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.78.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
package/src/commands/runner.ts
CHANGED
|
@@ -2257,8 +2257,14 @@ async function checkCompletedProcesses(
|
|
|
2257
2257
|
failureReason = result.failureReason;
|
|
2258
2258
|
console.log(`[${role}] Detected error for task ${taskId.slice(0, 8)}: ${failureReason}`);
|
|
2259
2259
|
|
|
2260
|
-
// If rate-limited and we know which key was used, report it
|
|
2261
|
-
|
|
2260
|
+
// If rate-limited and we know which key was used, report it.
|
|
2261
|
+
// Codex adapter prefixes failure reasons with `[rate-limit]` /
|
|
2262
|
+
// `[usage-limit]` (see codex-adapter.formatTerminalError); Claude
|
|
2263
|
+
// surfaces "rate limit" / "hit your limit" via SessionErrorTracker.
|
|
2264
|
+
if (
|
|
2265
|
+
credentialInfo &&
|
|
2266
|
+
/rate.?limit|hit your limit|usage[ _-]?limit|too many requests/i.test(failureReason)
|
|
2267
|
+
) {
|
|
2262
2268
|
// Try to extract reset time from the error message (e.g. "resets 3pm (UTC)")
|
|
2263
2269
|
const parsedResetTime = parseRateLimitResetTime(failureReason);
|
|
2264
2270
|
const defaultCooldownMs = 5 * 60 * 1000;
|
|
@@ -748,7 +748,10 @@ class CodexSession implements ProviderSession {
|
|
|
748
748
|
}
|
|
749
749
|
case "error": {
|
|
750
750
|
const errItem = item as ErrorItem;
|
|
751
|
-
this.emit({
|
|
751
|
+
this.emit({
|
|
752
|
+
type: "error",
|
|
753
|
+
message: this.formatTerminalError(errItem.message).message,
|
|
754
|
+
});
|
|
752
755
|
break;
|
|
753
756
|
}
|
|
754
757
|
}
|
|
@@ -792,12 +795,12 @@ class CodexSession implements ProviderSession {
|
|
|
792
795
|
break;
|
|
793
796
|
}
|
|
794
797
|
case "turn.failed": {
|
|
795
|
-
const message = this.formatTerminalError(event.error.message);
|
|
798
|
+
const { message } = this.formatTerminalError(event.error.message);
|
|
796
799
|
this.emit({ type: "error", message });
|
|
797
800
|
break;
|
|
798
801
|
}
|
|
799
802
|
case "error": {
|
|
800
|
-
const message = this.formatTerminalError(event.message);
|
|
803
|
+
const { message } = this.formatTerminalError(event.message);
|
|
801
804
|
this.emit({ type: "error", message });
|
|
802
805
|
break;
|
|
803
806
|
}
|
|
@@ -805,22 +808,27 @@ class CodexSession implements ProviderSession {
|
|
|
805
808
|
}
|
|
806
809
|
|
|
807
810
|
/**
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
* `
|
|
813
|
-
*
|
|
811
|
+
* Categorize a terminal error from the Codex SDK and rewrite with a clearer
|
|
812
|
+
* prefix that the runner / dashboard can key on. The Codex app-server emits a
|
|
813
|
+
* structured `codexErrorInfo` discriminator
|
|
814
|
+
* (https://developers.openai.com/codex/app-server#errors) with values like
|
|
815
|
+
* `ContextWindowExceeded`, `UsageLimitExceeded`, `Unauthorized`, etc. — but
|
|
816
|
+
* `@openai/codex-sdk`'s `ThreadError` only surfaces the flat `message`
|
|
817
|
+
* string, so we still detect by pattern. Patterns below match the canonical
|
|
818
|
+
* `codexErrorInfo` name (which sometimes appears literally in the message)
|
|
819
|
+
* AND the human-readable text Codex puts in `error.message`.
|
|
814
820
|
*
|
|
815
|
-
*
|
|
816
|
-
*
|
|
817
|
-
* -
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
* - "request too large"
|
|
821
|
+
* Categories returned are consumed two ways:
|
|
822
|
+
* 1. `errorCategory` on the `result` event (dashboard surfacing).
|
|
823
|
+
* 2. The bracketed prefix in `failureReason` (`[usage-limit]` etc.) is
|
|
824
|
+
* what runner.ts pattern-matches to flag the credential as
|
|
825
|
+
* rate-limited in the rotation pool.
|
|
821
826
|
*/
|
|
822
|
-
private formatTerminalError(raw: string): string {
|
|
827
|
+
private formatTerminalError(raw: string): { message: string; category?: string } {
|
|
823
828
|
const normalized = raw.toLowerCase();
|
|
829
|
+
|
|
830
|
+
// Context window exceeded — Codex has no auto-compact like Claude.
|
|
831
|
+
// See Linear DES-143 for the long-term fix.
|
|
824
832
|
const overflowPatterns = [
|
|
825
833
|
"context length exceeded",
|
|
826
834
|
"maximum context length",
|
|
@@ -828,11 +836,60 @@ class CodexSession implements ProviderSession {
|
|
|
828
836
|
"input too long",
|
|
829
837
|
"request too large",
|
|
830
838
|
"context_length_exceeded",
|
|
839
|
+
"contextwindowexceeded",
|
|
831
840
|
];
|
|
832
841
|
if (overflowPatterns.some((p) => normalized.includes(p))) {
|
|
833
|
-
return
|
|
842
|
+
return {
|
|
843
|
+
message: `[context-overflow] Codex turn exceeded the model's context window for ${this.resolvedModel} (${this.contextWindow.toLocaleString()} tokens). Codex does not auto-compact conversation history like Claude does — start a fresh task or split the work into smaller turns. Original error: ${raw}`,
|
|
844
|
+
category: "context_overflow",
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Pro / business quota exhausted — codexErrorInfo: "UsageLimitExceeded".
|
|
849
|
+
// Message text typically reads "You've hit your usage limit. Upgrade to Pro …".
|
|
850
|
+
const usageLimitPatterns = ["usage limit", "upgrade to pro", "usagelimitexceeded"];
|
|
851
|
+
if (usageLimitPatterns.some((p) => normalized.includes(p))) {
|
|
852
|
+
return {
|
|
853
|
+
message: `[usage-limit] Codex account quota exhausted — upgrade plan or wait for monthly reset. Original error: ${raw}`,
|
|
854
|
+
category: "usage_limit",
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Per-minute / per-hour API rate limiting (HTTP 429).
|
|
859
|
+
const rateLimitPatterns = [
|
|
860
|
+
"rate limit",
|
|
861
|
+
"rate_limit",
|
|
862
|
+
"ratelimit",
|
|
863
|
+
"too many requests",
|
|
864
|
+
"http 429",
|
|
865
|
+
" 429 ",
|
|
866
|
+
];
|
|
867
|
+
if (rateLimitPatterns.some((p) => normalized.includes(p))) {
|
|
868
|
+
return {
|
|
869
|
+
message: `[rate-limit] Codex API rate limit hit. Original error: ${raw}`,
|
|
870
|
+
category: "rate_limit",
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Bad / missing / invalid API key — codexErrorInfo: "Unauthorized".
|
|
875
|
+
const authPatterns = [
|
|
876
|
+
"unauthorized",
|
|
877
|
+
"http 401",
|
|
878
|
+
" 401 ",
|
|
879
|
+
"invalid api key",
|
|
880
|
+
"invalid_api_key",
|
|
881
|
+
"missing api key",
|
|
882
|
+
"no api key",
|
|
883
|
+
"authentication failed",
|
|
884
|
+
];
|
|
885
|
+
if (authPatterns.some((p) => normalized.includes(p))) {
|
|
886
|
+
return {
|
|
887
|
+
message: `[auth-error] Codex authentication failed — check OPENAI_API_KEY or ChatGPT login. Original error: ${raw}`,
|
|
888
|
+
category: "authentication_failed",
|
|
889
|
+
};
|
|
834
890
|
}
|
|
835
|
-
|
|
891
|
+
|
|
892
|
+
return { message: raw };
|
|
836
893
|
}
|
|
837
894
|
|
|
838
895
|
private async runSession(): Promise<void> {
|
|
@@ -840,7 +897,7 @@ class CodexSession implements ProviderSession {
|
|
|
840
897
|
// Expose the controller to the swarm event handler so it can trigger an
|
|
841
898
|
// abort from outside this method (tool-loop detection, cancellation poll).
|
|
842
899
|
this.abortRef.current = this.abortController;
|
|
843
|
-
let terminalError: string | undefined;
|
|
900
|
+
let terminalError: { message: string; category?: string } | undefined;
|
|
844
901
|
let sawTurnCompleted = false;
|
|
845
902
|
|
|
846
903
|
try {
|
|
@@ -897,14 +954,14 @@ class CodexSession implements ProviderSession {
|
|
|
897
954
|
type: "result",
|
|
898
955
|
cost,
|
|
899
956
|
isError,
|
|
900
|
-
errorCategory: terminalError ? "turn_failed" : undefined,
|
|
957
|
+
errorCategory: terminalError ? (terminalError.category ?? "turn_failed") : undefined,
|
|
901
958
|
});
|
|
902
959
|
this.settle({
|
|
903
960
|
exitCode: isError ? 1 : 0,
|
|
904
961
|
sessionId: this._sessionId,
|
|
905
962
|
cost,
|
|
906
963
|
isError,
|
|
907
|
-
failureReason: terminalError,
|
|
964
|
+
failureReason: terminalError?.message,
|
|
908
965
|
});
|
|
909
966
|
} catch (err) {
|
|
910
967
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -38,24 +38,31 @@ import type {
|
|
|
38
38
|
} from "./types";
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* Map a `MODEL_OVERRIDE` string to the env var that
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
41
|
+
* Map a `MODEL_OVERRIDE` string to the env var(s) that can satisfy it.
|
|
42
|
+
*
|
|
43
|
+
* Anthropic shortnames (`sonnet` / `haiku` / `opus`) accept EITHER
|
|
44
|
+
* `ANTHROPIC_API_KEY` (preferred — talks to Anthropic directly) OR
|
|
45
|
+
* `OPENROUTER_API_KEY` — in the latter case `resolveModel` swaps to the
|
|
46
|
+
* OpenRouter mirror of the same model so pi-ai's anthropic-provider env
|
|
47
|
+
* lookup (which only checks `ANTHROPIC_*`) doesn't fail with "No API key
|
|
48
|
+
* found for anthropic". Provider-prefixed model IDs only accept that one
|
|
49
|
+
* provider's key. Returns `null` for the permissive case (no MODEL_OVERRIDE
|
|
50
|
+
* or bare unprefixed model name).
|
|
45
51
|
*/
|
|
46
|
-
function
|
|
52
|
+
function modelToCredKeys(modelStr: string | undefined): string[] | null {
|
|
47
53
|
if (!modelStr) return null;
|
|
48
54
|
const lower = modelStr.toLowerCase();
|
|
49
|
-
// Hard-coded shortnames
|
|
55
|
+
// Hard-coded shortnames: anthropic-shape but pi-mono can route through
|
|
56
|
+
// OpenRouter (see `resolveModel`) when only an OR key is available.
|
|
50
57
|
if (lower === "opus" || lower === "sonnet" || lower === "haiku") {
|
|
51
|
-
return "ANTHROPIC_API_KEY";
|
|
58
|
+
return ["ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"];
|
|
52
59
|
}
|
|
53
60
|
if (modelStr.includes("/")) {
|
|
54
61
|
const provider = modelStr.slice(0, modelStr.indexOf("/")).toLowerCase();
|
|
55
|
-
if (provider === "anthropic") return "ANTHROPIC_API_KEY";
|
|
56
|
-
if (provider === "openrouter") return "OPENROUTER_API_KEY";
|
|
57
|
-
if (provider === "openai") return "OPENAI_API_KEY";
|
|
58
|
-
if (provider === "google") return "GOOGLE_API_KEY";
|
|
62
|
+
if (provider === "anthropic") return ["ANTHROPIC_API_KEY"];
|
|
63
|
+
if (provider === "openrouter") return ["OPENROUTER_API_KEY"];
|
|
64
|
+
if (provider === "openai") return ["OPENAI_API_KEY"];
|
|
65
|
+
if (provider === "google") return ["GOOGLE_API_KEY"];
|
|
59
66
|
}
|
|
60
67
|
// Bare model name with no provider prefix — adapter falls through to a
|
|
61
68
|
// best-effort resolution against multiple providers, so the boot loop
|
|
@@ -83,15 +90,16 @@ export function checkPiMonoCredentials(
|
|
|
83
90
|
return { ready: true, missing: [], satisfiedBy: "file" };
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
if (env[
|
|
93
|
+
const requiredKeys = modelToCredKeys(env.MODEL_OVERRIDE);
|
|
94
|
+
if (requiredKeys) {
|
|
95
|
+
if (requiredKeys.some((k) => env[k])) {
|
|
89
96
|
return { ready: true, missing: [], satisfiedBy: "env" };
|
|
90
97
|
}
|
|
98
|
+
const keyList = requiredKeys.join(" / ");
|
|
91
99
|
return {
|
|
92
100
|
ready: false,
|
|
93
|
-
missing: [
|
|
94
|
-
hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} requires ${
|
|
101
|
+
missing: [...requiredKeys, authFile],
|
|
102
|
+
hint: `MODEL_OVERRIDE=${env.MODEL_OVERRIDE} requires one of ${keyList}; or run \`pi auth login\` to create ${authFile}.`,
|
|
95
103
|
};
|
|
96
104
|
}
|
|
97
105
|
|
|
@@ -136,18 +144,67 @@ function mcpToolsToDefinitions(
|
|
|
136
144
|
}));
|
|
137
145
|
}
|
|
138
146
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Anthropic-shortname → OpenRouter-mirror model IDs. Used by `resolveModel`
|
|
149
|
+
* when the worker only has `OPENROUTER_API_KEY` so pi-ai's anthropic
|
|
150
|
+
* provider env lookup (`ANTHROPIC_OAUTH_TOKEN` / `ANTHROPIC_API_KEY` only)
|
|
151
|
+
* doesn't fail with "No API key found for anthropic".
|
|
152
|
+
*
|
|
153
|
+
* The mirror IDs match pi-ai's generated OpenRouter model catalog
|
|
154
|
+
* (`anthropic/claude-{opus,sonnet,haiku}-*`).
|
|
155
|
+
*/
|
|
156
|
+
const ANTHROPIC_SHORTNAME_OPENROUTER_MIRROR: Record<string, string> = {
|
|
157
|
+
opus: "anthropic/claude-opus-4",
|
|
158
|
+
sonnet: "anthropic/claude-sonnet-4",
|
|
159
|
+
haiku: "anthropic/claude-haiku-4.5",
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
function envHasAnthropicCred(env: Record<string, string | undefined>): boolean {
|
|
163
|
+
return !!(env.ANTHROPIC_API_KEY || env.ANTHROPIC_OAUTH_TOKEN);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve a model string to a pi-ai Model object.
|
|
168
|
+
*
|
|
169
|
+
* When `modelStr` is an anthropic shortname (`sonnet`/`haiku`/`opus`) AND
|
|
170
|
+
* the env only has `OPENROUTER_API_KEY` (no `ANTHROPIC_API_KEY` /
|
|
171
|
+
* `ANTHROPIC_OAUTH_TOKEN`), the shortname is rerouted through the
|
|
172
|
+
* OpenRouter mirror of the same model. This prevents pi-ai's
|
|
173
|
+
* anthropic-provider env lookup from failing at session-start with
|
|
174
|
+
* "No API key found for anthropic" — see task 37a4a87a and the chronic
|
|
175
|
+
* weekly-fire pattern (2026-04-13 → 2026-05-11) tracked in HEARTBEAT.md.
|
|
176
|
+
*/
|
|
177
|
+
export function resolveModel(
|
|
178
|
+
modelStr: string,
|
|
179
|
+
env: Record<string, string | undefined> = process.env,
|
|
180
|
+
) {
|
|
141
181
|
if (!modelStr) return undefined;
|
|
142
182
|
|
|
143
|
-
|
|
183
|
+
const lower = modelStr.toLowerCase();
|
|
184
|
+
const isAnthropicShortname = lower === "opus" || lower === "sonnet" || lower === "haiku";
|
|
185
|
+
|
|
186
|
+
// Reroute anthropic shortnames through OpenRouter when no anthropic cred
|
|
187
|
+
// is available. The OpenRouter mirror IDs (`anthropic/claude-sonnet-4`,
|
|
188
|
+
// etc.) are present in pi-ai's model catalog.
|
|
189
|
+
if (isAnthropicShortname && !envHasAnthropicCred(env) && env.OPENROUTER_API_KEY) {
|
|
190
|
+
const orModelId = ANTHROPIC_SHORTNAME_OPENROUTER_MIRROR[lower];
|
|
191
|
+
if (orModelId) {
|
|
192
|
+
try {
|
|
193
|
+
return getModel("openrouter" as "anthropic", orModelId as never);
|
|
194
|
+
} catch {
|
|
195
|
+
// Fall through to native anthropic mapping below.
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Map common shortnames to provider/model pairs (native anthropic path).
|
|
144
201
|
const shortnames: Record<string, [string, string]> = {
|
|
145
202
|
opus: ["anthropic", "claude-opus-4-20250514"],
|
|
146
203
|
sonnet: ["anthropic", "claude-sonnet-4-20250514"],
|
|
147
204
|
haiku: ["anthropic", "claude-haiku-4-5-20251001"],
|
|
148
205
|
};
|
|
149
206
|
|
|
150
|
-
const mapping = shortnames[
|
|
207
|
+
const mapping = shortnames[lower];
|
|
151
208
|
if (mapping) {
|
|
152
209
|
try {
|
|
153
210
|
return getModel(mapping[0] as "anthropic", mapping[1] as never);
|
package/src/server.ts
CHANGED
|
@@ -120,8 +120,7 @@ import {
|
|
|
120
120
|
|
|
121
121
|
// Capability-based feature flags
|
|
122
122
|
// Default: all capabilities enabled
|
|
123
|
-
const DEFAULT_CAPABILITIES =
|
|
124
|
-
"core,task-pool,messaging,profiles,services,scheduling,memory,workflows";
|
|
123
|
+
const DEFAULT_CAPABILITIES = "core,task-pool,profiles,services,scheduling,memory,workflows";
|
|
125
124
|
const CAPABILITIES = new Set(
|
|
126
125
|
(process.env.CAPABILITIES || DEFAULT_CAPABILITIES).split(",").map((s) => s.trim()),
|
|
127
126
|
);
|
|
@@ -204,13 +203,15 @@ export function createServer() {
|
|
|
204
203
|
registerTaskActionTool(server);
|
|
205
204
|
}
|
|
206
205
|
|
|
207
|
-
//
|
|
206
|
+
// Core messaging tools - always registered (post/read are CORE_TOOLS)
|
|
207
|
+
registerPostMessageTool(server);
|
|
208
|
+
registerReadMessagesTool(server);
|
|
209
|
+
|
|
210
|
+
// Messaging capability - channel management (CRUD on channels)
|
|
208
211
|
if (hasCapability("messaging")) {
|
|
209
212
|
registerListChannelsTool(server);
|
|
210
213
|
registerCreateChannelTool(server);
|
|
211
214
|
registerDeleteChannelTool(server);
|
|
212
|
-
registerPostMessageTool(server);
|
|
213
|
-
registerReadMessagesTool(server);
|
|
214
215
|
}
|
|
215
216
|
|
|
216
217
|
// Profiles capability - agent profile management
|
|
@@ -389,6 +389,88 @@ describe("CodexSession event mapping", () => {
|
|
|
389
389
|
expect(result.failureReason).toContain("[context-overflow]");
|
|
390
390
|
});
|
|
391
391
|
|
|
392
|
+
test("turn.failed with usage-limit message rewrites + sets errorCategory=usage_limit", async () => {
|
|
393
|
+
// Codex Pro-quota exhausted: codexErrorInfo: "UsageLimitExceeded".
|
|
394
|
+
// Adapter must prefix `[usage-limit]` so runner.ts marks the credential
|
|
395
|
+
// as rate-limited in the rotation pool.
|
|
396
|
+
const events: ThreadEvent[] = [
|
|
397
|
+
{ type: "thread.started", thread_id: "thread-usage" },
|
|
398
|
+
{ type: "turn.started" },
|
|
399
|
+
{
|
|
400
|
+
type: "turn.failed",
|
|
401
|
+
error: {
|
|
402
|
+
message: "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/pricing).",
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
];
|
|
406
|
+
|
|
407
|
+
const { emitted, result } = await runSessionWithFakeThread(
|
|
408
|
+
events,
|
|
409
|
+
testConfig({ logFile: join(tmpLogDir, "usage.log"), cwd: "" }),
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
const errorEvent = emitted.find((e) => e.type === "error");
|
|
413
|
+
expect(errorEvent?.type === "error" && errorEvent.message).toContain("[usage-limit]");
|
|
414
|
+
|
|
415
|
+
const resultEvent = emitted.findLast((e) => e.type === "result");
|
|
416
|
+
expect(resultEvent?.type === "result" && resultEvent.errorCategory).toBe("usage_limit");
|
|
417
|
+
|
|
418
|
+
expect(result.isError).toBe(true);
|
|
419
|
+
expect(result.failureReason).toContain("[usage-limit]");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("turn.failed with rate-limit message rewrites + sets errorCategory=rate_limit", async () => {
|
|
423
|
+
const events: ThreadEvent[] = [
|
|
424
|
+
{ type: "thread.started", thread_id: "thread-rate" },
|
|
425
|
+
{ type: "turn.started" },
|
|
426
|
+
{
|
|
427
|
+
type: "turn.failed",
|
|
428
|
+
error: { message: "Request failed: 429 Too Many Requests — rate_limit_exceeded." },
|
|
429
|
+
},
|
|
430
|
+
];
|
|
431
|
+
|
|
432
|
+
const { emitted, result } = await runSessionWithFakeThread(
|
|
433
|
+
events,
|
|
434
|
+
testConfig({ logFile: join(tmpLogDir, "rate.log"), cwd: "" }),
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const errorEvent = emitted.find((e) => e.type === "error");
|
|
438
|
+
expect(errorEvent?.type === "error" && errorEvent.message).toContain("[rate-limit]");
|
|
439
|
+
|
|
440
|
+
const resultEvent = emitted.findLast((e) => e.type === "result");
|
|
441
|
+
expect(resultEvent?.type === "result" && resultEvent.errorCategory).toBe("rate_limit");
|
|
442
|
+
|
|
443
|
+
expect(result.isError).toBe(true);
|
|
444
|
+
expect(result.failureReason).toContain("[rate-limit]");
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("turn.failed with auth error rewrites + sets errorCategory=authentication_failed", async () => {
|
|
448
|
+
const events: ThreadEvent[] = [
|
|
449
|
+
{ type: "thread.started", thread_id: "thread-auth" },
|
|
450
|
+
{ type: "turn.started" },
|
|
451
|
+
{
|
|
452
|
+
type: "turn.failed",
|
|
453
|
+
error: { message: "Request failed: HTTP 401 Unauthorized — Invalid API key provided." },
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
|
|
457
|
+
const { emitted, result } = await runSessionWithFakeThread(
|
|
458
|
+
events,
|
|
459
|
+
testConfig({ logFile: join(tmpLogDir, "auth.log"), cwd: "" }),
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
const errorEvent = emitted.find((e) => e.type === "error");
|
|
463
|
+
expect(errorEvent?.type === "error" && errorEvent.message).toContain("[auth-error]");
|
|
464
|
+
|
|
465
|
+
const resultEvent = emitted.findLast((e) => e.type === "result");
|
|
466
|
+
expect(resultEvent?.type === "result" && resultEvent.errorCategory).toBe(
|
|
467
|
+
"authentication_failed",
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
expect(result.isError).toBe(true);
|
|
471
|
+
expect(result.failureReason).toContain("[auth-error]");
|
|
472
|
+
});
|
|
473
|
+
|
|
392
474
|
test("abort() resolves the session with cancelled result", async () => {
|
|
393
475
|
// Patch startThread with a fake whose runStreamed yields a long stream
|
|
394
476
|
// that respects the AbortSignal — yields one event, awaits, and only
|
|
@@ -192,7 +192,13 @@ describe("checkPiMonoCredentials", () => {
|
|
|
192
192
|
).toBe(false);
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
-
test("
|
|
195
|
+
test("shortname `sonnet` accepts ANTHROPIC_API_KEY *or* OPENROUTER_API_KEY", () => {
|
|
196
|
+
// Anthropic-shortname models (sonnet/haiku/opus) prefer the native
|
|
197
|
+
// ANTHROPIC_* credential, but pi-mono-adapter reroutes through the
|
|
198
|
+
// OpenRouter mirror when only OPENROUTER_API_KEY is available — so the
|
|
199
|
+
// boot-time cred check must accept either key. See task 37a4a87a and
|
|
200
|
+
// the chronic pi-mono → "No API key found for anthropic" recurrence
|
|
201
|
+
// tracked in HEARTBEAT.md (2026-04-13 → 2026-05-11).
|
|
196
202
|
const env = { MODEL_OVERRIDE: "sonnet" };
|
|
197
203
|
expect(
|
|
198
204
|
checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
@@ -201,7 +207,22 @@ describe("checkPiMonoCredentials", () => {
|
|
|
201
207
|
expect(
|
|
202
208
|
checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
203
209
|
.ready,
|
|
204
|
-
).toBe(
|
|
210
|
+
).toBe(true);
|
|
211
|
+
// Neither key set → still not ready, and missing includes both options.
|
|
212
|
+
const empty = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
|
|
213
|
+
expect(empty.ready).toBe(false);
|
|
214
|
+
expect(empty.missing).toContain("ANTHROPIC_API_KEY");
|
|
215
|
+
expect(empty.missing).toContain("OPENROUTER_API_KEY");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("haiku and opus shortnames also accept OPENROUTER_API_KEY", () => {
|
|
219
|
+
for (const model of ["haiku", "opus"]) {
|
|
220
|
+
const env = { MODEL_OVERRIDE: model };
|
|
221
|
+
expect(
|
|
222
|
+
checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
|
|
223
|
+
.ready,
|
|
224
|
+
).toBe(true);
|
|
225
|
+
}
|
|
205
226
|
});
|
|
206
227
|
});
|
|
207
228
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
-
import { PiMonoAdapter } from "../providers/pi-mono-adapter";
|
|
4
|
+
import { PiMonoAdapter, resolveModel } from "../providers/pi-mono-adapter";
|
|
5
5
|
|
|
6
6
|
describe("PiMonoAdapter", () => {
|
|
7
7
|
test("name is 'pi'", () => {
|
|
@@ -115,6 +115,68 @@ describe("Model name mapping", () => {
|
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
describe("resolveModel — OpenRouter reroute for anthropic shortnames", () => {
|
|
119
|
+
// Regression coverage for task 37a4a87a: workers spawned with
|
|
120
|
+
// `provider: pi` + `OPENROUTER_API_KEY` (no ANTHROPIC_API_KEY) and a task
|
|
121
|
+
// model of `sonnet` / `haiku` / `opus` previously crashed at
|
|
122
|
+
// session-start with "No API key found for anthropic" because pi-ai's
|
|
123
|
+
// anthropic provider only checks ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY.
|
|
124
|
+
// The adapter now reroutes the shortname through the OpenRouter mirror.
|
|
125
|
+
|
|
126
|
+
test("sonnet → openrouter/anthropic/claude-sonnet-4 when only OPENROUTER_API_KEY is set", () => {
|
|
127
|
+
const env = { OPENROUTER_API_KEY: "sk-or-..." };
|
|
128
|
+
const model = resolveModel("sonnet", env);
|
|
129
|
+
expect(model).toBeDefined();
|
|
130
|
+
expect(model?.provider).toBe("openrouter");
|
|
131
|
+
expect(model?.id).toBe("anthropic/claude-sonnet-4");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("haiku → openrouter/anthropic/claude-haiku-4.5 when only OPENROUTER_API_KEY is set", () => {
|
|
135
|
+
const env = { OPENROUTER_API_KEY: "sk-or-..." };
|
|
136
|
+
const model = resolveModel("haiku", env);
|
|
137
|
+
expect(model).toBeDefined();
|
|
138
|
+
expect(model?.provider).toBe("openrouter");
|
|
139
|
+
expect(model?.id).toBe("anthropic/claude-haiku-4.5");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("opus → openrouter/anthropic/claude-opus-4 when only OPENROUTER_API_KEY is set", () => {
|
|
143
|
+
const env = { OPENROUTER_API_KEY: "sk-or-..." };
|
|
144
|
+
const model = resolveModel("opus", env);
|
|
145
|
+
expect(model).toBeDefined();
|
|
146
|
+
expect(model?.provider).toBe("openrouter");
|
|
147
|
+
expect(model?.id).toBe("anthropic/claude-opus-4");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("anthropic native path wins when ANTHROPIC_API_KEY is set (even alongside OPENROUTER_API_KEY)", () => {
|
|
151
|
+
const env = { ANTHROPIC_API_KEY: "sk-ant-...", OPENROUTER_API_KEY: "sk-or-..." };
|
|
152
|
+
const model = resolveModel("sonnet", env);
|
|
153
|
+
expect(model).toBeDefined();
|
|
154
|
+
expect(model?.provider).toBe("anthropic");
|
|
155
|
+
expect(model?.id).toBe("claude-sonnet-4-20250514");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("ANTHROPIC_OAUTH_TOKEN alone also wins over OPENROUTER reroute", () => {
|
|
159
|
+
const env = { ANTHROPIC_OAUTH_TOKEN: "sk-ant-oat-...", OPENROUTER_API_KEY: "sk-or-..." };
|
|
160
|
+
const model = resolveModel("sonnet", env);
|
|
161
|
+
expect(model).toBeDefined();
|
|
162
|
+
expect(model?.provider).toBe("anthropic");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("no rerouting for non-shortname `anthropic/<model>` strings", () => {
|
|
166
|
+
// Explicit provider prefix should not be silently swapped — that path is
|
|
167
|
+
// the caller's explicit choice, surface as-is.
|
|
168
|
+
const env = { OPENROUTER_API_KEY: "sk-or-..." };
|
|
169
|
+
const model = resolveModel("anthropic/claude-sonnet-4-20250514", env);
|
|
170
|
+
expect(model?.provider).toBe("anthropic");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("default env arg falls back to process.env (smoke test — no creds set)", () => {
|
|
174
|
+
// Just confirm the default parameter doesn't throw — the actual model
|
|
175
|
+
// resolution depends on the test runner's env.
|
|
176
|
+
expect(() => resolveModel("unknown-model-id")).not.toThrow();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
118
180
|
describe("Pi-mono event normalization", () => {
|
|
119
181
|
test("message_update with text content produces raw_log-style data", () => {
|
|
120
182
|
// Simulates what PiMonoSession.handleAgentEvent does
|
|
@@ -475,22 +475,31 @@ describe("ScriptExecutor", () => {
|
|
|
475
475
|
expect(result.nextPort).toBe("success");
|
|
476
476
|
});
|
|
477
477
|
|
|
478
|
-
test("captures stderr on
|
|
478
|
+
test("marks step failed and captures stderr on non-zero exit", async () => {
|
|
479
479
|
const result = await executor.run(
|
|
480
480
|
input({ runtime: "bash", script: "echo err >&2; exit 1" }, {}),
|
|
481
481
|
);
|
|
482
|
-
expect(result.status).toBe("
|
|
482
|
+
expect(result.status).toBe("failed");
|
|
483
|
+
expect(result.error).toBe("err");
|
|
483
484
|
const out = result.output as { exitCode: number; stdout: string; stderr: string };
|
|
484
485
|
expect(out.exitCode).toBe(1);
|
|
485
486
|
expect(out.stderr).toBe("err");
|
|
486
|
-
expect(result.nextPort).toBe("failure");
|
|
487
487
|
});
|
|
488
488
|
|
|
489
|
-
test("
|
|
489
|
+
test("marks step failed on non-zero exit code (exit 1)", async () => {
|
|
490
|
+
const result = await executor.run(input({ runtime: "bash", script: "exit 1" }, {}));
|
|
491
|
+
expect(result.status).toBe("failed");
|
|
492
|
+
expect(result.error).toBe("Script exited with code 1");
|
|
493
|
+
const out = result.output as { exitCode: number };
|
|
494
|
+
expect(out?.exitCode).toBe(1);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("marks step failed with exit code in error when no stderr (exit 42)", async () => {
|
|
490
498
|
const result = await executor.run(input({ runtime: "bash", script: "exit 42" }, {}));
|
|
491
|
-
expect(result.
|
|
499
|
+
expect(result.status).toBe("failed");
|
|
500
|
+
expect(result.error).toBe("Script exited with code 42");
|
|
492
501
|
const out = result.output as { exitCode: number };
|
|
493
|
-
expect(out
|
|
502
|
+
expect(out?.exitCode).toBe(42);
|
|
494
503
|
});
|
|
495
504
|
|
|
496
505
|
test("runs TypeScript script via bun", async () => {
|
|
@@ -261,8 +261,11 @@ describe("WaitExecutor — event mode end-to-end", () => {
|
|
|
261
261
|
|
|
262
262
|
// Skip the 5s poller — fast-forward by directly calling the resume helper
|
|
263
263
|
// with status='timeout' (the poller would do exactly this once expiresAt
|
|
264
|
-
// passes).
|
|
265
|
-
|
|
264
|
+
// passes). Sleep relative to the *actual* expiresAt so we don't race
|
|
265
|
+
// when startWorkflowExecution overhead eats the cushion on slow CI.
|
|
266
|
+
const expiresAtMs = new Date(ws!.expiresAt!).getTime();
|
|
267
|
+
const sleepMs = Math.max(0, expiresAtMs - Date.now()) + 250;
|
|
268
|
+
await new Promise((r) => setTimeout(r, sleepMs));
|
|
266
269
|
const due = getDueWaitStates();
|
|
267
270
|
expect(due.find((d) => d.id === ws!.id)).toBeDefined();
|
|
268
271
|
|
package/src/workflows/engine.ts
CHANGED
|
@@ -532,6 +532,11 @@ async function executeStep(
|
|
|
532
532
|
retryPolicy,
|
|
533
533
|
);
|
|
534
534
|
|
|
535
|
+
// Persist output for observability even on failure (e.g. script nodes keep {exitCode, stdout, stderr})
|
|
536
|
+
if (result.output !== undefined) {
|
|
537
|
+
updateWorkflowRunStep(stepId, { output: result.output });
|
|
538
|
+
}
|
|
539
|
+
|
|
535
540
|
if (!shouldRetry) {
|
|
536
541
|
throw new Error(result.error || "Step execution failed");
|
|
537
542
|
}
|
|
@@ -41,11 +41,21 @@ export class ScriptExecutor extends BaseExecutor<
|
|
|
41
41
|
try {
|
|
42
42
|
const result = await Promise.race([this.runScript(config), this.timeoutPromise(timeoutMs)]);
|
|
43
43
|
|
|
44
|
+
// Non-zero exit code is a hard failure — mark the step failed so the
|
|
45
|
+
// workflow engine stops the branch and operators can see what went wrong.
|
|
46
|
+
if (result.exitCode !== 0) {
|
|
47
|
+
return {
|
|
48
|
+
status: "failed",
|
|
49
|
+
error: result.stderr || `Script exited with code ${result.exitCode}`,
|
|
50
|
+
output: result as unknown as z.infer<typeof ScriptOutputSchema>,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
// If stdout is valid JSON object, merge parsed fields into output
|
|
45
55
|
// so downstream nodes can access them via {{myScript.field}} interpolation
|
|
46
56
|
// (mirrors how agent-task nodes parse JSON in resume.ts)
|
|
47
57
|
let output: Record<string, unknown> = result;
|
|
48
|
-
if (result.
|
|
58
|
+
if (result.stdout) {
|
|
49
59
|
try {
|
|
50
60
|
const parsed = JSON.parse(result.stdout);
|
|
51
61
|
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
@@ -58,8 +68,8 @@ export class ScriptExecutor extends BaseExecutor<
|
|
|
58
68
|
|
|
59
69
|
return {
|
|
60
70
|
status: "success",
|
|
61
|
-
output: output as typeof
|
|
62
|
-
nextPort:
|
|
71
|
+
output: output as z.infer<typeof ScriptOutputSchema>,
|
|
72
|
+
nextPort: "success",
|
|
63
73
|
};
|
|
64
74
|
} catch (err) {
|
|
65
75
|
return {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# x402 Payment Module
|
|
2
|
+
|
|
3
|
+
> **Alpha / Opt-in** — This module is experimental and not wired into any core swarm path. Import it explicitly if you need x402 payment support.
|
|
4
|
+
|
|
5
|
+
Gives agents the ability to make [x402](https://github.com/coinbase/x402) payments when calling external APIs that return HTTP 402 responses. Uses USDC on Base (or Base Sepolia for testing) with automatic payment handling.
|
|
6
|
+
|
|
7
|
+
## Status
|
|
8
|
+
|
|
9
|
+
This module is **not imported by any core swarm code**. It is an opt-in integration — include it only when you need automatic micropayment support in an agent task.
|
|
10
|
+
|
|
11
|
+
Knip and other dead-code scanners will flag this directory as unused because there are no production-code imports; that is expected.
|
|
12
|
+
|
|
13
|
+
## Signer backends
|
|
14
|
+
|
|
15
|
+
| Backend | When to use | Required env vars |
|
|
16
|
+
|---------|-------------|-------------------|
|
|
17
|
+
| Openfort (default) | Managed wallet, keys in TEE | `OPENFORT_API_KEY`, `OPENFORT_WALLET_SECRET` |
|
|
18
|
+
| viem | Raw EVM private key (local/dev) | `EVM_PRIVATE_KEY` |
|
|
19
|
+
|
|
20
|
+
## Opt-in usage
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
import { createX402Fetch, createX402Client } from "@/x402";
|
|
24
|
+
|
|
25
|
+
// Simple: drop-in replacement for fetch
|
|
26
|
+
const paidFetch = await createX402Fetch();
|
|
27
|
+
const response = await paidFetch("https://api.example.com/paid-endpoint");
|
|
28
|
+
|
|
29
|
+
// Advanced: full client with spending tracking
|
|
30
|
+
const client = await createX402Client();
|
|
31
|
+
const response = await client.fetch("https://api.example.com/paid-endpoint");
|
|
32
|
+
console.log(client.getSpendingSummary());
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Env vars
|
|
36
|
+
|
|
37
|
+
| Variable | Required | Description |
|
|
38
|
+
|----------|----------|-------------|
|
|
39
|
+
| `X402_SIGNER_TYPE` | No | `openfort` (default) or `viem` |
|
|
40
|
+
| `X402_NETWORK` | No | `eip155:8453` (Base mainnet, default) or `eip155:84532` (Base Sepolia) |
|
|
41
|
+
| `X402_MAX_AUTO_APPROVE_USD` | No | Per-request auto-approve ceiling in USD |
|
|
42
|
+
| `X402_DAILY_LIMIT_USD` | No | Daily spending cap in USD |
|
|
43
|
+
| `OPENFORT_API_KEY` | Openfort only | Openfort API key |
|
|
44
|
+
| `OPENFORT_WALLET_SECRET` | Openfort only | Openfort wallet secret |
|
|
45
|
+
| `OPENFORT_WALLET_ADDRESS` | No | Pre-existing wallet address (optional) |
|
|
46
|
+
| `EVM_PRIVATE_KEY` | viem only | Raw 32-byte hex private key |
|
|
47
|
+
|
|
48
|
+
## Architecture
|
|
49
|
+
|
|
50
|
+
```
|
|
51
|
+
src/x402/
|
|
52
|
+
index.ts # Public exports
|
|
53
|
+
client.ts # X402PaymentClient — wraps fetch with payment handling
|
|
54
|
+
config.ts # Env-var loader and config types
|
|
55
|
+
openfort-signer.ts # Openfort managed-wallet signer adapter
|
|
56
|
+
spending-tracker.ts # Per-request and daily spending limits
|
|
57
|
+
cli.ts # CLI helper for inspecting wallet / config
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Dependencies
|
|
61
|
+
|
|
62
|
+
`@x402/core`, `@x402/evm`, `@x402/fetch`, `viem`, `@openfort/openfort-node` are intentionally kept in `package.json` — they belong to this opt-in module.
|
|
63
|
+
|
|
64
|
+
## References
|
|
65
|
+
|
|
66
|
+
- [x402 protocol](https://github.com/coinbase/x402)
|
|
67
|
+
- [Openfort docs](https://openfort.xyz/docs)
|
package/src/x402/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* x402 Payment Module
|
|
2
|
+
* x402 Payment Module — **Alpha / Opt-in**
|
|
3
|
+
*
|
|
4
|
+
* @alpha
|
|
5
|
+
*
|
|
6
|
+
* This module is experimental and not imported by any core swarm path.
|
|
7
|
+
* Include it explicitly when you need automatic x402 micropayment support.
|
|
8
|
+
* See `src/x402/README.md` for setup, env vars, and usage examples.
|
|
3
9
|
*
|
|
4
10
|
* Gives agents the ability to make x402 payments when calling external APIs.
|
|
5
11
|
* Uses USDC on Base (or Base Sepolia for testing) with automatic 402 handling.
|