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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
node_modules
dist
docs/BUG_REPORT.md
docs/ISSUES_READY.md
.env
*.log
.DS_Store
Expand Down
73 changes: 50 additions & 23 deletions packages/core/src/agent/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -688,7 +705,7 @@ export class AgentLoop {
requestAttempt: attempt,
}
: undefined,
...(this.signal ? { signal: this.signal } : undefined),
...(attemptSignal ? { signal: attemptSignal } : undefined),
};

if (this.client.streamChatCompletion) {
Expand Down Expand Up @@ -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`,
);
Expand Down Expand Up @@ -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",
);
}
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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,
};
}

Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/agent/state-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
6 changes: 6 additions & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 5 additions & 2 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
},
},
Expand Down
Loading