v1.14.0-outer-loop: explicit while loop replaces inference recursion
Converts the ad-hoc executeToolPhase → runAssistantTurn recursion into an explicit while (stepNumber < effectiveCap) loop. A step is one stream-and- tool-execute iteration; the loop terminates on non-tool finish, step-cap hit, doom-loop, budget exhaustion, abort, or synthesis success. MAX_STEPS = 200 hard ceiling (4x old effective limit from budget). Per-agent steps: field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5, Architect: 20, others: unset = bounded only by MAX_STEPS). Resolution: effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS). executeToolPhase no longer recurses — returns ToolPhaseResult struct (action: 'continue' | 'paused' | 'synthesis_done') so the caller decides whether to continue or break. steps: 0 handled as "no tool calls allowed" via runTextOnlyTurn (one text-only stream phase, tool calls ignored with warn log). Step-cap hits produce a sentinel summary (reuses cap_hit kind so CapHitSentinel.tsx renders without frontend changes; text distinguishes "Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated to top of loop body — same predicate, same threshold (3), break instead of return. step_start parts are in the schema CHECK but not emitted as message_parts — writing before the stream phase creates a sequence-0 collision with partsFromAssistantMessage. Structured log line emitted instead. Adversarial review caught the collision pre-deploy. 332/332 server tests passing. No frontend changes. No schema changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -16,11 +16,9 @@ import { resolveProjectRoot } from '../path_guard.js';
|
||||
import { maybeAutoNameChat } from '../auto_name.js';
|
||||
import { getAgentById } from '../agents.js';
|
||||
import * as compaction from '../compaction.js';
|
||||
import * as modelContext from '../model-context.js';
|
||||
import type { Broker } from '../broker.js';
|
||||
import { resolveToolBudget } from './budget.js';
|
||||
import {
|
||||
DOOM_LOOP_THRESHOLD,
|
||||
detectDoomLoop,
|
||||
} from './sentinels.js';
|
||||
import {
|
||||
@@ -33,15 +31,23 @@ import {
|
||||
} from './error-handler.js';
|
||||
import {
|
||||
executeStreamPhase,
|
||||
streamCompletion,
|
||||
} from './stream-phase.js';
|
||||
import { executeToolPhase } from './tool-phase.js';
|
||||
import { DB_FLUSH_INTERVAL_MS, type StreamPhaseState } from './types.js';
|
||||
import { executeToolPhase, type ToolPhaseResult } from './tool-phase.js';
|
||||
import type { StreamPhaseState } from './types.js';
|
||||
import {
|
||||
runCapHitSummary,
|
||||
runDoomLoopSummary,
|
||||
runStepCapSummary,
|
||||
} from './sentinel-summaries.js';
|
||||
|
||||
// v1.14.0: hard ceiling on the number of stream-and-tool iterations per
|
||||
// user-message turn. Per-agent cap via agent.steps is the primary knob;
|
||||
// MAX_STEPS is the safety ceiling. 200 is 4x the effective budget ceiling
|
||||
// (50 tool calls) — in practice budget fires first unless the model makes
|
||||
// many 0-tool-call iterations (which exit the loop via the non-tool finish
|
||||
// path anyway).
|
||||
export const MAX_STEPS = 200;
|
||||
|
||||
// v1.12.4: re-exported so external callers (tests, future consumers) keep
|
||||
// importing from services/inference.js as the public surface.
|
||||
export { detectDoomLoop, DOOM_LOOP_THRESHOLD } from './sentinels.js';
|
||||
@@ -145,75 +151,185 @@ export async function runAssistantTurn(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
): Promise<void> {
|
||||
const { sessionId, chatId } = args;
|
||||
const { sessionId, chatId, signal } = args;
|
||||
|
||||
// v1.11: if the prior turn flagged this chat for compaction, run it first
|
||||
// so loadContext below reads the post-compaction history. We swallow
|
||||
// compaction failures (clearing the flag so we don't loop) and proceed
|
||||
// with the un-compacted history — a slow turn that hits the model's
|
||||
// hard limit is recoverable; a dead session is not.
|
||||
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
||||
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
||||
`;
|
||||
if (chatFlag[0]?.needs_compaction) {
|
||||
try {
|
||||
await compaction.process({
|
||||
sql: ctx.sql,
|
||||
config: ctx.config,
|
||||
log: ctx.log,
|
||||
broker: ctx.broker,
|
||||
chatId,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
|
||||
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
||||
}
|
||||
}
|
||||
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (!loaded) {
|
||||
// v1.14.0: resolve agent once at the top. The agent stays fixed for the
|
||||
// duration of this user-message turn — PATCH agent_id mid-conversation
|
||||
// takes effect on the next runInference, not mid-loop.
|
||||
const initialLoaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (!initialLoaded) {
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing');
|
||||
return;
|
||||
}
|
||||
const { session, project, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(project.path);
|
||||
// Agent resolution is per-turn so PATCH agent_id mid-conversation takes
|
||||
// effect on the next message. Unknown agent_id returns null silently —
|
||||
// session falls back to base prompt + all tools + default temperature.
|
||||
const { session, project } = initialLoaded;
|
||||
const agent = session.agent_id
|
||||
? await getAgentById(project.path, session.agent_id)
|
||||
: null;
|
||||
|
||||
// v1.8.2: cap-hit replaces the older "tool loop depth exceeded" failure.
|
||||
// When we've already burned the budget *before* this turn even runs, we
|
||||
// skip straight to the summary flow — the in-flight assistant message slot
|
||||
// gets reused for the wrap-up reply instead of being marked failed.
|
||||
const budget = resolveToolBudget(agent);
|
||||
if (args.toolsUsed >= budget) {
|
||||
await runCapHitSummary(ctx, args, session, project, history, agent, budget);
|
||||
|
||||
// v1.14.0: effectiveCap = min(agent.steps ?? Infinity, MAX_STEPS).
|
||||
// steps: 0 means "no tool calls allowed" — the first stream phase runs
|
||||
// but if it emits tool calls they are not executed (finalize as text-only).
|
||||
const effectiveCap = Math.min(agent?.steps ?? Infinity, MAX_STEPS);
|
||||
|
||||
// steps: 0 special case — model responds text-only. The while loop would
|
||||
// never enter (effectiveCap === 0), so we handle it explicitly before the
|
||||
// loop. The model always gets at least one chance to respond with text.
|
||||
if (effectiveCap === 0) {
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
await runTextOnlyTurn(ctx, args, loaded.session, loaded.project, loaded.history, agent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// v1.11.6: doom-loop guard. Detected BEFORE the budget cap (the model can
|
||||
// burn through 3 identical calls long before the 15-call budget fires).
|
||||
// Same in-flight-slot-reuse pattern as runCapHitSummary — wrap-up reply
|
||||
// lands in args.assistantMessageId, then a doom_loop sentinel is inserted
|
||||
// to make the abort visible in the chat history.
|
||||
const loop = detectDoomLoop(args.recentToolCalls);
|
||||
if (loop) {
|
||||
await runDoomLoopSummary(ctx, args, session, project, history, agent, loop);
|
||||
return;
|
||||
let stepNumber = 0;
|
||||
let toolsUsed = args.toolsUsed;
|
||||
let recentToolCalls = args.recentToolCalls;
|
||||
let assistantMessageId = args.assistantMessageId;
|
||||
|
||||
while (stepNumber < effectiveCap) {
|
||||
// ---- doom-loop check (moved from top-of-function) ----
|
||||
const loop = detectDoomLoop(recentToolCalls);
|
||||
if (loop) {
|
||||
// Need fresh history for the summary.
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
await runDoomLoopSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, loop);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- budget check (moved from top-of-function) ----
|
||||
if (toolsUsed >= budget) {
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
await runCapHitSummary(ctx, iterArgs, loaded.session, loaded.project, loaded.history, agent, budget);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- compaction check ----
|
||||
// v1.11: if the prior turn flagged this chat for compaction, run it
|
||||
// before loadContext so we read post-compaction history. Swallow
|
||||
// failures and proceed with un-compacted history.
|
||||
const chatFlag = await ctx.sql<{ needs_compaction: boolean }[]>`
|
||||
SELECT needs_compaction FROM chats WHERE id = ${chatId}
|
||||
`;
|
||||
if (chatFlag[0]?.needs_compaction) {
|
||||
try {
|
||||
await compaction.process({
|
||||
sql: ctx.sql,
|
||||
config: ctx.config,
|
||||
log: ctx.log,
|
||||
broker: ctx.broker,
|
||||
chatId,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.log.warn({ err, chatId }, 'auto-compaction failed; clearing flag and proceeding');
|
||||
await ctx.sql`UPDATE chats SET needs_compaction = false WHERE id = ${chatId}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- load context (must re-load each iteration — new messages since last step) ----
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (!loaded) {
|
||||
ctx.log.warn({ sessionId }, 'inference: session or project missing mid-loop');
|
||||
break;
|
||||
}
|
||||
const { session: iterSession, project: iterProject, history } = loaded;
|
||||
const projectRoot = await resolveProjectRoot(iterProject.path);
|
||||
|
||||
// v1.14.0: log step boundary for instrumentation. step_start parts are in
|
||||
// the schema CHECK but not emitted here — writing to the assistant message
|
||||
// before the stream phase creates a sequence-0 collision with
|
||||
// partsFromAssistantMessage. A WS frame or structured log is sufficient
|
||||
// since the frontend doesn't render step boundaries in v1.14.
|
||||
ctx.log.info({ sessionId, chatId, step: stepNumber, assistantMessageId }, 'step_start');
|
||||
|
||||
// ---- build messages + stream phase ----
|
||||
const messages = await buildMessagesPayload(iterSession, iterProject, history, agent, ctx.log);
|
||||
const webToolsEnabled =
|
||||
iterSession.web_search_enabled ?? iterProject.default_web_search_enabled ?? false;
|
||||
|
||||
const iterArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
const state: StreamPhaseState = { accumulated: '', startedAt: null };
|
||||
let result: StreamResult;
|
||||
try {
|
||||
result = await executeStreamPhase(ctx, iterArgs, iterSession, messages, state, agent, webToolsEnabled);
|
||||
} catch (err) {
|
||||
await handleAbortOrError(ctx, iterArgs, state.accumulated, err);
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- non-tool finish → finalize and exit ----
|
||||
if (result.toolCalls.length === 0) {
|
||||
await finalizeCompletion(ctx, iterArgs, result, state.startedAt, iterSession);
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- steps: 0 edge case ----
|
||||
// effectiveCap check above guarantees we're inside the loop, but this
|
||||
// guard handles the theoretical case where the model emits tool calls
|
||||
// on step 0 when effectiveCap would have been 0 (impossible since the
|
||||
// while condition prevents entry, but kept for safety). If effectiveCap
|
||||
// is 1 and we're on step 0, tool calls ARE executed — steps counts
|
||||
// iterations, not post-first-stream.
|
||||
|
||||
// ---- tool phase ----
|
||||
let toolPhaseResult: ToolPhaseResult;
|
||||
try {
|
||||
toolPhaseResult = await executeToolPhase(ctx, iterArgs, result, state.startedAt, iterSession, projectRoot);
|
||||
} catch (err) {
|
||||
// Tool phase errors are unexpected (individual tool failures are
|
||||
// caught inside executeToolPhase). Log and break.
|
||||
ctx.log.error({ err, sessionId, chatId, step: stepNumber }, 'tool phase threw unexpectedly');
|
||||
break;
|
||||
}
|
||||
|
||||
// ---- update loop locals ----
|
||||
toolsUsed += toolPhaseResult.toolCallCount;
|
||||
recentToolCalls = [...recentToolCalls, ...toolPhaseResult.toolCalls];
|
||||
stepNumber++;
|
||||
|
||||
if (toolPhaseResult.action !== 'continue') {
|
||||
// 'paused' (user input) or 'synthesis_done' — stop the loop.
|
||||
break;
|
||||
}
|
||||
// 'continue' — advance to next assistant message.
|
||||
assistantMessageId = toolPhaseResult.nextAssistantId!;
|
||||
}
|
||||
|
||||
// ---- post-loop: step-cap sentinel ----
|
||||
// When the loop exits because stepNumber reached effectiveCap, the last
|
||||
// iteration's tool phase returned 'continue' with a nextAssistantId that
|
||||
// is still in 'streaming' status (unfilled). Use it for the wrap-up.
|
||||
if (stepNumber >= effectiveCap && effectiveCap < Infinity) {
|
||||
const loaded = await loadContext(ctx.sql, sessionId, chatId);
|
||||
if (loaded) {
|
||||
const capArgs: TurnArgs = { sessionId, chatId, assistantMessageId, toolsUsed, recentToolCalls, signal };
|
||||
await runStepCapSummary(ctx, capArgs, loaded.session, loaded.project, loaded.history, agent, stepNumber, effectiveCap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// v1.14.0: special handling for steps: 0 — the model responds text-only.
|
||||
// The while loop never enters (effectiveCap === 0). We stream once with
|
||||
// no tools, finalize, and return. If the model emits tool calls despite
|
||||
// not being offered tools, they're ignored (finalize as text-only).
|
||||
async function runTextOnlyTurn(
|
||||
ctx: InferenceContext,
|
||||
args: TurnArgs,
|
||||
session: Session,
|
||||
project: Project,
|
||||
history: Message[],
|
||||
agent: Agent | null,
|
||||
): Promise<void> {
|
||||
const messages = await buildMessagesPayload(session, project, history, agent, ctx.log);
|
||||
|
||||
// v1.11.8: resolve per-chat web-tools opt-in. Tri-state on the wire:
|
||||
// - session.web_search_enabled = null → inherit project default
|
||||
// - session.web_search_enabled = true/false → explicit
|
||||
// Both web_search and web_fetch are gated by this single flag (the UI
|
||||
// label is "Enable web search and fetch" — same store, both tools).
|
||||
// Default is false unless explicitly opted in, matching the v1.9
|
||||
// plumbing intent ("inert until Batch 8 ships the actual tools").
|
||||
// Web tools are irrelevant when steps: 0 (no tool execution), but we
|
||||
// still need to resolve the flag for executeStreamPhase's signature.
|
||||
const webToolsEnabled =
|
||||
session.web_search_enabled ?? project.default_web_search_enabled ?? false;
|
||||
|
||||
@@ -227,8 +343,12 @@ export async function runAssistantTurn(
|
||||
}
|
||||
|
||||
if (result.toolCalls.length > 0) {
|
||||
await executeToolPhase(ctx, args, result, state.startedAt, session, projectRoot);
|
||||
return;
|
||||
ctx.log.warn(
|
||||
{ chatId: args.chatId, toolCallCount: result.toolCalls.length },
|
||||
'steps: 0 agent emitted tool calls; ignoring and finalizing as text-only',
|
||||
);
|
||||
// Override: strip tool calls so finalizeCompletion treats it as text-only.
|
||||
result = { ...result, toolCalls: [] };
|
||||
}
|
||||
|
||||
await finalizeCompletion(ctx, args, result, state.startedAt, session);
|
||||
|
||||
Reference in New Issue
Block a user