@oh-my-pi/pi-coding-agent 15.8.0 → 15.8.3
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 +37 -1
- package/dist/types/config/keybindings.d.ts +5 -1
- package/dist/types/edit/index.d.ts +6 -0
- package/dist/types/edit/streaming.d.ts +8 -0
- package/dist/types/export/ttsr.d.ts +9 -0
- package/dist/types/mcp/transports/stdio.d.ts +19 -0
- package/dist/types/plan-mode/plan-protection.d.ts +12 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/dist/types/utils/jj.d.ts +49 -0
- package/package.json +9 -9
- package/src/config/keybindings.ts +8 -1
- package/src/config/model-registry.ts +18 -7
- package/src/discovery/builtin-rules/index.ts +2 -0
- package/src/discovery/builtin-rules/ts-no-deprecated-leftovers.md +44 -0
- package/src/edit/index.ts +10 -0
- package/src/edit/streaming.ts +65 -0
- package/src/export/ttsr.ts +18 -1
- package/src/extensibility/custom-commands/bundled/review/index.ts +74 -45
- package/src/internal-urls/docs-index.generated.ts +2 -2
- package/src/mcp/transports/stdio.ts +55 -22
- package/src/plan-mode/plan-protection.ts +31 -0
- package/src/prompts/agents/reviewer.md +2 -2
- package/src/prompts/review-request.md +1 -1
- package/src/prompts/system/empty-stop-retry.md +6 -0
- package/src/prompts/tools/search-tool-bm25.md +9 -2
- package/src/prompts/tools/todo-write.md +8 -0
- package/src/session/agent-session.ts +162 -10
- package/src/tools/search-tool-bm25.ts +7 -1
- package/src/tools/write.ts +6 -0
- package/src/utils/git.ts +19 -23
- package/src/utils/jj.ts +248 -0
|
@@ -19,6 +19,34 @@ import type {
|
|
|
19
19
|
import { toJsonRpcError } from "../../mcp/types";
|
|
20
20
|
import { isMCPTimeoutEnabled, resolveMCPTimeoutMs } from "../timeout";
|
|
21
21
|
|
|
22
|
+
/** Minimal write surface of `Subprocess.stdin` we need for framed sends. */
|
|
23
|
+
interface FrameSink {
|
|
24
|
+
write(chunk: string): unknown;
|
|
25
|
+
flush(): unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Write a newline-delimited JSON-RPC frame to the subprocess's stdin sink,
|
|
30
|
+
* swallowing synchronous errors so the caller can decide how to react.
|
|
31
|
+
*
|
|
32
|
+
* Bun's `FileSink` may throw synchronously (most reliably on Windows) when
|
|
33
|
+
* the read end of the pipe has been closed by a subprocess that exited
|
|
34
|
+
* between read-loop ticks. Letting that throw escape an `async` method
|
|
35
|
+
* surfaces as an unhandled promise rejection at the call site.
|
|
36
|
+
*
|
|
37
|
+
* Returns `true` when the frame was accepted by the sink, `false` when the
|
|
38
|
+
* sink threw — callers signal transport closure on `false`.
|
|
39
|
+
*/
|
|
40
|
+
export function writeFrame(stdin: FrameSink, frame: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
stdin.write(frame);
|
|
43
|
+
stdin.flush();
|
|
44
|
+
return true;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* Stdio transport for MCP servers.
|
|
24
52
|
* Spawns a subprocess and communicates via stdin/stdout.
|
|
@@ -162,11 +190,7 @@ export class StdioTransport implements MCPTransport {
|
|
|
162
190
|
const result = await this.onRequest(request.method, request.params);
|
|
163
191
|
this.#sendResponse(request.id, result);
|
|
164
192
|
} catch (error) {
|
|
165
|
-
|
|
166
|
-
this.#sendResponse(request.id, undefined, toJsonRpcError(error));
|
|
167
|
-
} catch {
|
|
168
|
-
// Best-effort — process may have exited
|
|
169
|
-
}
|
|
193
|
+
this.#sendResponse(request.id, undefined, toJsonRpcError(error));
|
|
170
194
|
}
|
|
171
195
|
}
|
|
172
196
|
|
|
@@ -175,8 +199,9 @@ export class StdioTransport implements MCPTransport {
|
|
|
175
199
|
const response = error
|
|
176
200
|
? { jsonrpc: "2.0" as const, id, error }
|
|
177
201
|
: { jsonrpc: "2.0" as const, id, result: result ?? {} };
|
|
178
|
-
|
|
179
|
-
|
|
202
|
+
// Silent on failure — a dead subprocess has no use for the response,
|
|
203
|
+
// and the read loop will close the transport on EOF.
|
|
204
|
+
writeFrame(this.#process.stdin, `${JSON.stringify(response)}\n`);
|
|
180
205
|
}
|
|
181
206
|
|
|
182
207
|
#handleClose(): void {
|
|
@@ -286,35 +311,43 @@ export class StdioTransport implements MCPTransport {
|
|
|
286
311
|
params: params ?? {},
|
|
287
312
|
};
|
|
288
313
|
|
|
289
|
-
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
314
|
+
// Bun's FileSink can throw EPIPE synchronously on Windows when the
|
|
315
|
+
// subprocess has exited between the last read-loop tick and this
|
|
316
|
+
// write (e.g. an MCP server that dies after returning `initialize`
|
|
317
|
+
// but before `notifications/initialized` is delivered). Tear the
|
|
318
|
+
// transport down so any wired `onClose` (and reconnect machinery)
|
|
319
|
+
// engages, then surface the failure to the caller so a write that
|
|
320
|
+
// dropped on the floor is never silently treated as delivered —
|
|
321
|
+
// `initializeConnection()` runs before the manager installs its
|
|
322
|
+
// `onClose` handler, so a swallowed failure there would yield a
|
|
323
|
+
// "connected" handle wrapping a dead transport. See #1710.
|
|
324
|
+
if (!writeFrame(this.#process.stdin, `${JSON.stringify(notification)}\n`)) {
|
|
325
|
+
this.#handleClose();
|
|
326
|
+
throw new Error(`Transport closed while sending notification "${method}"`);
|
|
327
|
+
}
|
|
293
328
|
}
|
|
294
329
|
|
|
295
330
|
async close(): Promise<void> {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
|
|
301
|
-
|
|
331
|
+
// `close()` is the authoritative resource teardown. `#handleClose()`
|
|
332
|
+
// may have already run (read-loop EOF, or a notify() write failure
|
|
333
|
+
// that surfaces the dead transport to the caller) and flipped
|
|
334
|
+
// `#connected` to false — but the subprocess and read loop are still
|
|
335
|
+
// alive in that path, so we MUST keep cleaning up regardless. Each
|
|
336
|
+
// step is individually guarded so this remains idempotent across
|
|
337
|
+
// repeat calls.
|
|
338
|
+
if (this.#connected) {
|
|
339
|
+
this.#handleClose();
|
|
302
340
|
}
|
|
303
|
-
this.#pendingRequests.clear();
|
|
304
341
|
|
|
305
|
-
// Kill subprocess
|
|
306
342
|
if (this.#process) {
|
|
307
343
|
this.#process.kill();
|
|
308
344
|
this.#process = null;
|
|
309
345
|
}
|
|
310
346
|
|
|
311
|
-
// Wait for read loop to finish
|
|
312
347
|
if (this.#readLoop) {
|
|
313
348
|
await this.#readLoop.catch(() => {});
|
|
314
349
|
this.#readLoop = null;
|
|
315
350
|
}
|
|
316
|
-
|
|
317
|
-
this.onClose?.();
|
|
318
351
|
}
|
|
319
352
|
}
|
|
320
353
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getReadToolPath, type ProtectedToolContext } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
|
|
2
|
+
import { normalizeLocalScheme } from "../tools/path-utils";
|
|
3
|
+
|
|
4
|
+
/** Canonical plan alias every session's `local://` root resolves. */
|
|
5
|
+
const LOCAL_PLAN_ALIAS = "local://PLAN.md";
|
|
6
|
+
|
|
7
|
+
/** True when `readPath` targets `planTarget`, ignoring `local:/` vs `local://`
|
|
8
|
+
* scheme spelling and any trailing read selector (`:1-50`, `:raw`, …). */
|
|
9
|
+
function readTargetsPlan(readPath: string, planTarget: string): boolean {
|
|
10
|
+
const read = normalizeLocalScheme(readPath);
|
|
11
|
+
const target = normalizeLocalScheme(planTarget);
|
|
12
|
+
return read === target || read.startsWith(`${target}:`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build a compaction protection matcher that keeps `read` results for the active
|
|
17
|
+
* plan file intact through prune/shake — the plan analog of skill-read
|
|
18
|
+
* protection. Matches both the canonical `local://PLAN.md` alias and the
|
|
19
|
+
* session's current plan reference path (e.g. a titled `local://<title>.md`), so
|
|
20
|
+
* the plan survives compaction whether the agent reads it by alias or by title.
|
|
21
|
+
*
|
|
22
|
+
* `getPlanReferencePath` is evaluated at match time so a mid-session retitle
|
|
23
|
+
* (plan approval renames `PLAN.md` → `<title>.md`) is honored immediately.
|
|
24
|
+
*/
|
|
25
|
+
export function createPlanReadMatcher(getPlanReferencePath: () => string): (context: ProtectedToolContext) => boolean {
|
|
26
|
+
return (context: ProtectedToolContext) => {
|
|
27
|
+
const path = getReadToolPath(context);
|
|
28
|
+
if (path === undefined) return false;
|
|
29
|
+
return readTargetsPlan(path, LOCAL_PLAN_ALIAS) || readTargetsPlan(path, getPlanReferencePath());
|
|
30
|
+
};
|
|
31
|
+
}
|
|
@@ -59,12 +59,12 @@ output:
|
|
|
59
59
|
Identify bugs the author would want fixed before merge.
|
|
60
60
|
|
|
61
61
|
<procedure>
|
|
62
|
-
1. Run `git diff`
|
|
62
|
+
1. Run `git diff`, `jj diff --git`, or `gh pr diff <number>` to view patch
|
|
63
63
|
2. Read modified files for full context
|
|
64
64
|
3. Call `report_finding` per issue
|
|
65
65
|
4. Call `yield` with verdict
|
|
66
66
|
|
|
67
|
-
Bash is read-only: `git diff`, `git log`, `git show`, `gh pr diff`. You NEVER make file edits or trigger builds.
|
|
67
|
+
Bash is read-only: `git diff`, `git log`, `git show`, `jj diff --git`, `gh pr diff`. You NEVER make file edits or trigger builds.
|
|
68
68
|
</procedure>
|
|
69
69
|
|
|
70
70
|
<criteria>
|
|
@@ -36,7 +36,7 @@ Group files by locality, e.g.:
|
|
|
36
36
|
|
|
37
37
|
Reviewer MUST:
|
|
38
38
|
1. Focus ONLY on assigned files
|
|
39
|
-
2. {{#if skipDiff}}
|
|
39
|
+
2. {{#if skipDiff}}{{diffInstruction}}{{else}}MUST use diff hunks below (NEVER re-run git diff){{/if}}
|
|
40
40
|
3. MAY read full file context as needed via `read`
|
|
41
41
|
4. Call `report_finding` per issue
|
|
42
42
|
5. Call `yield` with verdict when done
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<system-reminder>
|
|
2
|
+
The previous assistant turn ended with no text, reasoning, or tool call.
|
|
3
|
+
Continue the active task from the current context. If the work is complete, reply with a concise final summary instead of an empty response.
|
|
4
|
+
|
|
5
|
+
(Empty response retry {{retryCount}}/{{maxRetries}})
|
|
6
|
+
</system-reminder>
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
Search hidden tool metadata to discover and activate tools.
|
|
2
2
|
|
|
3
3
|
Activate hidden tools (MCP and built-in) when you need a capability not in your active tool set.
|
|
4
|
-
{{#if hasDiscoverableMCPServers}}
|
|
5
|
-
|
|
4
|
+
{{#if hasDiscoverableMCPServers}}
|
|
5
|
+
Discoverable MCP servers in this session: {{#list discoverableMCPServerSummaries join=", "}}{{this}}{{/list}}.
|
|
6
|
+
{{/if}}
|
|
7
|
+
{{#if hasDiscoverableBuiltinTools}}
|
|
8
|
+
Discoverable built-in tools: {{#list discoverableBuiltinToolNames join=", "}}{{this}}{{/list}}.
|
|
9
|
+
{{/if}}
|
|
10
|
+
{{#if discoverableToolCount}}
|
|
11
|
+
Total discoverable tools available: {{discoverableToolCount}}.
|
|
12
|
+
{{/if}}
|
|
6
13
|
Input:
|
|
7
14
|
- `query` — required natural-language or keyword query
|
|
8
15
|
- `limit` — optional maximum number of tools to return and activate (default `8`)
|
|
@@ -48,3 +48,11 @@ Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, an
|
|
|
48
48
|
# Append tasks to a phase
|
|
49
49
|
`{"ops":[{"op":"append","phase":"Auth","items":["Handle retries","Run tests"]}]}`
|
|
50
50
|
</examples>
|
|
51
|
+
|
|
52
|
+
<critical>
|
|
53
|
+
When the user hands you a multi-step plan — a phased todo, a numbered or bulleted checklist, or "N bugs/items/tasks" to work through:
|
|
54
|
+
- You MUST `init` the list with EVERY item as its own task before doing the work.
|
|
55
|
+
- Enumerate all of them;
|
|
56
|
+
- NEVER summarize the plan into fewer tasks, sample "the important ones", drop items, or rely on memory to track the rest.
|
|
57
|
+
The entire point is to remember every one.
|
|
58
|
+
</critical>
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
shouldCompact,
|
|
56
56
|
} from "@oh-my-pi/pi-agent-core/compaction";
|
|
57
57
|
import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@oh-my-pi/pi-agent-core/compaction/pruning";
|
|
58
|
+
import type { ProtectedToolMatcher } from "@oh-my-pi/pi-agent-core/compaction/tool-protection";
|
|
58
59
|
import type {
|
|
59
60
|
AssistantMessage,
|
|
60
61
|
Context,
|
|
@@ -163,9 +164,11 @@ import { parseTurnBudget } from "../modes/turn-budget";
|
|
|
163
164
|
import { containsUltrathink, ULTRATHINK_NOTICE } from "../modes/ultrathink";
|
|
164
165
|
import { computeNonMessageTokens } from "../modes/utils/context-usage";
|
|
165
166
|
import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
|
|
167
|
+
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
|
166
168
|
import type { PlanModeState } from "../plan-mode/state";
|
|
167
169
|
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
|
168
170
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
171
|
+
import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
|
|
169
172
|
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
|
|
170
173
|
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
|
|
171
174
|
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
|
|
@@ -275,6 +278,8 @@ export type AgentSessionEvent =
|
|
|
275
278
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
276
279
|
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
|
277
280
|
|
|
281
|
+
const EMPTY_STOP_MAX_RETRIES = 3;
|
|
282
|
+
|
|
278
283
|
export interface AsyncJobSnapshot {
|
|
279
284
|
running: AsyncJobSnapshotItem[];
|
|
280
285
|
recent: AsyncJobSnapshotItem[];
|
|
@@ -995,6 +1000,7 @@ export class AgentSession {
|
|
|
995
1000
|
#checkpointState: CheckpointState | undefined = undefined;
|
|
996
1001
|
#pendingRewindReport: string | undefined = undefined;
|
|
997
1002
|
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
|
|
1003
|
+
#emptyStopRetryCount = 0;
|
|
998
1004
|
#promptGeneration = 0;
|
|
999
1005
|
#providerSessionState = new Map<string, ProviderSessionState>();
|
|
1000
1006
|
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
|
@@ -1600,17 +1606,19 @@ export class AgentSession {
|
|
|
1600
1606
|
if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
|
|
1601
1607
|
const assistantEvent = event.assistantMessageEvent;
|
|
1602
1608
|
let matchContext: TtsrMatchContext | undefined;
|
|
1609
|
+
let streamingToolCall: ToolCall | undefined;
|
|
1603
1610
|
|
|
1604
1611
|
if (assistantEvent.type === "text_delta") {
|
|
1605
1612
|
matchContext = { source: "text" };
|
|
1606
1613
|
} else if (assistantEvent.type === "thinking_delta") {
|
|
1607
1614
|
matchContext = { source: "thinking" };
|
|
1608
1615
|
} else if (assistantEvent.type === "toolcall_delta") {
|
|
1609
|
-
|
|
1616
|
+
streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
|
|
1617
|
+
matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
|
|
1610
1618
|
}
|
|
1611
1619
|
|
|
1612
1620
|
if (matchContext && "delta" in assistantEvent) {
|
|
1613
|
-
const matches = this.#
|
|
1621
|
+
const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
|
|
1614
1622
|
if (matches.length > 0) {
|
|
1615
1623
|
// Decide first: a non-interrupting tool-source match attaches to the
|
|
1616
1624
|
// specific tool call's result instead of driving a loop-wide follow-up.
|
|
@@ -1769,6 +1777,7 @@ export class AgentSession {
|
|
|
1769
1777
|
if (
|
|
1770
1778
|
assistantMsg.stopReason !== "error" &&
|
|
1771
1779
|
assistantMsg.stopReason !== "aborted" &&
|
|
1780
|
+
!this.#isEmptyAssistantStop(assistantMsg) &&
|
|
1772
1781
|
this.#retryAttempt > 0
|
|
1773
1782
|
) {
|
|
1774
1783
|
if (this.#activeRetryFallback && this.model) {
|
|
@@ -1883,6 +1892,10 @@ export class AgentSession {
|
|
|
1883
1892
|
}
|
|
1884
1893
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
1885
1894
|
|
|
1895
|
+
if (await this.#handleEmptyAssistantStop(msg)) {
|
|
1896
|
+
return;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1886
1899
|
// Check for retryable errors first (overloaded, rate limit, server errors)
|
|
1887
1900
|
if (this.#isRetryableError(msg)) {
|
|
1888
1901
|
const didRetry = await this.#handleRetryableError(msg);
|
|
@@ -2276,30 +2289,64 @@ export class AgentSession {
|
|
|
2276
2289
|
});
|
|
2277
2290
|
}
|
|
2278
2291
|
|
|
2279
|
-
/**
|
|
2280
|
-
#
|
|
2281
|
-
const context: TtsrMatchContext = { source: "tool" };
|
|
2292
|
+
/** Extract the tool-call block a toolcall_delta event refers to, if present. */
|
|
2293
|
+
#getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
|
|
2282
2294
|
if (message.role !== "assistant") {
|
|
2283
|
-
return
|
|
2295
|
+
return undefined;
|
|
2284
2296
|
}
|
|
2285
2297
|
|
|
2286
2298
|
const content = message.content;
|
|
2287
2299
|
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
|
|
2288
|
-
return
|
|
2300
|
+
return undefined;
|
|
2289
2301
|
}
|
|
2290
2302
|
|
|
2291
2303
|
const block = content[contentIndex];
|
|
2292
2304
|
if (!block || typeof block !== "object" || block.type !== "toolCall") {
|
|
2305
|
+
return undefined;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
return block as ToolCall;
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
/** Build TTSR match context for tool call argument deltas. */
|
|
2312
|
+
#getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
|
|
2313
|
+
const context: TtsrMatchContext = { source: "tool" };
|
|
2314
|
+
if (!toolCall) {
|
|
2293
2315
|
return context;
|
|
2294
2316
|
}
|
|
2295
2317
|
|
|
2296
|
-
const toolCall = block as ToolCall;
|
|
2297
2318
|
context.toolName = toolCall.name;
|
|
2298
2319
|
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
|
|
2299
2320
|
context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
|
|
2300
2321
|
return context;
|
|
2301
2322
|
}
|
|
2302
2323
|
|
|
2324
|
+
/**
|
|
2325
|
+
* Match a stream delta against TTSR rules.
|
|
2326
|
+
*
|
|
2327
|
+
* Tool argument streams prefer the tool's `matcherDigest` normalization — the
|
|
2328
|
+
* real content the call introduces — over the raw argument delta, so rule
|
|
2329
|
+
* conditions written against source text keep working regardless of the
|
|
2330
|
+
* tool's wire format (hashline patches, JSON-escaped strings, ...).
|
|
2331
|
+
*/
|
|
2332
|
+
#checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
|
|
2333
|
+
const manager = this.#ttsrManager;
|
|
2334
|
+
if (!manager) {
|
|
2335
|
+
return [];
|
|
2336
|
+
}
|
|
2337
|
+
if (toolCall) {
|
|
2338
|
+
const tools = this.agent.state.tools;
|
|
2339
|
+
const tool =
|
|
2340
|
+
tools.find(t => t.name === toolCall.name) ??
|
|
2341
|
+
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
|
|
2342
|
+
const digest = tool?.matcherDigest?.(toolCall.arguments ?? {});
|
|
2343
|
+
if (digest !== undefined) {
|
|
2344
|
+
return manager.checkSnapshot(digest, matchContext);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
return manager.checkDelta(delta, matchContext);
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2303
2350
|
/** Extract path-like arguments from tool call payload for TTSR glob matching. */
|
|
2304
2351
|
#extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
|
|
2305
2352
|
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
@@ -4274,6 +4321,7 @@ export class AgentSession {
|
|
|
4274
4321
|
|
|
4275
4322
|
// Reset todo reminder count on new user prompt
|
|
4276
4323
|
this.#todoReminderCount = 0;
|
|
4324
|
+
this.#emptyStopRetryCount = 0;
|
|
4277
4325
|
|
|
4278
4326
|
await this.#maybeRestoreRetryFallbackPrimary();
|
|
4279
4327
|
|
|
@@ -5612,9 +5660,20 @@ export class AgentSession {
|
|
|
5612
5660
|
// Compaction
|
|
5613
5661
|
// =========================================================================
|
|
5614
5662
|
|
|
5663
|
+
/**
|
|
5664
|
+
* Append plan-read protection to a prune/shake config so the active plan
|
|
5665
|
+
* file survives compaction alongside skill reads (the config defaults
|
|
5666
|
+
* already carry skill protection). The matcher reads the current plan
|
|
5667
|
+
* reference path at match time, so retitled plans are covered.
|
|
5668
|
+
*/
|
|
5669
|
+
#withPlanProtection<T extends { protectedTools: ProtectedToolMatcher[] }>(config: T): T {
|
|
5670
|
+
const planMatcher = createPlanReadMatcher(() => this.#planReferencePath);
|
|
5671
|
+
return { ...config, protectedTools: [...config.protectedTools, planMatcher] };
|
|
5672
|
+
}
|
|
5673
|
+
|
|
5615
5674
|
async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
|
|
5616
5675
|
const branchEntries = this.sessionManager.getBranch();
|
|
5617
|
-
const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
|
|
5676
|
+
const result = pruneToolOutputs(branchEntries, this.#withPlanProtection(DEFAULT_PRUNE_CONFIG));
|
|
5618
5677
|
if (result.prunedCount === 0) {
|
|
5619
5678
|
return undefined;
|
|
5620
5679
|
}
|
|
@@ -5695,7 +5754,7 @@ export class AgentSession {
|
|
|
5695
5754
|
return { mode, toolResultsDropped: 0, blocksDropped: 0, imagesDropped: removed, tokensFreed: 0 };
|
|
5696
5755
|
}
|
|
5697
5756
|
|
|
5698
|
-
const config = opts.config ?? AGGRESSIVE_SHAKE_CONFIG;
|
|
5757
|
+
const config = this.#withPlanProtection(opts.config ?? AGGRESSIVE_SHAKE_CONFIG);
|
|
5699
5758
|
const regions = collectShakeRegions(this.sessionManager.getBranch(), config);
|
|
5700
5759
|
if (regions.length === 0) {
|
|
5701
5760
|
return { mode, toolResultsDropped: 0, blocksDropped: 0, tokensFreed: 0 };
|
|
@@ -6287,6 +6346,99 @@ export class AgentSession {
|
|
|
6287
6346
|
return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
|
|
6288
6347
|
}
|
|
6289
6348
|
|
|
6349
|
+
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
|
|
6350
|
+
if (!this.#isEmptyAssistantStop(assistantMessage)) {
|
|
6351
|
+
this.#emptyStopRetryCount = 0;
|
|
6352
|
+
return false;
|
|
6353
|
+
}
|
|
6354
|
+
|
|
6355
|
+
this.#emptyStopRetryCount++;
|
|
6356
|
+
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
|
|
6357
|
+
logger.warn("Assistant returned empty stop after retry cap", {
|
|
6358
|
+
attempts: this.#emptyStopRetryCount - 1,
|
|
6359
|
+
model: assistantMessage.model,
|
|
6360
|
+
provider: assistantMessage.provider,
|
|
6361
|
+
});
|
|
6362
|
+
if (this.#retryAttempt > 0) {
|
|
6363
|
+
await this.#emitSessionEvent({
|
|
6364
|
+
type: "auto_retry_end",
|
|
6365
|
+
success: false,
|
|
6366
|
+
attempt: this.#retryAttempt,
|
|
6367
|
+
finalError: "Assistant returned empty stop after retry cap",
|
|
6368
|
+
});
|
|
6369
|
+
this.#retryAttempt = 0;
|
|
6370
|
+
}
|
|
6371
|
+
this.#resolveRetry();
|
|
6372
|
+
return true;
|
|
6373
|
+
}
|
|
6374
|
+
|
|
6375
|
+
this.#removeEmptyStopFromActiveContext(assistantMessage);
|
|
6376
|
+
this.agent.appendMessage({
|
|
6377
|
+
role: "developer",
|
|
6378
|
+
content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
|
|
6379
|
+
attribution: "agent",
|
|
6380
|
+
timestamp: Date.now(),
|
|
6381
|
+
});
|
|
6382
|
+
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
|
|
6383
|
+
return true;
|
|
6384
|
+
}
|
|
6385
|
+
|
|
6386
|
+
#isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
|
|
6387
|
+
if (assistantMessage.stopReason !== "stop") return false;
|
|
6388
|
+
return !assistantMessage.content.some(content => {
|
|
6389
|
+
if (content.type === "text") return content.text.trim().length > 0;
|
|
6390
|
+
if (content.type === "thinking") return content.thinking.trim().length > 0;
|
|
6391
|
+
return content.type === "toolCall";
|
|
6392
|
+
});
|
|
6393
|
+
}
|
|
6394
|
+
|
|
6395
|
+
#emptyStopRetryReminder(): string {
|
|
6396
|
+
return prompt.render(emptyStopRetryTemplate, {
|
|
6397
|
+
retryCount: this.#emptyStopRetryCount,
|
|
6398
|
+
maxRetries: EMPTY_STOP_MAX_RETRIES,
|
|
6399
|
+
});
|
|
6400
|
+
}
|
|
6401
|
+
|
|
6402
|
+
#removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
|
|
6403
|
+
const messages = this.agent.state.messages;
|
|
6404
|
+
const lastMessage = messages[messages.length - 1];
|
|
6405
|
+
if (
|
|
6406
|
+
lastMessage?.role === "assistant" &&
|
|
6407
|
+
this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
|
|
6408
|
+
) {
|
|
6409
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
6410
|
+
}
|
|
6411
|
+
|
|
6412
|
+
const emptyStopEntry = this.sessionManager
|
|
6413
|
+
.getBranch()
|
|
6414
|
+
.slice()
|
|
6415
|
+
.reverse()
|
|
6416
|
+
.find(
|
|
6417
|
+
entry =>
|
|
6418
|
+
entry.type === "message" &&
|
|
6419
|
+
entry.message.role === "assistant" &&
|
|
6420
|
+
this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
|
|
6421
|
+
);
|
|
6422
|
+
if (!emptyStopEntry) {
|
|
6423
|
+
return;
|
|
6424
|
+
}
|
|
6425
|
+
if (emptyStopEntry.parentId === null) {
|
|
6426
|
+
this.sessionManager.resetLeaf();
|
|
6427
|
+
} else {
|
|
6428
|
+
this.sessionManager.branch(emptyStopEntry.parentId);
|
|
6429
|
+
}
|
|
6430
|
+
}
|
|
6431
|
+
|
|
6432
|
+
#isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
|
|
6433
|
+
return (
|
|
6434
|
+
left === right ||
|
|
6435
|
+
(left.timestamp === right.timestamp &&
|
|
6436
|
+
left.provider === right.provider &&
|
|
6437
|
+
left.model === right.model &&
|
|
6438
|
+
left.stopReason === right.stopReason)
|
|
6439
|
+
);
|
|
6440
|
+
}
|
|
6441
|
+
|
|
6290
6442
|
#enforceRewindBeforeYield(): boolean {
|
|
6291
6443
|
if (!this.#checkpointState || this.#pendingRewindReport) {
|
|
6292
6444
|
return false;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
buildDiscoverableToolSearchIndex,
|
|
11
11
|
type DiscoverableTool,
|
|
12
12
|
type DiscoverableToolSearchIndex,
|
|
13
|
+
filterBySource,
|
|
13
14
|
formatDiscoverableToolServerSummary,
|
|
14
15
|
searchDiscoverableTools,
|
|
15
16
|
summarizeDiscoverableTools,
|
|
@@ -141,10 +142,15 @@ function isDiscoveryEnabled(session: ToolSession): boolean {
|
|
|
141
142
|
|
|
142
143
|
export function renderSearchToolBm25Description(discoverableTools: DiscoverableTool[] = []): string {
|
|
143
144
|
const summary = summarizeDiscoverableTools(discoverableTools);
|
|
145
|
+
const builtinToolNames = filterBySource(discoverableTools, "builtin")
|
|
146
|
+
.map(t => t.name)
|
|
147
|
+
.sort();
|
|
144
148
|
return prompt.render(searchToolBm25Description, {
|
|
145
|
-
|
|
149
|
+
discoverableToolCount: summary.toolCount,
|
|
146
150
|
discoverableMCPServerSummaries: summary.servers.map(formatDiscoverableToolServerSummary),
|
|
147
151
|
hasDiscoverableMCPServers: summary.servers.length > 0,
|
|
152
|
+
discoverableBuiltinToolNames: builtinToolNames,
|
|
153
|
+
hasDiscoverableBuiltinTools: builtinToolNames.length > 0,
|
|
148
154
|
});
|
|
149
155
|
}
|
|
150
156
|
|
package/src/tools/write.ts
CHANGED
|
@@ -273,6 +273,12 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
|
|
|
273
273
|
readonly loadMode = "discoverable";
|
|
274
274
|
readonly summary = "Write content to a file (creates or overwrites)";
|
|
275
275
|
|
|
276
|
+
/** Stream matchers should see the real file content, not its JSON-escaped argument encoding. */
|
|
277
|
+
matcherDigest(args: unknown): string | undefined {
|
|
278
|
+
const content = (args as Partial<WriteParams>).content;
|
|
279
|
+
return typeof content === "string" ? content : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
276
282
|
readonly #writethrough: WritethroughCallback;
|
|
277
283
|
|
|
278
284
|
constructor(private readonly session: ToolSession) {
|