refactor(rectification): split runV9AgentTurn into prepare / attempt / retry / finish

Move-only (TASK-rectification-code-split-20260926 T3/T4). No behavior,
receipt, billing, Skill or scoring change.

- agent-run.ts: 1400 -> 135 lines; runV9AgentTurn 1046 -> 25 lines, no
  nested functions. It keeps the public types, the budget constants and the
  phase order: agent-run-prepare.ts (reliability, session, year re-ask, Skill
  identity, delivered guard, reserve, turn row / replay),
  agent-run-retry.ts (attempt loop), agent-run-attempt.ts (the old nested
  streamAttempt), agent-run-finish.ts (billing settle, receipts, interview,
  finalize), agent-run-support.ts (outcome types, retry classification, turn
  receipt writers), agent-run-messages.ts (buildAgentMessages /
  buildOpeningBrief, re-exported).
- One token changed with the move: the attempt passes its own
  `previousErrorCode` argument to buildAgentMessages instead of reading the
  enclosing `lastAttemptError`; the loop passes that same value (clears the
  old unused-parameter warning).
- Tests: whole-source contracts read tests/rectification-agent-run-surface.ts;
  behavioral runV9AgentTurn tests unchanged. agent-run growth caps added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
Jesse_Chen
2026-09-26 19:52:51 +08:00
co-authored by Claude Opus 5.5
parent eb773f1299
commit a00c40d4b8
25 changed files with 1665 additions and 1332 deletions
@@ -0,0 +1,634 @@
/**
* One attempt of a rectification agent turn: build the agent, verify the
* bound Skill, stream the model with the read-case-first tool gate, publish
* tool activity and phases, stream the spoken answer, apply the host
* fallbacks, check the evidence write and the Case invariants, and log the
* run diagnostic. Moved out of runV9AgentTurn's nested `streamAttempt`
* unchanged (TASK-rectification-code-split-20260926); the retry constraint
* reads the `previousErrorCode` argument, which the loop passes as the
* previous attempt's error (the nested version read the same value from the
* enclosing `lastAttemptError`).
*/
import { resolveRectificationStepBudget } from "./step-budget";
import { insertV9SkillRunReceipt, loadV9CaseDossier, type V9CaseDossier } from "./tool-service";
import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
import { agentGenerationSettings, promptCacheUsage } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import { decideFromDossier } from "./decision-from-dossier";
import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
import {
RECTIFICATION_USER_COPY,
isAcceptableOpeningBody,
openingRangeFromCandidateRange,
openingSpokenBody,
withCompareFailedRetryNotice,
withRangeChangedAfterEvidence,
withRescoreSkippedNotice,
} from "../user-copy";
import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../rectification-run-budget.ts";
import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt";
import { focusSpokenPrompt } from "./turn-question";
import { previousInferenceFromReceipt } from "../core/compose-receipt.ts";
import {
activityChangedFromTool,
mapStreamChunkToActivity,
mapStreamChunkToPhase,
streamToolNames,
isPublicRectificationToolName,
turnProgressForChunk,
type PublicStreamEvent,
} from "./stream-mapping";
import {
diagnosticStepsFromTimings,
mapModelFinishToErrorCode,
recordStepChunk,
type RectificationRunDiagnostic,
type RectificationStepTiming,
} from "./run-diagnostic";
import { currentEngineCallTimings, reportTurnProgress } from "./turn-instrumentation.ts";
import {
batchResultFromToolChunk,
batchRescoreFailed,
composeHostFallbackNarration,
lastCompletedPublicTool,
publicWriteToolCompleted,
answerClaimsEvidenceRecorded,
turnExpectsEvidenceWrite,
} from "./host-fallback";
import { applyStepAnswerChunk, createStepAnswerState, flushStepAnswerOnStreamFinish } from "./step-answer";
import {
type AttemptOutcome,
MAX_ATTEMPTS,
streamFinishReason,
publicToolCallKey,
v9TurnReceipts,
} from "./agent-run-support";
import { buildAgentMessages } from "./agent-run-messages";
import type { V9TimedTurn } from "./agent-run-prepare";
export async function streamV9Attempt(
turn: V9TimedTurn,
attemptNumber: number,
attemptId: string,
previousErrorCode: string | null,
): Promise<AttemptOutcome> {
const {
options,
userId,
caseId,
action,
accounting,
buildAgent,
emit,
signal,
skillName,
dossier,
skillPackage,
birthTimeClue,
turnId,
startedAt,
minRetryAttemptMs,
attemptCapMs,
runDeadlineAt,
} = turn;
const { failedAttempt, persistCommittedPhase } = v9TurnReceipts(turn);
const attemptStartedAt = Date.now();
const stepTimings: RectificationStepTiming[] = [];
const engineCallsBefore = currentEngineCallTimings().length;
const agent = await buildAgent(turnId, skillPackage, attemptId);
let frameworkSkill: unknown = null;
try {
frameworkSkill = await (agent as unknown as { getSkill(name: string): Promise<unknown> }).getSkill(skillName);
} catch {
frameworkSkill = null;
}
if (!frameworkSkill) {
return failedAttempt(attemptId, "skill_not_loaded");
}
const rawSkillInstructions = (frameworkSkill as { instructions?: unknown }).instructions;
const skillInstructions = typeof rawSkillInstructions === "string"
? rawSkillInstructions.trim()
: "";
if (!skillInstructions) {
return failedAttempt(attemptId, "skill_not_loaded");
}
const messages = buildAgentMessages(
options,
attemptNumber,
dossier,
skillInstructions,
birthTimeClue,
previousErrorCode,
);
const maxSteps = resolveRectificationStepBudget(action);
const abortController = new AbortController();
const onAbort = () => abortController.abort();
signal?.addEventListener("abort", onAbort, { once: true });
let timedOut = false;
const timeout = setTimeout(() => {
timedOut = true;
abortController.abort();
}, attemptTimeoutForRemainingBudget(runDeadlineAt - Date.now(), attemptCapMs));
let skillBound = true;
let caseLoaded = false;
let intentClassified = false;
let streamFailed = false;
let finished = false;
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
let answerText = "";
const answerDeltas: string[] = [];
const phases: string[] = [];
const toolsUsed = new Set<string>();
const events: PublicStreamEvent[] = [];
const toolTerminalStatus = new Map<string, "completed" | "failed">();
let batchToolResult: unknown = null;
let hostFallbackUsed = false;
const emittedKeys = new Set<string>();
const emittedActivities = new Set<string>();
const repeatedCalls = new Map<string, number>();
let phaseSequence = 0;
const rangeBeforeCompare = previousInferenceFromReceipt(
dossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
const recordPhase = async (phase: string, tool: string | null = null) => {
if (
phase === "answer.delta"
|| phase === "thinking.delta"
|| phase === "activity.changed"
|| phase === "choice.applied"
|| phase === "attempt.reset"
|| emittedKeys.has(`${phase}:${tool ?? ""}`)
) return;
emittedKeys.add(`${phase}:${tool ?? ""}`);
phases.push(phase);
phaseSequence += 1;
await persistCommittedPhase(phase, tool, attemptId, phaseSequence);
};
const publish = async (event: PublicStreamEvent) => {
events.push(event);
await emit(event);
};
try {
await recordPhase("run.started");
await insertV9SkillRunReceipt(
accounting,
userId,
caseId,
turnId,
attemptId,
"turn",
skillPackage,
);
await recordPhase("skill.bound");
await publish({ type: "skill.bound" });
emittedKeys.add("event:skill.bound::");
const generation = agentGenerationSettings(options.generationModel, {
thinking: "enabled",
answerTokens: 8_192,
thinkingTokens: 8_192,
});
const result = await (agent as unknown as {
stream(
messages: unknown[],
streamOptions: {
maxSteps: number;
abortSignal: AbortSignal;
modelSettings?: { maxOutputTokens?: number };
providerOptions?: Record<string, unknown>;
prepareStep: (input: { stepNumber: number }) => {
activeTools: string[];
toolChoice: "auto";
};
},
): Promise<{
fullStream: AsyncIterable<{
type: string;
payload?: {
toolName?: unknown;
text?: unknown;
args?: unknown;
error?: unknown;
stepResult?: { reason?: unknown };
reason?: unknown;
};
object?: unknown;
}>;
totalUsage?: Promise<Record<string, unknown>>;
}>;
}).stream(messages, {
maxSteps,
abortSignal: abortController.signal,
...generation,
// Thinking-mode providers reject named/required tool_choice. Restrict
// the first step to read-case and keep tool_choice auto; the runner
// still refuses any other public tool before case.loaded.
prepareStep: ({ stepNumber }) => stepNumber === 0
? {
activeTools: ["rectification-read-case"],
toolChoice: "auto",
}
: {
activeTools: [...RECTIFICATION_AGENT_TOOLS],
toolChoice: "auto",
},
});
const stepAnswer = createStepAnswerState();
let spokenRaw = "";
let visibleEmitted = "";
const emitVisibleSpoken = async (visible: string) => {
if (!caseLoaded) return;
if (visible === visibleEmitted) {
answerText = visible;
return;
}
if (visible.startsWith(visibleEmitted)) {
const growth = visible.slice(visibleEmitted.length);
if (!growth) return;
answerText = visible;
answerDeltas.push(growth);
visibleEmitted = visible;
await emit({ type: "answer.delta", text: growth });
return;
}
answerText = visible;
answerDeltas.push(visible);
visibleEmitted = visible;
await emit({ type: "answer.delta", text: visible, replace: true });
};
const publishSpokenStep = async (pieces: readonly string[], live = false) => {
const joined = pieces.join("");
if (!joined) return;
if (!caseLoaded) return;
const spoken = live ? joined : joined.trim();
if (!spoken) return;
spokenRaw += spoken;
await emitVisibleSpoken(spokenRaw);
};
const retractSpoken = async () => {
const hadVisible = Boolean(visibleEmitted || answerText);
spokenRaw = "";
visibleEmitted = "";
answerText = "";
answerDeltas.length = 0;
if (hadVisible && caseLoaded) {
await emit({ type: "answer.delta", text: "", replace: true });
}
};
const applyHostFallback = async (): Promise<boolean> => {
if (answerText.trim()) return false;
if (toolTerminalStatus.get("rectification-record-evidence-batch") !== "completed") {
return false;
}
const spoken = composeHostFallbackNarration(batchToolResult ?? {});
if (!spoken) return false;
hostFallbackUsed = true;
await recordPhase("answer.host_fallback");
await publish({ type: "answer.host_fallback" });
await emitVisibleSpoken(spoken);
return true;
};
try {
for await (const chunk of result.fullStream) {
recordStepChunk(stepTimings, chunk, Date.now() - attemptStartedAt, isPublicRectificationToolName);
const progressStage = turnProgressForChunk(chunk as never);
if (progressStage) reportTurnProgress(progressStage);
const rawToolName = typeof chunk.payload?.toolName === "string" ? chunk.payload.toolName : "";
// Identical public tool-call + args are idempotent. Throwing
// `repeated_tool_call` (BUG-368 P0-3) aborted the turn after
// evidence / diagnostics / compare had already committed, because
// the model often re-issued compare with the same caseId. Bound
// loops with maxSteps / timeout instead; do not attempt.reset.
let skipDuplicateToolCallReceipt = false;
if (chunk.type === "tool-call") {
if (rawToolName === "rectification-read-case" && !skillBound) {
throw new Error("skill_not_bound");
}
if (isPublicRectificationToolName(rawToolName) && rawToolName !== "rectification-read-case" && !caseLoaded) {
throw new Error("case_not_loaded");
}
if (isPublicRectificationToolName(rawToolName)) {
const key = publicToolCallKey(rawToolName, chunk.payload);
const count = (repeatedCalls.get(key) ?? 0) + 1;
repeatedCalls.set(key, count);
skipDuplicateToolCallReceipt = count > 1;
}
}
const stepEffect = applyStepAnswerChunk(
stepAnswer,
chunk,
isPublicRectificationToolName,
);
if (stepEffect.kind === "live") await publishSpokenStep([stepEffect.text], true);
if (stepEffect.kind === "publish") await publishSpokenStep(stepEffect.pieces);
if (stepEffect.kind === "retract") await retractSpoken();
const activityEvent = skipDuplicateToolCallReceipt
? null
: mapStreamChunkToActivity(chunk as never);
if (activityEvent) {
await publish(activityEvent);
if (activityEvent.status === "started") {
const changed = activityChangedFromTool(activityEvent.tool);
if (!emittedActivities.has(changed.activity)) {
emittedActivities.add(changed.activity);
await publish(changed);
}
}
if (activityEvent.status === "failed" || activityEvent.status === "completed") {
toolTerminalStatus.set(activityEvent.tool, activityEvent.status);
}
}
const capturedBatch = batchResultFromToolChunk(chunk as never);
if (capturedBatch != null) batchToolResult = capturedBatch;
const phaseEvent = skipDuplicateToolCallReceipt
? null
: mapStreamChunkToPhase(chunk as never);
if (phaseEvent) {
if (phaseEvent.type === "skill.bound" && !skillBound) {
skillBound = true;
await insertV9SkillRunReceipt(
accounting,
userId,
caseId,
turnId,
attemptId,
"turn",
skillPackage,
);
}
if (phaseEvent.type === "case.loaded" && !skillBound) {
throw new Error("skill_not_bound");
}
await recordPhase(phaseEvent.type, phaseEvent.tool ?? null);
const key = `${phaseEvent.type}:${phaseEvent.tool ?? ""}:${(phaseEvent.methods ?? []).join(",")}`;
if (!emittedKeys.has(`event:${key}`)) {
emittedKeys.add(`event:${key}`);
await publish(phaseEvent);
}
if (phaseEvent.type === "case.loaded") {
caseLoaded = true;
if (!intentClassified) {
intentClassified = true;
await recordPhase("intent.classified");
await publish({ type: "intent.classified" });
}
}
}
for (const toolName of streamToolNames(chunk as never)) toolsUsed.add(toolName);
if (chunk.type === "error" || chunk.type === "abort") streamFailed = true;
if (chunk.type === "finish") {
finished = true;
finishReason = streamFinishReason(chunk);
}
}
} catch (error) {
if (!timedOut && !abortController.signal.aborted) throw error;
streamFailed = true;
}
if (finished && !streamFailed) {
const flushReason = finishReason === "length"
? "length"
: finishReason === "stop" || finishReason === "unknown" || finishReason === null
? "stop"
: finishReason;
const flushed = flushStepAnswerOnStreamFinish(stepAnswer, flushReason);
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
}
if (toolTerminalStatus.get("rectification-compare-candidates") === "failed" && !batchRescoreFailed(batchToolResult)) {
await emitVisibleSpoken(withCompareFailedRetryNotice(answerText));
}
const completeAttempt = async (settleBilling = true): Promise<AttemptOutcome> => {
let inputTokens = 0;
let outputTokens = 0;
let cache: ReturnType<typeof promptCacheUsage> = null;
try {
const raw = await (result.totalUsage ?? Promise.resolve({ inputTokens: 0, outputTokens: 0 }));
inputTokens = Math.max(0, Math.trunc(typeof raw.inputTokens === "number" ? raw.inputTokens : 0));
outputTokens = Math.max(0, Math.trunc(typeof raw.outputTokens === "number" ? raw.outputTokens : 0));
cache = promptCacheUsage(raw);
} catch {
// Timeout/abort can leave provider usage unread.
}
await recordPhase("answer.composed");
await publish({ type: "answer.composed" });
return {
ok: true,
status: "completed",
errorCode: null,
usage: { inputTokens, outputTokens, ...(cache ? { cache } : {}) },
answerText,
answerDeltas,
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
settleBilling,
};
};
if (!skillBound) return failedAttempt(attemptId, "skill_not_loaded");
if (!caseLoaded) return failedAttempt(attemptId, "case_not_loaded");
const mapped = mapModelFinishToErrorCode({
finishReason,
aborted: abortController.signal.aborted,
timedOut,
answerText,
stepCount: toolsUsed.size,
maxSteps,
});
if (mapped === "run_timeout") return failedAttempt(attemptId, "run_timeout");
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, mapped ?? "stream_aborted");
if (!finished) return failedAttempt(attemptId, mapped ?? "stream_unfinished");
if (mapped === "answer_truncated") {
if (!await applyHostFallback()) {
return {
ok: false,
status: "failed",
errorCode: "answer_truncated",
usage: { inputTokens: 0, outputTokens: 0 },
answerText,
answerDeltas,
phases,
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
} else if (mapped === "max_steps" || mapped === "provider_error") {
if (mapped !== "max_steps" || !await applyHostFallback()) {
return failedAttempt(attemptId, mapped);
}
} else if (!answerText.trim() && !await applyHostFallback()) {
const couldRetry = !publicWriteToolCompleted(toolTerminalStatus) && attemptNumber < MAX_ATTEMPTS;
const hasRetryBudget = canStartRetryAttempt(runDeadlineAt - Date.now(), minRetryAttemptMs);
if (couldRetry && hasRetryBudget) {
return {
ok: false,
status: "retryable",
errorCode: "empty_stream",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases: [...phases],
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
if (couldRetry && !hasRetryBudget) {
hostFallbackUsed = true;
await recordPhase("answer.host_fallback");
await publish({ type: "answer.host_fallback" });
await emitVisibleSpoken(
turnExpectsEvidenceWrite(action, options.expectedWrite)
? RECTIFICATION_USER_COPY.evidenceNotRecorded
: RECTIFICATION_USER_COPY.hostNarrationFallback,
);
return completeAttempt(false);
}
return {
ok: false,
status: "failed",
errorCode: "empty_stream",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases: [...phases],
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
const needsWrite = turnExpectsEvidenceWrite(action, options.expectedWrite)
|| (action !== "opening" && action !== "read_only" && answerClaimsEvidenceRecorded(answerText));
if (needsWrite && !publicWriteToolCompleted(toolTerminalStatus) && !hostFallbackUsed) {
await retractSpoken();
if (
attemptNumber < MAX_ATTEMPTS
&& canStartRetryAttempt(runDeadlineAt - Date.now(), minRetryAttemptMs)
) {
return {
ok: false,
status: "retryable",
errorCode: "evidence_not_written",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases: [...phases],
toolsUsed: [...toolsUsed],
events,
skillBound,
caseLoaded,
attemptId,
};
}
hostFallbackUsed = true;
await recordPhase("answer.host_fallback");
await publish({ type: "answer.host_fallback" });
await emitVisibleSpoken(RECTIFICATION_USER_COPY.evidenceNotRecorded);
return completeAttempt(false);
}
let latestDossier: V9CaseDossier;
try {
latestDossier = await loadV9CaseDossier(accounting, userId, caseId);
} catch {
return failedAttempt(attemptId, "state_invariant_failed");
}
const decision = decideFromDossier(latestDossier);
if (decision.nextAction === "ask_candidate_discriminator") {
const openFocus = latestDossier.conversationSummary.activeFocus;
if (!(
openFocus
&& isPersistedFocusId(openFocus.id)
&& parseAgentChoiceCopy(openFocus.expectedAnswerSchema)
)) {
return failedAttempt(attemptId, "state_invariant_failed");
}
}
const askedFocus = latestDossier.conversationSummary.activeFocus;
if (askedFocus?.askedTurnId === turnId) {
const stem = focusSpokenPrompt(askedFocus.expectedAnswerSchema);
if (stem) {
const stripped = stripQuestionSentences(answerText, stem);
const next = stripped || RECTIFICATION_USER_COPY.collectHandoff;
if (next !== answerText) answerText = next;
}
}
if (action === "opening") {
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
if (!isAcceptableOpeningBody(answerText)) {
answerText = openingSpokenBody(openingRange);
}
}
const rangeAfterEvidence = previousInferenceFromReceipt(
latestDossier.latestResult?.decisionReceipt ?? null,
)?.credible_range ?? null;
if (batchRescoreFailed(batchToolResult)) {
answerText = withRescoreSkippedNotice(answerText);
} else {
answerText = withRangeChangedAfterEvidence(
answerText,
rangeBeforeCompare,
rangeAfterEvidence,
);
}
answerText = stripVerbalWindowChange(answerText)
|| RECTIFICATION_USER_COPY.declaredWindowLockedReply;
if (answerText !== visibleEmitted) await emitVisibleSpoken(answerText);
return completeAttempt();
} finally {
clearTimeout(timeout);
signal?.removeEventListener("abort", onAbort);
const stepSummary = diagnosticStepsFromTimings(stepTimings);
const diagnostic: RectificationRunDiagnostic = {
runId: attemptId,
modelId: options.modelName,
finishReason: finishReason ?? "unknown",
inputTokens: stepSummary.inputTokens,
reasoningTokens: stepSummary.reasoningTokens,
outputTokens: stepSummary.outputTokens,
stepCount: stepSummary.stepCount,
toolCallCount: stepSummary.toolCallCount,
distinctToolCount: toolsUsed.size,
readCasePayloadBytes: null,
elapsedMs: Date.now() - startedAt,
attemptNumber,
attemptStartMs: attemptStartedAt - startedAt,
attemptElapsedMs: Date.now() - attemptStartedAt,
steps: stepSummary.steps,
lastCompletedTool: lastCompletedPublicTool(toolTerminalStatus),
stateMutationCommitted: publicWriteToolCompleted(toolTerminalStatus) || hostFallbackUsed,
expectedWrite: options.expectedWrite ?? null,
collectIntent: options.collectIntent ?? null,
classifier: options.classifierDiagnostic ?? null,
engineCalls: currentEngineCallTimings().slice(engineCallsBefore),
};
console.info(JSON.stringify({ scope: "RectificationRunDiagnostic", ...diagnostic }));
}
}
@@ -0,0 +1,197 @@
/**
* After the last attempt of a rectification agent turn: release or settle
* billing, write the terminal receipts, persist the next interview (with the
* exhaustion gate and the non-terminal repair), trim the spoken answer for the
* interview, finalize the turn and emit `run.completed` / `run.failed`.
* Moved out of runV9AgentTurn unchanged (TASK-rectification-code-split-20260926).
*/
import { finalizeV10RunAttempt } from "./tool-service";
import {
ensureNonTerminalTurnExit,
persistExhaustionGateTurn,
persistNextInterviewIfIdle,
} from "./answer-choice";
import { trimSpokenTurnForInterview, composeIdleGapIntoSpoken } from "./collect-prompt";
import { userFacingRunFailure } from "./run-diagnostic";
import { reportTurnProgress } from "./turn-instrumentation.ts";
import {
type AttemptOutcome,
safeErrorCode,
v9TurnReceipts,
} from "./agent-run-support";
import type { V9AgentRunResult } from "./agent-run";
import type { V9TimedTurn } from "./agent-run-prepare";
export async function finishV9AgentTurn(
turn: V9TimedTurn,
outcome: AttemptOutcome,
): Promise<V9AgentRunResult> {
const {
userId,
caseId,
action,
accounting,
billing,
emit,
previousFocusId,
resultId,
turnId,
startedAt,
} = turn;
const { persistCommittedPhase, finalizeTurn } = v9TurnReceipts(turn);
if (!outcome.ok) {
await billing.release();
await finalizeTurn(outcome.status, null, outcome.attemptId, null);
await emit({
type: "run.failed",
code: outcome.errorCode ?? "run_failed",
recoverable: outcome.status === "retryable",
message: userFacingRunFailure(outcome.errorCode),
});
return {
ok: false,
turnId,
turnStatus: outcome.status,
skillLoaded: false,
answerText: "",
phases: outcome.phases,
toolsUsed: outcome.toolsUsed,
errorCode: outcome.errorCode,
previousFocusId,
};
}
const skipBilling = outcome.settleBilling === false;
if (!skipBilling) {
const durationMs = Date.now() - startedAt;
const completed = await billing.complete({ ...outcome.usage, durationMs });
if (!completed) {
await persistCommittedPhase("run.failed", null, outcome.attemptId, outcome.phases.length + 1);
await finalizeV10RunAttempt(
accounting,
userId,
caseId,
turnId,
outcome.attemptId,
"retryable",
"usage_settlement_failed",
outcome.usage,
);
await finalizeTurn("retryable", null, outcome.attemptId, null);
await emit({
type: "run.failed",
code: "run_failed",
recoverable: true,
message: userFacingRunFailure("run_failed"),
});
return {
ok: false,
turnId,
turnStatus: "retryable",
skillLoaded: false,
answerText: "",
phases: [],
toolsUsed: [],
errorCode: "usage_settlement_failed",
previousFocusId,
};
}
await persistCommittedPhase(
"billing.settled",
null,
outcome.attemptId,
outcome.phases.length + 1,
true,
);
} else {
await billing.release();
}
await persistCommittedPhase(
"run.completed",
null,
outcome.attemptId,
outcome.phases.length + (skipBilling ? 1 : 2),
true,
);
await finalizeV10RunAttempt(
accounting,
userId,
caseId,
turnId,
outcome.attemptId,
"completed",
null,
outcome.usage,
);
const answerText = outcome.answerText;
let spokenAnswer = answerText;
let interviewIdle: Awaited<ReturnType<typeof persistNextInterviewIfIdle>> | null = null;
let interviewSettled = false;
if (action === "opening" || action === "evidence") {
try {
reportTurnProgress("preparing_question");
interviewIdle = await persistNextInterviewIfIdle({ accounting, userId, caseId, askedTurnId: turnId });
// Only the "focus already active" exit is provably idempotent: a second
// call re-reads the same focus and re-links the same turn (BUG-1047 D5).
interviewSettled = interviewIdle.focusActive === true;
if (interviewIdle.terminalNote && interviewIdle.hostNarration && !turnId) {
try {
await persistExhaustionGateTurn({
accounting,
userId,
caseId,
askedTurnId: turnId,
hostNarration: interviewIdle.hostNarration,
resultId,
});
} catch (error) {
console.warn(
`[rectification-v9] persist exhaustion gate before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
);
}
}
} catch (error) {
console.warn(
`[rectification-v9] persist interview before collect attach failed case=${caseId} reason=${safeErrorCode(error)}`,
);
try {
interviewIdle = await ensureNonTerminalTurnExit({ accounting, userId, caseId });
} catch (repairError) {
console.warn(
`[rectification-v9] nonterminal exit after idle failure failed case=${caseId} reason=${safeErrorCode(repairError)}`,
);
}
}
}
if (action === "evidence") {
spokenAnswer = trimSpokenTurnForInterview(answerText, interviewIdle?.terminalNote === true);
if (interviewIdle?.terminalNote && interviewIdle.hostNarration) {
spokenAnswer = composeIdleGapIntoSpoken(spokenAnswer, interviewIdle.hostNarration);
}
if (spokenAnswer !== answerText) {
await emit({ type: "answer.delta", text: spokenAnswer, replace: true });
}
}
await finalizeTurn("completed", spokenAnswer, outcome.attemptId, outcome.attemptId, true);
if (!skipBilling) await emit({ type: "billing.settled" });
await emit({ type: "run.completed", turnId });
return {
ok: true,
turnId,
turnStatus: "completed",
skillLoaded: outcome.skillBound,
answerText: spokenAnswer,
phases: skipBilling
? [...outcome.phases, "run.completed"]
: [...outcome.phases, "billing.settled", "run.completed"],
toolsUsed: outcome.toolsUsed,
errorCode: null,
previousFocusId,
interviewSettled,
};
}
@@ -0,0 +1,74 @@
/**
* The two messages a rectification agent attempt sends the model: the bound
* Skill bootstrap (with the retry constraint on a second attempt) and the user
* turn (the opening brief, or the typed message), both behind the server time
* and Case id lines. Moved out of agent-run.ts unchanged
* (TASK-rectification-code-split-20260926); agent-run.ts re-exports both
* builders.
*/
import type { V9CaseDossier } from "./tool-service";
import { cachedSystemMessage } from "../../agent-generation-settings.ts";
import { OPENING_COLLECT_DOMAINS } from "../user-copy";
import { retryConstraintForAttempt } from "./host-fallback";
import type { V9AgentRunOptions } from "./agent-run";
function clockWindow(range: { start_time?: string | null; end_time?: string | null } | null | undefined): string | null {
const start = range?.start_time?.trim().slice(0, 5) || "";
const end = range?.end_time?.trim().slice(0, 5) || "";
return start && end ? `${start}–${end}` : null;
}
export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string {
const confirmed = dossier.evidence.filter((item) => item.status === "confirmed");
const pending = dossier.evidence.filter((item) => item.status === "draft" || item.status === "pending_confirmation");
const domains = [...new Set(confirmed.map((item) => item.domain))].slice(0, 6);
const range = dossier.case.candidateRange;
const window = clockWindow(range);
const uncertaintyType = window
? `当前搜索窗口 ${window},来自用户在资料里声明的不确定档`
: "用户的出生时间精度仍需通过经历证据核对";
const clue = typeof birthTimeClue === "string" && birthTimeClue.trim()
? birthTimeClue.trim()
: "";
return [
"【服务端 opening brief】",
`Case 状态:${dossier.case.status}。`,
`当前搜索窗口:${window ?? "尚未锁定"}。来源:intake 声明的不确定档。`,
`出生时间不确定类型:${uncertaintyType}。`,
`已有证据摘要:已确认 ${confirmed.length} 条,待澄清或待确认 ${pending.length} 条${domains.length ? `;已覆盖 ${domains.join("、")}` : ""}。`,
...(clue
? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`]
: []),
`做法要点:一句当前窗口与核对做法;一句「最后给区间和代表分钟,不给精确到秒」;一句「想到几件说几件,有大概年月就行」并点出${OPENING_COLLECT_DOMAINS.join("、")}。一条消息可以报多件,想到几件说几件。不得写具体年份,不得要求先准备材料。不要提问。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,题干写成「先说你最容易想起的一两件,年月大概就行」。`,
].join("\n");
}
export function buildAgentMessages(
options: V9AgentRunOptions,
attempt: number,
dossier: V9CaseDossier,
skillInstructions: string,
birthTimeClue: string | null = null,
previousErrorCode: string | null = null,
): unknown[] {
const timeContext = options.timeContext
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
const caseContext = `【服务端 Case ID】${options.caseId}。所有 rectification 工具调用的 caseId 必须原样使用此值。`;
const bootstrapContent = [
"【服务器已绑定当前 Case 的精确 Skill】运行器已在本 attempt 内加载并核验下列指令;不要重复调用 skill。第一步必须调用 rectification-read-case。",
skillInstructions,
...(attempt > 1 ? [retryConstraintForAttempt(previousErrorCode)] : []),
].join("\n\n");
const bootstrap = cachedSystemMessage(bootstrapContent, options.generationModel)
?? { role: "system" as const, content: bootstrapContent };
if (options.action === "opening") {
return [bootstrap, {
role: "user",
content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue)].join("\n"),
}];
}
return [bootstrap, {
role: "user",
content: [timeContext, caseContext, options.message ?? ""].join("\n"),
}];
}
@@ -0,0 +1,274 @@
/**
* Before any attempt of a rectification agent turn: date-reliability answer,
* session check, the year-entry host re-ask, bound Skill identity, the
* already-delivered guard, the opening birth-time clue, billing reserve, and
* the pending turn row (with an idempotent replay of a finished one). Moved
* out of runV9AgentTurn unchanged (TASK-rectification-code-split-20260926).
* A finished answer comes back as the run result; otherwise the prepared
* turn the attempts run on.
*/
import type { RectificationAgentAction } from "./step-budget";
import {
loadV9CaseDossier,
loadV9CaseSkillIdentity,
loadV9CaseCompute,
RectificationToolServiceError,
persistV9DeterministicTurn,
resolveV10ConversationFocus,
setV9EvidenceDateReliability,
type RectificationRpcClient,
type V9CaseDossier,
} from "./tool-service";
import { RECTIFICATION_SKILL_NAME } from "./case-status";
import { classifyDateReliabilityUtterance, isDateReliabilitySchema } from "./date-reliability.ts";
import { shouldHostReaskYearEntry } from "./year-entry-host.ts";
import { alreadyDelivered } from "./delivery-turn-guard";
import { isPersistedFocusId } from "./choice-card";
import { resolveExactSkillPackage, type ResolvedSkillPackageIdentity } from "../../skill-package-registry.ts";
import { defaultMessageOrigin, isRectificationMessageOrigin, messageContentHash } from "./message-origin";
import {
rpcOf,
} from "./agent-run-support";
import type { V9AgentRunOptions, V9AgentRunResult, V9RunBilling } from "./agent-run";
/** Everything the attempts, the retry loop and the finish read from preparation. */
export type V9PreparedTurn = Readonly<{
options: V9AgentRunOptions;
userId: string;
caseId: string;
action: RectificationAgentAction;
message: string | null;
accounting: RectificationRpcClient;
buildAgent: V9AgentRunOptions["buildAgent"];
billing: V9RunBilling;
emit: V9AgentRunOptions["emit"];
signal: AbortSignal | undefined;
skillName: string;
dossier: V9CaseDossier;
previousFocusId: string | null;
resultId: string;
skillPackage: ResolvedSkillPackageIdentity;
birthTimeClue: string | null;
turnId: string;
}>;
/** The prepared turn plus the run clock runV9AgentTurn starts before the first attempt. */
export type V9TimedTurn = V9PreparedTurn & Readonly<{
startedAt: number;
runBudgetMs: number;
minRetryAttemptMs: number;
attemptCapMs: number;
runDeadlineAt: number;
}>;
export async function prepareV9AgentTurn(
options: V9AgentRunOptions,
): Promise<V9AgentRunResult | V9PreparedTurn> {
const {
userId, caseId, sessionId, action, message, modelName,
accounting, buildAgent, billing, emit, signal,
} = options;
const skillName = options.skillName ?? RECTIFICATION_SKILL_NAME;
let dossier = await loadV9CaseDossier(accounting, userId, caseId);
const reliabilityFocus = dossier.conversationSummary.activeFocus;
const reliabilitySchema = reliabilityFocus?.expectedAnswerSchema ?? null;
if (message && isDateReliabilitySchema(reliabilitySchema)) {
const classified = classifyDateReliabilityUtterance(message);
const evidenceId = String(reliabilitySchema.target_evidence_id ?? "");
const focusId = reliabilityFocus?.id ?? "";
try {
if (classified && evidenceId) {
await setV9EvidenceDateReliability(accounting, userId, caseId, evidenceId, classified);
if (isPersistedFocusId(focusId)) {
await resolveV10ConversationFocus(accounting, userId, caseId, {
focusId,
status: "resolved",
evidenceId,
});
}
} else if (isPersistedFocusId(focusId)) {
await resolveV10ConversationFocus(accounting, userId, caseId, {
focusId,
status: "skipped",
});
}
dossier = await loadV9CaseDossier(accounting, userId, caseId);
} catch (error) {
console.warn(
`[rectification-v9] date reliability write deferred case=${caseId} reason=${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
const previousFocusId = dossier.conversationSummary.activeFocus?.id ?? null;
if (dossier.case.sessionId !== sessionId) {
throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch");
}
// Date reliability (above) runs first. Year-stage typed collect with a
// yearless short reply does not enter the model (BUG-910). BUG-916: this host
// answer runs after the session check, so a message from another session is
// rejected instead of written as a deterministic turn; the phase reuses the
// existing `answer.host_fallback` allowlist entry.
const yearReask = shouldHostReaskYearEntry(dossier.conversationSummary.activeFocus, message);
if (yearReask && message) {
const persisted = await persistV9DeterministicTurn(accounting, userId, caseId, {
requestId: options.requestId,
userMessage: message,
assistantMessage: yearReask,
});
await emit({ type: "answer.delta", text: yearReask, replace: true });
return {
ok: true,
turnId: persisted.turnId,
turnStatus: "completed",
skillLoaded: true,
answerText: yearReask,
phases: ["answer.host_fallback"],
toolsUsed: [],
errorCode: null,
previousFocusId,
};
}
const boundIdentity = await loadV9CaseSkillIdentity(accounting, userId, caseId);
if ((options.skillName && options.skillName !== boundIdentity.name)
|| (options.skillVersion && options.skillVersion !== boundIdentity.version)
|| dossier.case.skillName !== boundIdentity.name
|| dossier.case.skillVersion !== boundIdentity.version) {
throw new RectificationToolServiceError("agentic_rectification_skill_identity_mismatch");
}
const resultId = dossier.latestResult?.resultId ?? "";
if (alreadyDelivered({
caseId,
resultId,
hasUserMessage: Boolean(message?.trim()),
})) {
return {
ok: true,
turnId: "",
turnStatus: "completed",
skillLoaded: true,
answerText: "",
phases: [],
toolsUsed: [],
errorCode: "already_delivered",
previousFocusId,
};
}
const skillPackage = resolveExactSkillPackage(
boundIdentity.name,
boundIdentity.version,
boundIdentity.sha256,
);
if (skillPackage.sourceCommit !== boundIdentity.sourceCommit) {
throw new RectificationToolServiceError("agentic_rectification_skill_identity_mismatch");
}
let birthTimeClue: string | null = null;
if (action === "opening") {
try {
const compute = await loadV9CaseCompute(accounting, userId, caseId);
const raw = compute.baselineBirthSnapshot.birth_time_clue;
birthTimeClue = typeof raw === "string" && raw.trim() ? raw.trim() : null;
} catch {
birthTimeClue = null;
}
}
const reserve = await billing.reserve();
if (!reserve.success) {
throw new RectificationToolServiceError(reserve.reason ?? "billing_denied");
}
let turnRow: unknown;
try {
turnRow = await rpcOf(accounting, "append_agentic_rectification_turn", {
p_user_id: userId,
p_case_id: caseId,
p_user_message: message,
p_assistant_message: null,
p_model_name: modelName,
p_model_version: null,
p_status: "pending",
p_request_id: options.requestId,
});
} catch (error) {
await billing.release();
throw error;
}
const turnRecord = turnRow && typeof turnRow === "object"
? turnRow as Record<string, unknown>
: null;
const turnId = typeof turnRecord?.turn_id === "string" ? turnRecord.turn_id : "";
if (!turnId) {
await billing.release();
throw new RectificationToolServiceError("agentic_rectification_turn_incomplete");
}
try {
await rpcOf(accounting, "record_agentic_rectification_turn_origin", {
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_origin: isRectificationMessageOrigin(options.messageOrigin)
? options.messageOrigin
: defaultMessageOrigin(options.action),
p_client_action_id: options.clientActionId ?? options.requestId,
p_content_hash: messageContentHash(message),
});
} catch {
// Origin is audit metadata; a missing RPC must not fail the turn.
}
const shouldExecute = turnRecord?.should_execute === undefined
? true
: turnRecord.should_execute === true;
const existingStatus = typeof turnRecord?.status === "string" ? turnRecord.status : "pending";
const existingAnswer = typeof turnRecord?.assistant_message === "string"
? turnRecord.assistant_message
: "";
if (!shouldExecute) {
if (existingStatus === "completed" && existingAnswer.trim()) {
await emit({ type: "run.started" });
await emit({ type: "answer.delta", text: existingAnswer });
await emit({ type: "run.completed", turnId });
return {
ok: true,
turnId,
turnStatus: "completed",
skillLoaded: typeof turnRecord?.successful_attempt_id === "string",
answerText: existingAnswer,
phases: ["run.completed"],
toolsUsed: [],
errorCode: null,
previousFocusId,
};
}
if (existingStatus !== "pending") await billing.release();
throw new RectificationToolServiceError(
existingStatus === "pending"
? "agentic_rectification_turn_in_progress"
: "agentic_rectification_turn_already_finalized",
);
}
return {
options,
userId,
caseId,
action,
message,
accounting,
buildAgent,
billing,
emit,
signal,
skillName,
dossier,
previousFocusId,
resultId,
skillPackage,
birthTimeClue,
turnId,
};
}
@@ -0,0 +1,125 @@
/**
* The attempt loop of a rectification agent turn: claim an attempt, stream
* it, record a failed attempt, and retry once (`attempt.reset` first) while
* the error is retryable and the run budget allows. Moved out of
* runV9AgentTurn unchanged (TASK-rectification-code-split-20260926).
*/
import { createV10RunAttempt, finalizeV10RunAttempt, RectificationToolServiceError } from "./tool-service";
import { canStartRetryAttempt } from "../../rectification-run-budget.ts";
import { reportTurnProgress, resetTurnProgress } from "./turn-instrumentation.ts";
import {
type AttemptOutcome,
MAX_ATTEMPTS,
safeErrorCode,
isRetryableError,
shouldAutoRetry,
v9TurnReceipts,
} from "./agent-run-support";
import { streamV9Attempt } from "./agent-run-attempt";
import type { V9TimedTurn } from "./agent-run-prepare";
export async function runV9AttemptsWithRetry(turn: V9TimedTurn): Promise<AttemptOutcome> {
const {
userId,
caseId,
accounting,
billing,
emit,
signal,
turnId,
minRetryAttemptMs,
runDeadlineAt,
} = turn;
const { persistCommittedPhase } = v9TurnReceipts(turn);
const streamAttempt = (
attemptNumber: number,
attemptId: string,
previousErrorCode: string | null,
) => streamV9Attempt(turn, attemptNumber, attemptId, previousErrorCode);
let lastAttemptError: string | null = null;
let finalOutcome: AttemptOutcome | null = null;
for (let attemptNumber = 1; attemptNumber <= MAX_ATTEMPTS; attemptNumber += 1) {
const remainingMs = runDeadlineAt - Date.now();
if (attemptNumber > 1 && !canStartRetryAttempt(remainingMs, minRetryAttemptMs)) break;
const claim = await createV10RunAttempt(
accounting,
userId,
caseId,
turnId,
attemptNumber,
);
const { attemptId } = claim;
if (!claim.shouldExecute) {
await billing.release();
throw new RectificationToolServiceError(
claim.alreadyInProgress
? "agentic_rectification_attempt_in_progress"
: "agentic_rectification_attempt_already_finalized",
);
}
let outcome: AttemptOutcome;
try {
outcome = await streamAttempt(attemptNumber, attemptId, lastAttemptError);
} catch (error) {
const errorCode = safeErrorCode(error);
const reason = error instanceof Error ? error.message.slice(0, 180) : "UnknownError";
console.error(
`[rectification-v10] attempt failed case=${caseId} turn=${turnId} attempt=${attemptId} code=${errorCode} reason=${reason}`,
);
outcome = {
ok: false,
status: isRetryableError(errorCode) ? "retryable" : "failed",
errorCode,
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases: [],
toolsUsed: [],
events: [],
skillBound: false,
caseLoaded: false,
attemptId,
};
}
if (!outcome.ok) {
await persistCommittedPhase("run.failed", null, attemptId, 1_000_000 + attemptNumber);
await finalizeV10RunAttempt(
accounting,
userId,
caseId,
turnId,
attemptId,
outcome.status,
outcome.errorCode,
outcome.usage,
);
}
finalOutcome = outcome;
if (!outcome.ok) lastAttemptError = outcome.errorCode;
if (outcome.ok
|| outcome.status === "failed"
|| !shouldAutoRetry(outcome.errorCode ?? "run_failed", signal, outcome.status)
|| attemptNumber === MAX_ATTEMPTS) break;
await emit({ type: "attempt.reset" });
resetTurnProgress();
reportTurnProgress("received");
}
const outcome = finalOutcome ?? {
ok: false,
status: "failed" as const,
errorCode: "run_failed",
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases: [],
toolsUsed: [],
events: [],
skillBound: false,
caseLoaded: false,
attemptId: "",
};
return outcome;
}
@@ -0,0 +1,207 @@
/**
* Shared pieces of the V10 rectification turn runner: attempt outcome types,
* retry classification, RPC unwrapping, and the turn-scoped receipt writers
* (committed phases, turn finalize). Moved out of agent-run.ts unchanged
* (TASK-rectification-code-split-20260926).
*/
import { insertV9RunPhase, RectificationToolServiceError, type RectificationRpcClient } from "./tool-service";
import { promptCacheUsage } from "../../agent-generation-settings.ts";
import { toAgentModelFinishReason } from "../../agent-observability.ts";
import type { PublicStreamEvent } from "./stream-mapping";
export type AttemptStatus = "completed" | "failed" | "retryable";
export type Usage = Readonly<{ inputTokens: number; outputTokens: number; cache?: ReturnType<typeof promptCacheUsage> }>;
export type AttemptOutcome = Readonly<{
ok: boolean;
status: AttemptStatus;
errorCode: string | null;
usage: Usage;
answerText: string;
answerDeltas: readonly string[];
phases: readonly string[];
toolsUsed: readonly string[];
events: readonly PublicStreamEvent[];
skillBound: boolean;
caseLoaded: boolean;
attemptId: string;
settleBilling?: boolean;
}>;
export const MAX_ATTEMPTS = 2;
export const RETRYABLE_ERROR_CODES = new Set([
"stream_aborted",
"stream_unfinished",
"skill_not_loaded",
"skill_not_bound",
"case_not_loaded",
]);
export function streamFinishReason(chunk: {
type: string;
payload?: { stepResult?: { reason?: unknown }; reason?: unknown };
}): ReturnType<typeof toAgentModelFinishReason> | null {
if (chunk.type !== "finish") return null;
const raw = chunk.payload?.stepResult?.reason ?? chunk.payload?.reason;
if (typeof raw !== "string" || !raw.trim()) return null;
return toAgentModelFinishReason(raw);
}
export function first(value: unknown): unknown {
if (Array.isArray(value)) return value[0] ?? null;
if (value && typeof value === "object" && "value" in value) {
return (value as { value?: unknown }).value;
}
return value;
}
/**
* Identity for a public tool-call chunk. Mastra may put the model input on
* `args`, `input`, or omit it; missing input collapses to `{}` so a second
* call of the same tool name still looks identical.
*/
export function publicToolCallKey(toolName: string, payload: unknown): string {
if (!payload || typeof payload !== "object") return `${toolName}:{}`;
const record = payload as Record<string, unknown>;
const args = record.args ?? record.input ?? record.toolArgs ?? {};
try {
return `${toolName}:${JSON.stringify(args)}`;
} catch {
return `${toolName}:{}`;
}
}
export async function rpcOf(
accounting: RectificationRpcClient,
fn: string,
args: Record<string, unknown>,
): Promise<unknown> {
const { data, error } = await accounting.rpc(fn, args);
if (error) throw new RectificationToolServiceError(error.message);
return first(data);
}
export function safeErrorCode(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
for (const code of [
"empty_stream",
"evidence_not_written",
"stream_aborted",
"stream_unfinished",
"skill_not_loaded",
"skill_not_bound",
"case_not_loaded",
"repeated_tool_call",
"focus_persistence_failed",
"answer_truncated",
"run_timeout",
"max_steps",
"provider_error",
]) {
if (message.includes(code)) return code;
}
if (message.includes("agentic_rectification_case_terminal")) return "case_terminal";
if (message.includes("agentic_rectification_case_not_found")) return "case_not_found";
if (message.includes("agentic_rectification_case_session_mismatch")) return "case_session_mismatch";
if (message.includes("Thinking mode does not support this tool_choice")) {
return "thinking_tool_choice_unsupported";
}
return "run_failed";
}
export function isRetryableError(errorCode: string): boolean {
return RETRYABLE_ERROR_CODES.has(errorCode);
}
export function shouldAutoRetry(
errorCode: string,
signal?: AbortSignal,
status?: AttemptStatus,
): boolean {
if (signal?.aborted) return false;
if (errorCode === "empty_stream") return status === "retryable";
if (errorCode === "evidence_not_written") return status === "retryable";
return isRetryableError(errorCode);
}
/** The Case turn the receipt writers below belong to. */
export type V9TurnScope = Readonly<{
accounting: RectificationRpcClient;
userId: string;
caseId: string;
turnId: string;
}>;
/**
* `failedAttempt`, `persistCommittedPhase` and `finalizeTurn` as they were
* nested inside runV9AgentTurn, bound to one turn so every phase of the
* runner calls them with the same arguments as before.
*/
export function v9TurnReceipts(scope: V9TurnScope) {
const { accounting, userId, caseId, turnId } = scope;
return { failedAttempt, persistCommittedPhase, finalizeTurn };
function failedAttempt(attemptId: string, errorCode: string): AttemptOutcome {
return {
ok: false,
status: isRetryableError(errorCode) ? "retryable" : "failed",
errorCode,
usage: { inputTokens: 0, outputTokens: 0 },
answerText: "",
answerDeltas: [],
phases: [],
toolsUsed: [],
events: [],
skillBound: false,
caseLoaded: false,
attemptId,
};
}
async function persistCommittedPhase(
phase: string,
toolName: string | null,
attemptId: string,
sequence: number,
strict = false,
) {
try {
await insertV9RunPhase(
accounting,
userId,
caseId,
turnId,
phase,
toolName,
sequence,
attemptId,
);
} catch (error) {
if (strict) throw error;
// Non-terminal activity receipts remain best effort. The completion
// receipts above are strict because they are part of business truth.
}
}
async function finalizeTurn(
status: AttemptStatus,
assistantText: string | null,
attemptId: string,
successfulAttemptId: string | null,
strict = false,
) {
try {
await rpcOf(accounting, "finalize_agentic_rectification_turn", {
p_user_id: userId,
p_case_id: caseId,
p_turn_id: turnId,
p_attempt_id: attemptId,
p_status: status,
p_assistant_message: status === "completed" ? assistantText : null,
p_successful_attempt_id: successfulAttemptId,
});
} catch (error) {
if (strict) throw error;
console.warn(`[rectification-v10] turn finalize failed turn=${turnId} status=${status} reason=${safeErrorCode(error)}`);
}
}
}
File diff suppressed because it is too large Load Diff