@smithers-orchestrator/agents 0.16.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/LICENSE +21 -0
- package/package.json +65 -0
- package/src/AgentLike.ts +28 -0
- package/src/AmpAgent.js +232 -0
- package/src/AmpAgentOptions.ts +26 -0
- package/src/AnthropicAgent.js +54 -0
- package/src/AnthropicAgentOptions.ts +8 -0
- package/src/BaseCliAgent/AgentCliActionKind.ts +10 -0
- package/src/BaseCliAgent/AgentCliEvent.ts +44 -0
- package/src/BaseCliAgent/BaseCliAgent.js +874 -0
- package/src/BaseCliAgent/BaseCliAgentOptions.ts +13 -0
- package/src/BaseCliAgent/CliOutputInterpreter.ts +8 -0
- package/src/BaseCliAgent/CliUsageInfo.ts +7 -0
- package/src/BaseCliAgent/CodexConfigOverrides.ts +3 -0
- package/src/BaseCliAgent/PiExtensionUiRequest.ts +10 -0
- package/src/BaseCliAgent/PiExtensionUiResponse.ts +7 -0
- package/src/BaseCliAgent/RunCommandResult.ts +5 -0
- package/src/BaseCliAgent/buildGenerateResult.js +57 -0
- package/src/BaseCliAgent/combineNonEmpty.js +8 -0
- package/src/BaseCliAgent/createAgentStdoutTextEmitter.js +198 -0
- package/src/BaseCliAgent/extractPrompt.js +88 -0
- package/src/BaseCliAgent/extractTextFromJsonValue.js +46 -0
- package/src/BaseCliAgent/index.js +32 -0
- package/src/BaseCliAgent/normalizeCodexConfig.js +22 -0
- package/src/BaseCliAgent/parseHelpers.js +111 -0
- package/src/BaseCliAgent/pushFlag.js +18 -0
- package/src/BaseCliAgent/pushList.js +10 -0
- package/src/BaseCliAgent/resolveTimeouts.js +24 -0
- package/src/BaseCliAgent/runCommandEffect.js +32 -0
- package/src/BaseCliAgent/runRpcCommandEffect.js +365 -0
- package/src/BaseCliAgent/truncateToBytes.js +13 -0
- package/src/BaseCliAgent/tryParseJson.js +18 -0
- package/src/ClaudeCodeAgent.js +455 -0
- package/src/ClaudeCodeAgentOptions.ts +52 -0
- package/src/CodexAgent.js +593 -0
- package/src/CodexAgentOptions.ts +23 -0
- package/src/ForgeAgent.js +128 -0
- package/src/ForgeAgentOptions.ts +14 -0
- package/src/GeminiAgent.js +273 -0
- package/src/GeminiAgentOptions.ts +20 -0
- package/src/KimiAgent.js +260 -0
- package/src/KimiAgentOptions.ts +21 -0
- package/src/OpenAIAgent.js +54 -0
- package/src/OpenAIAgentOptions.ts +8 -0
- package/src/PiAgent.js +468 -0
- package/src/PiAgentOptions.ts +40 -0
- package/src/SdkAgentOptions.ts +16 -0
- package/src/agent-contract/SmithersAgentContract.ts +10 -0
- package/src/agent-contract/SmithersAgentContractTool.ts +8 -0
- package/src/agent-contract/SmithersAgentToolCategory.ts +6 -0
- package/src/agent-contract/SmithersListedTool.ts +4 -0
- package/src/agent-contract/SmithersToolSurface.ts +1 -0
- package/src/agent-contract/createSmithersAgentContract.js +188 -0
- package/src/agent-contract/index.js +10 -0
- package/src/agent-contract/renderSmithersAgentPromptGuidance.js +81 -0
- package/src/capability-registry/AgentCapabilityRegistry.ts +22 -0
- package/src/capability-registry/AgentToolDescriptor.ts +4 -0
- package/src/capability-registry/hashCapabilityRegistry.js +43 -0
- package/src/capability-registry/index.js +8 -0
- package/src/capability-registry/normalizeCapabilityRegistry.js +52 -0
- package/src/capability-registry/normalizeCapabilityStringList.js +9 -0
- package/src/cli-capabilities/CliAgentCapabilityAdapterId.ts +6 -0
- package/src/cli-capabilities/CliAgentCapabilityDoctorReport.ts +18 -0
- package/src/cli-capabilities/CliAgentCapabilityReportEntry.ts +9 -0
- package/src/cli-capabilities/formatCliAgentCapabilityDoctorReport.js +24 -0
- package/src/cli-capabilities/getCliAgentCapabilityDoctorReport.js +92 -0
- package/src/cli-capabilities/getCliAgentCapabilityReport.js +52 -0
- package/src/cli-capabilities/index.js +11 -0
- package/src/diagnostics/DiagnosticCheck.ts +11 -0
- package/src/diagnostics/DiagnosticCheckId.ts +4 -0
- package/src/diagnostics/DiagnosticContext.ts +4 -0
- package/src/diagnostics/DiagnosticReport.ts +9 -0
- package/src/diagnostics/enrichReportWithErrorAnalysis.js +34 -0
- package/src/diagnostics/formatDiagnosticSummary.js +17 -0
- package/src/diagnostics/getDiagnosticStrategy.js +503 -0
- package/src/diagnostics/index.js +13 -0
- package/src/diagnostics/launchDiagnostics.js +16 -0
- package/src/diagnostics/runDiagnostics.js +52 -0
- package/src/index.d.ts +872 -0
- package/src/index.js +39 -0
- package/src/resolveSdkModel.js +9 -0
- package/src/sanitizeForOpenAI.js +47 -0
- package/src/streamResultToGenerateResult.js +70 -0
- package/src/zodToOpenAISchema.js +16 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
|
|
2
|
+
/** @typedef {import("./DiagnosticReport.ts").DiagnosticReport} DiagnosticReport */
|
|
3
|
+
const RATE_LIMIT_PATTERNS = [
|
|
4
|
+
/rate.?limit/i,
|
|
5
|
+
/\b429\b/,
|
|
6
|
+
/credit balance.*(too low|insufficient|exhausted)/i,
|
|
7
|
+
/overloaded/i,
|
|
8
|
+
/too many requests/i,
|
|
9
|
+
/quota.*(exceeded|exhausted)/i,
|
|
10
|
+
/retry.?after/i,
|
|
11
|
+
];
|
|
12
|
+
/**
|
|
13
|
+
* @param {DiagnosticReport} report
|
|
14
|
+
* @param {string} errorMessage
|
|
15
|
+
*/
|
|
16
|
+
export function enrichReportWithErrorAnalysis(report, errorMessage) {
|
|
17
|
+
if (!errorMessage)
|
|
18
|
+
return;
|
|
19
|
+
const rateLimitCheck = report.checks.find((c) => c.id === "rate_limit_status");
|
|
20
|
+
// Only enrich if the rate limit check was skipped or passed —
|
|
21
|
+
// if it already failed, the pre-flight probe already caught it.
|
|
22
|
+
if (rateLimitCheck && (rateLimitCheck.status === "skip" || rateLimitCheck.status === "pass")) {
|
|
23
|
+
const matched = RATE_LIMIT_PATTERNS.some((p) => p.test(errorMessage));
|
|
24
|
+
if (matched) {
|
|
25
|
+
rateLimitCheck.status = "fail";
|
|
26
|
+
rateLimitCheck.message = `Rate limit detected in error: ${errorMessage.slice(0, 200)}`;
|
|
27
|
+
rateLimitCheck.detail = {
|
|
28
|
+
...rateLimitCheck.detail,
|
|
29
|
+
detectedPostHoc: true,
|
|
30
|
+
errorExcerpt: errorMessage.slice(0, 500),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
|
|
2
|
+
/** @typedef {import("./DiagnosticReport.ts").DiagnosticReport} DiagnosticReport */
|
|
3
|
+
/**
|
|
4
|
+
* @param {DiagnosticReport} report
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function formatDiagnosticSummary(report) {
|
|
8
|
+
const failed = report.checks.filter((c) => c.status === "fail");
|
|
9
|
+
const errors = report.checks.filter((c) => c.status === "error");
|
|
10
|
+
if (failed.length === 0 && errors.length === 0) {
|
|
11
|
+
return `[diagnostics] ${report.agentId}: all checks passed (${Math.round(report.durationMs)}ms)`;
|
|
12
|
+
}
|
|
13
|
+
const issues = [...failed, ...errors]
|
|
14
|
+
.map((c) => `${c.id}=${c.status}: ${c.message}`)
|
|
15
|
+
.join("; ");
|
|
16
|
+
return `[diagnostics] ${report.agentId}: ${issues} (${Math.round(report.durationMs)}ms)`;
|
|
17
|
+
}
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** @typedef {import("./DiagnosticCheck.ts").DiagnosticCheck} DiagnosticCheck */
|
|
3
|
+
/** @typedef {import("./DiagnosticCheckId.ts").DiagnosticCheckId} DiagnosticCheckId */
|
|
4
|
+
/** @typedef {import("./DiagnosticContext.ts").DiagnosticContext} DiagnosticContext */
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {{ agentId: string; command: string; checks: DiagnosticCheckDef[]; }} AgentDiagnosticStrategy
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ id: DiagnosticCheckId; run: (ctx: DiagnosticContext) => Promise<DiagnosticCheck>; }} DiagnosticCheckDef
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Shared check helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} command
|
|
18
|
+
* @param {string} agentId
|
|
19
|
+
* @returns {DiagnosticCheckDef}
|
|
20
|
+
*/
|
|
21
|
+
function checkCliInstalled(command, agentId) {
|
|
22
|
+
return {
|
|
23
|
+
id: "cli_installed",
|
|
24
|
+
run: async () => {
|
|
25
|
+
const start = performance.now();
|
|
26
|
+
const result = spawnSync("which", [command], {
|
|
27
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
28
|
+
});
|
|
29
|
+
const elapsed = performance.now() - start;
|
|
30
|
+
const binaryPath = result.stdout?.toString("utf8").trim();
|
|
31
|
+
if (result.status === 0 && binaryPath) {
|
|
32
|
+
return {
|
|
33
|
+
id: "cli_installed",
|
|
34
|
+
status: "pass",
|
|
35
|
+
message: `${agentId} found at ${binaryPath}`,
|
|
36
|
+
detail: { binaryPath },
|
|
37
|
+
durationMs: elapsed,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
id: "cli_installed",
|
|
42
|
+
status: "fail",
|
|
43
|
+
message: `${command} not found on PATH`,
|
|
44
|
+
durationMs: elapsed,
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* @param {string | null} value
|
|
51
|
+
* @returns {number | undefined}
|
|
52
|
+
*/
|
|
53
|
+
function parseHeaderInt(value) {
|
|
54
|
+
if (value == null)
|
|
55
|
+
return undefined;
|
|
56
|
+
const n = parseInt(value, 10);
|
|
57
|
+
return Number.isNaN(n) ? undefined : n;
|
|
58
|
+
}
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Claude strategy
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
const claudeApiKeyCheck = {
|
|
63
|
+
id: "api_key_valid",
|
|
64
|
+
run: async (ctx) => {
|
|
65
|
+
const start = performance.now();
|
|
66
|
+
const apiKey = ctx.env.ANTHROPIC_API_KEY;
|
|
67
|
+
// No API key means subscription mode — valid for Claude Code CLI
|
|
68
|
+
if (!apiKey) {
|
|
69
|
+
return {
|
|
70
|
+
id: "api_key_valid",
|
|
71
|
+
status: "pass",
|
|
72
|
+
message: "No ANTHROPIC_API_KEY set — using subscription mode",
|
|
73
|
+
durationMs: performance.now() - start,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Validate key format
|
|
77
|
+
if (!apiKey.startsWith("sk-ant-")) {
|
|
78
|
+
return {
|
|
79
|
+
id: "api_key_valid",
|
|
80
|
+
status: "fail",
|
|
81
|
+
message: "ANTHROPIC_API_KEY has unexpected format (expected sk-ant-* prefix)",
|
|
82
|
+
detail: { prefix: apiKey.slice(0, 7) },
|
|
83
|
+
durationMs: performance.now() - start,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
id: "api_key_valid",
|
|
88
|
+
status: "pass",
|
|
89
|
+
message: "ANTHROPIC_API_KEY format valid",
|
|
90
|
+
durationMs: performance.now() - start,
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const claudeRateLimitCheck = {
|
|
95
|
+
id: "rate_limit_status",
|
|
96
|
+
run: async (ctx) => {
|
|
97
|
+
const start = performance.now();
|
|
98
|
+
const apiKey = ctx.env.ANTHROPIC_API_KEY;
|
|
99
|
+
if (!apiKey) {
|
|
100
|
+
return {
|
|
101
|
+
id: "rate_limit_status",
|
|
102
|
+
status: "skip",
|
|
103
|
+
message: "Subscription mode — cannot probe rate limits via API",
|
|
104
|
+
durationMs: performance.now() - start,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch("https://api.anthropic.com/v1/messages/count_tokens", {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: {
|
|
111
|
+
"x-api-key": apiKey,
|
|
112
|
+
"anthropic-version": "2023-06-01",
|
|
113
|
+
"content-type": "application/json",
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
model: "claude-sonnet-4-20250514",
|
|
117
|
+
messages: [{ role: "user", content: "hi" }],
|
|
118
|
+
}),
|
|
119
|
+
signal: AbortSignal.timeout(4_000),
|
|
120
|
+
});
|
|
121
|
+
const elapsed = performance.now() - start;
|
|
122
|
+
if (res.status === 401) {
|
|
123
|
+
return {
|
|
124
|
+
id: "rate_limit_status",
|
|
125
|
+
status: "fail",
|
|
126
|
+
message: "API key is invalid (401 Unauthorized)",
|
|
127
|
+
durationMs: elapsed,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (res.status === 429) {
|
|
131
|
+
const retryAfter = res.headers.get("retry-after");
|
|
132
|
+
return {
|
|
133
|
+
id: "rate_limit_status",
|
|
134
|
+
status: "fail",
|
|
135
|
+
message: `Currently rate limited (429)${retryAfter ? ` — retry after ${retryAfter}s` : ""}`,
|
|
136
|
+
detail: { retryAfter },
|
|
137
|
+
durationMs: elapsed,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Parse rate limit headers
|
|
141
|
+
const remaining = {
|
|
142
|
+
requests: parseHeaderInt(res.headers.get("anthropic-ratelimit-requests-remaining")),
|
|
143
|
+
inputTokens: parseHeaderInt(res.headers.get("anthropic-ratelimit-input-tokens-remaining")),
|
|
144
|
+
outputTokens: parseHeaderInt(res.headers.get("anthropic-ratelimit-output-tokens-remaining")),
|
|
145
|
+
};
|
|
146
|
+
const resets = {
|
|
147
|
+
requests: res.headers.get("anthropic-ratelimit-requests-reset"),
|
|
148
|
+
inputTokens: res.headers.get("anthropic-ratelimit-input-tokens-reset"),
|
|
149
|
+
outputTokens: res.headers.get("anthropic-ratelimit-output-tokens-reset"),
|
|
150
|
+
};
|
|
151
|
+
if (remaining.requests === 0 || remaining.inputTokens === 0 || remaining.outputTokens === 0) {
|
|
152
|
+
return {
|
|
153
|
+
id: "rate_limit_status",
|
|
154
|
+
status: "fail",
|
|
155
|
+
message: "Rate limit quota exhausted",
|
|
156
|
+
detail: { remaining, resets },
|
|
157
|
+
durationMs: elapsed,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
id: "rate_limit_status",
|
|
162
|
+
status: "pass",
|
|
163
|
+
message: "Rate limit OK",
|
|
164
|
+
detail: { remaining, resets },
|
|
165
|
+
durationMs: elapsed,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
return {
|
|
170
|
+
id: "rate_limit_status",
|
|
171
|
+
status: "error",
|
|
172
|
+
message: `Rate limit probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
173
|
+
durationMs: performance.now() - start,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const claudeStrategy = {
|
|
179
|
+
agentId: "claude-code",
|
|
180
|
+
command: "claude",
|
|
181
|
+
checks: [
|
|
182
|
+
checkCliInstalled("claude", "Claude Code"),
|
|
183
|
+
claudeApiKeyCheck,
|
|
184
|
+
claudeRateLimitCheck,
|
|
185
|
+
],
|
|
186
|
+
};
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Codex strategy
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Combined API key validation + rate limit check via GET /v1/models (free, no tokens)
|
|
191
|
+
const codexApiKeyAndRateLimitCheck = [
|
|
192
|
+
{
|
|
193
|
+
id: "api_key_valid",
|
|
194
|
+
run: async (ctx) => {
|
|
195
|
+
const start = performance.now();
|
|
196
|
+
const apiKey = ctx.env.OPENAI_API_KEY;
|
|
197
|
+
if (!apiKey) {
|
|
198
|
+
return {
|
|
199
|
+
id: "api_key_valid",
|
|
200
|
+
status: "fail",
|
|
201
|
+
message: "OPENAI_API_KEY not set",
|
|
202
|
+
durationMs: performance.now() - start,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch("https://api.openai.com/v1/models", {
|
|
207
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
208
|
+
signal: AbortSignal.timeout(4_000),
|
|
209
|
+
});
|
|
210
|
+
const elapsed = performance.now() - start;
|
|
211
|
+
if (res.status === 401) {
|
|
212
|
+
return {
|
|
213
|
+
id: "api_key_valid",
|
|
214
|
+
status: "fail",
|
|
215
|
+
message: "OPENAI_API_KEY is invalid (401 Unauthorized)",
|
|
216
|
+
durationMs: elapsed,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (res.status === 403) {
|
|
220
|
+
return {
|
|
221
|
+
id: "api_key_valid",
|
|
222
|
+
status: "fail",
|
|
223
|
+
message: "OPENAI_API_KEY lacks permission (403 Forbidden)",
|
|
224
|
+
durationMs: elapsed,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
id: "api_key_valid",
|
|
229
|
+
status: "pass",
|
|
230
|
+
message: "OPENAI_API_KEY is valid",
|
|
231
|
+
durationMs: elapsed,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return {
|
|
236
|
+
id: "api_key_valid",
|
|
237
|
+
status: "error",
|
|
238
|
+
message: `OpenAI probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
239
|
+
durationMs: performance.now() - start,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: "rate_limit_status",
|
|
246
|
+
run: async (ctx) => {
|
|
247
|
+
const start = performance.now();
|
|
248
|
+
const apiKey = ctx.env.OPENAI_API_KEY;
|
|
249
|
+
if (!apiKey) {
|
|
250
|
+
return {
|
|
251
|
+
id: "rate_limit_status",
|
|
252
|
+
status: "skip",
|
|
253
|
+
message: "No API key — cannot check rate limits",
|
|
254
|
+
durationMs: 0,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetch("https://api.openai.com/v1/models", {
|
|
259
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
260
|
+
signal: AbortSignal.timeout(4_000),
|
|
261
|
+
});
|
|
262
|
+
const elapsed = performance.now() - start;
|
|
263
|
+
if (res.status === 429) {
|
|
264
|
+
const retryAfter = res.headers.get("retry-after");
|
|
265
|
+
return {
|
|
266
|
+
id: "rate_limit_status",
|
|
267
|
+
status: "fail",
|
|
268
|
+
message: `Currently rate limited (429)${retryAfter ? ` — retry after ${retryAfter}s` : ""}`,
|
|
269
|
+
detail: { retryAfter },
|
|
270
|
+
durationMs: elapsed,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// Parse OpenAI rate limit headers if present
|
|
274
|
+
const remaining = {
|
|
275
|
+
requests: parseHeaderInt(res.headers.get("x-ratelimit-remaining-requests")),
|
|
276
|
+
tokens: parseHeaderInt(res.headers.get("x-ratelimit-remaining-tokens")),
|
|
277
|
+
};
|
|
278
|
+
const resets = {
|
|
279
|
+
requests: res.headers.get("x-ratelimit-reset-requests"),
|
|
280
|
+
tokens: res.headers.get("x-ratelimit-reset-tokens"),
|
|
281
|
+
};
|
|
282
|
+
const limits = {
|
|
283
|
+
requests: parseHeaderInt(res.headers.get("x-ratelimit-limit-requests")),
|
|
284
|
+
tokens: parseHeaderInt(res.headers.get("x-ratelimit-limit-tokens")),
|
|
285
|
+
};
|
|
286
|
+
const hasHeaders = remaining.requests !== undefined || remaining.tokens !== undefined;
|
|
287
|
+
if (hasHeaders && (remaining.requests === 0 || remaining.tokens === 0)) {
|
|
288
|
+
return {
|
|
289
|
+
id: "rate_limit_status",
|
|
290
|
+
status: "fail",
|
|
291
|
+
message: "Rate limit quota exhausted",
|
|
292
|
+
detail: { remaining, resets, limits },
|
|
293
|
+
durationMs: elapsed,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
id: "rate_limit_status",
|
|
298
|
+
status: "pass",
|
|
299
|
+
message: hasHeaders ? "Rate limit OK" : "Rate limit OK (no headers returned)",
|
|
300
|
+
detail: hasHeaders ? { remaining, resets, limits } : undefined,
|
|
301
|
+
durationMs: elapsed,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
return {
|
|
306
|
+
id: "rate_limit_status",
|
|
307
|
+
status: "error",
|
|
308
|
+
message: `Rate limit probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
309
|
+
durationMs: performance.now() - start,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
];
|
|
315
|
+
const codexStrategy = {
|
|
316
|
+
agentId: "codex",
|
|
317
|
+
command: "codex",
|
|
318
|
+
checks: [
|
|
319
|
+
checkCliInstalled("codex", "Codex"),
|
|
320
|
+
...codexApiKeyAndRateLimitCheck,
|
|
321
|
+
],
|
|
322
|
+
};
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
// Gemini strategy
|
|
325
|
+
// ---------------------------------------------------------------------------
|
|
326
|
+
// Validate Google auth via GET /v1beta/models (free, no tokens)
|
|
327
|
+
const googleAuthCheck = {
|
|
328
|
+
id: "api_key_valid",
|
|
329
|
+
run: async (ctx) => {
|
|
330
|
+
const start = performance.now();
|
|
331
|
+
const apiKey = ctx.env.GOOGLE_API_KEY ?? ctx.env.GEMINI_API_KEY;
|
|
332
|
+
if (apiKey) {
|
|
333
|
+
// Probe the models endpoint to validate the key
|
|
334
|
+
try {
|
|
335
|
+
const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models", {
|
|
336
|
+
headers: { "x-goog-api-key": apiKey },
|
|
337
|
+
signal: AbortSignal.timeout(4_000),
|
|
338
|
+
});
|
|
339
|
+
const elapsed = performance.now() - start;
|
|
340
|
+
if (res.status === 400 || res.status === 403) {
|
|
341
|
+
return {
|
|
342
|
+
id: "api_key_valid",
|
|
343
|
+
status: "fail",
|
|
344
|
+
message: `Google API key is invalid (${res.status})`,
|
|
345
|
+
durationMs: elapsed,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
id: "api_key_valid",
|
|
350
|
+
status: "pass",
|
|
351
|
+
message: "Google API key is valid",
|
|
352
|
+
durationMs: elapsed,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
return {
|
|
357
|
+
id: "api_key_valid",
|
|
358
|
+
status: "error",
|
|
359
|
+
message: `Google API probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
360
|
+
durationMs: performance.now() - start,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
// No API key — check gcloud auth
|
|
365
|
+
const result = spawnSync("gcloud", ["auth", "print-access-token"], {
|
|
366
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
367
|
+
timeout: 3_000,
|
|
368
|
+
});
|
|
369
|
+
const elapsed = performance.now() - start;
|
|
370
|
+
if (result.status === 0 && result.stdout?.toString("utf8").trim()) {
|
|
371
|
+
return {
|
|
372
|
+
id: "api_key_valid",
|
|
373
|
+
status: "pass",
|
|
374
|
+
message: "Authenticated via gcloud",
|
|
375
|
+
durationMs: elapsed,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return {
|
|
379
|
+
id: "api_key_valid",
|
|
380
|
+
status: "fail",
|
|
381
|
+
message: "No GOOGLE_API_KEY/GEMINI_API_KEY set and gcloud auth not configured",
|
|
382
|
+
durationMs: elapsed,
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
const googleRateLimitCheck = {
|
|
387
|
+
id: "rate_limit_status",
|
|
388
|
+
run: async (ctx) => {
|
|
389
|
+
const start = performance.now();
|
|
390
|
+
const apiKey = ctx.env.GOOGLE_API_KEY ?? ctx.env.GEMINI_API_KEY;
|
|
391
|
+
if (!apiKey) {
|
|
392
|
+
return {
|
|
393
|
+
id: "rate_limit_status",
|
|
394
|
+
status: "skip",
|
|
395
|
+
message: "gcloud auth mode — cannot probe rate limits via API key",
|
|
396
|
+
durationMs: 0,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetch("https://generativelanguage.googleapis.com/v1beta/models", {
|
|
401
|
+
headers: { "x-goog-api-key": apiKey },
|
|
402
|
+
signal: AbortSignal.timeout(4_000),
|
|
403
|
+
});
|
|
404
|
+
const elapsed = performance.now() - start;
|
|
405
|
+
if (res.status === 429) {
|
|
406
|
+
const retryAfter = res.headers.get("retry-after");
|
|
407
|
+
return {
|
|
408
|
+
id: "rate_limit_status",
|
|
409
|
+
status: "fail",
|
|
410
|
+
message: `Currently rate limited (429)${retryAfter ? ` — retry after ${retryAfter}s` : ""}`,
|
|
411
|
+
detail: { retryAfter },
|
|
412
|
+
durationMs: elapsed,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
id: "rate_limit_status",
|
|
417
|
+
status: "pass",
|
|
418
|
+
message: "Rate limit OK",
|
|
419
|
+
durationMs: elapsed,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
return {
|
|
424
|
+
id: "rate_limit_status",
|
|
425
|
+
status: "error",
|
|
426
|
+
message: `Rate limit probe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
427
|
+
durationMs: performance.now() - start,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
};
|
|
432
|
+
const geminiStrategy = {
|
|
433
|
+
agentId: "gemini",
|
|
434
|
+
command: "gemini",
|
|
435
|
+
checks: [
|
|
436
|
+
checkCliInstalled("gemini", "Gemini CLI"),
|
|
437
|
+
googleAuthCheck,
|
|
438
|
+
googleRateLimitCheck,
|
|
439
|
+
],
|
|
440
|
+
};
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// Pi strategy
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
const piStrategy = {
|
|
445
|
+
agentId: "pi",
|
|
446
|
+
command: "pi",
|
|
447
|
+
checks: [
|
|
448
|
+
checkCliInstalled("pi", "Pi"),
|
|
449
|
+
googleAuthCheck,
|
|
450
|
+
googleRateLimitCheck,
|
|
451
|
+
],
|
|
452
|
+
};
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// Amp strategy
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
const ampApiKeySkip = {
|
|
457
|
+
id: "api_key_valid",
|
|
458
|
+
run: async () => {
|
|
459
|
+
return {
|
|
460
|
+
id: "api_key_valid",
|
|
461
|
+
status: "skip",
|
|
462
|
+
message: "Amp uses its own auth — skipping API key check",
|
|
463
|
+
durationMs: 0,
|
|
464
|
+
};
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
const ampRateLimitSkip = {
|
|
468
|
+
id: "rate_limit_status",
|
|
469
|
+
run: async () => {
|
|
470
|
+
return {
|
|
471
|
+
id: "rate_limit_status",
|
|
472
|
+
status: "skip",
|
|
473
|
+
message: "Amp uses its own auth — skipping rate limit check",
|
|
474
|
+
durationMs: 0,
|
|
475
|
+
};
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
const ampStrategy = {
|
|
479
|
+
agentId: "amp",
|
|
480
|
+
command: "amp",
|
|
481
|
+
checks: [
|
|
482
|
+
checkCliInstalled("amp", "Amp"),
|
|
483
|
+
ampApiKeySkip,
|
|
484
|
+
ampRateLimitSkip,
|
|
485
|
+
],
|
|
486
|
+
};
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
// Strategy registry
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
const strategies = {
|
|
491
|
+
claude: claudeStrategy,
|
|
492
|
+
codex: codexStrategy,
|
|
493
|
+
gemini: geminiStrategy,
|
|
494
|
+
pi: piStrategy,
|
|
495
|
+
amp: ampStrategy,
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* @param {string} command
|
|
499
|
+
* @returns {AgentDiagnosticStrategy | null}
|
|
500
|
+
*/
|
|
501
|
+
export function getDiagnosticStrategy(command) {
|
|
502
|
+
return strategies[command] ?? null;
|
|
503
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// @smithers-type-exports-begin
|
|
2
|
+
/** @typedef {import("./DiagnosticCheck.ts").DiagnosticCheck} DiagnosticCheck */
|
|
3
|
+
/** @typedef {import("./DiagnosticCheckId.ts").DiagnosticCheckId} DiagnosticCheckId */
|
|
4
|
+
/** @typedef {import("./DiagnosticCheck.ts").DiagnosticCheckStatus} DiagnosticCheckStatus */
|
|
5
|
+
/** @typedef {import("./DiagnosticContext.ts").DiagnosticContext} DiagnosticContext */
|
|
6
|
+
/** @typedef {import("./DiagnosticReport.ts").DiagnosticReport} DiagnosticReport */
|
|
7
|
+
// @smithers-type-exports-end
|
|
8
|
+
|
|
9
|
+
export { runDiagnostics } from "./runDiagnostics.js";
|
|
10
|
+
export { getDiagnosticStrategy } from "./getDiagnosticStrategy.js";
|
|
11
|
+
export { enrichReportWithErrorAnalysis } from "./enrichReportWithErrorAnalysis.js";
|
|
12
|
+
export { formatDiagnosticSummary } from "./formatDiagnosticSummary.js";
|
|
13
|
+
export { launchDiagnostics } from "./launchDiagnostics.js";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { getDiagnosticStrategy } from "./getDiagnosticStrategy.js";
|
|
2
|
+
import { runDiagnostics } from "./runDiagnostics.js";
|
|
3
|
+
/** @typedef {import("./DiagnosticReport.ts").DiagnosticReport} DiagnosticReport */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} command
|
|
7
|
+
* @param {Record<string, string>} env
|
|
8
|
+
* @param {string} cwd
|
|
9
|
+
* @returns {Promise<DiagnosticReport> | null}
|
|
10
|
+
*/
|
|
11
|
+
export function launchDiagnostics(command, env, cwd) {
|
|
12
|
+
const strategy = getDiagnosticStrategy(command);
|
|
13
|
+
if (!strategy)
|
|
14
|
+
return null;
|
|
15
|
+
return runDiagnostics(strategy, { env, cwd }).catch(() => null);
|
|
16
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
|
|
2
|
+
/** @typedef {import("./DiagnosticCheckId.ts").DiagnosticCheckId} DiagnosticCheckId */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{ agentId: string; command: string; checks: DiagnosticCheckDef[]; }} AgentDiagnosticStrategy
|
|
6
|
+
*/
|
|
7
|
+
/** @typedef {import("./DiagnosticCheck.ts").DiagnosticCheck} DiagnosticCheck */
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {{ id: DiagnosticCheckId; run: (ctx: DiagnosticContext) => Promise<DiagnosticCheck>; }} DiagnosticCheckDef
|
|
10
|
+
*/
|
|
11
|
+
/** @typedef {import("./DiagnosticContext.ts").DiagnosticContext} DiagnosticContext */
|
|
12
|
+
/** @typedef {import("./DiagnosticReport.ts").DiagnosticReport} DiagnosticReport */
|
|
13
|
+
|
|
14
|
+
const PER_CHECK_TIMEOUT_MS = 5_000;
|
|
15
|
+
/**
|
|
16
|
+
* @param {DiagnosticCheckDef} check
|
|
17
|
+
* @param {DiagnosticContext} ctx
|
|
18
|
+
* @returns {Promise<DiagnosticCheck>}
|
|
19
|
+
*/
|
|
20
|
+
async function runCheck(check, ctx) {
|
|
21
|
+
const start = performance.now();
|
|
22
|
+
try {
|
|
23
|
+
return await Promise.race([
|
|
24
|
+
check.run(ctx),
|
|
25
|
+
new Promise((_, reject) => setTimeout(() => reject(new SmithersError("AGENT_DIAGNOSTIC_TIMEOUT", "diagnostic check timed out", { timeoutMs: PER_CHECK_TIMEOUT_MS })), PER_CHECK_TIMEOUT_MS)),
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
return {
|
|
30
|
+
id: check.id,
|
|
31
|
+
status: "error",
|
|
32
|
+
message: err instanceof Error ? err.message : String(err),
|
|
33
|
+
durationMs: performance.now() - start,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* @param {AgentDiagnosticStrategy} strategy
|
|
39
|
+
* @param {DiagnosticContext} ctx
|
|
40
|
+
* @returns {Promise<DiagnosticReport>}
|
|
41
|
+
*/
|
|
42
|
+
export async function runDiagnostics(strategy, ctx) {
|
|
43
|
+
const start = performance.now();
|
|
44
|
+
const results = await Promise.all(strategy.checks.map((check) => runCheck(check, ctx)));
|
|
45
|
+
return {
|
|
46
|
+
agentId: strategy.agentId,
|
|
47
|
+
command: strategy.command,
|
|
48
|
+
timestamp: new Date().toISOString(),
|
|
49
|
+
checks: results,
|
|
50
|
+
durationMs: performance.now() - start,
|
|
51
|
+
};
|
|
52
|
+
}
|