@oh-my-pi/pi-coding-agent 15.8.0 → 15.8.2
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 +26 -0
- 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/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/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/session/agent-session.ts +147 -8
- 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 +225 -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
|
|
|
@@ -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`)
|
|
@@ -166,6 +166,7 @@ import { containsWorkflow, WORKFLOW_NOTICE } from "../modes/workflow";
|
|
|
166
166
|
import type { PlanModeState } from "../plan-mode/state";
|
|
167
167
|
import autoContinuePrompt from "../prompts/system/auto-continue.md" with { type: "text" };
|
|
168
168
|
import eagerTodoPrompt from "../prompts/system/eager-todo.md" with { type: "text" };
|
|
169
|
+
import emptyStopRetryTemplate from "../prompts/system/empty-stop-retry.md" with { type: "text" };
|
|
169
170
|
import ircIncomingTemplate from "../prompts/system/irc-incoming.md" with { type: "text" };
|
|
170
171
|
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
|
|
171
172
|
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
|
|
@@ -275,6 +276,8 @@ export type AgentSessionEvent =
|
|
|
275
276
|
export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
|
276
277
|
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
|
277
278
|
|
|
279
|
+
const EMPTY_STOP_MAX_RETRIES = 3;
|
|
280
|
+
|
|
278
281
|
export interface AsyncJobSnapshot {
|
|
279
282
|
running: AsyncJobSnapshotItem[];
|
|
280
283
|
recent: AsyncJobSnapshotItem[];
|
|
@@ -995,6 +998,7 @@ export class AgentSession {
|
|
|
995
998
|
#checkpointState: CheckpointState | undefined = undefined;
|
|
996
999
|
#pendingRewindReport: string | undefined = undefined;
|
|
997
1000
|
#lastSuccessfulYieldToolCallId: string | undefined = undefined;
|
|
1001
|
+
#emptyStopRetryCount = 0;
|
|
998
1002
|
#promptGeneration = 0;
|
|
999
1003
|
#providerSessionState = new Map<string, ProviderSessionState>();
|
|
1000
1004
|
#hindsightSessionState: HindsightSessionState | undefined = undefined;
|
|
@@ -1600,17 +1604,19 @@ export class AgentSession {
|
|
|
1600
1604
|
if (event.type === "message_update" && this.#ttsrManager?.hasRules()) {
|
|
1601
1605
|
const assistantEvent = event.assistantMessageEvent;
|
|
1602
1606
|
let matchContext: TtsrMatchContext | undefined;
|
|
1607
|
+
let streamingToolCall: ToolCall | undefined;
|
|
1603
1608
|
|
|
1604
1609
|
if (assistantEvent.type === "text_delta") {
|
|
1605
1610
|
matchContext = { source: "text" };
|
|
1606
1611
|
} else if (assistantEvent.type === "thinking_delta") {
|
|
1607
1612
|
matchContext = { source: "thinking" };
|
|
1608
1613
|
} else if (assistantEvent.type === "toolcall_delta") {
|
|
1609
|
-
|
|
1614
|
+
streamingToolCall = this.#getStreamingToolCallBlock(event.message, assistantEvent.contentIndex);
|
|
1615
|
+
matchContext = this.#getTtsrToolMatchContext(streamingToolCall, assistantEvent.contentIndex);
|
|
1610
1616
|
}
|
|
1611
1617
|
|
|
1612
1618
|
if (matchContext && "delta" in assistantEvent) {
|
|
1613
|
-
const matches = this.#
|
|
1619
|
+
const matches = this.#checkTtsrStream(assistantEvent.delta, matchContext, streamingToolCall);
|
|
1614
1620
|
if (matches.length > 0) {
|
|
1615
1621
|
// Decide first: a non-interrupting tool-source match attaches to the
|
|
1616
1622
|
// specific tool call's result instead of driving a loop-wide follow-up.
|
|
@@ -1769,6 +1775,7 @@ export class AgentSession {
|
|
|
1769
1775
|
if (
|
|
1770
1776
|
assistantMsg.stopReason !== "error" &&
|
|
1771
1777
|
assistantMsg.stopReason !== "aborted" &&
|
|
1778
|
+
!this.#isEmptyAssistantStop(assistantMsg) &&
|
|
1772
1779
|
this.#retryAttempt > 0
|
|
1773
1780
|
) {
|
|
1774
1781
|
if (this.#activeRetryFallback && this.model) {
|
|
@@ -1883,6 +1890,10 @@ export class AgentSession {
|
|
|
1883
1890
|
}
|
|
1884
1891
|
this.#lastSuccessfulYieldToolCallId = undefined;
|
|
1885
1892
|
|
|
1893
|
+
if (await this.#handleEmptyAssistantStop(msg)) {
|
|
1894
|
+
return;
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1886
1897
|
// Check for retryable errors first (overloaded, rate limit, server errors)
|
|
1887
1898
|
if (this.#isRetryableError(msg)) {
|
|
1888
1899
|
const didRetry = await this.#handleRetryableError(msg);
|
|
@@ -2276,30 +2287,64 @@ export class AgentSession {
|
|
|
2276
2287
|
});
|
|
2277
2288
|
}
|
|
2278
2289
|
|
|
2279
|
-
/**
|
|
2280
|
-
#
|
|
2281
|
-
const context: TtsrMatchContext = { source: "tool" };
|
|
2290
|
+
/** Extract the tool-call block a toolcall_delta event refers to, if present. */
|
|
2291
|
+
#getStreamingToolCallBlock(message: AgentMessage, contentIndex: number): ToolCall | undefined {
|
|
2282
2292
|
if (message.role !== "assistant") {
|
|
2283
|
-
return
|
|
2293
|
+
return undefined;
|
|
2284
2294
|
}
|
|
2285
2295
|
|
|
2286
2296
|
const content = message.content;
|
|
2287
2297
|
if (!Array.isArray(content) || contentIndex < 0 || contentIndex >= content.length) {
|
|
2288
|
-
return
|
|
2298
|
+
return undefined;
|
|
2289
2299
|
}
|
|
2290
2300
|
|
|
2291
2301
|
const block = content[contentIndex];
|
|
2292
2302
|
if (!block || typeof block !== "object" || block.type !== "toolCall") {
|
|
2303
|
+
return undefined;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
return block as ToolCall;
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
/** Build TTSR match context for tool call argument deltas. */
|
|
2310
|
+
#getTtsrToolMatchContext(toolCall: ToolCall | undefined, contentIndex: number): TtsrMatchContext {
|
|
2311
|
+
const context: TtsrMatchContext = { source: "tool" };
|
|
2312
|
+
if (!toolCall) {
|
|
2293
2313
|
return context;
|
|
2294
2314
|
}
|
|
2295
2315
|
|
|
2296
|
-
const toolCall = block as ToolCall;
|
|
2297
2316
|
context.toolName = toolCall.name;
|
|
2298
2317
|
context.streamKey = toolCall.id ? `toolcall:${toolCall.id}` : `tool:${toolCall.name}:${contentIndex}`;
|
|
2299
2318
|
context.filePaths = this.#extractTtsrFilePathsFromArgs(toolCall.arguments);
|
|
2300
2319
|
return context;
|
|
2301
2320
|
}
|
|
2302
2321
|
|
|
2322
|
+
/**
|
|
2323
|
+
* Match a stream delta against TTSR rules.
|
|
2324
|
+
*
|
|
2325
|
+
* Tool argument streams prefer the tool's `matcherDigest` normalization — the
|
|
2326
|
+
* real content the call introduces — over the raw argument delta, so rule
|
|
2327
|
+
* conditions written against source text keep working regardless of the
|
|
2328
|
+
* tool's wire format (hashline patches, JSON-escaped strings, ...).
|
|
2329
|
+
*/
|
|
2330
|
+
#checkTtsrStream(delta: string, matchContext: TtsrMatchContext, toolCall: ToolCall | undefined): Rule[] {
|
|
2331
|
+
const manager = this.#ttsrManager;
|
|
2332
|
+
if (!manager) {
|
|
2333
|
+
return [];
|
|
2334
|
+
}
|
|
2335
|
+
if (toolCall) {
|
|
2336
|
+
const tools = this.agent.state.tools;
|
|
2337
|
+
const tool =
|
|
2338
|
+
tools.find(t => t.name === toolCall.name) ??
|
|
2339
|
+
tools.find(t => t.customWireName !== undefined && t.customWireName === toolCall.name);
|
|
2340
|
+
const digest = tool?.matcherDigest?.(toolCall.arguments ?? {});
|
|
2341
|
+
if (digest !== undefined) {
|
|
2342
|
+
return manager.checkSnapshot(digest, matchContext);
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
return manager.checkDelta(delta, matchContext);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2303
2348
|
/** Extract path-like arguments from tool call payload for TTSR glob matching. */
|
|
2304
2349
|
#extractTtsrFilePathsFromArgs(args: unknown): string[] | undefined {
|
|
2305
2350
|
if (!args || typeof args !== "object" || Array.isArray(args)) {
|
|
@@ -4274,6 +4319,7 @@ export class AgentSession {
|
|
|
4274
4319
|
|
|
4275
4320
|
// Reset todo reminder count on new user prompt
|
|
4276
4321
|
this.#todoReminderCount = 0;
|
|
4322
|
+
this.#emptyStopRetryCount = 0;
|
|
4277
4323
|
|
|
4278
4324
|
await this.#maybeRestoreRetryFallbackPrimary();
|
|
4279
4325
|
|
|
@@ -6287,6 +6333,99 @@ export class AgentSession {
|
|
|
6287
6333
|
return lastToolCall?.name === "yield" && lastToolCall.id === toolCallId;
|
|
6288
6334
|
}
|
|
6289
6335
|
|
|
6336
|
+
async #handleEmptyAssistantStop(assistantMessage: AssistantMessage): Promise<boolean> {
|
|
6337
|
+
if (!this.#isEmptyAssistantStop(assistantMessage)) {
|
|
6338
|
+
this.#emptyStopRetryCount = 0;
|
|
6339
|
+
return false;
|
|
6340
|
+
}
|
|
6341
|
+
|
|
6342
|
+
this.#emptyStopRetryCount++;
|
|
6343
|
+
if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
|
|
6344
|
+
logger.warn("Assistant returned empty stop after retry cap", {
|
|
6345
|
+
attempts: this.#emptyStopRetryCount - 1,
|
|
6346
|
+
model: assistantMessage.model,
|
|
6347
|
+
provider: assistantMessage.provider,
|
|
6348
|
+
});
|
|
6349
|
+
if (this.#retryAttempt > 0) {
|
|
6350
|
+
await this.#emitSessionEvent({
|
|
6351
|
+
type: "auto_retry_end",
|
|
6352
|
+
success: false,
|
|
6353
|
+
attempt: this.#retryAttempt,
|
|
6354
|
+
finalError: "Assistant returned empty stop after retry cap",
|
|
6355
|
+
});
|
|
6356
|
+
this.#retryAttempt = 0;
|
|
6357
|
+
}
|
|
6358
|
+
this.#resolveRetry();
|
|
6359
|
+
return true;
|
|
6360
|
+
}
|
|
6361
|
+
|
|
6362
|
+
this.#removeEmptyStopFromActiveContext(assistantMessage);
|
|
6363
|
+
this.agent.appendMessage({
|
|
6364
|
+
role: "developer",
|
|
6365
|
+
content: [{ type: "text", text: this.#emptyStopRetryReminder() }],
|
|
6366
|
+
attribution: "agent",
|
|
6367
|
+
timestamp: Date.now(),
|
|
6368
|
+
});
|
|
6369
|
+
this.#scheduleAgentContinue({ generation: this.#promptGeneration });
|
|
6370
|
+
return true;
|
|
6371
|
+
}
|
|
6372
|
+
|
|
6373
|
+
#isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
|
|
6374
|
+
if (assistantMessage.stopReason !== "stop") return false;
|
|
6375
|
+
return !assistantMessage.content.some(content => {
|
|
6376
|
+
if (content.type === "text") return content.text.trim().length > 0;
|
|
6377
|
+
if (content.type === "thinking") return content.thinking.trim().length > 0;
|
|
6378
|
+
return content.type === "toolCall";
|
|
6379
|
+
});
|
|
6380
|
+
}
|
|
6381
|
+
|
|
6382
|
+
#emptyStopRetryReminder(): string {
|
|
6383
|
+
return prompt.render(emptyStopRetryTemplate, {
|
|
6384
|
+
retryCount: this.#emptyStopRetryCount,
|
|
6385
|
+
maxRetries: EMPTY_STOP_MAX_RETRIES,
|
|
6386
|
+
});
|
|
6387
|
+
}
|
|
6388
|
+
|
|
6389
|
+
#removeEmptyStopFromActiveContext(assistantMessage: AssistantMessage): void {
|
|
6390
|
+
const messages = this.agent.state.messages;
|
|
6391
|
+
const lastMessage = messages[messages.length - 1];
|
|
6392
|
+
if (
|
|
6393
|
+
lastMessage?.role === "assistant" &&
|
|
6394
|
+
this.#isSameAssistantMessage(lastMessage as AssistantMessage, assistantMessage)
|
|
6395
|
+
) {
|
|
6396
|
+
this.agent.replaceMessages(messages.slice(0, -1));
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
const emptyStopEntry = this.sessionManager
|
|
6400
|
+
.getBranch()
|
|
6401
|
+
.slice()
|
|
6402
|
+
.reverse()
|
|
6403
|
+
.find(
|
|
6404
|
+
entry =>
|
|
6405
|
+
entry.type === "message" &&
|
|
6406
|
+
entry.message.role === "assistant" &&
|
|
6407
|
+
this.#isSameAssistantMessage(entry.message as AssistantMessage, assistantMessage),
|
|
6408
|
+
);
|
|
6409
|
+
if (!emptyStopEntry) {
|
|
6410
|
+
return;
|
|
6411
|
+
}
|
|
6412
|
+
if (emptyStopEntry.parentId === null) {
|
|
6413
|
+
this.sessionManager.resetLeaf();
|
|
6414
|
+
} else {
|
|
6415
|
+
this.sessionManager.branch(emptyStopEntry.parentId);
|
|
6416
|
+
}
|
|
6417
|
+
}
|
|
6418
|
+
|
|
6419
|
+
#isSameAssistantMessage(left: AssistantMessage, right: AssistantMessage): boolean {
|
|
6420
|
+
return (
|
|
6421
|
+
left === right ||
|
|
6422
|
+
(left.timestamp === right.timestamp &&
|
|
6423
|
+
left.provider === right.provider &&
|
|
6424
|
+
left.model === right.model &&
|
|
6425
|
+
left.stopReason === right.stopReason)
|
|
6426
|
+
);
|
|
6427
|
+
}
|
|
6428
|
+
|
|
6290
6429
|
#enforceRewindBeforeYield(): boolean {
|
|
6291
6430
|
if (!this.#checkpointState || this.#pendingRewindReport) {
|
|
6292
6431
|
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) {
|
package/src/utils/git.ts
CHANGED
|
@@ -189,11 +189,7 @@ function formatCommandFailure(
|
|
|
189
189
|
return `git ${args.join(" ")} failed with exit code ${result.exitCode}`;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
async function
|
|
193
|
-
cwd: string,
|
|
194
|
-
args: readonly string[],
|
|
195
|
-
options: CommandOptions = {},
|
|
196
|
-
): Promise<GitCommandResult> {
|
|
192
|
+
async function git(cwd: string, args: readonly string[], options: CommandOptions = {}): Promise<GitCommandResult> {
|
|
197
193
|
const commandArgs = withShortLivedGitConfig(options.readOnly ? withNoOptionalLocks(args) : [...args]);
|
|
198
194
|
const child = Bun.spawn(["git", ...commandArgs], {
|
|
199
195
|
cwd,
|
|
@@ -248,7 +244,7 @@ async function runChecked(
|
|
|
248
244
|
options: CommandOptions = {},
|
|
249
245
|
): Promise<GitCommandResult> {
|
|
250
246
|
ensureAvailable();
|
|
251
|
-
const result = await
|
|
247
|
+
const result = await git(cwd, args, options);
|
|
252
248
|
if (result.exitCode !== 0) {
|
|
253
249
|
throw new GitCommandError(args, result);
|
|
254
250
|
}
|
|
@@ -269,7 +265,7 @@ async function tryText(
|
|
|
269
265
|
options: CommandOptions = {},
|
|
270
266
|
): Promise<string | undefined> {
|
|
271
267
|
ensureAvailable();
|
|
272
|
-
const result = await
|
|
268
|
+
const result = await git(cwd, args, options);
|
|
273
269
|
if (result.exitCode !== 0) return undefined;
|
|
274
270
|
return result.stdout;
|
|
275
271
|
}
|
|
@@ -709,7 +705,7 @@ export const diff = Object.assign(
|
|
|
709
705
|
async function diff(cwd: string, options: DiffOptions = {}): Promise<string> {
|
|
710
706
|
const args = buildDiffArgs(options);
|
|
711
707
|
if (options.allowFailure) {
|
|
712
|
-
return (await
|
|
708
|
+
return (await git(cwd, args, { env: options.env, readOnly: true, signal: options.signal })).stdout;
|
|
713
709
|
}
|
|
714
710
|
return runText(cwd, args, { env: options.env, readOnly: true, signal: options.signal });
|
|
715
711
|
},
|
|
@@ -741,7 +737,7 @@ export const diff = Object.assign(
|
|
|
741
737
|
if (options.cached) args.push("--cached");
|
|
742
738
|
args.push("--quiet");
|
|
743
739
|
if (options.files?.length) args.push("--", ...options.files);
|
|
744
|
-
const result = await
|
|
740
|
+
const result = await git(cwd, args, { readOnly: true, signal: options.signal });
|
|
745
741
|
if (result.exitCode === 0) return false;
|
|
746
742
|
if (result.exitCode === 1) return true;
|
|
747
743
|
throw new GitCommandError(args, result);
|
|
@@ -757,7 +753,7 @@ export const diff = Object.assign(
|
|
|
757
753
|
if (options.binary) args.push("--binary");
|
|
758
754
|
args.push(base, headRef);
|
|
759
755
|
if (options.allowFailure) {
|
|
760
|
-
return (await
|
|
756
|
+
return (await git(cwd, args, { readOnly: true, signal: options.signal })).stdout;
|
|
761
757
|
}
|
|
762
758
|
return runText(cwd, args, { readOnly: true, signal: options.signal });
|
|
763
759
|
},
|
|
@@ -789,7 +785,7 @@ export const status = Object.assign(
|
|
|
789
785
|
{
|
|
790
786
|
/** Parsed status counts (staged, unstaged, untracked). */
|
|
791
787
|
async summary(cwd: string, signal?: AbortSignal): Promise<GitStatusSummary | null> {
|
|
792
|
-
const result = await
|
|
788
|
+
const result = await git(cwd, ["status", "--porcelain"], { readOnly: true, signal });
|
|
793
789
|
if (result.exitCode !== 0) return null;
|
|
794
790
|
return parseStatusPorcelain(result.stdout);
|
|
795
791
|
},
|
|
@@ -950,7 +946,7 @@ export const branch = {
|
|
|
950
946
|
async current(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
951
947
|
const headState = await resolveHead(cwd);
|
|
952
948
|
if (headState?.kind === "ref") return headState.branchName ?? headState.ref;
|
|
953
|
-
const result = await
|
|
949
|
+
const result = await git(cwd, ["symbolic-ref", "--short", "HEAD"], { readOnly: true, signal });
|
|
954
950
|
if (result.exitCode !== 0) return null;
|
|
955
951
|
return result.stdout.trim() || null;
|
|
956
952
|
},
|
|
@@ -966,7 +962,7 @@ export const branch = {
|
|
|
966
962
|
}
|
|
967
963
|
}
|
|
968
964
|
for (const remoteRef of ["origin/HEAD", "upstream/HEAD"]) {
|
|
969
|
-
const result = await
|
|
965
|
+
const result = await git(cwd, ["rev-parse", "--abbrev-ref", remoteRef], { readOnly: true, signal });
|
|
970
966
|
if (result.exitCode !== 0) continue;
|
|
971
967
|
const branchName = stripRemotePrefix(result.stdout.trim());
|
|
972
968
|
if (branchName) return branchName;
|
|
@@ -995,7 +991,7 @@ export const branch = {
|
|
|
995
991
|
name: string,
|
|
996
992
|
options: { force?: boolean; signal?: AbortSignal } = {},
|
|
997
993
|
): Promise<boolean> {
|
|
998
|
-
const result = await
|
|
994
|
+
const result = await git(cwd, ["branch", options.force === false ? "-d" : "-D", name], {
|
|
999
995
|
signal: options.signal,
|
|
1000
996
|
});
|
|
1001
997
|
return result.exitCode === 0;
|
|
@@ -1038,7 +1034,7 @@ export const remote = {
|
|
|
1038
1034
|
* needs to resolve, not paper over.
|
|
1039
1035
|
*/
|
|
1040
1036
|
async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise<void> {
|
|
1041
|
-
const result = await
|
|
1037
|
+
const result = await git(cwd, ["remote", "add", name, url], { signal });
|
|
1042
1038
|
if (result.exitCode === 0) return;
|
|
1043
1039
|
if (REMOTE_ALREADY_EXISTS.test(result.stderr)) {
|
|
1044
1040
|
const existing = await remote.url(cwd, name, signal);
|
|
@@ -1059,7 +1055,7 @@ export const ref = {
|
|
|
1059
1055
|
if (refName === "HEAD") return (await head.sha(cwd, signal)) !== null;
|
|
1060
1056
|
const repository = await resolveRepository(cwd);
|
|
1061
1057
|
if (repository && refName.startsWith("refs/")) return (await readRef(repository, refName)) !== null;
|
|
1062
|
-
const result = await
|
|
1058
|
+
const result = await git(cwd, ["show-ref", "--verify", "--quiet", refName], { readOnly: true, signal });
|
|
1063
1059
|
return result.exitCode === 0;
|
|
1064
1060
|
},
|
|
1065
1061
|
|
|
@@ -1068,7 +1064,7 @@ export const ref = {
|
|
|
1068
1064
|
if (refName === "HEAD") return head.sha(cwd, signal);
|
|
1069
1065
|
const repository = await resolveRepository(cwd);
|
|
1070
1066
|
if (repository && refName.startsWith("refs/")) return readRef(repository, refName);
|
|
1071
|
-
const result = await
|
|
1067
|
+
const result = await git(cwd, ["rev-parse", refName], { readOnly: true, signal });
|
|
1072
1068
|
if (result.exitCode !== 0) return null;
|
|
1073
1069
|
return result.stdout.trim() || null;
|
|
1074
1070
|
},
|
|
@@ -1150,7 +1146,7 @@ export const worktree = {
|
|
|
1150
1146
|
const args = ["worktree", "remove"];
|
|
1151
1147
|
if (options.force ?? true) args.push("-f");
|
|
1152
1148
|
args.push(worktreePath);
|
|
1153
|
-
const result = await
|
|
1149
|
+
const result = await git(cwd, args, { signal: options.signal });
|
|
1154
1150
|
return result.exitCode === 0;
|
|
1155
1151
|
},
|
|
1156
1152
|
|
|
@@ -1186,7 +1182,7 @@ export const patch = {
|
|
|
1186
1182
|
|
|
1187
1183
|
/** Check if a patch file can be applied cleanly. */
|
|
1188
1184
|
async canApply(cwd: string, patchPath: string, options: Omit<PatchOptions, "check"> = {}): Promise<boolean> {
|
|
1189
|
-
const result = await
|
|
1185
|
+
const result = await git(cwd, buildApplyArgs(patchPath, { ...options, check: true }), {
|
|
1190
1186
|
env: options.env,
|
|
1191
1187
|
readOnly: true,
|
|
1192
1188
|
signal: options.signal,
|
|
@@ -1346,7 +1342,7 @@ export const ls = {
|
|
|
1346
1342
|
|
|
1347
1343
|
/** List submodule paths (recursive). */
|
|
1348
1344
|
async submodules(cwd: string, signal?: AbortSignal): Promise<string[]> {
|
|
1349
|
-
const output = await
|
|
1345
|
+
const output = await git(cwd, ["submodule", "--quiet", "foreach", "--recursive", "echo $sm_path"], {
|
|
1350
1346
|
readOnly: true,
|
|
1351
1347
|
signal,
|
|
1352
1348
|
});
|
|
@@ -1381,14 +1377,14 @@ export const head = {
|
|
|
1381
1377
|
async sha(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
1382
1378
|
const headState = await head.resolve(cwd);
|
|
1383
1379
|
if (headState?.commit) return headState.commit;
|
|
1384
|
-
const result = await
|
|
1380
|
+
const result = await git(cwd, ["rev-parse", "HEAD"], { readOnly: true, signal });
|
|
1385
1381
|
if (result.exitCode !== 0) return null;
|
|
1386
1382
|
return result.stdout.trim() || null;
|
|
1387
1383
|
},
|
|
1388
1384
|
|
|
1389
1385
|
/** Abbreviated HEAD commit SHA. */
|
|
1390
1386
|
async short(cwd: string, length = 7, signal?: AbortSignal): Promise<string | null> {
|
|
1391
|
-
const result = await
|
|
1387
|
+
const result = await git(cwd, ["rev-parse", `--short=${length}`, "HEAD"], { readOnly: true, signal });
|
|
1392
1388
|
if (result.exitCode !== 0) return null;
|
|
1393
1389
|
return result.stdout.trim() || null;
|
|
1394
1390
|
},
|
|
@@ -1403,7 +1399,7 @@ export const repo = {
|
|
|
1403
1399
|
async root(cwd: string, signal?: AbortSignal): Promise<string | null> {
|
|
1404
1400
|
const repository = await resolveRepository(cwd);
|
|
1405
1401
|
if (repository) return repository.repoRoot;
|
|
1406
|
-
const result = await
|
|
1402
|
+
const result = await git(cwd, ["rev-parse", "--show-toplevel"], { readOnly: true, signal });
|
|
1407
1403
|
if (result.exitCode !== 0) return null;
|
|
1408
1404
|
return result.stdout.trim() || null;
|
|
1409
1405
|
},
|