This commit is contained in:
oogway 2026-01-30 11:55:29 +00:00 committed by GitHub
commit 366ccc0586
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 467 additions and 7 deletions

View File

@ -52,6 +52,7 @@ import {
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { resolveDefaultModelForAgent } from "../../model-selection.js";
import { sanitizeToolUseResultPairing } from "../../session-transcript-repair.js";
import { isAbortError } from "../abort.js";
import { buildEmbeddedExtensionPaths } from "../extensions.js";
@ -535,9 +536,11 @@ export async function runEmbeddedAttempt(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
cacheTrace?.recordStage("session:limited", { messages: limited });
if (limited.length > 0) {
activeSession.agent.replaceMessages(limited);
// Fix: Repair tool_use/tool_result pairings AFTER truncation (issue #4367)
const repaired = sanitizeToolUseResultPairing(limited);
cacheTrace?.recordStage("session:limited", { messages: repaired });
if (repaired.length > 0) {
activeSession.agent.replaceMessages(repaired);
}
} catch (err) {
sessionManager.flushPendingToolResults?.();

View File

@ -192,6 +192,41 @@ async function maybeQueueSubagentAnnounce(params: {
return "none";
}
/**
* Build a descriptive error status label from outcome data.
* Includes error type, message, and hint if available.
*/
function buildErrorStatusLabel(outcome: SubagentRunOutcome): string {
const parts: string[] = [];
// Start with "failed"
parts.push("failed");
// Add error type context
if (outcome.errorType) {
const typeLabel: Record<string, string> = {
model: "API error",
tool: "tool error",
network: "network error",
config: "configuration error",
timeout: "timeout",
};
const label = typeLabel[outcome.errorType] || "error";
parts.push(`(${label}):`);
}
// Add error message
const errorMsg = outcome.error || "unknown error";
parts.push(errorMsg);
// Add hint if available
if (outcome.errorHint) {
parts.push(`${outcome.errorHint}`);
}
return parts.join(" ");
}
async function buildSubagentStatsLine(params: {
sessionKey: string;
startedAt?: number;
@ -299,6 +334,8 @@ export function buildSubagentSystemPrompt(params: {
export type SubagentRunOutcome = {
status: "ok" | "error" | "timeout" | "unknown";
error?: string;
errorType?: "model" | "tool" | "network" | "config" | "timeout" | "unknown";
errorHint?: string;
};
export async function runSubagentAnnounceFlow(params: {
@ -380,7 +417,7 @@ export async function runSubagentAnnounceFlow(params: {
: outcome.status === "timeout"
? "timed out"
: outcome.status === "error"
? `failed: ${outcome.error || "unknown error"}`
? buildErrorStatusLabel(outcome)
: "finished with unknown status";
// Build instructional message for main agent

View File

@ -184,7 +184,16 @@ function ensureListener() {
entry.endedAt = endedAt;
if (phase === "error") {
const error = typeof evt.data?.error === "string" ? (evt.data.error as string) : undefined;
entry.outcome = { status: "error", error };
const errorType =
typeof evt.data?.errorType === "string" ? (evt.data.errorType as string) : undefined;
const errorHint =
typeof evt.data?.errorHint === "string" ? (evt.data.errorHint as string) : undefined;
entry.outcome = {
status: "error",
error,
errorType: errorType as SubagentRunOutcome["errorType"],
errorHint,
};
} else {
entry.outcome = { status: "ok" };
}

View File

@ -51,6 +51,85 @@ export type AgentRunLoopResult =
}
| { kind: "final"; payload: ReplyPayload };
/**
* Categorize errors to provide better error messages to users.
* Returns error message, type, and optional hint for remediation.
*/
export function categorizeError(err: unknown): {
message: string;
type: "model" | "tool" | "network" | "config" | "timeout" | "unknown";
hint?: string;
} {
const message = err instanceof Error ? err.message : String(err);
// File system errors
if (message.includes("ENOENT") || message.includes("ENOTDIR")) {
return { message, type: "tool", hint: "File or directory not found" };
}
if (message.includes("EACCES") || message.includes("EPERM")) {
return { message, type: "tool", hint: "Permission denied" };
}
if (message.includes("EISDIR")) {
return { message, type: "tool", hint: "Expected file but found directory" };
}
// API/Model errors
if (message.includes("rate limit") || message.includes("429")) {
return { message, type: "model", hint: "Rate limit exceeded - retry in a few moments" };
}
if (
message.includes("401") ||
message.includes("unauthorized") ||
message.includes("authentication")
) {
return { message, type: "config", hint: "Check API credentials and permissions" };
}
if (message.includes("403") || message.includes("forbidden")) {
return { message, type: "config", hint: "Access denied - check permissions" };
}
if (message.includes("400") || message.includes("invalid request")) {
return { message, type: "model", hint: "Invalid request parameters" };
}
if (message.includes("500") || message.includes("503")) {
return { message, type: "model", hint: "API service error - try again later" };
}
if (message.includes("quota") || message.includes("billing")) {
return { message, type: "config", hint: "Check billing and API quota limits" };
}
// Network errors
if (message.includes("ECONNREFUSED") || message.includes("ETIMEDOUT")) {
return { message, type: "network", hint: "Connection failed - check network connectivity" };
}
if (message.includes("ENOTFOUND") || message.includes("DNS") || message.includes("EAI_AGAIN")) {
return { message, type: "network", hint: "DNS resolution failed - check hostname" };
}
if (message.includes("ENETUNREACH") || message.includes("EHOSTUNREACH")) {
return { message, type: "network", hint: "Network unreachable - check connection" };
}
// Timeout errors
if (
message.toLowerCase().includes("timeout") ||
message.toLowerCase().includes("timed out") ||
message.includes("ETIMEDOUT")
) {
return { message, type: "timeout", hint: "Operation took too long - try increasing timeout" };
}
// Context/memory errors
if (message.includes("context") && message.includes("too large")) {
return { message, type: "model", hint: "Conversation too long - try clearing history" };
}
// Missing environment/config
if (message.includes("missing") && (message.includes("key") || message.includes("token"))) {
return { message, type: "config", hint: "Missing required configuration or credentials" };
}
return { message, type: "unknown" };
}
export async function runAgentTurnWithFallback(params: {
commandBody: string;
followupRun: FollowupRun;
@ -204,6 +283,7 @@ export async function runAgentTurnWithFallback(params: {
return result;
})
.catch((err) => {
const { message, type, hint } = categorizeError(err);
emitAgentEvent({
runId,
stream: "lifecycle",
@ -211,7 +291,9 @@ export async function runAgentTurnWithFallback(params: {
phase: "error",
startedAt,
endedAt: Date.now(),
error: err instanceof Error ? err.message : String(err),
error: message,
errorType: type,
errorHint: hint,
},
});
throw err;

View File

@ -0,0 +1,318 @@
import { describe, expect, it } from "vitest";
import { categorizeError } from "./agent-runner-execution.js";
describe("categorizeError", () => {
describe("timeout errors", () => {
it("categorizes lowercase 'timeout' as timeout type", () => {
const error = new Error("Request timeout after 30s");
const result = categorizeError(error);
expect(result.type).toBe("timeout");
expect(result.message).toBe("Request timeout after 30s");
expect(result.hint).toBe("Operation took too long - try increasing timeout");
});
it("categorizes 'timed out' as timeout type", () => {
const error = new Error("Connection timed out");
const result = categorizeError(error);
expect(result.type).toBe("timeout");
expect(result.hint).toBe("Operation took too long - try increasing timeout");
});
it("categorizes ETIMEDOUT as network type (network error code takes precedence)", () => {
const error = new Error("ETIMEDOUT: socket hang up");
const result = categorizeError(error);
// ETIMEDOUT is caught by network errors before timeout section
expect(result.type).toBe("network");
expect(result.hint).toBe("Connection failed - check network connectivity");
});
it("handles uppercase TIMEOUT", () => {
const error = new Error("TIMEOUT ERROR");
const result = categorizeError(error);
expect(result.type).toBe("timeout");
});
});
describe("authentication errors", () => {
it("categorizes 401 as config type", () => {
const error = new Error("HTTP 401: Unauthorized");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.message).toBe("HTTP 401: Unauthorized");
expect(result.hint).toBe("Check API credentials and permissions");
});
it("categorizes 'unauthorized' as config type", () => {
const error = new Error("Request failed: unauthorized access");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.hint).toBe("Check API credentials and permissions");
});
it("categorizes 'authentication' errors as config type (case-sensitive)", () => {
const error = new Error("authentication failed for API key");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.hint).toBe("Check API credentials and permissions");
});
it("categorizes 403 forbidden as config type", () => {
const error = new Error("HTTP 403 forbidden");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.hint).toBe("Access denied - check permissions");
});
it("categorizes 'forbidden' keyword as config type", () => {
const error = new Error("Access forbidden to resource");
const result = categorizeError(error);
expect(result.type).toBe("config");
});
});
describe("rate limit errors", () => {
it("categorizes 'rate limit' as model type", () => {
const error = new Error("rate limit exceeded");
const result = categorizeError(error);
expect(result.type).toBe("model");
expect(result.message).toBe("rate limit exceeded");
expect(result.hint).toBe("Rate limit exceeded - retry in a few moments");
});
it("categorizes HTTP 429 as model type", () => {
const error = new Error("HTTP 429: Too Many Requests");
const result = categorizeError(error);
expect(result.type).toBe("model");
expect(result.hint).toBe("Rate limit exceeded - retry in a few moments");
});
it("handles rate limit with mixed case", () => {
const error = new Error("rate limit exceeded");
const result = categorizeError(error);
expect(result.type).toBe("model");
});
});
describe("unknown errors", () => {
it("categorizes unrecognized error as unknown type", () => {
const error = new Error("Something weird happened");
const result = categorizeError(error);
expect(result.type).toBe("unknown");
expect(result.message).toBe("Something weird happened");
expect(result.hint).toBeUndefined();
});
it("categorizes generic error message as unknown", () => {
const error = new Error("An unexpected error occurred");
const result = categorizeError(error);
expect(result.type).toBe("unknown");
expect(result.hint).toBeUndefined();
});
it("handles non-Error objects", () => {
const result = categorizeError("plain string error");
expect(result.type).toBe("unknown");
expect(result.message).toBe("plain string error");
});
it("handles null/undefined errors", () => {
const result = categorizeError(null);
expect(result.type).toBe("unknown");
expect(result.message).toBe("null");
});
});
describe("API/model errors", () => {
it("categorizes HTTP 400 as model type", () => {
const error = new Error("HTTP 400: Bad Request");
const result = categorizeError(error);
expect(result.type).toBe("model");
expect(result.hint).toBe("Invalid request parameters");
});
it("categorizes 'invalid request' as model type", () => {
const error = new Error("invalid request format");
const result = categorizeError(error);
expect(result.type).toBe("model");
});
it("categorizes HTTP 500 as model type", () => {
const error = new Error("HTTP 500: Internal Server Error");
const result = categorizeError(error);
expect(result.type).toBe("model");
expect(result.hint).toBe("API service error - try again later");
});
it("categorizes HTTP 503 as model type", () => {
const error = new Error("HTTP 503: Service Unavailable");
const result = categorizeError(error);
expect(result.type).toBe("model");
expect(result.hint).toBe("API service error - try again later");
});
it("categorizes quota errors as config type", () => {
const error = new Error("quota exceeded for this account");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.hint).toBe("Check billing and API quota limits");
});
it("categorizes billing errors as config type", () => {
const error = new Error("billing issue detected");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.hint).toBe("Check billing and API quota limits");
});
});
describe("network errors", () => {
it("categorizes ECONNREFUSED as network type", () => {
const error = new Error("ECONNREFUSED: Connection refused");
const result = categorizeError(error);
expect(result.type).toBe("network");
expect(result.hint).toBe("Connection failed - check network connectivity");
});
it("categorizes ENOTFOUND as network type", () => {
const error = new Error("ENOTFOUND: DNS lookup failed");
const result = categorizeError(error);
expect(result.type).toBe("network");
expect(result.hint).toBe("DNS resolution failed - check hostname");
});
it("categorizes DNS errors as network type", () => {
const error = new Error("DNS resolution error");
const result = categorizeError(error);
expect(result.type).toBe("network");
});
it("categorizes EAI_AGAIN as network type", () => {
const error = new Error("EAI_AGAIN: temporary failure");
const result = categorizeError(error);
expect(result.type).toBe("network");
expect(result.hint).toBe("DNS resolution failed - check hostname");
});
it("categorizes ENETUNREACH as network type", () => {
const error = new Error("ENETUNREACH: Network is unreachable");
const result = categorizeError(error);
expect(result.type).toBe("network");
expect(result.hint).toBe("Network unreachable - check connection");
});
it("categorizes EHOSTUNREACH as network type", () => {
const error = new Error("EHOSTUNREACH: No route to host");
const result = categorizeError(error);
expect(result.type).toBe("network");
expect(result.hint).toBe("Network unreachable - check connection");
});
});
describe("file system errors (tool type)", () => {
it("categorizes ENOENT as tool type", () => {
const error = new Error("ENOENT: no such file or directory");
const result = categorizeError(error);
expect(result.type).toBe("tool");
expect(result.hint).toBe("File or directory not found");
});
it("categorizes ENOTDIR as tool type", () => {
const error = new Error("ENOTDIR: not a directory");
const result = categorizeError(error);
expect(result.type).toBe("tool");
expect(result.hint).toBe("File or directory not found");
});
it("categorizes EACCES as tool type", () => {
const error = new Error("EACCES: permission denied");
const result = categorizeError(error);
expect(result.type).toBe("tool");
expect(result.hint).toBe("Permission denied");
});
it("categorizes EPERM as tool type", () => {
const error = new Error("EPERM: operation not permitted");
const result = categorizeError(error);
expect(result.type).toBe("tool");
expect(result.hint).toBe("Permission denied");
});
it("categorizes EISDIR as tool type", () => {
const error = new Error("EISDIR: illegal operation on a directory");
const result = categorizeError(error);
expect(result.type).toBe("tool");
expect(result.hint).toBe("Expected file but found directory");
});
});
describe("configuration errors", () => {
it("categorizes missing API key as config type", () => {
const error = new Error("missing API key");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.hint).toBe("Missing required configuration or credentials");
});
it("categorizes missing token as config type", () => {
const error = new Error("missing authentication token");
const result = categorizeError(error);
expect(result.type).toBe("config");
// "authentication" keyword triggers auth error hint first
expect(result.hint).toBe("Check API credentials and permissions");
});
it("categorizes missing API token without authentication keyword", () => {
const error = new Error("missing API token for request");
const result = categorizeError(error);
expect(result.type).toBe("config");
expect(result.hint).toBe("Missing required configuration or credentials");
});
});
describe("context/memory errors", () => {
it("categorizes context too large as model type", () => {
const error = new Error("context window too large");
const result = categorizeError(error);
expect(result.type).toBe("model");
expect(result.hint).toBe("Conversation too long - try clearing history");
});
});
});

View File

@ -12,6 +12,7 @@ import {
resolveAgentWorkspaceDir,
resolveAgentDir,
} from "../agents/agent-scope.js";
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
/**
* Generate a short 1-2 word filename slug from session content using LLM
@ -38,6 +39,11 @@ ${params.sessionContent.slice(0, 2000)}
Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", "bug-fix"`;
// Resolve user's configured default model instead of hardcoded Opus
const { provider, model } = resolveDefaultModelForAgent({
cfg: params.cfg,
});
const result = await runEmbeddedPiAgent({
sessionId: `slug-generator-${Date.now()}`,
sessionKey: "temp:slug-generator",
@ -46,6 +52,8 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design",
agentDir,
config: params.cfg,
prompt,
provider,
model,
timeoutMs: 15_000, // 15 second timeout
runId: `slug-gen-${Date.now()}`,
});
@ -75,7 +83,10 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design",
// Clean up temporary session file
if (tempSessionFile) {
try {
await fs.rm(path.dirname(tempSessionFile), { recursive: true, force: true });
await fs.rm(path.dirname(tempSessionFile), {
recursive: true,
force: true,
});
} catch {
// Ignore cleanup errors
}