Skip to content

Commit 2848acb

Browse files
authored
fix(agents): safe codex tool-terminal continuation, auth-true simple-completion routes, doctor opt-out (#108966)
* fix(agents): safe codex continuation, auth-true simple-completion routes, doctor opt-out Three verified codex-cluster fixes: - Tool-call-terminal turns (stopReason=toolUse, no final assistant message) now get one bounded continuation instead of dying with an incomplete-turn error. Completion is proven per tool-call id (every terminal toolCall needs a non-error toolResult in the snapshot) so undispatched or partially dispatched batches keep failing closed; the continuation appends a fresh native-thread turn and never replays completed tools. (#108517) - Simple-completion (narration/titles/labels) resolves auth before finalizing official dual-route OpenAI models and re-materializes the auth-compatible physical route through the runtime-plan seam; api-key credentials are no longer legal for the ChatGPT-backend codex transport (fail closed), ending silent 401s. Custom base URLs and explicit refs stay honored. (#104779) - openclaw doctor --fix no longer flips an explicit plugins.entries.codex enabled:false; explicit user opt-out now warns with a fix hint like the deny-list case, while missing-entry and allowlist repairs stay automatic. (#97180) Fixes #108517 Fixes #104779 Fixes #97180 * fix(agents): accept ChatGPT token profiles on codex transport, thread messagesSnapshot, update sibling hints The symmetric codex-transport auth gate now accepts both subscription credential classes (oauth and ChatGPT token) instead of oauth only, so prepared-candidate fallback keeps skipping to a valid subscription profile. attempt-result threads messagesSnapshot into the incomplete-turn attempt shape, and the doctor-lint sibling test expects the manual hint now that an explicit plugins.entries.codex.enabled=false blocks auto-repair. * chore(agents): route test mock casts through unknown, un-export unused doctor helper
1 parent 175181f commit 2848acb

16 files changed

Lines changed: 723 additions & 184 deletions

src/agents/embedded-agent-runner/run.incomplete-turn.test.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
resolveReplayInvalidFlag,
3434
resolveRunLivenessState,
3535
resolveSilentToolResultReplyPayload,
36+
resolveToolUseTerminalContinuationInstruction,
3637
shouldRetryMissingAssistantTurn,
3738
shouldRetrySilentErrorAssistantTurn,
3839
shouldTreatEmptyAssistantReplyAsSilent,
@@ -43,6 +44,8 @@ const REASONING_ONLY_RETRY_INSTRUCTION =
4344
"The previous assistant turn recorded reasoning but did not produce a user-visible answer. Continue from that partial turn and produce the visible answer now. Do not restate the reasoning or restart from scratch.";
4445
const EMPTY_RESPONSE_RETRY_INSTRUCTION =
4546
"The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch.";
47+
const TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION =
48+
"The previous assistant turn completed its tool calls but did not produce a user-visible answer. Continue from the current transcript and produce the final user-visible answer now. Do not repeat completed tool calls or restart from scratch.";
4649

4750
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
4851

@@ -1007,6 +1010,159 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
10071010
expectWarnMessageWith("reasoning-only assistant turn detected");
10081011
});
10091012

1013+
it("continues once after settled side-effecting tools finish without a final answer", async () => {
1014+
const toolUseAssistant = {
1015+
role: "assistant",
1016+
stopReason: "toolUse",
1017+
provider: "openai",
1018+
model: "gpt-5.5",
1019+
content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "note.txt" } }],
1020+
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
1021+
const settledToolResults = [
1022+
{ role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false },
1023+
] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"];
1024+
mockedClassifyFailoverReason.mockReturnValue(null);
1025+
mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => {
1026+
markUserMessagePersisted(attemptParams);
1027+
return makeAttemptResult({
1028+
assistantTexts: [],
1029+
toolMetas: [{ toolName: "write", meta: "path=note.txt" }],
1030+
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
1031+
messagesSnapshot: settledToolResults,
1032+
lastAssistant: toolUseAssistant,
1033+
currentAttemptAssistant: toolUseAssistant,
1034+
});
1035+
});
1036+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
1037+
makeAttemptResult({ assistantTexts: ["Write completed. Here is the final answer."] }),
1038+
);
1039+
mockedBuildEmbeddedRunPayloads
1040+
.mockReturnValueOnce([])
1041+
.mockReturnValueOnce([{ text: "Write completed. Here is the final answer." }]);
1042+
1043+
const result = await runEmbeddedAgent({
1044+
...overflowBaseRunParams,
1045+
provider: "openai",
1046+
model: "gpt-5.5",
1047+
runId: "run-tool-use-terminal-continuation",
1048+
});
1049+
1050+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
1051+
expect(result.payloads?.[0]?.text).toBe("Write completed. Here is the final answer.");
1052+
const secondCall = runAttemptCall(1);
1053+
expect(secondCall.prompt).toBe(TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION);
1054+
expect(secondCall.suppressNextUserMessagePersistence).toBe(false);
1055+
expect(secondCall.skipPreparedUserTurnMessage).toBe(true);
1056+
expectWarnMessageWith("tool-use terminal turn lacked a final answer");
1057+
});
1058+
1059+
it("surfaces the existing incomplete-turn error after one tool-use continuation", async () => {
1060+
const toolUseAssistant = {
1061+
role: "assistant",
1062+
stopReason: "toolUse",
1063+
provider: "openai",
1064+
model: "gpt-5.5",
1065+
content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "note.txt" } }],
1066+
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
1067+
mockedClassifyFailoverReason.mockReturnValue(null);
1068+
mockedRunEmbeddedAttempt.mockResolvedValue(
1069+
makeAttemptResult({
1070+
assistantTexts: [],
1071+
toolMetas: [{ toolName: "write", meta: "path=note.txt" }],
1072+
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
1073+
messagesSnapshot: [
1074+
{ role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false },
1075+
] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"],
1076+
lastAssistant: toolUseAssistant,
1077+
currentAttemptAssistant: toolUseAssistant,
1078+
}),
1079+
);
1080+
1081+
const result = await runEmbeddedAgent({
1082+
...overflowBaseRunParams,
1083+
provider: "openai",
1084+
model: "gpt-5.5",
1085+
runId: "run-tool-use-terminal-continuation-exhausted",
1086+
});
1087+
1088+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
1089+
expect(result.payloads?.[0]?.isError).toBe(true);
1090+
expect(result.payloads?.[0]?.text).toContain(
1091+
"some tool actions may have already been executed",
1092+
);
1093+
expectWarnMessageWith("toolUseContinuations=1/1");
1094+
});
1095+
1096+
it("does not claim completion for a toolUse terminal whose tools never started", async () => {
1097+
const toolUseAssistant = {
1098+
role: "assistant",
1099+
stopReason: "toolUse",
1100+
provider: "openai",
1101+
model: "gpt-5.5",
1102+
content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "note.txt" } }],
1103+
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
1104+
mockedClassifyFailoverReason.mockReturnValue(null);
1105+
mockedRunEmbeddedAttempt.mockResolvedValue(
1106+
makeAttemptResult({
1107+
assistantTexts: [],
1108+
toolMetas: [],
1109+
itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 },
1110+
lastAssistant: toolUseAssistant,
1111+
currentAttemptAssistant: toolUseAssistant,
1112+
}),
1113+
);
1114+
1115+
await runEmbeddedAgent({
1116+
...overflowBaseRunParams,
1117+
provider: "openai",
1118+
model: "gpt-5.5",
1119+
runId: "run-tool-use-terminal-never-started",
1120+
});
1121+
1122+
for (let call = 0; call < mockedRunEmbeddedAttempt.mock.calls.length; call += 1) {
1123+
expect(runAttemptCall(call).prompt).not.toContain(TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION);
1124+
}
1125+
expectNoWarnMessageWith("tool-use terminal turn lacked a final answer");
1126+
});
1127+
1128+
it("does not claim completion when only part of a multi-tool request dispatched", async () => {
1129+
const toolUseAssistant = {
1130+
role: "assistant",
1131+
stopReason: "toolUse",
1132+
provider: "openai",
1133+
model: "gpt-5.5",
1134+
content: [
1135+
{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "a.txt" } },
1136+
{ type: "toolCall", id: "tool_2", name: "write", arguments: { path: "b.txt" } },
1137+
],
1138+
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
1139+
mockedClassifyFailoverReason.mockReturnValue(null);
1140+
mockedRunEmbeddedAttempt.mockResolvedValue(
1141+
makeAttemptResult({
1142+
assistantTexts: [],
1143+
toolMetas: [{ toolName: "write", meta: "path=a.txt" }],
1144+
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
1145+
messagesSnapshot: [
1146+
{ role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false },
1147+
] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"],
1148+
lastAssistant: toolUseAssistant,
1149+
currentAttemptAssistant: toolUseAssistant,
1150+
}),
1151+
);
1152+
1153+
await runEmbeddedAgent({
1154+
...overflowBaseRunParams,
1155+
provider: "openai",
1156+
model: "gpt-5.5",
1157+
runId: "run-tool-use-terminal-partial-dispatch",
1158+
});
1159+
1160+
for (let call = 0; call < mockedRunEmbeddedAttempt.mock.calls.length; call += 1) {
1161+
expect(runAttemptCall(call).prompt).not.toContain(TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION);
1162+
}
1163+
expectNoWarnMessageWith("tool-use terminal turn lacked a final answer");
1164+
});
1165+
10101166
it("returns NO_REPLY without retrying reasoning-only assistant turns when silence is allowed", async () => {
10111167
mockedClassifyFailoverReason.mockReturnValue(null);
10121168
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
@@ -1903,6 +2059,38 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
19032059
).toBe(true);
19042060
});
19052061

2062+
it.each([
2063+
{ label: "aborted", aborted: true, timedOut: false, promptError: null },
2064+
{ label: "timed out", aborted: false, timedOut: true, promptError: null },
2065+
{ label: "prompt error", aborted: false, timedOut: false, promptError: new Error("closed") },
2066+
])("does not continue a $label tool-use terminal turn", ({ aborted, timedOut, promptError }) => {
2067+
const toolUseAssistant = {
2068+
role: "assistant",
2069+
stopReason: "toolUse",
2070+
provider: "openai",
2071+
model: "gpt-5.5",
2072+
content: [{ type: "tool_use", id: "tool_1", name: "bash", input: {} }],
2073+
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
2074+
const instruction = resolveToolUseTerminalContinuationInstruction({
2075+
provider: "openai",
2076+
modelId: "gpt-5.5",
2077+
modelApi: "openai-chatgpt-responses",
2078+
payloadCount: 0,
2079+
aborted,
2080+
timedOut,
2081+
promptError,
2082+
attempt: makeAttemptResult({
2083+
assistantTexts: [],
2084+
toolMetas: [{ toolName: "bash" }],
2085+
itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 },
2086+
lastAssistant: toolUseAssistant,
2087+
currentAttemptAssistant: toolUseAssistant,
2088+
}),
2089+
});
2090+
2091+
expect(instruction).toBeNull();
2092+
});
2093+
19062094
it("does not flag stale lastAssistant=toolUse when currentAttemptAssistant=stop exists (#80918)", () => {
19072095
const incompleteTurnText = resolveIncompleteTurnPayloadText({
19082096
payloadCount: 1,

src/agents/embedded-agent-runner/run/attempt-result.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ export function completeEmbeddedAttemptResult(
367367
lastToolError,
368368
lastAssistant: state.lastAssistant,
369369
itemLifecycle: getItemLifecycle(),
370+
messagesSnapshot: state.messagesSnapshot,
370371
toolMetas: toolMetasNormalized,
371372
replayMetadata,
372373
promptErrorSource: state.promptErrorSource,

src/agents/embedded-agent-runner/run/incomplete-turn.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type IncompleteTurnAttempt = Pick<
5656
| "lastToolError"
5757
| "lastAssistant"
5858
| "itemLifecycle"
59+
| "messagesSnapshot"
5960
| "replayMetadata"
6061
| "promptErrorSource"
6162
| "timedOutDuringCompaction"
@@ -136,6 +137,8 @@ const REASONING_ONLY_RETRY_INSTRUCTION =
136137
"The previous assistant turn recorded reasoning but did not produce a user-visible answer. Continue from that partial turn and produce the visible answer now. Do not restate the reasoning or restart from scratch.";
137138
const EMPTY_RESPONSE_RETRY_INSTRUCTION =
138139
"The previous attempt did not produce a user-visible answer. Continue from the current state and produce the visible answer now. Do not restart from scratch.";
140+
const TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION =
141+
"The previous assistant turn completed its tool calls but did not produce a user-visible answer. Continue from the current transcript and produce the final user-visible answer now. Do not repeat completed tool calls or restart from scratch.";
139142

140143
/**
141144
* Marks whether retrying the attempt can safely replay the prompt. Concrete
@@ -722,6 +725,76 @@ export function resolveReasoningOnlyRetryInstruction(params: {
722725
return REASONING_ONLY_RETRY_INSTRUCTION;
723726
}
724727

728+
/** Builds a fresh continuation for a clean tool-use terminal turn with settled tool activity. */
729+
export function resolveToolUseTerminalContinuationInstruction(params: {
730+
provider?: string;
731+
modelId?: string;
732+
modelApi?: string;
733+
executionContract?: string;
734+
payloadCount: number;
735+
hasTerminalToolPresentation?: boolean;
736+
aborted: boolean;
737+
promptError?: unknown;
738+
timedOut: boolean;
739+
attempt: IncompleteTurnAttempt;
740+
}): string | null {
741+
const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant;
742+
// Idle is not proof of completion: a toolUse terminal whose requested tools never
743+
// (or only partially) dispatched must keep the incomplete-turn error, or the model
744+
// could claim skipped side effects succeeded. Lifecycle counts are attempt-cumulative
745+
// and alias across batches, so completion is proven per tool-call id: every toolCall
746+
// in the terminal assistant needs a non-error toolResult in the message snapshot.
747+
const requestedToolCallIds = Array.isArray(assistant?.content)
748+
? assistant.content.flatMap((item) => {
749+
const block = item as { type?: unknown; id?: unknown } | null;
750+
return block?.type === "toolCall" ? [typeof block.id === "string" ? block.id : null] : [];
751+
})
752+
: [];
753+
const completedToolCallIds = new Set(
754+
(params.attempt.messagesSnapshot ?? []).flatMap((message) => {
755+
const result = message as { role?: unknown; toolCallId?: unknown; isError?: unknown };
756+
return result.role === "toolResult" &&
757+
result.isError !== true &&
758+
typeof result.toolCallId === "string"
759+
? [result.toolCallId]
760+
: [];
761+
}),
762+
);
763+
const allToolsProvenComplete =
764+
params.attempt.itemLifecycle?.activeCount === 0 &&
765+
requestedToolCallIds.length > 0 &&
766+
requestedToolCallIds.every((id) => id !== null && completedToolCallIds.has(id));
767+
if (
768+
params.payloadCount !== 0 ||
769+
params.hasTerminalToolPresentation ||
770+
params.aborted ||
771+
params.promptError != null ||
772+
params.timedOut ||
773+
assistant?.stopReason !== "toolUse" ||
774+
!allToolsProvenComplete ||
775+
params.attempt.lastToolError ||
776+
params.attempt.clientToolCalls ||
777+
params.attempt.yieldDetected ||
778+
params.attempt.didSendDeterministicApprovalPrompt
779+
) {
780+
return null;
781+
}
782+
if (hasMessagingToolDeliveryEvidence(params.attempt)) {
783+
return null;
784+
}
785+
if (
786+
!shouldApplyNonVisibleTurnRetryGuard({
787+
provider: params.provider,
788+
modelId: params.modelId,
789+
modelApi: params.modelApi,
790+
executionContract: params.executionContract,
791+
})
792+
) {
793+
return null;
794+
}
795+
return TOOL_USE_TERMINAL_CONTINUATION_INSTRUCTION;
796+
}
797+
725798
/**
726799
* Builds the retry instruction for empty assistant turns when the provider/model
727800
* is eligible for non-visible turn recovery.

src/agents/embedded-agent-runner/run/terminal-resolution.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
resolveReasoningOnlyRetryInstruction,
2727
resolveRunLivenessState,
2828
resolveSilentToolResultReplyPayload,
29+
resolveToolUseTerminalContinuationInstruction,
2930
shouldRetryMissingAssistantTurn,
3031
shouldTreatEmptyAssistantReplyAsSilent,
3132
} from "./incomplete-turn.js";
@@ -37,6 +38,7 @@ import {
3738
import type { EmbeddedRunAttemptResult } from "./types.js";
3839

3940
const MAX_MISSING_ASSISTANT_RETRIES = 1;
41+
const MAX_TOOL_USE_TERMINAL_CONTINUATIONS = 1;
4042
const COMPACTION_CONTINUATION_RETRY_INSTRUCTION =
4143
"The previous attempt compacted the conversation context before producing a final user-visible answer. Continue from the compacted transcript and produce the final answer now. Do not restart from scratch, do not repeat completed work, and do not rerun tools unless the transcript clearly lacks required evidence.";
4244
const BEFORE_AGENT_FINALIZE_RETRY_PROMPT_PREFIX =
@@ -202,6 +204,36 @@ export async function resolveEmbeddedRunTerminal(input: {
202204
);
203205
return { action: "retry" };
204206
}
207+
const availableTerminalToolPresentation = input.readTerminalToolPresentation();
208+
const nextToolUseTerminalContinuationInstruction = emptyAssistantReplyIsSilent
209+
? null
210+
: resolveToolUseTerminalContinuationInstruction({
211+
provider: input.activeErrorContext.provider,
212+
modelId: input.activeErrorContext.model,
213+
modelApi: input.modelApi,
214+
executionContract: input.executionContract,
215+
payloadCount,
216+
hasTerminalToolPresentation: Boolean(availableTerminalToolPresentation),
217+
aborted: input.terminalAborted,
218+
promptError: input.promptError,
219+
timedOut: input.terminalTimedOut,
220+
attempt,
221+
});
222+
if (
223+
nextToolUseTerminalContinuationInstruction &&
224+
retryState.toolUseContinuationAttempts < MAX_TOOL_USE_TERMINAL_CONTINUATIONS
225+
) {
226+
retryState.toolUseContinuationAttempts += 1;
227+
// This starts a new persisted native-thread turn after settled tool results; it does not
228+
// replay the failed prompt or completed tools. Therefore replaySafe does not apply.
229+
input.activateInternalPrompt(nextToolUseTerminalContinuationInstruction, false);
230+
log.warn(
231+
`tool-use terminal turn lacked a final answer: runId=${runParams.runId} sessionId=${runParams.sessionId} ` +
232+
`provider=${input.activeErrorContext.provider}/${input.activeErrorContext.model} — continuing ${retryState.toolUseContinuationAttempts}/${MAX_TOOL_USE_TERMINAL_CONTINUATIONS} ` +
233+
`from settled tool results`,
234+
);
235+
return { action: "retry" };
236+
}
205237
const incompleteTurnText = emptyAssistantReplyIsSilent
206238
? null
207239
: resolveIncompleteTurnPayloadText({
@@ -220,7 +252,7 @@ export async function resolveEmbeddedRunTerminal(input: {
220252
!input.replayState.hadPotentialSideEffects,
221253
);
222254
const terminalToolPresentation = incompleteTurnFallbackSafe
223-
? input.readTerminalToolPresentation()
255+
? availableTerminalToolPresentation
224256
: undefined;
225257
if (
226258
!emptyAssistantReplyIsSilent &&
@@ -282,7 +314,8 @@ export async function resolveEmbeddedRunTerminal(input: {
282314
`tools=${attempt.toolMetas?.length ?? 0} replaySafe=${replayMetadata.replaySafe ? "yes" : "no"} ` +
283315
`compactions=${input.attemptCompactionCount} reasoningRetries=${retryState.reasoningOnlyAttempts}/${input.maxReasoningOnlyRetryAttempts} ` +
284316
`emptyRetries=${retryState.emptyResponseAttempts}/${input.maxEmptyResponseRetryAttempts} ` +
285-
`missingAssistantRetries=${retryState.missingAssistantAttempts}/${MAX_MISSING_ASSISTANT_RETRIES} — ` +
317+
`missingAssistantRetries=${retryState.missingAssistantAttempts}/${MAX_MISSING_ASSISTANT_RETRIES} ` +
318+
`toolUseContinuations=${retryState.toolUseContinuationAttempts}/${MAX_TOOL_USE_TERMINAL_CONTINUATIONS} — ` +
286319
(terminalToolPresentation
287320
? "surfacing tool-authored terminal presentation"
288321
: "surfacing error to user"),

0 commit comments

Comments
 (0)