diff --git a/.gitignore b/.gitignore index dcc957f..219e1fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules dist +docs/BUG_REPORT.md +docs/ISSUES_READY.md .env *.log .DS_Store diff --git a/packages/core/src/agent/agent-loop.ts b/packages/core/src/agent/agent-loop.ts index 6c62d3b..b780798 100644 --- a/packages/core/src/agent/agent-loop.ts +++ b/packages/core/src/agent/agent-loop.ts @@ -497,12 +497,18 @@ export class AgentLoop { if (hookRejection) { result = hookRejection; } else { - this.hooks?.onToolStart?.({ - toolName, - rawArgs: toolCall.function.arguments, - workspaceRoot: this.workspaceRoot, - inspection, - }); + try { + await this.hooks?.onToolStart?.({ + toolName, + rawArgs: toolCall.function.arguments, + workspaceRoot: this.workspaceRoot, + inspection, + }); + } catch (error) { + this.memory.recordDecision( + `onToolStart hook failed: ${shorten(toErrorMessage(error), 160)}`, + ); + } result = await this.executeToolWithRetries( toolName, toolCall.function.arguments, @@ -511,11 +517,17 @@ export class AgentLoop { } } - this.hooks?.onToolResult?.({ - toolName, - toolCallId: toolCall.id, - result, - }); + try { + await this.hooks?.onToolResult?.({ + toolName, + toolCallId: toolCall.id, + result, + }); + } catch (error) { + this.memory.recordDecision( + `onToolResult hook failed: ${shorten(toErrorMessage(error), 160)}`, + ); + } this.memory.addTool( toolCall.id, @@ -680,6 +692,11 @@ export class AgentLoop { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { this.throwIfInterrupted(); try { + // Derive a fresh signal per attempt so listeners don't accumulate + // on a shared signal object across retries. + const attemptSignal = this.signal + ? AbortSignal.any([this.signal]) + : undefined; const currentRequest: CompletionRequest = { ...request, trace: request.trace @@ -688,7 +705,7 @@ export class AgentLoop { requestAttempt: attempt, } : undefined, - ...(this.signal ? { signal: this.signal } : undefined), + ...(attemptSignal ? { signal: attemptSignal } : undefined), }; if (this.client.streamChatCompletion) { @@ -723,7 +740,7 @@ export class AgentLoop { break; } - const delayMs = computeRetryDelayMs(attempt); + const delayMs = computeRetryDelayMs(attempt, this.config); this.memory.recordDecision( `Model request failed (attempt ${attempt}/${maxAttempts}): ${shorten(toErrorMessage(error), 180)}; retrying in ${delayMs}ms`, ); @@ -962,8 +979,10 @@ export class AgentLoop { try { await this.hooks.onCheckpoint({ step, toolCalls, snapshot }); } catch (error) { + const msg = `Checkpoint hook failed: ${shorten(toErrorMessage(error), 160)}`; + this.memory.recordDecision(msg); this.memory.recordDecision( - `Checkpoint hook failed: ${shorten(toErrorMessage(error), 160)}`, + "WARNING: checkpoint not persisted — state may be lost on recovery", ); } } @@ -1041,10 +1060,17 @@ function shouldRetryToolResult(result: ToolExecutionResult): boolean { return code === "TOOL_EXECUTION_FAILED"; } -function computeRetryDelayMs(attempt: number): number { - const base = 300; - const exponential = Math.min(2_500, base * 2 ** (attempt - 1)); - const jitter = Math.floor(Math.random() * 120); +function computeRetryDelayMs( + attempt: number, + config?: Pick< + AgentRunConfig, + "retryDelayBase" | "retryDelayMax" | "retryDelayJitter" + >, +): number { + const base = config?.retryDelayBase ?? 300; + const maxDelay = config?.retryDelayMax ?? 2_500; + const jitter = Math.floor(Math.random() * (config?.retryDelayJitter ?? 120)); + const exponential = Math.min(maxDelay, base * 2 ** (attempt - 1)); return exponential + jitter; } @@ -1180,13 +1206,14 @@ function computeStepMaxTokens( config: AgentRunConfig, promptTokens: number, ): number { + const remainingBudget = + config.maxContextTokens - promptTokens - config.outputTokenSafetyMargin; + if (remainingBudget <= 0) return 1; const minOutputTokens = Math.min( config.minOutputTokens, config.maxOutputTokens, ); const maxOutputTokens = Math.max(minOutputTokens, config.maxOutputTokens); - const remainingBudget = - config.maxContextTokens - promptTokens - config.outputTokenSafetyMargin; return clamp(remainingBudget, minOutputTokens, maxOutputTokens); } @@ -1254,9 +1281,9 @@ function toRunMetadata( goalId: context?.goalId, attemptId: context?.attemptId, runStartedAt: context?.runStartedAt, - workspaceMode: context?.executionProfile.workspaceMode, - memoryMode: context?.executionProfile.memoryMode, - priority: context?.executionProfile.priority, + workspaceMode: context?.executionProfile?.workspaceMode, + memoryMode: context?.executionProfile?.memoryMode, + priority: context?.executionProfile?.priority, }; } diff --git a/packages/core/src/agent/state-machine.ts b/packages/core/src/agent/state-machine.ts index e6f5341..fbd4456 100644 --- a/packages/core/src/agent/state-machine.ts +++ b/packages/core/src/agent/state-machine.ts @@ -38,6 +38,11 @@ export interface AgentStateSnapshot { export class AgentStateMachine { private currentState: AgentState = "prepare_context"; private readonly timeline: AgentStateSnapshot[] = []; + private readonly maxTimelineSize: number; + + constructor(maxTimelineSize = 200) { + this.maxTimelineSize = maxTimelineSize; + } transition(input: { state: AgentState; @@ -59,13 +64,13 @@ export class AgentStateMachine { sessionId: context?.sessionId, goalId: context?.goalId, attemptId: context?.attemptId, - workspaceMode: context?.executionProfile.workspaceMode, - memoryMode: context?.executionProfile.memoryMode, - priority: context?.executionProfile.priority, + workspaceMode: context?.executionProfile?.workspaceMode, + memoryMode: context?.executionProfile?.memoryMode, + priority: context?.executionProfile?.priority, }; this.timeline.push(snapshot); - if (this.timeline.length > 200) { - this.timeline.splice(0, this.timeline.length - 200); + if (this.timeline.length > this.maxTimelineSize) { + this.timeline.splice(0, this.timeline.length - this.maxTimelineSize); } return snapshot; } diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index b121618..ca9b4ba 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -498,6 +498,12 @@ export interface AgentRunConfig { maxToolResultCharsInContext: number; modelRequestRetries: number; toolExecutionRetries: number; + /** Base delay in ms for model request retry backoff (default 300). */ + retryDelayBase?: number; + /** Maximum delay in ms for retry backoff (default 2_500). */ + retryDelayMax?: number; + /** Jitter in ms applied to retry delay (default 120). */ + retryDelayJitter?: number; } export interface MemoryStats { diff --git a/vitest.config.ts b/vitest.config.ts index 2ea1608..0678fd1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -82,10 +82,13 @@ export default defineConfig({ ], exclude: ["**/*.test.ts", "**/*.spec.ts", "dist/**", "node_modules/**"], thresholds: { - // Single source of truth for coverage gating (referenced by - // CONTRIBUTING.md and docs/TESTING.md — they must not hardcode numbers). statements: 80, branches: 80, + perFile: true, + // Module-specific thresholds for high-risk areas + "packages/core/src/agent/**/*.ts": { statements: 70, branches: 65 }, + "packages/core/src/tools/**/*.ts": { statements: 65, branches: 60 }, + "packages/realtime/src/**/*.ts": { statements: 75, branches: 70 }, }, }, },