Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/agent/loop/AgentLoop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,8 @@ export class AgentLoop {
errors: [agentError(
"agent_tool_error_loop",
`Terminated: ${consecutiveAllInvalidTurns} consecutive turns with all tool calls failing input validation. The model appears stuck in a loop.`,
undefined,
"The model is repeatedly producing invalid tool calls. Consider switching to a more capable model via settings.",
)],
});
yield { type: "turn_failed", sessionId: input.sessionId, turnId: input.turnId, error: result.errors![0]! };
Expand Down Expand Up @@ -868,7 +870,12 @@ export class AgentLoop {
startedAt,
finalMessage,
structuredOutput,
errors: [agentError("agent_max_turns_reached", `Reached maximum number of turns (${input.maxTurns}).`)],
errors: [agentError(
"agent_max_turns_reached",
`Reached maximum number of turns (${input.maxTurns}).`,
undefined,
"Max turn limit reached. Increase maxTurns in config or break the task into smaller steps.",
)],
});
await captureTurn(result.type === "error");
yield { type: "turn_completed", sessionId: input.sessionId, turnId: input.turnId, result };
Expand Down Expand Up @@ -1568,12 +1575,17 @@ function classifyModelError(error: CanonicalModelError): {
if (isPromptTooLong(error)) {
return {
stopReason: "prompt_too_long",
error: agentError("agent_prompt_too_long", error.message, error),
error: agentError(
"agent_prompt_too_long",
error.message,
error,
error.userHint ?? "Input exceeds the model context window. Try /compact to compress history or /new for a fresh session.",
),
};
}
return {
stopReason: "model_error",
error: agentError("agent_model_error", error.message, error),
error: agentError("agent_model_error", error.message, error, error.userHint),
};
}

Expand Down
10 changes: 8 additions & 2 deletions src/agent/protocol/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type AgentError = {
code: AgentErrorCode;
message: string;
details?: unknown;
/** User-facing actionable hint for resolving this error. */
userHint?: string;
};

export class AgentRuntimeError extends Error {
Expand All @@ -29,8 +31,12 @@ export class AgentRuntimeError extends Error {
}
}

export function agentError(code: AgentErrorCode, message: string, details?: unknown): AgentError {
return { code, message, details };
export function agentError(code: AgentErrorCode, message: string, details?: unknown, userHint?: string): AgentError {
const result: AgentError = { code, message, details };
if (userHint) {
result.userHint = userHint;
}
return result;
}

export function normalizeAgentError(error: unknown): AgentError {
Expand Down
2 changes: 2 additions & 0 deletions src/agent/protocol/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AgentError } from "./errors.js";
import type { AgentTurnResult } from "./result.js";
import type { AgentLoopTransition } from "./state.js";
import type { TokenBudgetSnapshot } from "../../context/budget/TokenBudgetManager.js";
import type { RouterRetryProgressEvent } from "../../router/protocol/events.js";

export type AgentEvent =
| { type: "session_started"; sessionId: string }
Expand Down Expand Up @@ -51,6 +52,7 @@ export type AgentEvent =
| { type: "turn_continued"; sessionId: string; turnId: string; reason: AgentLoopTransition["reason"] }
| { type: "turn_completed"; sessionId: string; turnId: string; result: AgentTurnResult }
| { type: "turn_failed"; sessionId: string; turnId: string; error: AgentError }
| { type: "retry_progress"; sessionId: string; turnId: string; detail: RouterRetryProgressEvent }
| { type: "session_aborted"; sessionId: string; reason?: string };

export type AgentEventEmitter = (event: AgentEvent) => void;
Expand Down
6 changes: 6 additions & 0 deletions src/cli/createLocalGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,11 +431,17 @@ class ProjectRuntimeRegistry {
renameSync(oldPath, eventsPath);
}
} catch { /* best-effort migration */ }
const self = this;
return {
emit(event: RouterEvent) {
try {
appendFileSync(eventsPath, JSON.stringify(event) + "\n");
} catch { /* best-effort, never crash the agent loop */ }
if (event.type === "pilotdeck_router_retry_progress") {
try {
self.gateway?.broadcastRetryProgress(event);
} catch { /* best-effort */ }
}
},
};
}
Expand Down
12 changes: 11 additions & 1 deletion src/context/DefaultContextRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,17 @@ export class DefaultContextRuntime implements ContextRuntime {
reason: "multimodal-processor-error",
};
}
if (input.error.code !== "prompt_too_long") {
if (input.error.code === "image_too_large") {
return {
type: "strip_images_and_retry",
reason: "image-too-large",
};
}
const isContextError =
input.error.code === "prompt_too_long" ||
input.error.code === "context_overflow" ||
input.error.recoverableViaCompact === true;
if (!isContextError) {
return {
type: "give_up",
reason: `non_recoverable_model_error:${input.error.code}`,
Expand Down
9 changes: 8 additions & 1 deletion src/context/recovery/ContextOverflowRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ export class ContextOverflowRecovery {
if (input.error.recoverableViaImageStrip) {
return { type: "strip_images_and_retry", reason: "multimodal-processor-error" };
}
if (input.error.code !== "prompt_too_long") {
if (input.error.code === "image_too_large") {
return { type: "strip_images_and_retry", reason: "image-too-large" };
}
const isContextError =
input.error.code === "prompt_too_long" ||
input.error.code === "context_overflow" ||
input.error.recoverableViaCompact === true;
if (!isContextError) {
return { type: "give_up", reason: `non_recoverable_model_error:${input.error.code}` };
}
if (input.hasAttemptedCompact) {
Expand Down
38 changes: 38 additions & 0 deletions src/gateway/client/InProcessGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,30 @@ export class InProcessGateway implements Gateway {
return true;
}

broadcastRetryProgress(detail: {
sessionId: string;
attempt: number;
maxAttempts: number;
delayMs: number;
reason: string;
provider: string;
model: string;
}): void {
const event: GatewayEvent = {
type: "agent_status",
event: "retry_progress",
detail: {
attempt: detail.attempt,
maxAttempts: detail.maxAttempts,
delayMs: detail.delayMs,
reason: detail.reason,
provider: detail.provider,
model: detail.model,
},
};
this.emitForSession(detail.sessionId, event);
}

async *submitTurn(input: GatewaySubmitTurnInput): AsyncIterable<GatewayEvent> {
// Per-turn config refresh (defensive). The fs watcher path already
// catches most edits, but this guarantees a fresh apiKey/url is in
Expand Down Expand Up @@ -1116,6 +1140,7 @@ export function mapAgentEvent(event: AgentEvent, runId: string): GatewayEvent[]
code: event.error.code,
message: event.error.message,
recoverable: false,
userHint: event.error.userHint,
},
];
case "session_aborted":
Expand Down Expand Up @@ -1232,6 +1257,19 @@ export function mapAgentEvent(event: AgentEvent, runId: string): GatewayEvent[]
durationMs: event.durationMs,
},
}];
case "retry_progress":
return [{
type: "agent_status",
event: "retry_progress",
detail: {
attempt: event.detail.attempt,
maxAttempts: event.detail.maxAttempts,
delayMs: event.detail.delayMs,
reason: event.detail.reason,
provider: event.detail.provider,
model: event.detail.model,
},
}];
case "session_ended":
case "user_prompt_submitted":
case "setup_completed":
Expand Down
2 changes: 1 addition & 1 deletion src/gateway/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ export type GatewayEvent =
}
| { type: "turn_completed"; usage: TurnUsage; finishReason: AgentTurnResult["stopReason"] | string }
| { type: "agent_status"; event: string; detail?: Record<string, unknown> }
| { type: "error"; message: string; code?: string; recoverable: boolean };
| { type: "error"; message: string; code?: string; recoverable: boolean; userHint?: string };

export type GatewayActiveTurnSnapshotInput = {
sessionKey: string;
Expand Down
23 changes: 22 additions & 1 deletion src/model/config/parseModelConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ModelDefinition,
ModelProtocol,
ProviderConfig,
ProviderRetryConfig,
} from "../protocol/canonical.js";
import { mergeCapabilities, type ModelCapabilities } from "../protocol/capabilities.js";
import { ModelConfigError } from "../protocol/errors.js";
Expand Down Expand Up @@ -101,11 +102,31 @@ function parseProvider(providerId: string, rawProvider: unknown, env?: Credentia
timeoutMs: readOptionalPositiveNumber(provider.timeoutMs, "timeoutMs"),
headers: readStringRecord(provider.headers, "headers"),
extraBody: isRecord(provider.extraBody) ? (provider.extraBody as Record<string, unknown>) : undefined,
retry: isRecord(provider.retry) ? provider.retry : undefined,
retry: parseRetryConfig(provider.retry),
models,
};
}

function parseRetryConfig(raw: unknown): ProviderRetryConfig | undefined {
if (raw === undefined) return undefined;
if (!isRecord(raw)) return undefined;
const result: ProviderRetryConfig = {};
const numFields = [
"requestMaxRetries", "streamMaxRetries", "streamIdleTimeoutMs",
"baseDelayMs", "maxDelayMs",
] as const;
for (const key of numFields) {
const value = raw[key];
if (value !== undefined) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
throw new ModelConfigError("invalid_config_value", `retry.${key} must be a non-negative number.`);
}
result[key] = value;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}

function parseModelDefinition(
modelId: string,
protocol: ModelProtocol,
Expand Down
Loading