@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.6
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 +209 -0
- package/dist/cli.js +3616 -3676
- package/dist/types/advisor/advise-tool.d.ts +3 -0
- package/dist/types/advisor/runtime.d.ts +7 -1
- package/dist/types/async/job-manager.d.ts +2 -0
- package/dist/types/config/model-registry.d.ts +7 -0
- package/dist/types/config/model-resolver.d.ts +8 -0
- package/dist/types/config/models-config-schema.d.ts +10 -0
- package/dist/types/config/models-config.d.ts +6 -0
- package/dist/types/dap/client.d.ts +10 -0
- package/dist/types/dap/types.d.ts +6 -5
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +4 -2
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
- package/dist/types/launch/spawn-options.d.ts +10 -0
- package/dist/types/launch/spawn-options.test.d.ts +1 -0
- package/dist/types/modes/components/status-line/component.d.ts +10 -3
- package/dist/types/modes/components/status-line/types.d.ts +3 -1
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/components/transcript-container.d.ts +4 -3
- package/dist/types/modes/components/tree-selector.d.ts +6 -2
- package/dist/types/modes/components/usage-row.d.ts +1 -1
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/print-mode.d.ts +4 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
- package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/types/modes/setup-wizard/index.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +42 -6
- package/dist/types/session/messages.d.ts +6 -0
- package/dist/types/session/session-paths.d.ts +21 -4
- package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
- package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/gh.d.ts +5 -3
- package/dist/types/tools/hub/index.d.ts +1 -1
- package/dist/types/tools/hub/jobs.d.ts +1 -0
- package/dist/types/tools/hub/types.d.ts +2 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/dist/types/tools/xdev.d.ts +5 -3
- package/dist/types/utils/git.d.ts +33 -0
- package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
- package/dist/types/vibe/runtime.d.ts +23 -0
- package/dist/types/web/search/providers/codex.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +150 -0
- package/src/advisor/advise-tool.ts +4 -0
- package/src/advisor/runtime.ts +38 -14
- package/src/async/job-manager.ts +3 -0
- package/src/cli/bench-cli.ts +8 -2
- package/src/cli/dry-balance-cli.ts +1 -0
- package/src/config/model-registry.ts +89 -8
- package/src/config/model-resolver.ts +78 -14
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings.ts +3 -1
- package/src/dap/client.ts +168 -1
- package/src/dap/config.ts +51 -1
- package/src/dap/session.ts +575 -234
- package/src/dap/types.ts +6 -5
- package/src/discovery/agents.ts +2 -2
- package/src/exec/bash-executor.ts +68 -8
- package/src/extensibility/extensions/load-errors.ts +13 -0
- package/src/extensibility/extensions/runner.ts +6 -4
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
- package/src/launch/broker.ts +34 -31
- package/src/launch/client.ts +7 -1
- package/src/launch/spawn-options.test.ts +31 -0
- package/src/launch/spawn-options.ts +17 -0
- package/src/lsp/types.ts +5 -1
- package/src/main.ts +25 -4
- package/src/mcp/transports/stdio.test.ts +9 -1
- package/src/modes/components/ask-dialog.ts +137 -73
- package/src/modes/components/chat-transcript-builder.ts +10 -1
- package/src/modes/components/status-line/component.ts +57 -3
- package/src/modes/components/status-line/segments.ts +20 -3
- package/src/modes/components/status-line/types.ts +3 -1
- package/src/modes/components/tool-execution.ts +5 -0
- package/src/modes/components/transcript-container.ts +23 -122
- package/src/modes/components/tree-selector.ts +10 -4
- package/src/modes/components/usage-row.ts +17 -1
- package/src/modes/controllers/command-controller.ts +41 -1
- package/src/modes/controllers/event-controller.ts +7 -3
- package/src/modes/controllers/input-controller.ts +1 -1
- package/src/modes/controllers/selector-controller.ts +29 -8
- package/src/modes/interactive-mode.ts +102 -60
- package/src/modes/noninteractive-dispose.test.ts +14 -1
- package/src/modes/print-mode.ts +81 -9
- package/src/modes/rpc/rpc-client.ts +76 -35
- package/src/modes/rpc/rpc-frame.ts +156 -0
- package/src/modes/rpc/rpc-input.ts +38 -0
- package/src/modes/rpc/rpc-mode.ts +11 -4
- package/src/modes/setup-wizard/index.ts +2 -0
- package/src/modes/setup-wizard/scenes/model.ts +134 -0
- package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/ui-helpers.ts +9 -3
- package/src/prompts/system/workflow-notice.md +89 -74
- package/src/prompts/tools/browser.md +3 -2
- package/src/prompts/tools/debug.md +2 -7
- package/src/prompts/tools/github.md +6 -1
- package/src/prompts/tools/hub.md +4 -2
- package/src/prompts/tools/task-async-contract.md +1 -0
- package/src/prompts/tools/task.md +9 -2
- package/src/sdk.ts +38 -6
- package/src/session/agent-session.ts +484 -188
- package/src/session/messages.test.ts +91 -0
- package/src/session/messages.ts +248 -110
- package/src/session/session-manager.ts +34 -3
- package/src/session/session-paths.ts +38 -9
- package/src/slash-commands/builtin-registry.ts +1 -0
- package/src/task/executor.ts +104 -33
- package/src/task/index.ts +46 -9
- package/src/task/worktree.ts +10 -0
- package/src/tools/browser/aria/aria-snapshot.ts +36 -8
- package/src/tools/browser/cmux/cmux-tab.ts +2 -1
- package/src/tools/browser/cmux/socket-client.ts +139 -3
- package/src/tools/browser/run-cancellation.ts +4 -0
- package/src/tools/browser/tab-protocol.ts +2 -0
- package/src/tools/browser/tab-supervisor.ts +21 -11
- package/src/tools/browser/tab-worker.ts +199 -33
- package/src/tools/debug.ts +3 -0
- package/src/tools/gh.ts +40 -2
- package/src/tools/hub/index.ts +4 -1
- package/src/tools/hub/jobs.ts +42 -1
- package/src/tools/hub/types.ts +2 -0
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/tools/xdev.ts +11 -4
- package/src/tools/yield.ts +4 -1
- package/src/utils/git.ts +237 -0
- package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
- package/src/vibe/__tests__/token-rate.test.ts +96 -0
- package/src/vibe/runtime.ts +50 -0
- package/src/web/search/index.ts +9 -5
- package/src/web/search/providers/codex.ts +195 -99
- /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
estimateTokens,
|
|
64
64
|
generateBranchSummary,
|
|
65
65
|
generateHandoffFromContext,
|
|
66
|
+
invalidateMessageCache,
|
|
66
67
|
prepareCompaction,
|
|
67
68
|
renderHandoffPrompt,
|
|
68
69
|
resolveBudgetReserveTokens,
|
|
@@ -121,6 +122,7 @@ import {
|
|
|
121
122
|
} from "@oh-my-pi/pi-ai";
|
|
122
123
|
import * as AIError from "@oh-my-pi/pi-ai/error";
|
|
123
124
|
import { resetOpenAICodexHistoryAfterCompaction } from "@oh-my-pi/pi-ai/providers/openai-codex-responses";
|
|
125
|
+
import { kCursorExecResolved } from "@oh-my-pi/pi-ai/utils/block-symbols";
|
|
124
126
|
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
|
|
125
127
|
import { GeminiHeaderRunDetector, isGeminiThinkingModel } from "@oh-my-pi/pi-ai/utils/thinking-loop";
|
|
126
128
|
import { type RepeatedToolCallDetection, ToolCallLoopGuard } from "@oh-my-pi/pi-ai/utils/tool-call-loop-guard";
|
|
@@ -137,6 +139,7 @@ import {
|
|
|
137
139
|
getInstallId,
|
|
138
140
|
isBunTestRuntime,
|
|
139
141
|
isEnoent,
|
|
142
|
+
isInteractiveHost,
|
|
140
143
|
logger,
|
|
141
144
|
postmortem,
|
|
142
145
|
prompt,
|
|
@@ -261,7 +264,7 @@ import {
|
|
|
261
264
|
estimateToolSchemaTokens,
|
|
262
265
|
} from "../modes/utils/context-usage";
|
|
263
266
|
import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
|
|
264
|
-
import { resolveApprovedPlan } from "../plan-mode/approved-plan";
|
|
267
|
+
import { type PlanApprovalDetails, resolveApprovedPlan } from "../plan-mode/approved-plan";
|
|
265
268
|
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
|
266
269
|
import type { PlanModeState } from "../plan-mode/state";
|
|
267
270
|
import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
|
|
@@ -1896,6 +1899,8 @@ export class AgentSession {
|
|
|
1896
1899
|
* suppresses advisor concern/blocker auto-resume until the user next resumes.
|
|
1897
1900
|
* Advisor advice is still recorded into the transcript, just not auto-run. */
|
|
1898
1901
|
#advisorAutoResumeSuppressed = false;
|
|
1902
|
+
/** Print-mode sessions preserve advisor notes without starting hidden primary turns. */
|
|
1903
|
+
#preserveAdvisorAdvice = false;
|
|
1899
1904
|
#advisorPrimaryTurnsCompleted = 0;
|
|
1900
1905
|
#advisorInterruptImmuneTurnStart: number | undefined;
|
|
1901
1906
|
#planModeState: PlanModeState | undefined;
|
|
@@ -2034,6 +2039,8 @@ export class AgentSession {
|
|
|
2034
2039
|
#turnIndex = 0;
|
|
2035
2040
|
#messageEndPersistenceTail: Promise<void> = Promise.resolve();
|
|
2036
2041
|
#pendingMessageEndPersistence = new Map<string, Promise<void>>();
|
|
2042
|
+
/** Async lifecycle handlers for visible advisor cards emitted outside the primary loop. */
|
|
2043
|
+
#pendingAdvisorCardEvents = new Set<Promise<void>>();
|
|
2037
2044
|
#persistedMessageKeys: { anchor: string; keys: Set<string> } | undefined;
|
|
2038
2045
|
|
|
2039
2046
|
#skills: Skill[];
|
|
@@ -2524,7 +2531,13 @@ export class AgentSession {
|
|
|
2524
2531
|
const isPlanNudge = (m: AgentMessage): boolean =>
|
|
2525
2532
|
m.role === "custom" && m.customType === PREWALK_PLAN_MESSAGE_TYPE;
|
|
2526
2533
|
for (let i = liveMessages.length - 1; i >= 0; i--) {
|
|
2527
|
-
if (isPlanNudge(liveMessages[i]))
|
|
2534
|
+
if (isPlanNudge(liveMessages[i])) {
|
|
2535
|
+
// Interior removal on the live array: drop the scrubbed message from
|
|
2536
|
+
// the convert/estimate caches so the next convert can't reuse a prefix
|
|
2537
|
+
// that still carries its fragment (the array shrinks in place).
|
|
2538
|
+
invalidateMessageCache(liveMessages[i]);
|
|
2539
|
+
liveMessages.splice(i, 1);
|
|
2540
|
+
}
|
|
2528
2541
|
}
|
|
2529
2542
|
const stateMessages = this.agent.state.messages;
|
|
2530
2543
|
const filtered = stateMessages.filter(m => !isPlanNudge(m));
|
|
@@ -2554,6 +2567,24 @@ export class AgentSession {
|
|
|
2554
2567
|
this.setPlanProposalHandler(title => this.#approvePlanYoloProposal(title));
|
|
2555
2568
|
}
|
|
2556
2569
|
|
|
2570
|
+
/** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */
|
|
2571
|
+
async preparePlanForReview(title: string): Promise<AgentToolResult<PlanApprovalDetails>> {
|
|
2572
|
+
const state = this.getPlanModeState();
|
|
2573
|
+
if (!state?.enabled) {
|
|
2574
|
+
throw new ToolError("Plan mode is not active.");
|
|
2575
|
+
}
|
|
2576
|
+
const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
|
|
2577
|
+
suppliedTitle: title,
|
|
2578
|
+
statePlanFilePath: state.planFilePath,
|
|
2579
|
+
readPlan: url => this.#readPlanFile(url),
|
|
2580
|
+
listPlanFiles: () => this.#listPlanFiles(),
|
|
2581
|
+
});
|
|
2582
|
+
return {
|
|
2583
|
+
content: [{ type: "text", text: "Plan ready for review." }],
|
|
2584
|
+
details: { planFilePath, title: resolvedTitle, planExists: true },
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2557
2588
|
/**
|
|
2558
2589
|
* Plan-proposal handler while PlanYolo's plan phase is active. Auto-approves
|
|
2559
2590
|
* the instant the model writes the plan slug/title to `xd://propose` — no
|
|
@@ -2574,8 +2605,8 @@ export class AgentSession {
|
|
|
2574
2605
|
const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
|
|
2575
2606
|
suppliedTitle: title,
|
|
2576
2607
|
statePlanFilePath: state.planFilePath,
|
|
2577
|
-
readPlan: url => this.#
|
|
2578
|
-
listPlanFiles: () => this.#
|
|
2608
|
+
readPlan: url => this.#readPlanFile(url),
|
|
2609
|
+
listPlanFiles: () => this.#listPlanFiles(),
|
|
2579
2610
|
});
|
|
2580
2611
|
this.setPlanModeState(undefined);
|
|
2581
2612
|
const previousTools = this.#planYoloPreviousTools;
|
|
@@ -2610,7 +2641,7 @@ export class AgentSession {
|
|
|
2610
2641
|
};
|
|
2611
2642
|
}
|
|
2612
2643
|
|
|
2613
|
-
async #
|
|
2644
|
+
async #readPlanFile(planFilePath: string): Promise<string | null> {
|
|
2614
2645
|
const resolvedPath = planFilePath.startsWith("local:")
|
|
2615
2646
|
? resolveLocalUrlToPath(normalizeLocalScheme(planFilePath), this.#localProtocolOptions())
|
|
2616
2647
|
: resolveToCwd(planFilePath, this.sessionManager.getCwd());
|
|
@@ -2624,7 +2655,7 @@ export class AgentSession {
|
|
|
2624
2655
|
|
|
2625
2656
|
/** `local://` URLs of plan files in the session-local root, newest first —
|
|
2626
2657
|
* a fallback for `resolveApprovedPlan` when the agent dropped `extra.title`. */
|
|
2627
|
-
async #
|
|
2658
|
+
async #listPlanFiles(): Promise<string[]> {
|
|
2628
2659
|
const localRoot = resolveLocalUrlToPath("local://", this.#localProtocolOptions());
|
|
2629
2660
|
try {
|
|
2630
2661
|
const entries = await fs.promises.readdir(localRoot, { withFileTypes: true });
|
|
@@ -3364,6 +3395,7 @@ export class AgentSession {
|
|
|
3364
3395
|
const channel = resolveAdvisorDeliveryChannel({
|
|
3365
3396
|
severity,
|
|
3366
3397
|
autoResumeSuppressed: this.#advisorAutoResumeSuppressed,
|
|
3398
|
+
preserveOnly: this.#preserveAdvisorAdvice,
|
|
3367
3399
|
// Key on the live agent-core loop, not session `isStreaming` (which also
|
|
3368
3400
|
// counts `#promptInFlightCount` during post-turn unwind). Only a running
|
|
3369
3401
|
// loop consumes a steer at its next boundary.
|
|
@@ -4163,30 +4195,58 @@ export class AgentSession {
|
|
|
4163
4195
|
return queued;
|
|
4164
4196
|
}
|
|
4165
4197
|
|
|
4198
|
+
/**
|
|
4199
|
+
* Orders subscriber fan-out across concurrent `#emitSessionEvent` calls.
|
|
4200
|
+
* Extension emits only await when the event type has handlers, so an event
|
|
4201
|
+
* with no handlers could otherwise overtake an earlier event still inside
|
|
4202
|
+
* its extension emit — an instant refusal delivered its assistant
|
|
4203
|
+
* `message_end` to the TUI before its own `message_start`, skipping the
|
|
4204
|
+
* turn-ending error render entirely.
|
|
4205
|
+
*/
|
|
4206
|
+
#subscriberEmitGate: Promise<void> = Promise.resolve();
|
|
4207
|
+
|
|
4166
4208
|
async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
|
|
4167
4209
|
if (event.type === "message_update") {
|
|
4168
4210
|
this.#emit(event);
|
|
4169
4211
|
void this.#queueExtensionEvent(event);
|
|
4170
4212
|
return;
|
|
4171
4213
|
}
|
|
4172
|
-
|
|
4173
|
-
//
|
|
4174
|
-
//
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4214
|
+
// Take a FIFO ticket before the extension emit: extension deliveries for
|
|
4215
|
+
// consecutive events still run concurrently, but subscriber fan-out waits
|
|
4216
|
+
// for every earlier event's fan-out (or deferral) to happen first.
|
|
4217
|
+
const previousGate = this.#subscriberEmitGate;
|
|
4218
|
+
const { promise: gate, resolve: releaseGate } = Promise.withResolvers<void>();
|
|
4219
|
+
this.#subscriberEmitGate = gate;
|
|
4220
|
+
try {
|
|
4221
|
+
await this.#emitExtensionEvent(event);
|
|
4222
|
+
await previousGate;
|
|
4223
|
+
// Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
|
|
4224
|
+
// (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
|
|
4225
|
+
// emitting while #promptInFlightCount > 0 lets a client fire its next
|
|
4226
|
+
// `prompt` into a session that still reports isStreaming === true. Flush
|
|
4227
|
+
// happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
|
|
4228
|
+
// an auto-compaction turn that starts before the original prompt unwinds)
|
|
4229
|
+
// supersedes the pending one, which is what subscribers want — they only
|
|
4230
|
+
// care about the final settle.
|
|
4231
|
+
if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
|
|
4232
|
+
this.#pendingAgentEndEmit = event;
|
|
4233
|
+
return;
|
|
4234
|
+
}
|
|
4235
|
+
this.#emit(event);
|
|
4236
|
+
} finally {
|
|
4237
|
+
releaseGate();
|
|
4184
4238
|
}
|
|
4185
|
-
this.#emit(event);
|
|
4186
4239
|
}
|
|
4187
4240
|
|
|
4188
4241
|
// Track last assistant message for auto-compaction check
|
|
4189
4242
|
#lastAssistantMessage: AssistantMessage | undefined = undefined;
|
|
4243
|
+
/**
|
|
4244
|
+
* Classifier-refusal turn pruned from active context at settle (#3591).
|
|
4245
|
+
* Retained until the next run starts so post-settle readers
|
|
4246
|
+
* ({@link getLastAssistantMessage}: print mode, task executor) still see
|
|
4247
|
+
* the terminal error instead of a silently successful-looking state.
|
|
4248
|
+
*/
|
|
4249
|
+
#prunedTerminalRefusal: AssistantMessage | undefined = undefined;
|
|
4190
4250
|
|
|
4191
4251
|
/** Internal handler for agent events - shared by subscribe and reconnect.
|
|
4192
4252
|
*
|
|
@@ -4204,7 +4264,12 @@ export class AgentSession {
|
|
|
4204
4264
|
* everything it schedules — settles. */
|
|
4205
4265
|
#handleAgentEvent = async (event: AgentEvent): Promise<void> => {
|
|
4206
4266
|
if (event.type !== "agent_end") {
|
|
4207
|
-
|
|
4267
|
+
const processing = this.#processAgentEvent(event);
|
|
4268
|
+
if ((event.type === "message_start" || event.type === "message_end") && isAdvisorCard(event.message)) {
|
|
4269
|
+
this.#pendingAdvisorCardEvents.add(processing);
|
|
4270
|
+
void processing.finally(() => this.#pendingAdvisorCardEvents.delete(processing)).catch(() => {});
|
|
4271
|
+
}
|
|
4272
|
+
return processing;
|
|
4208
4273
|
}
|
|
4209
4274
|
const { promise, resolve } = Promise.withResolvers<void>();
|
|
4210
4275
|
this.#trackPostPromptTask(promise);
|
|
@@ -4446,6 +4511,11 @@ export class AgentSession {
|
|
|
4446
4511
|
}
|
|
4447
4512
|
|
|
4448
4513
|
#processAgentEvent = async (event: AgentEvent): Promise<void> => {
|
|
4514
|
+
// A fresh run supersedes the previously settled (and pruned) refusal
|
|
4515
|
+
// turn: state-based lookups take over again.
|
|
4516
|
+
if (event.type === "agent_start") {
|
|
4517
|
+
this.#prunedTerminalRefusal = undefined;
|
|
4518
|
+
}
|
|
4449
4519
|
// Step the mid-run todo counter synchronously, BEFORE any await in this
|
|
4450
4520
|
// handler. The agent loop's next-turn `getAsideMessages` poll can run
|
|
4451
4521
|
// before queued microtasks drain, so `#takeMidRunTodoNudge` MUST see the
|
|
@@ -4464,6 +4534,19 @@ export class AgentSession {
|
|
|
4464
4534
|
} else if (!isError && MID_RUN_TODO_NUDGE_MUTATING_TOOLS[toolName]) {
|
|
4465
4535
|
this.#mutationsSinceLastTodoTouch++;
|
|
4466
4536
|
}
|
|
4537
|
+
// A tool actually ran. Clear the post-reminder suppression synchronously
|
|
4538
|
+
// too: the settle check (`#checkTodoCompletion` in agent_end maintenance)
|
|
4539
|
+
// can otherwise read the stale flag when a tool result and the terminal
|
|
4540
|
+
// stop land in the same tick, swallowing the earned re-escalation.
|
|
4541
|
+
this.#todoReminderAwaitingProgress = false;
|
|
4542
|
+
}
|
|
4543
|
+
// Track the settled assistant turn synchronously as well: agent_end
|
|
4544
|
+
// maintenance reads `#lastAssistantMessage`, and when a turn's events all
|
|
4545
|
+
// land in one tick its handler can run before this handler's post-emit
|
|
4546
|
+
// bookkeeping — leaving maintenance looking at the previous (e.g.
|
|
4547
|
+
// toolUse) assistant message and skipping settle-only work.
|
|
4548
|
+
if (event.type === "message_end" && event.message.role === "assistant") {
|
|
4549
|
+
this.#lastAssistantMessage = event.message;
|
|
4467
4550
|
}
|
|
4468
4551
|
// Plan-mode internal transition: stamp `SILENT_ABORT_MARKER` on the
|
|
4469
4552
|
// persisted message BEFORE the obfuscator's display-side copy below.
|
|
@@ -4670,9 +4753,7 @@ export class AgentSession {
|
|
|
4670
4753
|
}
|
|
4671
4754
|
// Other message types (bashExecution, compactionSummary, branchSummary) are persisted elsewhere
|
|
4672
4755
|
|
|
4673
|
-
// Track assistant message for auto-compaction (checked on agent_end)
|
|
4674
4756
|
if (event.message.role === "assistant") {
|
|
4675
|
-
this.#lastAssistantMessage = event.message;
|
|
4676
4757
|
const assistantMsg = event.message as AssistantMessage;
|
|
4677
4758
|
// Fold this turn's timing into per-model perf aggregates (drives the
|
|
4678
4759
|
// /models TPS/TTFT display). Errored turns measure nothing; aborted
|
|
@@ -4742,10 +4823,6 @@ export class AgentSession {
|
|
|
4742
4823
|
const details = isRecord(event.message.details) ? event.message.details : undefined;
|
|
4743
4824
|
const semanticResult = semanticToolResult(toolName, event.message);
|
|
4744
4825
|
const semanticDetails = isRecord(semanticResult?.details) ? semanticResult.details : undefined;
|
|
4745
|
-
// A tool actually ran. Clear the post-reminder suppression: the agent did
|
|
4746
|
-
// productive work in response to the prior nudge, so the next text-only stop
|
|
4747
|
-
// is allowed to escalate to the next reminder if todos remain incomplete.
|
|
4748
|
-
this.#todoReminderAwaitingProgress = false;
|
|
4749
4826
|
// Invalidate streaming edit cache when edit tool completes to prevent stale data
|
|
4750
4827
|
const editedPath = details ? getStringProperty(details, "path") : undefined;
|
|
4751
4828
|
if (toolName === "edit" && editedPath) {
|
|
@@ -4971,8 +5048,12 @@ export class AgentSession {
|
|
|
4971
5048
|
return;
|
|
4972
5049
|
}
|
|
4973
5050
|
}
|
|
4974
|
-
|
|
4975
|
-
|
|
5051
|
+
const resumeCursorStreamStall = this.#canResumeCursorStreamStall(msg);
|
|
5052
|
+
if (resumeCursorStreamStall || this.#isRetryableError(msg)) {
|
|
5053
|
+
const didRetry = await this.#handleRetryableError(
|
|
5054
|
+
msg,
|
|
5055
|
+
resumeCursorStreamStall ? { preserveFailedTurn: true } : undefined,
|
|
5056
|
+
);
|
|
4976
5057
|
if (didRetry) {
|
|
4977
5058
|
await emitAgentEndNotification({ willContinue: true });
|
|
4978
5059
|
return;
|
|
@@ -4991,10 +5072,14 @@ export class AgentSession {
|
|
|
4991
5072
|
}
|
|
4992
5073
|
// Classifier refusals are persisted-skipped above; also prune the trailing
|
|
4993
5074
|
// stub from active context so the next turn's prompt does not replay it.
|
|
5075
|
+
// Keep a reference for post-settle readers (print mode, task executor via
|
|
5076
|
+
// getLastAssistantMessage) — pruning made the terminal error invisible to
|
|
5077
|
+
// anything inspecting agent state after prompt() resolved.
|
|
4994
5078
|
// Fall through to the standard error tail so `session_stop` hooks (block,
|
|
4995
5079
|
// continue, telemetry) still fire — matching the pre-fix flow for
|
|
4996
5080
|
// `stopReason === "error"`.
|
|
4997
5081
|
if (this.#isClassifierRefusal(msg)) {
|
|
5082
|
+
this.#prunedTerminalRefusal = msg;
|
|
4998
5083
|
this.#removeAssistantMessageFromActiveContext(msg);
|
|
4999
5084
|
}
|
|
5000
5085
|
this.#resolveRetry();
|
|
@@ -6714,6 +6799,83 @@ export class AgentSession {
|
|
|
6714
6799
|
return this.#disposeCall;
|
|
6715
6800
|
}
|
|
6716
6801
|
|
|
6802
|
+
async #disposeOwnedAsyncJobs(): Promise<void> {
|
|
6803
|
+
this.#cancelOwnAsyncJobs();
|
|
6804
|
+
const manager = this.#ownedAsyncJobManager;
|
|
6805
|
+
if (!manager) return;
|
|
6806
|
+
|
|
6807
|
+
try {
|
|
6808
|
+
const drained = await manager.dispose({ timeoutMs: 3_000 });
|
|
6809
|
+
const deliveryState = manager.getDeliveryState();
|
|
6810
|
+
if (drained === false && deliveryState) {
|
|
6811
|
+
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
|
|
6812
|
+
}
|
|
6813
|
+
} finally {
|
|
6814
|
+
if (AsyncJobManager.instance() === manager) {
|
|
6815
|
+
AsyncJobManager.setInstance(undefined);
|
|
6816
|
+
}
|
|
6817
|
+
}
|
|
6818
|
+
}
|
|
6819
|
+
|
|
6820
|
+
async #disposeEvalKernels(): Promise<void> {
|
|
6821
|
+
const settled = await this.#prepareEvalExecutionsForDispose();
|
|
6822
|
+
if (!settled) {
|
|
6823
|
+
logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
|
|
6824
|
+
}
|
|
6825
|
+
|
|
6826
|
+
const results = await Promise.allSettled([
|
|
6827
|
+
disposeKernelSessionsByOwner(this.#evalKernelOwnerId),
|
|
6828
|
+
disposeRubyKernelSessionsByOwner(this.#evalKernelOwnerId),
|
|
6829
|
+
disposeJuliaKernelSessionsByOwner(this.#evalKernelOwnerId),
|
|
6830
|
+
]);
|
|
6831
|
+
const errors: unknown[] = [];
|
|
6832
|
+
for (const result of results) {
|
|
6833
|
+
if (result.status === "rejected") errors.push(result.reason);
|
|
6834
|
+
}
|
|
6835
|
+
if (errors.length > 0) throw new AggregateError(errors, "Failed to dispose one or more eval kernels");
|
|
6836
|
+
}
|
|
6837
|
+
|
|
6838
|
+
async #releaseOwnedBrowserTabs(ownerId: string | undefined): Promise<void> {
|
|
6839
|
+
if (!ownerId) return;
|
|
6840
|
+
try {
|
|
6841
|
+
const released = await withTimeout(
|
|
6842
|
+
releaseTabsForOwner(ownerId, { kill: true }),
|
|
6843
|
+
3_000,
|
|
6844
|
+
"Timed out releasing owned browser tabs during dispose",
|
|
6845
|
+
);
|
|
6846
|
+
if (released > 0) {
|
|
6847
|
+
logger.debug("Released owned browser tabs during dispose", { ownerId, released });
|
|
6848
|
+
}
|
|
6849
|
+
} catch (error) {
|
|
6850
|
+
logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) });
|
|
6851
|
+
}
|
|
6852
|
+
}
|
|
6853
|
+
|
|
6854
|
+
async #disconnectOwnedMcp(): Promise<void> {
|
|
6855
|
+
if (!this.#disconnectOwnedMcpManager) return;
|
|
6856
|
+
try {
|
|
6857
|
+
await withTimeout(
|
|
6858
|
+
this.#disconnectOwnedMcpManager(),
|
|
6859
|
+
3_000,
|
|
6860
|
+
"Timed out disconnecting owned MCP manager during dispose",
|
|
6861
|
+
);
|
|
6862
|
+
} catch (error) {
|
|
6863
|
+
logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
|
|
6864
|
+
}
|
|
6865
|
+
}
|
|
6866
|
+
|
|
6867
|
+
async #disposeMnemopi(
|
|
6868
|
+
state: MnemopiSessionState | undefined,
|
|
6869
|
+
consolidateTimeoutMs: number | undefined,
|
|
6870
|
+
): Promise<void> {
|
|
6871
|
+
try {
|
|
6872
|
+
await state?.dispose({ timeoutMs: consolidateTimeoutMs });
|
|
6873
|
+
} finally {
|
|
6874
|
+
// Consolidation may embed final memories, so terminate its worker only afterward.
|
|
6875
|
+
await shutdownMnemopiEmbedClient();
|
|
6876
|
+
}
|
|
6877
|
+
}
|
|
6878
|
+
|
|
6717
6879
|
async #doDispose(options: AgentSessionDisposeOptions = {}): Promise<void> {
|
|
6718
6880
|
this.beginDispose();
|
|
6719
6881
|
this.#recordSessionExit(options.reason ?? "dispose");
|
|
@@ -6726,124 +6888,50 @@ export class AgentSession {
|
|
|
6726
6888
|
} catch (error) {
|
|
6727
6889
|
logger.warn("Failed to emit session_shutdown event", { error: String(error) });
|
|
6728
6890
|
}
|
|
6729
|
-
|
|
6730
|
-
//
|
|
6731
|
-
// Optional-called: hosts and tests may inject partial runner facades that
|
|
6732
|
-
// implement only the dispatch surface.
|
|
6891
|
+
|
|
6892
|
+
// Stop extension timers before aborting deferred work they could enqueue.
|
|
6733
6893
|
this.#extensionRunner?.clearManagedTimers?.();
|
|
6734
6894
|
this.#fallbackExtensionTimers?.clearAll();
|
|
6735
|
-
// Abort post-prompt work so the drain below can complete. Without this, a
|
|
6736
|
-
// deferred-handoff task that has already advanced into
|
|
6737
|
-
// `await this.handoff(...) → generateHandoff(...)` keeps awaiting a live LLM stream
|
|
6738
|
-
// — Promise.allSettled() in #cancelPostPromptTasks then waits forever, freezing
|
|
6739
|
-
// /exit and Ctrl+C-double-tap. The post-prompt task's own AbortSignal does not
|
|
6740
|
-
// propagate into the inner handoff/compaction controllers, so we abort them
|
|
6741
|
-
// explicitly. agent.abort() is needed for an agent.continue() that may have
|
|
6742
|
-
// raced the deferred handoff (its streaming loop is awaited by the wrapper IIFE).
|
|
6743
|
-
//
|
|
6744
|
-
// Tool work (bash/eval/python) is NOT aborted here — those have their own
|
|
6745
|
-
// dispose paths and shared kernels are contractually allowed to survive a
|
|
6746
|
-
// session's dispose.
|
|
6747
6895
|
this.abortRetry();
|
|
6748
6896
|
this.abortCompaction();
|
|
6749
6897
|
const postPromptDrain = this.#cancelPostPromptTasks();
|
|
6750
6898
|
this.agent.abort();
|
|
6751
|
-
|
|
6752
|
-
|
|
6753
|
-
|
|
6754
|
-
|
|
6755
|
-
// the session that owns the manager goes on to dispose it (which itself
|
|
6756
|
-
// nukes any leftover jobs and pending deliveries).
|
|
6757
|
-
this.#cancelOwnAsyncJobs();
|
|
6758
|
-
const ownedAsyncManager = this.#ownedAsyncJobManager;
|
|
6759
|
-
if (ownedAsyncManager) {
|
|
6760
|
-
const drained = await ownedAsyncManager.dispose({ timeoutMs: 3_000 });
|
|
6761
|
-
const deliveryState = ownedAsyncManager.getDeliveryState();
|
|
6762
|
-
if (drained === false && deliveryState) {
|
|
6763
|
-
logger.warn("Async job completion deliveries still pending during dispose", { ...deliveryState });
|
|
6764
|
-
}
|
|
6765
|
-
if (AsyncJobManager.instance() === ownedAsyncManager) {
|
|
6766
|
-
AsyncJobManager.setInstance(undefined);
|
|
6767
|
-
}
|
|
6768
|
-
}
|
|
6769
|
-
const evalExecutionsSettled = await this.#prepareEvalExecutionsForDispose();
|
|
6770
|
-
if (!evalExecutionsSettled) {
|
|
6771
|
-
logger.warn("Detaching retained eval-kernel ownership during dispose while eval execution is still active");
|
|
6899
|
+
try {
|
|
6900
|
+
await withTimeout(postPromptDrain, 5_000, "Timed out draining post-prompt tasks during dispose");
|
|
6901
|
+
} catch (error) {
|
|
6902
|
+
logger.warn("Post-prompt tasks still draining at dispose deadline", { error: String(error) });
|
|
6772
6903
|
}
|
|
6773
|
-
await
|
|
6774
|
-
|
|
6775
|
-
|
|
6776
|
-
|
|
6777
|
-
|
|
6778
|
-
|
|
6779
|
-
|
|
6780
|
-
|
|
6781
|
-
|
|
6782
|
-
|
|
6783
|
-
|
|
6784
|
-
|
|
6785
|
-
|
|
6786
|
-
|
|
6787
|
-
|
|
6788
|
-
|
|
6789
|
-
|
|
6790
|
-
|
|
6791
|
-
|
|
6792
|
-
|
|
6793
|
-
}
|
|
6794
|
-
} catch (error) {
|
|
6795
|
-
logger.warn("Failed to release owned browser tabs during dispose", { error: String(error) });
|
|
6904
|
+
await this.#drainAutolearnCapture();
|
|
6905
|
+
|
|
6906
|
+
const hindsightState = this.getHindsightSessionState();
|
|
6907
|
+
const mnemopiState = setMnemopiSessionState(this, undefined);
|
|
6908
|
+
const advisorRecorderClosed = this.#advisorRecorderClosed;
|
|
6909
|
+
const results = await Promise.allSettled([
|
|
6910
|
+
this.#disposeOwnedAsyncJobs(),
|
|
6911
|
+
this.#disposeEvalKernels(),
|
|
6912
|
+
this.#releaseOwnedBrowserTabs(this.sessionManager.getSessionId()),
|
|
6913
|
+
shutdownTinyTitleClient(),
|
|
6914
|
+
this.#disconnectOwnedMcp(),
|
|
6915
|
+
advisorRecorderClosed,
|
|
6916
|
+
hindsightState?.flushRetainQueue() ?? Promise.resolve(),
|
|
6917
|
+
this.#disposeMnemopi(mnemopiState, options.mnemopiConsolidateTimeoutMs),
|
|
6918
|
+
]);
|
|
6919
|
+
for (const result of results) {
|
|
6920
|
+
if (result.status === "rejected") {
|
|
6921
|
+
logger.warn("Session dispose subsystem failed during parallel teardown", {
|
|
6922
|
+
error: String(result.reason),
|
|
6923
|
+
});
|
|
6796
6924
|
}
|
|
6797
6925
|
}
|
|
6798
|
-
|
|
6926
|
+
|
|
6799
6927
|
this.#releasePowerAssertion();
|
|
6800
|
-
// Clean up an empty session created by this session's /move so it doesn't accumulate.
|
|
6801
6928
|
await cleanupEmptyMoveSession(this.sessionManager, this.#movedFromEmptySessionFile);
|
|
6802
6929
|
this.#movedFromEmptySessionFile = undefined;
|
|
6930
|
+
// All teardown branches that can append session entries have settled.
|
|
6803
6931
|
await this.sessionManager.close();
|
|
6804
|
-
// beginDispose() stopped the advisor and captured its recorder close; await
|
|
6805
|
-
// it so the final advisor turn is flushed before the process may exit.
|
|
6806
|
-
await this.#advisorRecorderClosed;
|
|
6807
6932
|
this.#closeAllProviderSessions("dispose");
|
|
6808
|
-
// Disconnect the MCP manager this session OWNS so its stdio servers are
|
|
6809
|
-
// not orphaned at exit. Best-effort: a failure here must never throw out
|
|
6810
|
-
// of dispose. Only owning (top-level) sessions provide this callback;
|
|
6811
|
-
// subagents reuse a parent's manager and must not tear it down. Idempotent
|
|
6812
|
-
// with the deferred-discovery disconnect in `createAgentSession`.
|
|
6813
|
-
//
|
|
6814
|
-
// BOUNDED: an owned manager may hold an HTTP/SSE server whose session-
|
|
6815
|
-
// termination DELETE blocks up to the MCP request timeout (30s default,
|
|
6816
|
-
// unbounded when OMP_MCP_TIMEOUT_MS=0), so awaiting `disconnectAll()`
|
|
6817
|
-
// unbounded would stall /exit and print-mode shutdown on a broken remote
|
|
6818
|
-
// endpoint. Race it against a short deadline — stdio close (the subprocess
|
|
6819
|
-
// reap this targets) completes well within the bound; a slow transport
|
|
6820
|
-
// close is left to finish detached. Mirrors the bounded async-job teardown.
|
|
6821
|
-
if (this.#disconnectOwnedMcpManager) {
|
|
6822
|
-
try {
|
|
6823
|
-
await withTimeout(
|
|
6824
|
-
this.#disconnectOwnedMcpManager(),
|
|
6825
|
-
3_000,
|
|
6826
|
-
"Timed out disconnecting owned MCP manager during dispose",
|
|
6827
|
-
);
|
|
6828
|
-
} catch (error) {
|
|
6829
|
-
logger.warn("Failed to disconnect owned MCP manager during dispose", { error: String(error) });
|
|
6830
|
-
}
|
|
6831
|
-
}
|
|
6832
|
-
// Flush the retain queue BEFORE clearing the session's pointer so
|
|
6833
|
-
// `HindsightRetainQueue.#doFlush` still sees `session.getHindsightSessionState() === state`.
|
|
6834
|
-
// Reversed, the spliced batch survives just long enough to fail the
|
|
6835
|
-
// identity check and get dropped with a `session vanished` warning.
|
|
6836
|
-
const hindsightState = this.getHindsightSessionState();
|
|
6837
|
-
await hindsightState?.flushRetainQueue();
|
|
6838
6933
|
this.setHindsightSessionState(undefined);
|
|
6839
6934
|
hindsightState?.dispose();
|
|
6840
|
-
const mnemopiState = setMnemopiSessionState(this, undefined);
|
|
6841
|
-
await mnemopiState?.dispose({ timeoutMs: options.mnemopiConsolidateTimeoutMs });
|
|
6842
|
-
// Tear down the embeddings subprocess AFTER mnemopi state.dispose:
|
|
6843
|
-
// consolidate-on-dispose may still call `embed()` to store the final
|
|
6844
|
-
// memories, and that round-trips through the worker we are about to
|
|
6845
|
-
// hard-kill (issue #3031).
|
|
6846
|
-
await shutdownMnemopiEmbedClient();
|
|
6847
6935
|
this.#disconnectFromAgent();
|
|
6848
6936
|
if (this.#unsubscribeAppendOnly) {
|
|
6849
6937
|
this.#unsubscribeAppendOnly();
|
|
@@ -6944,6 +7032,53 @@ export class AgentSession {
|
|
|
6944
7032
|
await this.agent.waitForIdle();
|
|
6945
7033
|
await this.#waitForPostPromptRecovery();
|
|
6946
7034
|
}
|
|
7035
|
+
/**
|
|
7036
|
+
* Prevent advisor notes from starting hidden primary turns while a headless
|
|
7037
|
+
* caller prints and drains the final primary response.
|
|
7038
|
+
*/
|
|
7039
|
+
prepareForHeadlessAdvisorDrain(): void {
|
|
7040
|
+
this.#preserveAdvisorAdvice = true;
|
|
7041
|
+
}
|
|
7042
|
+
|
|
7043
|
+
async #waitForPendingAdvisorCardEvents(timeoutMs: number): Promise<boolean> {
|
|
7044
|
+
const deadline = Date.now() + Math.max(0, timeoutMs);
|
|
7045
|
+
while (this.#pendingAdvisorCardEvents.size > 0) {
|
|
7046
|
+
const remainingMs = deadline - Date.now();
|
|
7047
|
+
if (remainingMs <= 0) return false;
|
|
7048
|
+
const settled = Promise.allSettled([...this.#pendingAdvisorCardEvents]).then(() => true as const);
|
|
7049
|
+
const { promise: timedOut, resolve } = Promise.withResolvers<false>();
|
|
7050
|
+
const timer = setTimeout(() => resolve(false), remainingMs);
|
|
7051
|
+
try {
|
|
7052
|
+
if (!(await Promise.race([settled, timedOut]))) return false;
|
|
7053
|
+
} finally {
|
|
7054
|
+
clearTimeout(timer);
|
|
7055
|
+
}
|
|
7056
|
+
}
|
|
7057
|
+
return true;
|
|
7058
|
+
}
|
|
7059
|
+
|
|
7060
|
+
/**
|
|
7061
|
+
* Wait for active advisor reviews and their emitted card events before a
|
|
7062
|
+
* headless caller disposes the session. Returns `false` and logs work disposal
|
|
7063
|
+
* will abandon when the shared deadline expires or an advisor fails.
|
|
7064
|
+
*/
|
|
7065
|
+
async waitForAdvisorCatchup(timeoutMs: number): Promise<boolean> {
|
|
7066
|
+
const deadline = Date.now() + timeoutMs;
|
|
7067
|
+
const results = await Promise.all(this.#advisors.map(advisor => advisor.runtime.waitForCatchup(timeoutMs, 1)));
|
|
7068
|
+
const cardEventsCaughtUp = await this.#waitForPendingAdvisorCardEvents(Math.max(0, deadline - Date.now()));
|
|
7069
|
+
const abandoned = this.#advisors.filter(
|
|
7070
|
+
(advisor, index) => results[index] === false && advisor.runtime.backlog > 0,
|
|
7071
|
+
);
|
|
7072
|
+
if (abandoned.length > 0 || !cardEventsCaughtUp) {
|
|
7073
|
+
logger.warn("advisor shutdown drain incomplete; disposal will abandon reviews or cards", {
|
|
7074
|
+
timeoutMs,
|
|
7075
|
+
advisors: abandoned.map(advisor => ({ name: advisor.name, backlog: advisor.runtime.backlog })),
|
|
7076
|
+
pendingAdvisorCards: this.#pendingAdvisorCardEvents.size,
|
|
7077
|
+
});
|
|
7078
|
+
return false;
|
|
7079
|
+
}
|
|
7080
|
+
return true;
|
|
7081
|
+
}
|
|
6947
7082
|
|
|
6948
7083
|
async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
|
|
6949
7084
|
const manager = this.#asyncJobManager;
|
|
@@ -6962,9 +7097,14 @@ export class AgentSession {
|
|
|
6962
7097
|
}
|
|
6963
7098
|
}
|
|
6964
7099
|
|
|
6965
|
-
/**
|
|
7100
|
+
/**
|
|
7101
|
+
* Most recent settled assistant message. A classifier-refusal turn pruned
|
|
7102
|
+
* from active context at settle is still reported until the next run
|
|
7103
|
+
* starts, so terminal-outcome consumers (print mode, task executor) see
|
|
7104
|
+
* the refusal error rather than the previous turn — or nothing.
|
|
7105
|
+
*/
|
|
6966
7106
|
getLastAssistantMessage(): AssistantMessage | undefined {
|
|
6967
|
-
return this.#findLastAssistantMessage();
|
|
7107
|
+
return this.#prunedTerminalRefusal ?? this.#findLastAssistantMessage();
|
|
6968
7108
|
}
|
|
6969
7109
|
/** Current effective system prompt blocks (includes any per-turn extension modifications) */
|
|
6970
7110
|
get systemPrompt(): string[] {
|
|
@@ -7414,11 +7554,54 @@ export class AgentSession {
|
|
|
7414
7554
|
* Changes take effect before the next model call.
|
|
7415
7555
|
*/
|
|
7416
7556
|
async setActiveToolsByName(toolNames: string[]): Promise<void> {
|
|
7417
|
-
const mounted = this.#mountedXdevToolNames;
|
|
7418
7557
|
const normalized = normalizeToolNames(toolNames);
|
|
7558
|
+
// Transport-write eligibility keys off the *current* active set: an ordinary
|
|
7559
|
+
// selection change should not demote `write` unless it is already active.
|
|
7560
|
+
await this.#applyToolPresentation(
|
|
7561
|
+
normalized,
|
|
7562
|
+
this.#mountedXdevToolNames,
|
|
7563
|
+
this.getActiveToolNames().includes("write"),
|
|
7564
|
+
);
|
|
7565
|
+
}
|
|
7566
|
+
|
|
7567
|
+
/**
|
|
7568
|
+
* Restore an enabled tool set with its exact top-level versus `xd://` partition.
|
|
7569
|
+
*
|
|
7570
|
+
* Both inputs are required because {@link setActiveToolsByName} only receives the
|
|
7571
|
+
* enabled name list and classifies mounts from the *current* `#mountedXdevToolNames`.
|
|
7572
|
+
* Rollback/restore callers must pass the snapshotted mounted subset so names that
|
|
7573
|
+
* were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
|
|
7574
|
+
* `xd://` remain mount-eligible, even when the live mount set has drifted.
|
|
7575
|
+
*
|
|
7576
|
+
* Names outside `mountedToolNames` are pinned top-level for this application;
|
|
7577
|
+
* names in the mounted subset remain eligible for xdev mounting. Delegates the
|
|
7578
|
+
* actual apply through `#applyActiveToolsByName` and restores the prior runtime
|
|
7579
|
+
* selection if that apply throws.
|
|
7580
|
+
*/
|
|
7581
|
+
async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
|
|
7582
|
+
const normalized = normalizeToolNames(toolNames);
|
|
7583
|
+
// Restoration targets a snapshot, so write eligibility comes from the
|
|
7584
|
+
// *target* set rather than whatever happens to be active mid-rollback.
|
|
7585
|
+
await this.#applyToolPresentation(
|
|
7586
|
+
normalized,
|
|
7587
|
+
new Set(normalizeToolNames(mountedToolNames)),
|
|
7588
|
+
normalized.includes("write"),
|
|
7589
|
+
);
|
|
7590
|
+
}
|
|
7591
|
+
|
|
7592
|
+
/**
|
|
7593
|
+
* Shared body for {@link setActiveToolsByName} and {@link setActiveToolPresentation}:
|
|
7594
|
+
* pins non-mounted names as the runtime selection (holding `write` back when it is
|
|
7595
|
+
* transport-only) and applies the set, rolling the selection back if apply throws.
|
|
7596
|
+
*/
|
|
7597
|
+
async #applyToolPresentation(
|
|
7598
|
+
normalized: string[],
|
|
7599
|
+
mounted: ReadonlySet<string>,
|
|
7600
|
+
writeSelected: boolean,
|
|
7601
|
+
): Promise<void> {
|
|
7419
7602
|
const transportWriteActive =
|
|
7603
|
+
writeSelected &&
|
|
7420
7604
|
this.#builtInToolNames.has("write") &&
|
|
7421
|
-
this.getActiveToolNames().includes("write") &&
|
|
7422
7605
|
this.#presentationPinnedToolNames?.has("write") !== true &&
|
|
7423
7606
|
this.#runtimeSelectedToolNames?.has("write") !== true &&
|
|
7424
7607
|
(mounted.size > 0 || this.#planModeState?.enabled === true);
|
|
@@ -7695,6 +7878,12 @@ export class AgentSession {
|
|
|
7695
7878
|
return this.#postPromptTasks.size > 0;
|
|
7696
7879
|
}
|
|
7697
7880
|
|
|
7881
|
+
/** Register post-prompt work in tests without driving a full agent turn. */
|
|
7882
|
+
trackPostPromptTaskForTests(task: Promise<unknown>): void {
|
|
7883
|
+
if (!isBunTestRuntime()) throw new Error("trackPostPromptTaskForTests is test-only");
|
|
7884
|
+
this.#trackPostPromptTask(task);
|
|
7885
|
+
}
|
|
7886
|
+
|
|
7698
7887
|
/** All messages including custom types like BashExecutionMessage */
|
|
7699
7888
|
get messages(): AgentMessage[] {
|
|
7700
7889
|
return this.agent.state.messages;
|
|
@@ -8441,19 +8630,18 @@ export class AgentSession {
|
|
|
8441
8630
|
timestamp,
|
|
8442
8631
|
});
|
|
8443
8632
|
}
|
|
8444
|
-
if (
|
|
8445
|
-
this
|
|
8446
|
-
|
|
8447
|
-
|
|
8448
|
-
|
|
8449
|
-
|
|
8450
|
-
|
|
8451
|
-
|
|
8452
|
-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
});
|
|
8633
|
+
if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
|
|
8634
|
+
const activeToolNames = this.getActiveToolNames();
|
|
8635
|
+
if (activeToolNames.includes("task") && activeToolNames.includes("eval")) {
|
|
8636
|
+
keywordNotices.push({
|
|
8637
|
+
role: "custom",
|
|
8638
|
+
customType: "workflow-notice",
|
|
8639
|
+
content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
|
|
8640
|
+
display: false,
|
|
8641
|
+
attribution: "user",
|
|
8642
|
+
timestamp,
|
|
8643
|
+
});
|
|
8644
|
+
}
|
|
8457
8645
|
}
|
|
8458
8646
|
return keywordNotices;
|
|
8459
8647
|
}
|
|
@@ -9574,6 +9762,13 @@ export class AgentSession {
|
|
|
9574
9762
|
}
|
|
9575
9763
|
|
|
9576
9764
|
#scheduleReplanTitleRefresh(): void {
|
|
9765
|
+
// Headless subagent sessions have no operator-visible title, so a todo-init
|
|
9766
|
+
// replan refresh only burns a tiny-model call whose result lands in JSONL
|
|
9767
|
+
// and is never shown (issue #5910). In an interactive host the operator can
|
|
9768
|
+
// focus a live subagent from the Agent Hub, where the status line renders
|
|
9769
|
+
// its session name — so keep the refresh there and only skip subagents when
|
|
9770
|
+
// no focusable UI exists (print/RPC/ACP/eval/SDK/CI).
|
|
9771
|
+
if (this.#agentKind === "sub" && !isInteractiveHost()) return;
|
|
9577
9772
|
if (this.#replanTitleRefreshInFlight) return;
|
|
9578
9773
|
if (!this.settings.get("title.refreshOnReplan")) return;
|
|
9579
9774
|
if (this.sessionManager.titleSource === "user") return;
|
|
@@ -10210,15 +10405,16 @@ export class AgentSession {
|
|
|
10210
10405
|
}
|
|
10211
10406
|
|
|
10212
10407
|
/**
|
|
10213
|
-
* Set the thinking level. `auto` enables per-turn classification
|
|
10214
|
-
*
|
|
10215
|
-
*
|
|
10216
|
-
*
|
|
10408
|
+
* Set the thinking level. `auto` enables per-turn classification. Entering
|
|
10409
|
+
* auto writes its provisional level plus `configured: "auto"` immediately,
|
|
10410
|
+
* giving external readers an authoritative selection receipt before the next
|
|
10411
|
+
* user turn. Later classifications persist only changed concrete resolutions.
|
|
10217
10412
|
*/
|
|
10218
10413
|
setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void {
|
|
10219
10414
|
if (level === AUTO_THINKING) {
|
|
10220
10415
|
const provisional = resolveProvisionalAutoLevel(this.model);
|
|
10221
10416
|
const wasAuto = this.#autoThinking;
|
|
10417
|
+
const previousLevel = this.#thinkingLevel;
|
|
10222
10418
|
this.#autoThinking = true;
|
|
10223
10419
|
this.#autoResolvedLevel = undefined;
|
|
10224
10420
|
this.#thinkingLevel = provisional;
|
|
@@ -10229,7 +10425,9 @@ export class AgentSession {
|
|
|
10229
10425
|
if (persist) {
|
|
10230
10426
|
this.settings.set("defaultThinkingLevel", AUTO_THINKING);
|
|
10231
10427
|
}
|
|
10232
|
-
|
|
10428
|
+
const isChanging = !wasAuto || previousLevel !== provisional;
|
|
10429
|
+
if (isChanging) {
|
|
10430
|
+
this.sessionManager.appendThinkingLevelChange(provisional, AUTO_THINKING);
|
|
10233
10431
|
this.#emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING });
|
|
10234
10432
|
}
|
|
10235
10433
|
return;
|
|
@@ -10338,7 +10536,7 @@ export class AgentSession {
|
|
|
10338
10536
|
|
|
10339
10537
|
const effort = resolved ?? resolveProvisionalAutoLevel(model);
|
|
10340
10538
|
if (effort === undefined) return;
|
|
10341
|
-
const shouldPersistResolution = this.#
|
|
10539
|
+
const shouldPersistResolution = this.#thinkingLevel !== effort;
|
|
10342
10540
|
this.#autoResolvedLevel = effort;
|
|
10343
10541
|
this.#thinkingLevel = effort;
|
|
10344
10542
|
this.#applyThinkingLevelToAgent(effort);
|
|
@@ -11929,7 +12127,8 @@ export class AgentSession {
|
|
|
11929
12127
|
this.#emptyStopRetryCount++;
|
|
11930
12128
|
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
|
|
11931
12129
|
const attempts = this.#emptyStopRetryCount - 1;
|
|
11932
|
-
const finalError =
|
|
12130
|
+
const finalError =
|
|
12131
|
+
"Assistant returned empty stop after retry cap; try switching models or `/shake images` to remove archived frames";
|
|
11933
12132
|
logger.warn(finalError, {
|
|
11934
12133
|
attempts,
|
|
11935
12134
|
model: assistantMessage.model,
|
|
@@ -11944,11 +12143,11 @@ export class AgentSession {
|
|
|
11944
12143
|
this.#clearPendingRecoveredRetryErrors();
|
|
11945
12144
|
this.#retryAttempt = 0;
|
|
11946
12145
|
this.#resolveRetry();
|
|
11947
|
-
//
|
|
11948
|
-
//
|
|
11949
|
-
|
|
11950
|
-
|
|
11951
|
-
|
|
12146
|
+
// A zero-content turn carries no transcript value, while its provider usage
|
|
12147
|
+
// can anchor the next prompt at the full failed-request size and re-trigger
|
|
12148
|
+
// compaction at the same boundary. Remove every capped empty stop; toolUse
|
|
12149
|
+
// orphans still need this for Anthropic message-history validity.
|
|
12150
|
+
await this.#dropPersistedAssistantTurn(assistantMessage);
|
|
11952
12151
|
return false;
|
|
11953
12152
|
}
|
|
11954
12153
|
this.#discardAssistantTurn(assistantMessage);
|
|
@@ -13758,35 +13957,80 @@ export class AgentSession {
|
|
|
13758
13957
|
this.#getCompactionModelCandidates(availableModels),
|
|
13759
13958
|
this.sessionId,
|
|
13760
13959
|
);
|
|
13761
|
-
|
|
13960
|
+
let pathEntriesForCompaction = pathEntries;
|
|
13961
|
+
let preparation = prepareCompaction(pathEntriesForCompaction, compactionSettings, autoCompactionCandidates);
|
|
13762
13962
|
if (!preparation) {
|
|
13763
|
-
|
|
13764
|
-
|
|
13765
|
-
|
|
13766
|
-
|
|
13767
|
-
|
|
13768
|
-
|
|
13769
|
-
|
|
13770
|
-
|
|
13771
|
-
|
|
13772
|
-
|
|
13773
|
-
|
|
13774
|
-
|
|
13775
|
-
|
|
13776
|
-
|
|
13777
|
-
|
|
13963
|
+
// prepareCompaction found nothing to summarize because the kept region
|
|
13964
|
+
// is a single oversized recent turn — findCutPoint never cuts inside a
|
|
13965
|
+
// tool result, so a huge tool-result / fenced block tail leaves nothing
|
|
13966
|
+
// on the summarizable side and summary compaction cannot even start.
|
|
13967
|
+
// That is exactly the dead-end the elide shake rescues: it reaches
|
|
13968
|
+
// INSIDE the tail and offloads heavy content to an artifact placeholder,
|
|
13969
|
+
// shrinking the tail so findCutPoint can then move the cut and leave
|
|
13970
|
+
// older turns to summarize. Run the same tiered rescue the
|
|
13971
|
+
// post-maintenance guard uses (elide, then image drop), with progress
|
|
13972
|
+
// defined as "prepareCompaction now succeeds on the rewritten branch",
|
|
13973
|
+
// and fall through to the normal compaction body when it does (writing
|
|
13974
|
+
// a compaction entry anchors the stale billed usage so the
|
|
13975
|
+
// auto-continue re-check cannot re-trip and loop the warning — issue
|
|
13976
|
+
// #4786). `skipElide` when we already fell through from a shake
|
|
13977
|
+
// strategy pass (it tried and found nothing); skip entirely on the
|
|
13978
|
+
// idle timer (it re-checks usage on its own cadence).
|
|
13979
|
+
let rescueRewroteHistory = false;
|
|
13980
|
+
if (reason !== "idle") {
|
|
13981
|
+
await this.#rescueCompactionDeadEnd(autoCompactionSignal, {
|
|
13982
|
+
skipElide: fallbackFromShake,
|
|
13983
|
+
hasProgress: () => {
|
|
13984
|
+
// Only reached when a tier actually freed something, so the
|
|
13985
|
+
// branch has been rewritten either way.
|
|
13986
|
+
rescueRewroteHistory = true;
|
|
13987
|
+
pathEntriesForCompaction = this.sessionManager.getBranch();
|
|
13988
|
+
preparation = prepareCompaction(
|
|
13989
|
+
pathEntriesForCompaction,
|
|
13990
|
+
compactionSettings,
|
|
13991
|
+
autoCompactionCandidates,
|
|
13992
|
+
);
|
|
13993
|
+
return preparation !== undefined;
|
|
13994
|
+
},
|
|
13778
13995
|
});
|
|
13779
|
-
continuationScheduled = true;
|
|
13780
13996
|
}
|
|
13781
|
-
if (
|
|
13782
|
-
this
|
|
13783
|
-
"
|
|
13784
|
-
|
|
13785
|
-
|
|
13786
|
-
|
|
13997
|
+
if (!preparation) {
|
|
13998
|
+
await this.#emitSessionEvent({
|
|
13999
|
+
type: "auto_compaction_end",
|
|
14000
|
+
action,
|
|
14001
|
+
result: undefined,
|
|
14002
|
+
aborted: false,
|
|
14003
|
+
willRetry: false,
|
|
14004
|
+
skipped: true,
|
|
14005
|
+
});
|
|
14006
|
+
const noProgressDeadEnd = reason !== "idle";
|
|
14007
|
+
let continuationScheduled = false;
|
|
14008
|
+
if (!suppressContinuation && this.agent.hasQueuedMessages()) {
|
|
14009
|
+
this.#scheduleAgentContinue({
|
|
14010
|
+
delayMs: 100,
|
|
14011
|
+
generation,
|
|
14012
|
+
shouldContinue: () => this.agent.hasQueuedMessages(),
|
|
14013
|
+
});
|
|
14014
|
+
continuationScheduled = true;
|
|
14015
|
+
}
|
|
14016
|
+
if (noProgressDeadEnd) {
|
|
14017
|
+
this.emitNotice(
|
|
14018
|
+
"warning",
|
|
14019
|
+
compactionDeadEndWarning("shrink it (e.g. clear large tool output)"),
|
|
14020
|
+
"compaction",
|
|
14021
|
+
);
|
|
14022
|
+
}
|
|
14023
|
+
// A rescue that offloaded content but still could not produce a
|
|
14024
|
+
// preparation rewrote the branch; flag it so the overflow-recovery
|
|
14025
|
+
// rollback does not re-restore the just-failed assistant turn on top
|
|
14026
|
+
// of the elided tail.
|
|
14027
|
+
const base = continuationScheduled
|
|
14028
|
+
? COMPACTION_CHECK_CONTINUATION
|
|
14029
|
+
: noProgressDeadEnd
|
|
14030
|
+
? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION
|
|
14031
|
+
: COMPACTION_CHECK_NONE;
|
|
14032
|
+
return rescueRewroteHistory ? { ...base, historyRewritten: true } : base;
|
|
13787
14033
|
}
|
|
13788
|
-
if (continuationScheduled) return COMPACTION_CHECK_CONTINUATION;
|
|
13789
|
-
return noProgressDeadEnd ? COMPACTION_CHECK_BLOCK_AUTOMATIC_CONTINUATION : COMPACTION_CHECK_NONE;
|
|
13790
14034
|
}
|
|
13791
14035
|
|
|
13792
14036
|
let hookCompaction: CompactionResult | undefined;
|
|
@@ -13798,7 +14042,7 @@ export class AgentSession {
|
|
|
13798
14042
|
const hookResult = (await this.#extensionRunner.emit({
|
|
13799
14043
|
type: "session_before_compact",
|
|
13800
14044
|
preparation,
|
|
13801
|
-
branchEntries:
|
|
14045
|
+
branchEntries: pathEntriesForCompaction,
|
|
13802
14046
|
customInstructions: undefined,
|
|
13803
14047
|
signal: autoCompactionSignal,
|
|
13804
14048
|
})) as SessionBeforeCompactResult | undefined;
|
|
@@ -14512,6 +14756,50 @@ export class AgentSession {
|
|
|
14512
14756
|
if (this.#isClassifierRefusal(message)) return true;
|
|
14513
14757
|
return AIError.retriable(id, { replayUnsafe: this.#hasReplayUnsafeToolOutput(message) });
|
|
14514
14758
|
}
|
|
14759
|
+
|
|
14760
|
+
/**
|
|
14761
|
+
* Resume a stalled Cursor turn after every server-executed tool has produced
|
|
14762
|
+
* a result. The failed assistant/tool-result pair must stay in context: it
|
|
14763
|
+
* records completed side effects and lets the next request continue from
|
|
14764
|
+
* them instead of replaying the original turn.
|
|
14765
|
+
*/
|
|
14766
|
+
#canResumeCursorStreamStall(message: AssistantMessage): boolean {
|
|
14767
|
+
if (
|
|
14768
|
+
message.provider !== "cursor" ||
|
|
14769
|
+
message.stopReason !== "error" ||
|
|
14770
|
+
!message.errorMessage?.toLowerCase().includes("stream stall")
|
|
14771
|
+
) {
|
|
14772
|
+
return false;
|
|
14773
|
+
}
|
|
14774
|
+
const id = this.#classifyRetryMessage(message);
|
|
14775
|
+
if (!AIError.retriable(id)) return false;
|
|
14776
|
+
|
|
14777
|
+
const resolvedToolCallIds: string[] = [];
|
|
14778
|
+
for (const block of message.content) {
|
|
14779
|
+
if (block.type !== "toolCall") continue;
|
|
14780
|
+
if (!(kCursorExecResolved in block) || block[kCursorExecResolved] !== true) return false;
|
|
14781
|
+
resolvedToolCallIds.push(block.id);
|
|
14782
|
+
}
|
|
14783
|
+
if (resolvedToolCallIds.length === 0) return false;
|
|
14784
|
+
|
|
14785
|
+
const messages = this.agent.state.messages;
|
|
14786
|
+
let assistantIndex = -1;
|
|
14787
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
14788
|
+
const candidate = messages[i];
|
|
14789
|
+
if (candidate.role === "assistant" && this.#isSameAssistantMessage(candidate, message)) {
|
|
14790
|
+
assistantIndex = i;
|
|
14791
|
+
break;
|
|
14792
|
+
}
|
|
14793
|
+
}
|
|
14794
|
+
if (assistantIndex < 0) return false;
|
|
14795
|
+
|
|
14796
|
+
const unresolvedToolCallIds = new Set(resolvedToolCallIds);
|
|
14797
|
+
for (let i = assistantIndex + 1; i < messages.length; i++) {
|
|
14798
|
+
const candidate = messages[i];
|
|
14799
|
+
if (candidate.role === "toolResult") unresolvedToolCallIds.delete(candidate.toolCallId);
|
|
14800
|
+
}
|
|
14801
|
+
return unresolvedToolCallIds.size === 0;
|
|
14802
|
+
}
|
|
14515
14803
|
/**
|
|
14516
14804
|
* Retried turns remove the failed assistant message from active context.
|
|
14517
14805
|
* Text/thinking-only partials are safe to discard and replay. Retained
|
|
@@ -15089,7 +15377,12 @@ export class AgentSession {
|
|
|
15089
15377
|
*/
|
|
15090
15378
|
async #handleRetryableError(
|
|
15091
15379
|
message: AssistantMessage,
|
|
15092
|
-
options?: {
|
|
15380
|
+
options?: {
|
|
15381
|
+
allowModelFallback?: boolean;
|
|
15382
|
+
fireworksFastFallback?: boolean;
|
|
15383
|
+
hardErrorFallback?: boolean;
|
|
15384
|
+
preserveFailedTurn?: boolean;
|
|
15385
|
+
},
|
|
15093
15386
|
): Promise<boolean> {
|
|
15094
15387
|
const retrySettings = this.settings.getGroup("retry");
|
|
15095
15388
|
// The Fireworks Fast→base degrade is an intrinsic model-selection safety net,
|
|
@@ -15280,8 +15573,11 @@ export class AgentSession {
|
|
|
15280
15573
|
errorId: message.errorId,
|
|
15281
15574
|
});
|
|
15282
15575
|
|
|
15283
|
-
//
|
|
15284
|
-
|
|
15576
|
+
// Cursor exec-channel tools have already run and emitted results. Keep that
|
|
15577
|
+
// failed turn intact so continuation cannot repeat their side effects.
|
|
15578
|
+
if (!options?.preserveFailedTurn) {
|
|
15579
|
+
this.#removeAssistantMessageFromActiveContext(message, "auto-retry");
|
|
15580
|
+
}
|
|
15285
15581
|
|
|
15286
15582
|
// A thinking/response loop retried into identical context loops again. Inject a
|
|
15287
15583
|
// hidden redirect so the retried turn sees a directive to break the repeated
|