@oh-my-pi/pi-coding-agent 17.0.6 → 17.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/cli.js +4967 -4948
- package/dist/types/config/model-registry.d.ts +1 -1
- package/dist/types/config/model-resolver.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +16 -0
- package/dist/types/dap/client.d.ts +19 -0
- package/dist/types/extensibility/extensions/runner.d.ts +4 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/extensibility/typebox.d.ts +4 -0
- package/dist/types/hindsight/client.d.ts +10 -0
- package/dist/types/hindsight/config.d.ts +8 -0
- package/dist/types/mcp/tool-bridge.d.ts +9 -0
- package/dist/types/mnemopi/state.d.ts +2 -0
- package/dist/types/modes/components/user-message.d.ts +25 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
- package/dist/types/modes/interactive-mode.d.ts +7 -1
- package/dist/types/modes/types.d.ts +14 -1
- package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
- package/dist/types/sdk.d.ts +1 -0
- package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
- package/dist/types/session/agent-session.d.ts +78 -2
- package/dist/types/task/executor.d.ts +10 -0
- package/dist/types/task/index.d.ts +1 -0
- package/dist/types/task/prewalk.d.ts +3 -0
- package/dist/types/tools/ask.d.ts +10 -0
- package/dist/types/tools/tool-timeouts.d.ts +8 -2
- package/dist/types/utils/changelog.d.ts +4 -6
- package/package.json +12 -13
- package/src/cli/models-cli.ts +37 -17
- package/src/cli/update-cli.ts +14 -1
- package/src/config/model-discovery.ts +3 -2
- package/src/config/model-registry.ts +53 -26
- package/src/config/model-resolver.ts +56 -31
- package/src/config/settings-schema.ts +5 -0
- package/src/config/settings.ts +68 -5
- package/src/dap/client.ts +86 -7
- package/src/edit/diff.ts +4 -4
- package/src/eval/py/prelude.py +5 -5
- package/src/export/html/template.js +23 -9
- package/src/extensibility/extensions/runner.ts +9 -4
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/typebox.ts +22 -9
- package/src/hindsight/client.test.ts +42 -0
- package/src/hindsight/client.ts +40 -3
- package/src/hindsight/config.ts +18 -0
- package/src/launch/broker.ts +31 -5
- package/src/lsp/index.ts +1 -1
- package/src/main.ts +15 -5
- package/src/mcp/tool-bridge.ts +9 -0
- package/src/mnemopi/state.ts +70 -3
- package/src/modes/components/agent-dashboard.ts +6 -2
- package/src/modes/components/chat-transcript-builder.ts +13 -2
- package/src/modes/components/diff.ts +2 -2
- package/src/modes/components/plan-review-overlay.ts +20 -4
- package/src/modes/components/user-message.ts +100 -1
- package/src/modes/controllers/command-controller.ts +22 -5
- package/src/modes/controllers/event-controller.ts +4 -1
- package/src/modes/controllers/extension-ui-controller.ts +18 -0
- package/src/modes/controllers/input-controller.ts +47 -12
- package/src/modes/controllers/selector-controller.ts +82 -6
- package/src/modes/interactive-mode.ts +36 -4
- package/src/modes/types.ts +16 -3
- package/src/modes/utils/ui-helpers.ts +40 -20
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/sdk.ts +134 -48
- package/src/session/agent-session-error-log.test.ts +59 -0
- package/src/session/agent-session.ts +300 -26
- package/src/system-prompt.ts +1 -2
- package/src/task/executor.ts +41 -27
- package/src/task/index.ts +11 -0
- package/src/task/prewalk.ts +6 -0
- package/src/task/worktree.ts +25 -11
- package/src/tools/ask.ts +15 -0
- package/src/tools/bash.ts +14 -6
- package/src/tools/browser.ts +1 -1
- package/src/tools/debug.ts +1 -1
- package/src/tools/eval.ts +4 -5
- package/src/tools/fetch.ts +1 -1
- package/src/tools/hub/launch.ts +7 -0
- package/src/tools/tool-timeouts.ts +10 -3
- package/src/tools/write.ts +31 -0
- package/src/utils/changelog.ts +54 -58
- package/src/utils/git.ts +57 -49
|
@@ -30,6 +30,8 @@ import {
|
|
|
30
30
|
type AgentMessage,
|
|
31
31
|
type AgentState,
|
|
32
32
|
type AgentTool,
|
|
33
|
+
type AgentToolCall,
|
|
34
|
+
type AgentToolContext,
|
|
33
35
|
type AgentToolResult,
|
|
34
36
|
type AgentTurnEndContext,
|
|
35
37
|
AppendOnlyContextManager,
|
|
@@ -37,6 +39,7 @@ import {
|
|
|
37
39
|
type CompactionSummaryMessage,
|
|
38
40
|
countTokens,
|
|
39
41
|
createToolScopedAbortReason,
|
|
42
|
+
isSyntheticToolResultMessage,
|
|
40
43
|
resolveTelemetry,
|
|
41
44
|
type StreamFn,
|
|
42
45
|
TERMINAL_TOOL_RESULT_ABORT_REASON,
|
|
@@ -105,6 +108,7 @@ import type {
|
|
|
105
108
|
TextContent,
|
|
106
109
|
ToolCall,
|
|
107
110
|
ToolChoice,
|
|
111
|
+
ToolResultMessage,
|
|
108
112
|
Usage,
|
|
109
113
|
UsageReport,
|
|
110
114
|
} from "@oh-my-pi/pi-ai";
|
|
@@ -238,6 +242,7 @@ import type {
|
|
|
238
242
|
TurnEndEvent,
|
|
239
243
|
TurnStartEvent,
|
|
240
244
|
} from "../extensibility/extensions";
|
|
245
|
+
import { emitSessionShutdownEvent } from "../extensibility/extensions";
|
|
241
246
|
import { ManagedTimers } from "../extensibility/extensions/managed-timers";
|
|
242
247
|
import { createExtensionModelQuery } from "../extensibility/extensions/model-api";
|
|
243
248
|
import type { CompactOptions, ContextUsage } from "../extensibility/extensions/types";
|
|
@@ -302,6 +307,7 @@ import { AgentRegistry } from "../registry/agent-registry";
|
|
|
302
307
|
import {
|
|
303
308
|
deobfuscateAssistantContent,
|
|
304
309
|
deobfuscateSessionContext,
|
|
310
|
+
deobfuscateToolArguments,
|
|
305
311
|
obfuscateMessages,
|
|
306
312
|
obfuscateProviderContext,
|
|
307
313
|
type SecretObfuscator,
|
|
@@ -320,6 +326,7 @@ import {
|
|
|
320
326
|
} from "../thinking";
|
|
321
327
|
import { formatTitleConversationContext, type TitleConversationTurn } from "../tiny/message-preproc";
|
|
322
328
|
import { shutdownTinyTitleClient } from "../tiny/title-client";
|
|
329
|
+
import { type AskToolDetails, type AskToolInput, recoverAskQuestions } from "../tools/ask";
|
|
323
330
|
import { assertEditableFile } from "../tools/auto-generated-guard";
|
|
324
331
|
import { releaseTabsForOwner } from "../tools/browser/tab-supervisor";
|
|
325
332
|
import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
|
|
@@ -872,6 +879,16 @@ export interface PlanYolo {
|
|
|
872
879
|
// Types
|
|
873
880
|
// ============================================================================
|
|
874
881
|
|
|
882
|
+
/** Identifies a retry fallback chain already entered during startup model resolution. */
|
|
883
|
+
export interface InitialRetryFallbackState {
|
|
884
|
+
/** Role whose configured primary was unavailable. */
|
|
885
|
+
role: string;
|
|
886
|
+
/** Configured primary selector retained for restoration when it becomes available. */
|
|
887
|
+
originalSelector: string;
|
|
888
|
+
/** Thinking selector configured for the unavailable primary. */
|
|
889
|
+
originalThinkingLevel: ConfiguredThinkingLevel | undefined;
|
|
890
|
+
}
|
|
891
|
+
|
|
875
892
|
export interface AgentSessionConfig {
|
|
876
893
|
agent: Agent;
|
|
877
894
|
sessionManager: SessionManager;
|
|
@@ -882,6 +899,8 @@ export interface AgentSessionConfig {
|
|
|
882
899
|
scopedModels?: Array<{ model: Model; thinkingLevel?: ThinkingLevel }>;
|
|
883
900
|
/** Initial session thinking selector. */
|
|
884
901
|
thinkingLevel?: ConfiguredThinkingLevel;
|
|
902
|
+
/** Retry chain ownership when startup selected one of its fallback entries. */
|
|
903
|
+
initialRetryFallback?: InitialRetryFallbackState;
|
|
885
904
|
/** Prewalk from the starting model to a fast/cheap target at the first edit/write once the todo list exists. */
|
|
886
905
|
prewalk?: Prewalk;
|
|
887
906
|
/** Force read-only plan mode at start, auto-approve on the model's first
|
|
@@ -1682,6 +1701,25 @@ function isAdvisorCard(message: AgentMessage): message is CustomMessage {
|
|
|
1682
1701
|
return message.role === "custom" && message.customType === "advisor";
|
|
1683
1702
|
}
|
|
1684
1703
|
|
|
1704
|
+
/**
|
|
1705
|
+
* Emit a warn-level log for a turn that ended in a provider error so recurring
|
|
1706
|
+
* stream failures are diagnosable from the main log alone. The `agent_end`
|
|
1707
|
+
* routing trace is debug-only and omits the error fields; without this a
|
|
1708
|
+
* session dying on provider errors leaves only `stopReason:"error"` debug lines
|
|
1709
|
+
* and the real cause lives solely in the session transcript (issue #6177).
|
|
1710
|
+
* No-op for any non-error stop reason.
|
|
1711
|
+
*/
|
|
1712
|
+
export function logProviderTurnError(msg: AssistantMessage): void {
|
|
1713
|
+
if (msg.stopReason !== "error") return;
|
|
1714
|
+
logger.warn("agent turn ended with provider error", {
|
|
1715
|
+
provider: msg.provider,
|
|
1716
|
+
model: msg.model,
|
|
1717
|
+
errorMessage: msg.errorMessage,
|
|
1718
|
+
errorStatus: msg.errorStatus,
|
|
1719
|
+
errorId: msg.errorId,
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1685
1723
|
function isTerminalTextAssistantAnswer(message: AgentMessage | undefined): message is AssistantMessage {
|
|
1686
1724
|
if (message?.role !== "assistant" || message.stopReason !== "stop") return false;
|
|
1687
1725
|
let hasText = false;
|
|
@@ -2698,6 +2736,13 @@ export class AgentSession {
|
|
|
2698
2736
|
} else {
|
|
2699
2737
|
this.#thinkingLevel = config.thinkingLevel;
|
|
2700
2738
|
}
|
|
2739
|
+
if (config.initialRetryFallback) {
|
|
2740
|
+
this.#activeRetryFallback = {
|
|
2741
|
+
...config.initialRetryFallback,
|
|
2742
|
+
lastAppliedFallbackThinkingLevel: this.configuredThinkingLevel(),
|
|
2743
|
+
pinned: false,
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2701
2746
|
if (config.prewalk) {
|
|
2702
2747
|
this.#prewalk = config.prewalk;
|
|
2703
2748
|
}
|
|
@@ -4828,9 +4873,10 @@ export class AgentSession {
|
|
|
4828
4873
|
if (toolName === "edit" && editedPath) {
|
|
4829
4874
|
this.#invalidateFileCacheForPath(editedPath);
|
|
4830
4875
|
}
|
|
4876
|
+
// TodoTool commits its state during execute. Replaying the result after
|
|
4877
|
+
// awaited event fan-out can overwrite a newer call from the same batch.
|
|
4831
4878
|
const phases = details?.phases;
|
|
4832
4879
|
if (toolName === "todo" && !isError && details && Array.isArray(phases) && phases.every(isTodoPhase)) {
|
|
4833
|
-
this.setTodoPhases(phases);
|
|
4834
4880
|
if (this.#isTodoInitResult(details, toolCallId)) {
|
|
4835
4881
|
this.#scheduleReplanTitleRefresh();
|
|
4836
4882
|
}
|
|
@@ -4930,6 +4976,12 @@ export class AgentSession {
|
|
|
4930
4976
|
};
|
|
4931
4977
|
maintenanceRoute("entered");
|
|
4932
4978
|
|
|
4979
|
+
// Surface provider stream failures in the main log. The routing trace
|
|
4980
|
+
// above is debug-only and drops the error fields, so a session dying
|
|
4981
|
+
// repeatedly on provider errors otherwise leaves no actionable trace
|
|
4982
|
+
// outside the session transcript (issue #6177).
|
|
4983
|
+
logProviderTurnError(msg);
|
|
4984
|
+
|
|
4933
4985
|
// Invalidate GitHub Copilot credentials on auth failure so stale tokens
|
|
4934
4986
|
// aren't reused on the next request
|
|
4935
4987
|
if (
|
|
@@ -5081,6 +5133,15 @@ export class AgentSession {
|
|
|
5081
5133
|
if (this.#isClassifierRefusal(msg)) {
|
|
5082
5134
|
this.#prunedTerminalRefusal = msg;
|
|
5083
5135
|
this.#removeAssistantMessageFromActiveContext(msg);
|
|
5136
|
+
} else if (!AIError.isContextOverflow(msg, this.model?.contextWindow ?? 0)) {
|
|
5137
|
+
// No retry, fallback, or compaction continuation fired: this errored
|
|
5138
|
+
// turn ends the run. #persistSessionMessageIfMissing dropped it as an
|
|
5139
|
+
// empty error turn, so record it here — otherwise the JSONL stops at
|
|
5140
|
+
// the last tool result and the provider's errorMessage is lost (#6249).
|
|
5141
|
+
// Idempotent and a no-op for non-empty turns. Content-less overflow
|
|
5142
|
+
// rejections stay live-UI only per the auto-compaction progress guard:
|
|
5143
|
+
// persisting one would replay an empty assistant turn on reload.
|
|
5144
|
+
await this.#persistTerminalEmptyErrorTurn(msg);
|
|
5084
5145
|
}
|
|
5085
5146
|
this.#resolveRetry();
|
|
5086
5147
|
|
|
@@ -6399,6 +6460,10 @@ export class AgentSession {
|
|
|
6399
6460
|
messages: AgentMessage[],
|
|
6400
6461
|
lastAssistantMessage = this.getLastAssistantMessage(),
|
|
6401
6462
|
): Promise<boolean> {
|
|
6463
|
+
if (this.#abortInProgress || this.#isDisposed) {
|
|
6464
|
+
this.#resetSessionStopContinuationState();
|
|
6465
|
+
return false;
|
|
6466
|
+
}
|
|
6402
6467
|
if (this.#agentKind === "sub" || !this.#extensionRunner?.hasHandlers("session_stop")) {
|
|
6403
6468
|
return false;
|
|
6404
6469
|
}
|
|
@@ -6882,15 +6947,12 @@ export class AgentSession {
|
|
|
6882
6947
|
this.#cancelExitRecorder?.();
|
|
6883
6948
|
this.#cancelExitRecorder = undefined;
|
|
6884
6949
|
try {
|
|
6885
|
-
|
|
6886
|
-
await this.#extensionRunner.emit({ type: "session_shutdown" });
|
|
6887
|
-
}
|
|
6950
|
+
await emitSessionShutdownEvent(this.#extensionRunner);
|
|
6888
6951
|
} catch (error) {
|
|
6889
6952
|
logger.warn("Failed to emit session_shutdown event", { error: String(error) });
|
|
6890
6953
|
}
|
|
6891
6954
|
|
|
6892
|
-
// Stop extension timers before aborting deferred work they could enqueue.
|
|
6893
|
-
this.#extensionRunner?.clearManagedTimers?.();
|
|
6955
|
+
// Stop fallback extension timers before aborting deferred work they could enqueue.
|
|
6894
6956
|
this.#fallbackExtensionTimers?.clearAll();
|
|
6895
6957
|
this.abortRetry();
|
|
6896
6958
|
this.abortCompaction();
|
|
@@ -11988,7 +12050,17 @@ export class AgentSession {
|
|
|
11988
12050
|
this.#pendingRecoveredRetryErrors = [];
|
|
11989
12051
|
}
|
|
11990
12052
|
|
|
11991
|
-
|
|
12053
|
+
/**
|
|
12054
|
+
* Durably record a terminal empty error turn (`stopReason: "error"` with no
|
|
12055
|
+
* substantive content) that `#persistSessionMessageIfMissing` skipped, so the
|
|
12056
|
+
* session JSONL keeps a record of why the run stopped instead of ending at the
|
|
12057
|
+
* last tool result. A no-op for non-empty/non-error turns and idempotent via
|
|
12058
|
+
* the already-persisted guard; the turn is dropped from active context by the
|
|
12059
|
+
* caller (or `isProviderRefusalMessage`/`isEmptyErrorTurn` filters) so it is
|
|
12060
|
+
* never replayed on the wire. Used by the retry-lifecycle dead-ends and the
|
|
12061
|
+
* non-retry terminal error tail.
|
|
12062
|
+
*/
|
|
12063
|
+
async #persistTerminalEmptyErrorTurn(message: AssistantMessage): Promise<void> {
|
|
11992
12064
|
await this.#waitForSessionMessagePersistence(message);
|
|
11993
12065
|
if (!isEmptyErrorTurn(message)) return;
|
|
11994
12066
|
if (this.#sessionMessageAlreadyPersisted(message)) return;
|
|
@@ -12030,7 +12102,7 @@ export class AgentSession {
|
|
|
12030
12102
|
id: number,
|
|
12031
12103
|
options: { switchedCredential: boolean; switchedModel: boolean; delayMs: number },
|
|
12032
12104
|
): Promise<void> {
|
|
12033
|
-
await this.#
|
|
12105
|
+
await this.#persistTerminalEmptyErrorTurn(message);
|
|
12034
12106
|
const persistenceKey = sessionMessagePersistenceKey(message);
|
|
12035
12107
|
if (!persistenceKey) return;
|
|
12036
12108
|
let branchEntry: SessionEntry | undefined;
|
|
@@ -14810,6 +14882,19 @@ export class AgentSession {
|
|
|
14810
14882
|
return message.content.some(block => block.type === "toolCall");
|
|
14811
14883
|
}
|
|
14812
14884
|
|
|
14885
|
+
/**
|
|
14886
|
+
* OpenRouter can repeatedly close Gemini streams at the reasoning-to-payload
|
|
14887
|
+
* transition. One retry covers a transient edge failure; the normal ten-retry
|
|
14888
|
+
* budget would otherwise re-run the same expensive reasoning cycle unchanged.
|
|
14889
|
+
*/
|
|
14890
|
+
#isOpenRouterThinkingStreamClose(message: AssistantMessage): boolean {
|
|
14891
|
+
return (
|
|
14892
|
+
message.provider === "openrouter" &&
|
|
14893
|
+
/server_error:\s*stream closed with reason:\s*error/i.test(message.errorMessage ?? "") &&
|
|
14894
|
+
message.content.some(block => block.type === "thinking" && block.thinking.trim().length > 0)
|
|
14895
|
+
);
|
|
14896
|
+
}
|
|
14897
|
+
|
|
14813
14898
|
#isClassifierRefusal(message: AssistantMessage): boolean {
|
|
14814
14899
|
if (message.stopReason !== "error") return false;
|
|
14815
14900
|
const stopType = message.stopDetails?.type;
|
|
@@ -15408,7 +15493,10 @@ export class AgentSession {
|
|
|
15408
15493
|
// (every rotation sets switchedCredential and skips it), so without
|
|
15409
15494
|
// this last resort a provider-wide usage cap never fails over to the
|
|
15410
15495
|
// configured chain.
|
|
15411
|
-
const
|
|
15496
|
+
const maxRetries = this.#isOpenRouterThinkingStreamClose(message)
|
|
15497
|
+
? Math.min(retrySettings.maxRetries, 1)
|
|
15498
|
+
: retrySettings.maxRetries;
|
|
15499
|
+
const retryBudgetExhausted = this.#retryAttempt > maxRetries;
|
|
15412
15500
|
|
|
15413
15501
|
const errorMessage = message.errorMessage || "Unknown error";
|
|
15414
15502
|
const id = this.#classifyRetryMessage(message);
|
|
@@ -15501,7 +15589,7 @@ export class AgentSession {
|
|
|
15501
15589
|
}
|
|
15502
15590
|
if (retryBudgetExhausted) {
|
|
15503
15591
|
if (!switchedModel) {
|
|
15504
|
-
await this.#
|
|
15592
|
+
await this.#persistTerminalEmptyErrorTurn(message);
|
|
15505
15593
|
// Max retries exceeded and no fallback model to switch to: emit
|
|
15506
15594
|
// final failure and reset.
|
|
15507
15595
|
await this.#emitSessionEvent({
|
|
@@ -15548,7 +15636,7 @@ export class AgentSession {
|
|
|
15548
15636
|
// can act on it.
|
|
15549
15637
|
const maxDelayMs = retrySettings.maxDelayMs;
|
|
15550
15638
|
if (maxDelayMs > 0 && delayMs > maxDelayMs && !switchedCredential && !switchedModel) {
|
|
15551
|
-
await this.#
|
|
15639
|
+
await this.#persistTerminalEmptyErrorTurn(message);
|
|
15552
15640
|
const attempt = this.#retryAttempt;
|
|
15553
15641
|
this.#retryAttempt = 0;
|
|
15554
15642
|
await this.#emitSessionEvent({
|
|
@@ -15567,7 +15655,7 @@ export class AgentSession {
|
|
|
15567
15655
|
await this.#emitSessionEvent({
|
|
15568
15656
|
type: "auto_retry_start",
|
|
15569
15657
|
attempt: this.#retryAttempt,
|
|
15570
|
-
maxAttempts:
|
|
15658
|
+
maxAttempts: maxRetries,
|
|
15571
15659
|
delayMs,
|
|
15572
15660
|
errorMessage,
|
|
15573
15661
|
errorId: message.errorId,
|
|
@@ -15693,20 +15781,40 @@ export class AgentSession {
|
|
|
15693
15781
|
/**
|
|
15694
15782
|
* Manually retry the last failed assistant turn.
|
|
15695
15783
|
* Removes the error message from agent state and re-attempts with a fresh retry budget.
|
|
15784
|
+
*
|
|
15785
|
+
* A stream that stalls or aborts mid-tool-call ends the turn with
|
|
15786
|
+
* `stopReason: "error" | "aborted"` and then appends one synthetic
|
|
15787
|
+
* {@link isSyntheticToolResultMessage tool_result} per emitted tool call to
|
|
15788
|
+
* preserve the provider's tool_use/tool_result pairing (see
|
|
15789
|
+
* `createAbortedToolResult` in `agent-loop.ts`). Those placeholders trail the
|
|
15790
|
+
* failed assistant turn, so the retry lookback walks back over them before
|
|
15791
|
+
* checking the assistant message; it strips both the placeholders and the
|
|
15792
|
+
* failed turn before re-attempting.
|
|
15793
|
+
*
|
|
15696
15794
|
* @returns true if retry was initiated, false if no failed turn to retry or agent is busy
|
|
15697
15795
|
*/
|
|
15698
15796
|
async retry(): Promise<boolean> {
|
|
15699
15797
|
if (this.isStreaming || this.isCompacting || this.isRetrying) return false;
|
|
15700
15798
|
|
|
15701
15799
|
const messages = this.agent.state.messages;
|
|
15702
|
-
|
|
15800
|
+
|
|
15801
|
+
// Walk back past trailing synthetic tool_result placeholders emitted for
|
|
15802
|
+
// tool calls that never ran because the turn stalled/aborted mid-tool-call.
|
|
15803
|
+
// They shadow the failed assistant turn from the single-message lookback.
|
|
15804
|
+
let turnEnd = messages.length;
|
|
15805
|
+
while (turnEnd > 0 && isSyntheticToolResultMessage(messages[turnEnd - 1])) {
|
|
15806
|
+
turnEnd--;
|
|
15807
|
+
}
|
|
15808
|
+
|
|
15809
|
+
const lastMsg = messages[turnEnd - 1];
|
|
15703
15810
|
if (lastMsg?.role !== "assistant") return false;
|
|
15704
15811
|
|
|
15705
15812
|
const assistantMsg = lastMsg as AssistantMessage;
|
|
15706
15813
|
if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false;
|
|
15707
15814
|
|
|
15708
|
-
// Remove the failed/aborted assistant message
|
|
15709
|
-
|
|
15815
|
+
// Remove the failed/aborted assistant message plus its synthetic tool
|
|
15816
|
+
// results (same as auto-retry does before re-attempting).
|
|
15817
|
+
this.agent.replaceMessages(messages.slice(0, turnEnd - 1));
|
|
15710
15818
|
|
|
15711
15819
|
// Reset retry budget for a fresh attempt
|
|
15712
15820
|
this.#retryAttempt = 0;
|
|
@@ -15941,7 +16049,7 @@ export class AgentSession {
|
|
|
15941
16049
|
signal: abortController.signal,
|
|
15942
16050
|
sessionKey: target.sessionId,
|
|
15943
16051
|
cwd,
|
|
15944
|
-
timeout: clampTimeout("bash") * 1000,
|
|
16052
|
+
timeout: clampTimeout("bash", undefined, this.settings.get("tools.maxTimeout")) * 1000,
|
|
15945
16053
|
onMinimizedSave: originalText => this.#saveBashOriginalArtifact(target, originalText),
|
|
15946
16054
|
useUserShell: options?.useUserShell,
|
|
15947
16055
|
});
|
|
@@ -17023,7 +17131,30 @@ export class AgentSession {
|
|
|
17023
17131
|
*/
|
|
17024
17132
|
async navigateTree(
|
|
17025
17133
|
targetId: string,
|
|
17026
|
-
options: {
|
|
17134
|
+
options: {
|
|
17135
|
+
summarize?: boolean;
|
|
17136
|
+
customInstructions?: string;
|
|
17137
|
+
/**
|
|
17138
|
+
* Opts into the two-phase `ask` toolResult re-answer protocol
|
|
17139
|
+
* (issue #5642): set only by the interactive `/tree` selector, which
|
|
17140
|
+
* knows how to re-open the picker on `reopenAsk` and complete the
|
|
17141
|
+
* navigation with `reanswerAskResult`. Every other public caller
|
|
17142
|
+
* (extensions, hooks, ACP, session-extension actions) leaves this
|
|
17143
|
+
* unset and gets the pre-#5642 plain leaf move onto `ask`
|
|
17144
|
+
* toolResults instead — they have no picker to re-open and would
|
|
17145
|
+
* otherwise report a successful no-op navigation (roboomp review on
|
|
17146
|
+
* #5895).
|
|
17147
|
+
*/
|
|
17148
|
+
allowAskReopen?: boolean;
|
|
17149
|
+
/**
|
|
17150
|
+
* Completes an in-progress `ask` re-answer (issue #5642): the caller
|
|
17151
|
+
* already received `reopenAsk` from a prior call on the same
|
|
17152
|
+
* `targetId`, re-opened the picker, and is handing back the fresh
|
|
17153
|
+
* answer. Branches a new toolResult sibling instead of landing on
|
|
17154
|
+
* the original one.
|
|
17155
|
+
*/
|
|
17156
|
+
reanswerAskResult?: AgentToolResult<AskToolDetails>;
|
|
17157
|
+
} = {},
|
|
17027
17158
|
): Promise<{
|
|
17028
17159
|
editorText?: string;
|
|
17029
17160
|
cancelled: boolean;
|
|
@@ -17031,12 +17162,36 @@ export class AgentSession {
|
|
|
17031
17162
|
summaryEntry?: BranchSummaryEntry;
|
|
17032
17163
|
/** Raw session context built during navigation — pass to renderInitialMessages to skip a second O(N) walk. */
|
|
17033
17164
|
sessionContext?: SessionContext;
|
|
17165
|
+
/**
|
|
17166
|
+
* Set when `targetId` is an `ask` toolResult, `options.allowAskReopen`
|
|
17167
|
+
* was set, and `options.reanswerAskResult` was not supplied: nothing was
|
|
17168
|
+
* mutated. The caller must re-open the ask picker with these
|
|
17169
|
+
* `questions`, then call `navigateTree(targetId, { ...options,
|
|
17170
|
+
* reanswerAskResult })` with the produced result to actually branch
|
|
17171
|
+
* (issue #5642).
|
|
17172
|
+
*/
|
|
17173
|
+
reopenAsk?: { toolCallId: string; questions: AskToolInput["questions"] };
|
|
17034
17174
|
}> {
|
|
17035
17175
|
await this.#flushPendingBashMessages();
|
|
17036
17176
|
const oldLeafId = this.sessionManager.getLeafId();
|
|
17037
17177
|
|
|
17038
|
-
|
|
17039
|
-
if (
|
|
17178
|
+
const targetEntry = this.sessionManager.getEntry(targetId);
|
|
17179
|
+
if (!targetEntry) {
|
|
17180
|
+
throw new Error(`Entry ${targetId} not found`);
|
|
17181
|
+
}
|
|
17182
|
+
const targetIsAskResult =
|
|
17183
|
+
targetEntry.type === "message" &&
|
|
17184
|
+
targetEntry.message.role === "toolResult" &&
|
|
17185
|
+
targetEntry.message.toolName === "ask";
|
|
17186
|
+
|
|
17187
|
+
// No-op if already at target — except mid-flight through the `ask`
|
|
17188
|
+
// re-answer protocol (issue #5642): a probe or completion call can
|
|
17189
|
+
// legitimately target the *current* leaf (e.g. the user interrupted
|
|
17190
|
+
// right after answering `ask`, before a follow-up assistant message
|
|
17191
|
+
// landed, or another caller navigated straight onto the ask result),
|
|
17192
|
+
// and must still return `reopenAsk` / branch the new answer instead of
|
|
17193
|
+
// silently reporting a no-op (chatgpt-codex review on #5895).
|
|
17194
|
+
if (targetId === oldLeafId && !(options.allowAskReopen && targetIsAskResult)) {
|
|
17040
17195
|
return { cancelled: false };
|
|
17041
17196
|
}
|
|
17042
17197
|
|
|
@@ -17045,16 +17200,47 @@ export class AgentSession {
|
|
|
17045
17200
|
throw new Error("No model available for summarization");
|
|
17046
17201
|
}
|
|
17047
17202
|
|
|
17048
|
-
|
|
17049
|
-
|
|
17050
|
-
|
|
17051
|
-
|
|
17052
|
-
|
|
17053
|
-
//
|
|
17203
|
+
// `ask` toolResult, first pass: hand control back to the caller to
|
|
17204
|
+
// re-open the picker instead of landing on the stale answer in place.
|
|
17205
|
+
// Nothing is mutated here — see the `reanswerAskResult` branch below for
|
|
17206
|
+
// the actual sibling-branch construction once the caller has an answer.
|
|
17207
|
+
// Gated on `allowAskReopen` — callers that don't understand `reopenAsk`
|
|
17208
|
+
// fall straight through to the plain leaf move below instead of
|
|
17209
|
+
// reporting a successful no-op (roboomp review on #5895).
|
|
17210
|
+
if (
|
|
17211
|
+
options.allowAskReopen &&
|
|
17212
|
+
!options.reanswerAskResult &&
|
|
17213
|
+
targetEntry.type === "message" &&
|
|
17214
|
+
targetEntry.message.role === "toolResult" &&
|
|
17215
|
+
targetEntry.message.toolName === "ask"
|
|
17216
|
+
) {
|
|
17217
|
+
const toolCallId = targetEntry.message.toolCallId;
|
|
17218
|
+
const questions = this.#recoverAskReanswerQuestions(targetEntry.parentId, toolCallId);
|
|
17219
|
+
if (questions) {
|
|
17220
|
+
return { cancelled: false, reopenAsk: { toolCallId, questions } };
|
|
17221
|
+
}
|
|
17222
|
+
// Original arguments couldn't be recovered (corrupted/legacy session
|
|
17223
|
+
// data) — fall through to a plain leaf move so navigation still works.
|
|
17224
|
+
}
|
|
17225
|
+
|
|
17226
|
+
// Collect entries to summarize (from old leaf to common ancestor). For an
|
|
17227
|
+
// `ask` re-answer completion, the branch point is `targetEntry.parentId`
|
|
17228
|
+
// (the new sibling toolResult lands there, not on `targetId`) — anchor
|
|
17229
|
+
// the collection there too, or the old answer entry is neither on the
|
|
17230
|
+
// new branch nor included in the summary (chatgpt-codex review on
|
|
17231
|
+
// #5895).
|
|
17232
|
+
const summaryAnchorId =
|
|
17233
|
+
options.reanswerAskResult !== undefined &&
|
|
17234
|
+
targetEntry.type === "message" &&
|
|
17235
|
+
targetEntry.message.role === "toolResult" &&
|
|
17236
|
+
targetEntry.message.toolName === "ask" &&
|
|
17237
|
+
targetEntry.parentId !== null
|
|
17238
|
+
? targetEntry.parentId
|
|
17239
|
+
: targetId;
|
|
17054
17240
|
const { entries: entriesToSummarize, commonAncestorId } = collectEntriesForBranchSummary(
|
|
17055
17241
|
this.sessionManager,
|
|
17056
17242
|
oldLeafId,
|
|
17057
|
-
|
|
17243
|
+
summaryAnchorId,
|
|
17058
17244
|
);
|
|
17059
17245
|
|
|
17060
17246
|
// Prepare event data
|
|
@@ -17150,6 +17336,27 @@ export class AgentSession {
|
|
|
17150
17336
|
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
17151
17337
|
.map(c => c.text)
|
|
17152
17338
|
.join("");
|
|
17339
|
+
} else if (
|
|
17340
|
+
targetEntry.type === "message" &&
|
|
17341
|
+
targetEntry.message.role === "toolResult" &&
|
|
17342
|
+
targetEntry.message.toolName === "ask" &&
|
|
17343
|
+
options.reanswerAskResult
|
|
17344
|
+
) {
|
|
17345
|
+
// `ask` toolResult, second pass: the caller re-opened the picker and
|
|
17346
|
+
// is handing back a fresh answer. Branch a *new* sibling toolResult
|
|
17347
|
+
// off the same `ask` toolCall instead of reusing `targetId` — the
|
|
17348
|
+
// original answer's branch stays reachable (issue #5642).
|
|
17349
|
+
const reanswer = options.reanswerAskResult;
|
|
17350
|
+
const toolResultMessage: ToolResultMessage = {
|
|
17351
|
+
role: "toolResult",
|
|
17352
|
+
toolCallId: targetEntry.message.toolCallId,
|
|
17353
|
+
toolName: "ask",
|
|
17354
|
+
content: reanswer.content,
|
|
17355
|
+
details: reanswer.details,
|
|
17356
|
+
isError: reanswer.isError === true,
|
|
17357
|
+
timestamp: Date.now(),
|
|
17358
|
+
};
|
|
17359
|
+
newLeafId = this.sessionManager.appendMessageToBranch(toolResultMessage, targetEntry.parentId);
|
|
17153
17360
|
} else {
|
|
17154
17361
|
// Non-user message (or a user-invoked skill-prompt injection): land the
|
|
17155
17362
|
// leaf on the selected node so it stays on the active branch. Skill
|
|
@@ -17212,6 +17419,73 @@ export class AgentSession {
|
|
|
17212
17419
|
return { editorText, cancelled: false, summaryEntry, sessionContext: stateContext };
|
|
17213
17420
|
}
|
|
17214
17421
|
|
|
17422
|
+
/**
|
|
17423
|
+
* Look up the `ask` toolCall's persisted `arguments` and validate them
|
|
17424
|
+
* back into `questions`, for `/tree` `ask` re-answer (issue #5642). Walks
|
|
17425
|
+
* up from the toolResult's parent past any interleaved ancestor entries
|
|
17426
|
+
* — sibling toolResults from other tool calls in the same turn (`ask`
|
|
17427
|
+
* runs `exclusive`, which only serializes *execution*, not persistence
|
|
17428
|
+
* order — roboomp review on #5895), and bookkeeping entries such as the
|
|
17429
|
+
* `tool_execution_start` custom entry `#recordToolExecutionStart()`
|
|
17430
|
+
* appends before every toolResult in real persisted sessions (chatgpt-codex
|
|
17431
|
+
* review on #5895) — until it finds the assistant entry that actually
|
|
17432
|
+
* emitted `toolCallId`. Stops at a `user` message (turn boundary) or a
|
|
17433
|
+
* dead end. Returns `undefined` when no ancestor entry holds a matching
|
|
17434
|
+
* `ask` toolCall, or the arguments can't be resolved — the caller falls
|
|
17435
|
+
* back to a plain leaf move rather than opening a picker with bad data.
|
|
17436
|
+
*/
|
|
17437
|
+
#recoverAskReanswerQuestions(parentId: string | null, toolCallId: string): AskToolInput["questions"] | undefined {
|
|
17438
|
+
let current = parentId;
|
|
17439
|
+
while (current !== null) {
|
|
17440
|
+
const entry = this.sessionManager.getEntry(current);
|
|
17441
|
+
if (!entry) return undefined;
|
|
17442
|
+
if (entry.type === "message") {
|
|
17443
|
+
if (entry.message.role === "assistant") {
|
|
17444
|
+
const toolCall = entry.message.content.find(
|
|
17445
|
+
(block): block is AgentToolCall => block.type === "toolCall" && block.id === toolCallId,
|
|
17446
|
+
);
|
|
17447
|
+
if (!toolCall) return undefined;
|
|
17448
|
+
if (toolCall.name !== "ask") return undefined;
|
|
17449
|
+
const args = this.#obfuscator?.hasSecrets()
|
|
17450
|
+
? deobfuscateToolArguments(this.#obfuscator, toolCall.arguments)
|
|
17451
|
+
: toolCall.arguments;
|
|
17452
|
+
return recoverAskQuestions(args);
|
|
17453
|
+
}
|
|
17454
|
+
if (entry.message.role === "user") return undefined;
|
|
17455
|
+
}
|
|
17456
|
+
current = entry.parentId;
|
|
17457
|
+
}
|
|
17458
|
+
return undefined;
|
|
17459
|
+
}
|
|
17460
|
+
|
|
17461
|
+
/**
|
|
17462
|
+
* Build a standalone `AgentToolContext` for running `AskTool.execute()`
|
|
17463
|
+
* outside a normal agent turn, for `/tree` `ask` re-answer (issue #5642).
|
|
17464
|
+
* `SelectorController` has no reachable `ToolContextStore` (that store is
|
|
17465
|
+
* built inside `sdk.ts` and never threaded through to mode controllers),
|
|
17466
|
+
* so this mirrors `refreshMCPTools()`'s `getCustomToolContext` factory
|
|
17467
|
+
* with real session state instead of a `{ ... } as unknown as
|
|
17468
|
+
* AgentToolContext` cast that could silently compile with an incomplete
|
|
17469
|
+
* context (roboomp review on #5895) — every `CustomToolContext` field is
|
|
17470
|
+
* backed by live session state, so a future required field fails to
|
|
17471
|
+
* compile here instead of surfacing as `undefined` at runtime.
|
|
17472
|
+
*/
|
|
17473
|
+
buildAskReanswerContext(uiContext: ExtensionUIContext): AgentToolContext {
|
|
17474
|
+
return {
|
|
17475
|
+
sessionManager: this.sessionManager,
|
|
17476
|
+
modelRegistry: this.#modelRegistry,
|
|
17477
|
+
model: this.model,
|
|
17478
|
+
isIdle: () => !this.isStreaming,
|
|
17479
|
+
hasQueuedMessages: () => this.queuedMessageCount > 0,
|
|
17480
|
+
abort: () => {
|
|
17481
|
+
this.agent.abort();
|
|
17482
|
+
},
|
|
17483
|
+
settings: this.settings,
|
|
17484
|
+
ui: uiContext,
|
|
17485
|
+
hasUI: true,
|
|
17486
|
+
};
|
|
17487
|
+
}
|
|
17488
|
+
|
|
17215
17489
|
/**
|
|
17216
17490
|
* Get all user messages from session for branch selector.
|
|
17217
17491
|
*/
|
package/src/system-prompt.ts
CHANGED
|
@@ -24,7 +24,6 @@ import projectPromptTemplate from "./prompts/system/project-prompt.md" with { ty
|
|
|
24
24
|
import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
|
|
25
25
|
import { normalizeConcurrencyLimit } from "./task/parallel";
|
|
26
26
|
import { usesCodexTaskPrompt } from "./task/prompt-policy";
|
|
27
|
-
import { shortenPath } from "./tools/render-utils";
|
|
28
27
|
import { type ActiveRepoContext, resolveActiveRepoContext } from "./utils/active-repo-context";
|
|
29
28
|
import { formatLocalCalendarDate } from "./utils/local-date";
|
|
30
29
|
import { normalizePromptPath } from "./utils/prompt-path";
|
|
@@ -710,7 +709,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
|
|
|
710
709
|
|
|
711
710
|
const date = formatLocalCalendarDate();
|
|
712
711
|
const dateTime = date;
|
|
713
|
-
const promptCwd =
|
|
712
|
+
const promptCwd = normalizePromptPath(resolvedCwd);
|
|
714
713
|
const activeRepoContextPrompt = renderActiveRepoContextPrompt(activeRepoContext);
|
|
715
714
|
|
|
716
715
|
// Build tool metadata for system prompt rendering.
|