@oh-my-pi/pi-coding-agent 13.5.2 → 13.5.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 +21 -0
- package/package.json +7 -7
- package/src/config/prompt-templates.ts +2 -1
- package/src/exa/mcp-client.ts +57 -2
- package/src/modes/controllers/extension-ui-controller.ts +52 -7
- package/src/patch/hashline.ts +41 -0
- package/src/prompts/system/plan-mode-active.md +12 -11
- package/src/prompts/system/plan-mode-subagent.md +3 -5
- package/src/prompts/system/plan-mode-tool-decision-reminder.md +9 -0
- package/src/prompts/tools/bash.md +6 -4
- package/src/prompts/tools/hashline.md +26 -69
- package/src/session/agent-session.ts +58 -2
- package/src/tools/ask.ts +83 -51
- package/src/tools/bash.ts +5 -1
- package/src/tools/index.ts +18 -0
- package/src/utils/prompt-format.ts +16 -18
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [13.5.3] - 2026-03-01
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Auto-include `ast_grep` and `ast_edit` tools when their text-based counterparts (`grep`, `edit`) are requested and the AST tools are enabled
|
|
10
|
+
- Enforced tool decision in plan mode—agent now requires calling either `ask` or `exit_plan_mode` when a turn ends without a required tool call
|
|
11
|
+
- Auto-correction of escaped tab indentation in edits (enabled by default, controllable via `PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS` environment variable)
|
|
12
|
+
- Warning when suspicious Unicode escape placeholder `\uDDDD` is detected in edit content
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Updated bash tool description to conditionally show `ast_grep` and `ast_edit` guidance based on tool availability in the session
|
|
17
|
+
- Replaced timeout-based cancellation with AbortSignal-based cancellation in the `ask` tool for more reliable user interaction handling
|
|
18
|
+
- Updated `ask` tool to distinguish between user-initiated cancellation and timeout-driven auto-selection, with only user cancellation aborting the turn
|
|
19
|
+
- Updated hashline documentation to clarify that `\t` in JSON represents a real tab character, not a literal backslash-t sequence
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed race condition in dialog overlay handling where multiple concurrent resolutions could occur
|
|
24
|
+
- Cancelling the `ask` tool now aborts the current turn instead of returning a normal cancelled selection, while timeout-driven auto-cancel still returns without aborting
|
|
25
|
+
|
|
5
26
|
## [13.5.2] - 2026-03-01
|
|
6
27
|
|
|
7
28
|
### Added
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
4
|
-
"version": "13.5.
|
|
4
|
+
"version": "13.5.3",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/can1357/oh-my-pi",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@mozilla/readability": "^0.6",
|
|
44
|
-
"@oh-my-pi/omp-stats": "13.5.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "13.5.
|
|
46
|
-
"@oh-my-pi/pi-ai": "13.5.
|
|
47
|
-
"@oh-my-pi/pi-natives": "13.5.
|
|
48
|
-
"@oh-my-pi/pi-tui": "13.5.
|
|
49
|
-
"@oh-my-pi/pi-utils": "13.5.
|
|
44
|
+
"@oh-my-pi/omp-stats": "13.5.3",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "13.5.3",
|
|
46
|
+
"@oh-my-pi/pi-ai": "13.5.3",
|
|
47
|
+
"@oh-my-pi/pi-natives": "13.5.3",
|
|
48
|
+
"@oh-my-pi/pi-tui": "13.5.3",
|
|
49
|
+
"@oh-my-pi/pi-utils": "13.5.3",
|
|
50
50
|
"@sinclair/typebox": "^0.34",
|
|
51
51
|
"@xterm/headless": "^6.0",
|
|
52
52
|
"ajv": "^8.18",
|
|
@@ -255,7 +255,8 @@ handlebars.registerHelper("SECTION_SEPERATOR", (name: unknown): string => sectio
|
|
|
255
255
|
*/
|
|
256
256
|
function formatHashlineRef(lineNum: unknown, content: unknown): { num: number; text: string; ref: string } {
|
|
257
257
|
const num = typeof lineNum === "number" ? lineNum : Number.parseInt(String(lineNum), 10);
|
|
258
|
-
const
|
|
258
|
+
const raw = typeof content === "string" ? content : String(content ?? "");
|
|
259
|
+
const text = raw.replace(/\\t/g, "\t").replace(/\\n/g, "\n").replace(/\\r/g, "\r");
|
|
259
260
|
const ref = `${num}#${computeLineHash(num, text)}`;
|
|
260
261
|
return { num, text, ref };
|
|
261
262
|
}
|
package/src/exa/mcp-client.ts
CHANGED
|
@@ -16,6 +16,61 @@ export function findApiKey(): string | null {
|
|
|
16
16
|
return $env.EXA_API_KEY;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
20
|
+
if (typeof value !== "object" || value === null) return null;
|
|
21
|
+
return value as Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseJsonContent(text: string): unknown | null {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Normalize tools/call payloads across MCP servers.
|
|
34
|
+
*
|
|
35
|
+
* Exa currently returns different shapes depending on deployment/environment:
|
|
36
|
+
* - direct payload in result
|
|
37
|
+
* - structured payload under result.structuredContent / result.data / result.result
|
|
38
|
+
* - JSON payload embedded as text in result.content[]
|
|
39
|
+
*/
|
|
40
|
+
function normalizeMcpToolPayload(payload: unknown): unknown {
|
|
41
|
+
const candidates: unknown[] = [];
|
|
42
|
+
const root = asRecord(payload);
|
|
43
|
+
|
|
44
|
+
if (root) {
|
|
45
|
+
if (root.structuredContent !== undefined) candidates.push(root.structuredContent);
|
|
46
|
+
if (root.data !== undefined) candidates.push(root.data);
|
|
47
|
+
if (root.result !== undefined) candidates.push(root.result);
|
|
48
|
+
candidates.push(root);
|
|
49
|
+
|
|
50
|
+
const content = root.content;
|
|
51
|
+
if (Array.isArray(content)) {
|
|
52
|
+
for (const item of content) {
|
|
53
|
+
const part = asRecord(item);
|
|
54
|
+
if (!part) continue;
|
|
55
|
+
const text = part.text;
|
|
56
|
+
if (typeof text !== "string" || text.trim().length === 0) continue;
|
|
57
|
+
const parsed = parseJsonContent(text);
|
|
58
|
+
if (parsed !== null) candidates.push(parsed);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
candidates.push(payload);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
for (const candidate of candidates) {
|
|
66
|
+
if (isSearchResponse(candidate)) {
|
|
67
|
+
return candidate;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return payload;
|
|
72
|
+
}
|
|
73
|
+
|
|
19
74
|
/** Fetch available tools from Exa MCP */
|
|
20
75
|
export async function fetchExaTools(apiKey: string | null, toolNames: string[]): Promise<MCPTool[]> {
|
|
21
76
|
const params = new URLSearchParams();
|
|
@@ -65,7 +120,7 @@ export async function callExaTool(
|
|
|
65
120
|
throw new Error(`MCP error: ${response.error.message}`);
|
|
66
121
|
}
|
|
67
122
|
|
|
68
|
-
return response.result;
|
|
123
|
+
return normalizeMcpToolPayload(response.result);
|
|
69
124
|
}
|
|
70
125
|
|
|
71
126
|
/** Call a tool on Websets MCP */
|
|
@@ -85,7 +140,7 @@ export async function callWebsetsTool(
|
|
|
85
140
|
throw new Error(`MCP error: ${response.error.message}`);
|
|
86
141
|
}
|
|
87
142
|
|
|
88
|
-
return response.result;
|
|
143
|
+
return normalizeMcpToolPayload(response.result);
|
|
89
144
|
}
|
|
90
145
|
|
|
91
146
|
/** Format search results for LLM */
|
|
@@ -41,7 +41,7 @@ export class ExtensionUiController {
|
|
|
41
41
|
const uiContext: ExtensionUIContext = {
|
|
42
42
|
select: (title, options, dialogOptions) => this.showHookSelector(title, options, dialogOptions),
|
|
43
43
|
confirm: (title, message, _dialogOptions) => this.showHookConfirm(title, message),
|
|
44
|
-
input: (title, placeholder,
|
|
44
|
+
input: (title, placeholder, dialogOptions) => this.showHookInput(title, placeholder, dialogOptions),
|
|
45
45
|
notify: (message, type) => this.showHookNotify(message, type),
|
|
46
46
|
onTerminalInput: handler => this.addExtensionTerminalInputListener(handler),
|
|
47
47
|
setStatus: (key, text) => this.setHookStatus(key, text),
|
|
@@ -561,6 +561,20 @@ export class ExtensionUiController {
|
|
|
561
561
|
dialogOptions?: ExtensionUIDialogOptions,
|
|
562
562
|
): Promise<string | undefined> {
|
|
563
563
|
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
564
|
+
let settled = false;
|
|
565
|
+
const onAbort = () => {
|
|
566
|
+
this.hideHookSelector();
|
|
567
|
+
if (!settled) {
|
|
568
|
+
settled = true;
|
|
569
|
+
resolve(undefined);
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
const finish = (value: string | undefined) => {
|
|
573
|
+
if (settled) return;
|
|
574
|
+
settled = true;
|
|
575
|
+
dialogOptions?.signal?.removeEventListener("abort", onAbort);
|
|
576
|
+
resolve(value);
|
|
577
|
+
};
|
|
564
578
|
this.#hookSelectorOverlay?.hide();
|
|
565
579
|
this.#hookSelectorOverlay = undefined;
|
|
566
580
|
const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
|
|
@@ -569,11 +583,11 @@ export class ExtensionUiController {
|
|
|
569
583
|
options,
|
|
570
584
|
option => {
|
|
571
585
|
this.hideHookSelector();
|
|
572
|
-
|
|
586
|
+
finish(option);
|
|
573
587
|
},
|
|
574
588
|
() => {
|
|
575
589
|
this.hideHookSelector();
|
|
576
|
-
|
|
590
|
+
finish(undefined);
|
|
577
591
|
},
|
|
578
592
|
{
|
|
579
593
|
initialIndex: dialogOptions?.initialIndex,
|
|
@@ -584,9 +598,15 @@ export class ExtensionUiController {
|
|
|
584
598
|
},
|
|
585
599
|
);
|
|
586
600
|
this.#hookSelectorOverlay = this.ctx.ui.showOverlay(this.ctx.hookSelector, this.#dialogOverlayOptions);
|
|
601
|
+
if (dialogOptions?.signal) {
|
|
602
|
+
if (dialogOptions.signal.aborted) {
|
|
603
|
+
onAbort();
|
|
604
|
+
} else {
|
|
605
|
+
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
|
|
606
|
+
}
|
|
607
|
+
}
|
|
587
608
|
return promise;
|
|
588
609
|
}
|
|
589
|
-
|
|
590
610
|
/**
|
|
591
611
|
* Hide the hook selector.
|
|
592
612
|
*/
|
|
@@ -610,8 +630,26 @@ export class ExtensionUiController {
|
|
|
610
630
|
/**
|
|
611
631
|
* Show a text input for hooks.
|
|
612
632
|
*/
|
|
613
|
-
showHookInput(
|
|
633
|
+
showHookInput(
|
|
634
|
+
title: string,
|
|
635
|
+
placeholder?: string,
|
|
636
|
+
dialogOptions?: ExtensionUIDialogOptions,
|
|
637
|
+
): Promise<string | undefined> {
|
|
614
638
|
const { promise, resolve } = Promise.withResolvers<string | undefined>();
|
|
639
|
+
let settled = false;
|
|
640
|
+
const onAbort = () => {
|
|
641
|
+
this.hideHookInput();
|
|
642
|
+
if (!settled) {
|
|
643
|
+
settled = true;
|
|
644
|
+
resolve(undefined);
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
const finish = (value: string | undefined) => {
|
|
648
|
+
if (settled) return;
|
|
649
|
+
settled = true;
|
|
650
|
+
dialogOptions?.signal?.removeEventListener("abort", onAbort);
|
|
651
|
+
resolve(value);
|
|
652
|
+
};
|
|
615
653
|
this.#hookInputOverlay?.hide();
|
|
616
654
|
this.#hookInputOverlay = undefined;
|
|
617
655
|
this.ctx.hookInput = new HookInputComponent(
|
|
@@ -619,14 +657,21 @@ export class ExtensionUiController {
|
|
|
619
657
|
placeholder,
|
|
620
658
|
value => {
|
|
621
659
|
this.hideHookInput();
|
|
622
|
-
|
|
660
|
+
finish(value);
|
|
623
661
|
},
|
|
624
662
|
() => {
|
|
625
663
|
this.hideHookInput();
|
|
626
|
-
|
|
664
|
+
finish(undefined);
|
|
627
665
|
},
|
|
628
666
|
);
|
|
629
667
|
this.#hookInputOverlay = this.ctx.ui.showOverlay(this.ctx.hookInput, this.#dialogOverlayOptions);
|
|
668
|
+
if (dialogOptions?.signal) {
|
|
669
|
+
if (dialogOptions.signal.aborted) {
|
|
670
|
+
onAbort();
|
|
671
|
+
} else {
|
|
672
|
+
dialogOptions.signal.addEventListener("abort", onAbort, { once: true });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
630
675
|
return promise;
|
|
631
676
|
}
|
|
632
677
|
|
package/src/patch/hashline.ts
CHANGED
|
@@ -411,6 +411,45 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
|
|
|
411
411
|
}
|
|
412
412
|
}
|
|
413
413
|
|
|
414
|
+
function isEscapedTabAutocorrectEnabled(): boolean {
|
|
415
|
+
const value = Bun.env.PI_HASHLINE_AUTOCORRECT_ESCAPED_TABS;
|
|
416
|
+
if (value === "0") return false;
|
|
417
|
+
if (value === "1") return true;
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function maybeAutocorrectEscapedTabIndentation(edits: HashlineEdit[], warnings: string[]): void {
|
|
422
|
+
if (!isEscapedTabAutocorrectEnabled()) return;
|
|
423
|
+
for (const edit of edits) {
|
|
424
|
+
if (edit.lines.length === 0) continue;
|
|
425
|
+
const hasEscapedTabs = edit.lines.some(line => line.includes("\\t"));
|
|
426
|
+
if (!hasEscapedTabs) continue;
|
|
427
|
+
const hasRealTabs = edit.lines.some(line => line.includes("\t"));
|
|
428
|
+
if (hasRealTabs) continue;
|
|
429
|
+
let correctedCount = 0;
|
|
430
|
+
const corrected = edit.lines.map(line =>
|
|
431
|
+
line.replace(/^((?:\\t)+)/, escaped => {
|
|
432
|
+
correctedCount += escaped.length / 2;
|
|
433
|
+
return "\t".repeat(escaped.length / 2);
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
if (correctedCount === 0) continue;
|
|
437
|
+
edit.lines = corrected;
|
|
438
|
+
warnings.push(
|
|
439
|
+
`Auto-corrected escaped tab indentation in edit: converted leading \\t sequence(s) to real tab characters`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function maybeWarnSuspiciousUnicodeEscapePlaceholder(edits: HashlineEdit[], warnings: string[]): void {
|
|
445
|
+
for (const edit of edits) {
|
|
446
|
+
if (edit.lines.length === 0) continue;
|
|
447
|
+
if (!edit.lines.some(line => /\\uDDDD/i.test(line))) continue;
|
|
448
|
+
warnings.push(
|
|
449
|
+
`Detected literal \\uDDDD in edit content; no autocorrection applied. Verify whether this should be a real Unicode escape or plain text.`,
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
414
453
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
415
454
|
// Edit Application
|
|
416
455
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -493,6 +532,8 @@ export function applyHashlineEdits(
|
|
|
493
532
|
if (mismatches.length > 0) {
|
|
494
533
|
throw new HashlineMismatchError(mismatches, fileLines);
|
|
495
534
|
}
|
|
535
|
+
maybeAutocorrectEscapedTabIndentation(edits, warnings);
|
|
536
|
+
maybeWarnSuspiciousUnicodeEscapePlaceholder(edits, warnings);
|
|
496
537
|
// Deduplicate identical edits targeting the same line(s)
|
|
497
538
|
const seenEditKeys = new Map<string, number>();
|
|
498
539
|
const dedupIndices = new Set<number>();
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
Plan mode active. You **MUST** perform READ-ONLY operations only.
|
|
3
3
|
|
|
4
4
|
You **MUST NOT**:
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
5
|
+
- Create, edit, or delete files (except plan file below)
|
|
6
|
+
- Run state-changing commands (git commit, npm install, etc.)
|
|
7
|
+
- Make any system changes
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
To implement: call `{{exitToolName}}` → user approves → new session starts with full write access to execute the plan.
|
|
10
|
+
You **MUST NOT** ask the user to exit plan mode for you; you **MUST** call `{{exitToolName}}` yourself.
|
|
10
11
|
</critical>
|
|
11
12
|
|
|
12
13
|
## Plan File
|
|
@@ -32,7 +33,7 @@ Plan execution runs in fresh context (session cleared). You **MUST** make the pl
|
|
|
32
33
|
3. Decide:
|
|
33
34
|
- **Different task** → Overwrite plan
|
|
34
35
|
- **Same task, continuing** → Update and clean outdated sections
|
|
35
|
-
4. Call `
|
|
36
|
+
4. Call `{{exitToolName}}` when complete
|
|
36
37
|
</procedure>
|
|
37
38
|
{{/if}}
|
|
38
39
|
|
|
@@ -43,7 +44,7 @@ Plan execution runs in fresh context (session cleared). You **MUST** make the pl
|
|
|
43
44
|
### 1. Explore
|
|
44
45
|
You **MUST** use `find`, `grep`, `read`, `ls` to understand the codebase.
|
|
45
46
|
### 2. Interview
|
|
46
|
-
You **MUST** use `
|
|
47
|
+
You **MUST** use `{{askToolName}}` to clarify:
|
|
47
48
|
- Ambiguous requirements
|
|
48
49
|
- Technical decisions and tradeoffs
|
|
49
50
|
- Preferences: UI/UX, performance, edge cases
|
|
@@ -78,7 +79,7 @@ You **MUST** focus on the request and associated code. You **SHOULD** launch par
|
|
|
78
79
|
You **MUST** draft an approach based on exploration. You **MUST** consider trade-offs briefly, then choose.
|
|
79
80
|
|
|
80
81
|
### Phase 3: Review
|
|
81
|
-
You **MUST** read critical files. You **MUST** verify plan matches original request. You **SHOULD** use `
|
|
82
|
+
You **MUST** read critical files. You **MUST** verify plan matches original request. You **SHOULD** use `{{askToolName}}` to clarify remaining questions.
|
|
82
83
|
|
|
83
84
|
### Phase 4: Update Plan
|
|
84
85
|
You **MUST** update `{{planFilePath}}` (`{{editToolName}}` for changes, `{{writeToolName}}` only if creating from scratch):
|
|
@@ -93,14 +94,14 @@ You **MUST** ask questions throughout. You **MUST NOT** make large assumptions a
|
|
|
93
94
|
{{/if}}
|
|
94
95
|
|
|
95
96
|
<directives>
|
|
96
|
-
- You **MUST** use `
|
|
97
|
+
- You **MUST** use `{{askToolName}}` only for clarifying requirements or choosing approaches
|
|
97
98
|
</directives>
|
|
98
99
|
|
|
99
100
|
<critical>
|
|
100
101
|
Your turn ends ONLY by:
|
|
101
|
-
1. Using `
|
|
102
|
-
2. Calling `
|
|
102
|
+
1. Using `{{askToolName}}` to gather information, OR
|
|
103
|
+
2. Calling `{{exitToolName}}` when ready — this triggers user approval, then a new implementation session with full tool access
|
|
103
104
|
|
|
104
|
-
You **MUST NOT** ask plan approval via text or `
|
|
105
|
+
You **MUST NOT** ask plan approval via text or `{{askToolName}}`; you **MUST** use `{{exitToolName}}`.
|
|
105
106
|
You **MUST** keep going until complete.
|
|
106
107
|
</critical>
|
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
Plan mode active. You **MUST** perform READ-ONLY operations only.
|
|
3
3
|
|
|
4
4
|
You **MUST NOT**:
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
Supersedes all other instructions.
|
|
5
|
+
- Create, edit, delete, move, or copy files
|
|
6
|
+
- Run state-changing commands
|
|
7
|
+
- Make any changes to the system
|
|
10
8
|
</critical>
|
|
11
9
|
|
|
12
10
|
<role>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<system-reminder>
|
|
2
|
+
Plan mode turn ended without a required tool call.
|
|
3
|
+
|
|
4
|
+
You **MUST** choose exactly one next action now:
|
|
5
|
+
1. Call `{{askToolName}}` to gather required clarification, OR
|
|
6
|
+
2. Call `{{exitToolName}}` to finish planning and request approval
|
|
7
|
+
|
|
8
|
+
You **MUST NOT** output plain text in this turn.
|
|
9
|
+
</system-reminder>
|
|
@@ -33,10 +33,12 @@ You **MUST** use specialized tools instead of bash for ALL file operations:
|
|
|
33
33
|
|`rg 'pattern' dir/`|`grep(pattern="pattern", path="dir/")`|
|
|
34
34
|
|`find dir -name '*.ts'`|`find(pattern="dir/**/*.ts")`|
|
|
35
35
|
|`ls dir/`|`read(path="dir/")`|
|
|
36
|
-
|`cat <<'EOF' > file`|`write(path="file", content="
|
|
37
|
-
|`sed -i 's/old/new/' file`|`edit(path="file", edits=[
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
|`cat <<'EOF' > file`|`write(path="file", content="…")`|
|
|
37
|
+
|`sed -i 's/old/new/' file`|`edit(path="file", edits=[…])`|
|
|
38
|
+
|
|
39
|
+
{{#if hasAstEdit}}|`sed -i 's/oldFn(/newFn(/' src/*.ts`|`ast_edit({ops:[{pat:"oldFn($$$A)", out:"newFn($$$A)"}], path:"src/"})`|{{/if}}
|
|
40
|
+
{{#if hasAstGrep}}- You **MUST** use `ast_grep` for structural code search instead of bash `grep`/`awk`/`perl` pipelines{{/if}}
|
|
41
|
+
{{#if hasAstEdit}}- You **MUST** use `ast_edit` for structural rewrites instead of bash `sed`/`awk`/`perl` pipelines{{/if}}
|
|
40
42
|
- You **MUST NOT** use Bash for these operations like read, grep, find, edit, write, where specialized tools exist.
|
|
41
43
|
- You **MUST NOT** use `2>&1` | `2>/dev/null` pattern, stdout and stderr are already merged.
|
|
42
44
|
- You **MUST NOT** use `| head -n 50` or `| tail -n 100` pattern, use `head` and `tail` parameters instead.
|
|
@@ -49,6 +49,7 @@ Every edit has `op`, `pos`, and `lines`. Range replaces also have `end`. Both `p
|
|
|
49
49
|
- You **MUST NOT** set `end` to an interior line and then re-add the boundary token in `lines`; that duplicates the next surviving line.
|
|
50
50
|
- To remove a line while keeping its neighbors, **delete** it (`lines: null`). You **MUST NOT** replace it with the content of an adjacent line — that line still exists and will be duplicated.
|
|
51
51
|
4. **Match surrounding indentation:** Leading whitespace in `lines` **MUST** be copied verbatim from adjacent lines in the `read` output. Do not infer or reconstruct indentation from memory — count the actual leading spaces on the lines immediately above and below the insertion or replacement point.
|
|
52
|
+
5. **Preserve idiomatic sibling spacing:** When inserting declarations between top-level siblings, you **MUST** preserve existing blank-line separators. If siblings are separated by one blank line, include a trailing `""` in `lines` so inserted code keeps the same spacing.
|
|
52
53
|
</rules>
|
|
53
54
|
|
|
54
55
|
<recovery>
|
|
@@ -121,18 +122,17 @@ Range — add `end`:
|
|
|
121
122
|
{{hlinefull 62 " return null;"}}
|
|
122
123
|
{{hlinefull 63 " }"}}
|
|
123
124
|
```
|
|
125
|
+
Target only the inner lines that change — leave unchanged boundaries out of the range.
|
|
124
126
|
```
|
|
125
127
|
{
|
|
126
128
|
path: "…",
|
|
127
129
|
edits: [{
|
|
128
130
|
op: "replace",
|
|
129
|
-
pos: {{hlinejsonref
|
|
130
|
-
end: {{hlinejsonref
|
|
131
|
+
pos: {{hlinejsonref 61 " console.error(err);"}},
|
|
132
|
+
end: {{hlinejsonref 62 " return null;"}},
|
|
131
133
|
lines: [
|
|
132
|
-
" } catch (err) {",
|
|
133
134
|
" if (isEnoent(err)) return null;",
|
|
134
|
-
" throw err;"
|
|
135
|
-
" }"
|
|
135
|
+
" throw err;"
|
|
136
136
|
]
|
|
137
137
|
}]
|
|
138
138
|
}
|
|
@@ -141,39 +141,24 @@ Range — add `end`:
|
|
|
141
141
|
|
|
142
142
|
<example name="inclusive end avoids duplicate boundary">
|
|
143
143
|
```ts
|
|
144
|
-
{{hlinefull 70 "
|
|
145
|
-
{{hlinefull 71 "
|
|
146
|
-
{{hlinefull 72 "}"}}
|
|
147
|
-
{{hlinefull 73 "
|
|
144
|
+
{{hlinefull 70 "\tif (user.isAdmin) {"}}
|
|
145
|
+
{{hlinefull 71 "\t\tdeleteRecord(id);"}}
|
|
146
|
+
{{hlinefull 72 "\t}"}}
|
|
147
|
+
{{hlinefull 73 "\tafter();"}}
|
|
148
148
|
```
|
|
149
|
-
|
|
149
|
+
The block grows by one line and the condition changes — two single-line ops would be needed otherwise. Since `}` appears in `lines`, `end` must include `72`:
|
|
150
150
|
```
|
|
151
151
|
{
|
|
152
152
|
path: "…",
|
|
153
153
|
edits: [{
|
|
154
154
|
op: "replace",
|
|
155
|
-
pos: {{hlinejsonref 70 "
|
|
156
|
-
end: {{hlinejsonref
|
|
155
|
+
pos: {{hlinejsonref 70 "\tif (user.isAdmin) {"}},
|
|
156
|
+
end: {{hlinejsonref 72 "\t}"}},
|
|
157
157
|
lines: [
|
|
158
|
-
"
|
|
159
|
-
"
|
|
160
|
-
"
|
|
161
|
-
|
|
162
|
-
}]
|
|
163
|
-
}
|
|
164
|
-
```
|
|
165
|
-
Good — include original `}` in the replaced range when replacement keeps `}`:
|
|
166
|
-
```
|
|
167
|
-
{
|
|
168
|
-
path: "…",
|
|
169
|
-
edits: [{
|
|
170
|
-
op: "replace",
|
|
171
|
-
pos: {{hlinejsonref 70 "if (ok) {"}},
|
|
172
|
-
end: {{hlinejsonref 72 "}"}},
|
|
173
|
-
lines: [
|
|
174
|
-
"if (ok) {",
|
|
175
|
-
" runSafe();",
|
|
176
|
-
"}"
|
|
158
|
+
"\tif (user.isAdmin && confirmed) {",
|
|
159
|
+
"\t\tauditLog(id);",
|
|
160
|
+
"\t\tdeleteRecord(id);",
|
|
161
|
+
"\t}"
|
|
177
162
|
]
|
|
178
163
|
}]
|
|
179
164
|
}
|
|
@@ -191,6 +176,7 @@ Also apply the same rule to `);`, `],`, and `},` closers: if replacement include
|
|
|
191
176
|
{{hlinefull 49 " runY();"}}
|
|
192
177
|
{{hlinefull 50 "}"}}
|
|
193
178
|
```
|
|
179
|
+
Use a trailing `""` to preserve the blank line between top-level sibling declarations.
|
|
194
180
|
```
|
|
195
181
|
{
|
|
196
182
|
path: "…",
|
|
@@ -206,20 +192,6 @@ Also apply the same rule to `);`, `],`, and `},` closers: if replacement include
|
|
|
206
192
|
}]
|
|
207
193
|
}
|
|
208
194
|
```
|
|
209
|
-
Result:
|
|
210
|
-
```ts
|
|
211
|
-
{{hlinefull 44 "function x() {"}}
|
|
212
|
-
{{hlinefull 45 " runX();"}}
|
|
213
|
-
{{hlinefull 46 "}"}}
|
|
214
|
-
{{hlinefull 47 ""}}
|
|
215
|
-
{{hlinefull 48 "function z() {"}}
|
|
216
|
-
{{hlinefull 49 " runZ();"}}
|
|
217
|
-
{{hlinefull 50 "}"}}
|
|
218
|
-
{{hlinefull 51 ""}}
|
|
219
|
-
{{hlinefull 52 "function y() {"}}
|
|
220
|
-
{{hlinefull 53 " runY();"}}
|
|
221
|
-
{{hlinefull 54 "}"}}
|
|
222
|
-
```
|
|
223
195
|
</example>
|
|
224
196
|
|
|
225
197
|
<example name="anchor to structure, not whitespace">
|
|
@@ -249,30 +221,15 @@ Good — anchors to structural line:
|
|
|
249
221
|
</example>
|
|
250
222
|
|
|
251
223
|
<example name="indentation must match context">
|
|
252
|
-
Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory.
|
|
224
|
+
Leading whitespace in `lines` **MUST** be copied from the `read` output, not reconstructed from memory. If the file uses tabs, use `\t` in JSON — you **MUST NOT** use `\\t`, which produces a literal backslash-t in the file.
|
|
253
225
|
```ts
|
|
254
226
|
{{hlinefull 10 "class Foo {"}}
|
|
255
|
-
{{hlinefull 11 "
|
|
256
|
-
{{hlinefull 12 "
|
|
257
|
-
{{hlinefull 13 "
|
|
227
|
+
{{hlinefull 11 "\tbar() {"}}
|
|
228
|
+
{{hlinefull 12 "\t\treturn 1;"}}
|
|
229
|
+
{{hlinefull 13 "\t}"}}
|
|
258
230
|
{{hlinefull 14 "}"}}
|
|
259
231
|
```
|
|
260
|
-
|
|
261
|
-
```
|
|
262
|
-
{
|
|
263
|
-
path: "…",
|
|
264
|
-
edits: [{
|
|
265
|
-
op: "prepend",
|
|
266
|
-
pos: {{hlinejsonref 14 "}"}},
|
|
267
|
-
lines: [
|
|
268
|
-
" baz() {",
|
|
269
|
-
" return 2;",
|
|
270
|
-
" }"
|
|
271
|
-
]
|
|
272
|
-
}]
|
|
273
|
-
}
|
|
274
|
-
```
|
|
275
|
-
Good — indent matches the 2-space style visible on adjacent lines:
|
|
232
|
+
Good — `\t` in JSON is a real tab, matching the file's indentation:
|
|
276
233
|
```
|
|
277
234
|
{
|
|
278
235
|
path: "…",
|
|
@@ -280,9 +237,9 @@ Good — indent matches the 2-space style visible on adjacent lines:
|
|
|
280
237
|
op: "prepend",
|
|
281
238
|
pos: {{hlinejsonref 14 "}"}},
|
|
282
239
|
lines: [
|
|
283
|
-
"
|
|
284
|
-
"
|
|
285
|
-
"
|
|
240
|
+
"\tbaz() {",
|
|
241
|
+
"\t\treturn 2;",
|
|
242
|
+
"\t}"
|
|
286
243
|
]
|
|
287
244
|
}]
|
|
288
245
|
}
|
|
@@ -294,5 +251,5 @@ Good — indent matches the 2-space style visible on adjacent lines:
|
|
|
294
251
|
- Every tag **MUST** be copied exactly from fresh tool result as `N#ID`.
|
|
295
252
|
- You **MUST** re-read after each edit call before issuing another on same file.
|
|
296
253
|
- Formatting is a batch operation. You **MUST NOT** use this tool to reformat, reindent, or adjust whitespace — run the project's formatter instead. If the only change is whitespace, it is formatting; do not touch it.
|
|
297
|
-
- `lines` entries **MUST** be literal file content with
|
|
254
|
+
- `lines` entries **MUST** be literal file content with indentation copied exactly from the `read` output. If the file uses tabs, use `\t` in JSON (a real tab character) — you **MUST NOT** use `\\t` (two characters: backslash + t), which produces the literal string `\t` in the file.
|
|
298
255
|
</critical>
|
|
@@ -82,6 +82,9 @@ import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from
|
|
|
82
82
|
import type { PlanModeState } from "../plan-mode/state";
|
|
83
83
|
import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
|
|
84
84
|
import planModeReferencePrompt from "../prompts/system/plan-mode-reference.md" with { type: "text" };
|
|
85
|
+
import planModeToolDecisionReminderPrompt from "../prompts/system/plan-mode-tool-decision-reminder.md" with {
|
|
86
|
+
type: "text",
|
|
87
|
+
};
|
|
85
88
|
import ttsrInterruptTemplate from "../prompts/system/ttsr-interrupt.md" with { type: "text" };
|
|
86
89
|
import type { SecretObfuscator } from "../secrets/obfuscator";
|
|
87
90
|
import type { CheckpointState } from "../tools/checkpoint";
|
|
@@ -733,9 +736,13 @@ export class AgentSession {
|
|
|
733
736
|
}
|
|
734
737
|
|
|
735
738
|
// Check auto-retry and auto-compaction after agent completes
|
|
736
|
-
if (event.type === "agent_end"
|
|
737
|
-
const
|
|
739
|
+
if (event.type === "agent_end") {
|
|
740
|
+
const fallbackAssistant = [...event.messages]
|
|
741
|
+
.reverse()
|
|
742
|
+
.find((message): message is AssistantMessage => message.role === "assistant");
|
|
743
|
+
const msg = this.#lastAssistantMessage ?? fallbackAssistant;
|
|
738
744
|
this.#lastAssistantMessage = undefined;
|
|
745
|
+
if (!msg) return;
|
|
739
746
|
|
|
740
747
|
if (this.#skipPostTurnMaintenanceAssistantTimestamp === msg.timestamp) {
|
|
741
748
|
this.#skipPostTurnMaintenanceAssistantTimestamp = undefined;
|
|
@@ -1901,6 +1908,9 @@ export class AgentSession {
|
|
|
1901
1908
|
: { role: "user" as const, content: userContent, timestamp: Date.now() };
|
|
1902
1909
|
|
|
1903
1910
|
await this.#promptWithMessage(message, expandedText, options);
|
|
1911
|
+
if (!options?.synthetic) {
|
|
1912
|
+
await this.#enforcePlanModeToolDecision();
|
|
1913
|
+
}
|
|
1904
1914
|
}
|
|
1905
1915
|
|
|
1906
1916
|
async promptCustomMessage<T = unknown>(
|
|
@@ -3329,6 +3339,52 @@ Be thorough - include exact file paths, function names, error messages, and tech
|
|
|
3329
3339
|
this.#checkpointState = undefined;
|
|
3330
3340
|
this.#pendingRewindReport = undefined;
|
|
3331
3341
|
}
|
|
3342
|
+
async #enforcePlanModeToolDecision(): Promise<void> {
|
|
3343
|
+
if (!this.#planModeState?.enabled) {
|
|
3344
|
+
return;
|
|
3345
|
+
}
|
|
3346
|
+
const assistantMessage = this.#findLastAssistantMessage();
|
|
3347
|
+
if (!assistantMessage) {
|
|
3348
|
+
return;
|
|
3349
|
+
}
|
|
3350
|
+
if (assistantMessage.stopReason === "error" || assistantMessage.stopReason === "aborted") {
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
const calledRequiredTool = assistantMessage.content.some(
|
|
3355
|
+
content => content.type === "toolCall" && (content.name === "ask" || content.name === "exit_plan_mode"),
|
|
3356
|
+
);
|
|
3357
|
+
if (calledRequiredTool) {
|
|
3358
|
+
return;
|
|
3359
|
+
}
|
|
3360
|
+
|
|
3361
|
+
const askTool = this.#toolRegistry.get("ask");
|
|
3362
|
+
const exitPlanModeTool = this.#toolRegistry.get("exit_plan_mode");
|
|
3363
|
+
if (!askTool || !exitPlanModeTool) {
|
|
3364
|
+
logger.warn("Plan mode enforcement skipped because ask/exit tools are unavailable", {
|
|
3365
|
+
activeToolNames: this.agent.state.tools.map(tool => tool.name),
|
|
3366
|
+
});
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
const forcedTools = [askTool, exitPlanModeTool];
|
|
3370
|
+
|
|
3371
|
+
const reminder = renderPromptTemplate(planModeToolDecisionReminderPrompt, {
|
|
3372
|
+
askToolName: "ask",
|
|
3373
|
+
exitToolName: "exit_plan_mode",
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
const previousTools = this.agent.state.tools;
|
|
3377
|
+
this.agent.setTools(forcedTools);
|
|
3378
|
+
try {
|
|
3379
|
+
await this.prompt(reminder, {
|
|
3380
|
+
synthetic: true,
|
|
3381
|
+
expandPromptTemplates: false,
|
|
3382
|
+
toolChoice: "required",
|
|
3383
|
+
});
|
|
3384
|
+
} finally {
|
|
3385
|
+
this.agent.setTools(previousTools);
|
|
3386
|
+
}
|
|
3387
|
+
}
|
|
3332
3388
|
/**
|
|
3333
3389
|
* Check if agent stopped with incomplete todos and prompt to continue.
|
|
3334
3390
|
*/
|
package/src/tools/ask.ts
CHANGED
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* - Use recommended: <index> to mark the default option; "(Recommended)" suffix is added automatically
|
|
15
15
|
* - Questions may time out and auto-select the recommended option (configurable, disabled in plan mode)
|
|
16
16
|
*/
|
|
17
|
+
|
|
17
18
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
18
19
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
19
20
|
import { TERMINAL, Text } from "@oh-my-pi/pi-tui";
|
|
21
|
+
import { ptree, untilAborted } from "@oh-my-pi/pi-utils";
|
|
20
22
|
import { type Static, Type } from "@sinclair/typebox";
|
|
21
23
|
import { renderPromptTemplate } from "../config/prompt-templates";
|
|
22
24
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
@@ -25,6 +27,7 @@ import askDescription from "../prompts/tools/ask.md" with { type: "text" };
|
|
|
25
27
|
import { renderStatusLine } from "../tui";
|
|
26
28
|
import type { ToolSession } from ".";
|
|
27
29
|
import { formatErrorMessage, formatMeta, formatTitle } from "./render-utils";
|
|
30
|
+
import { ToolAbortError } from "./tool-errors";
|
|
28
31
|
|
|
29
32
|
// =============================================================================
|
|
30
33
|
// Types
|
|
@@ -110,14 +113,9 @@ interface UIContext {
|
|
|
110
113
|
select(
|
|
111
114
|
prompt: string,
|
|
112
115
|
options: string[],
|
|
113
|
-
options_?: { initialIndex?: number;
|
|
116
|
+
options_?: { initialIndex?: number; signal?: AbortSignal; outline?: boolean },
|
|
114
117
|
): Promise<string | undefined>;
|
|
115
|
-
input(prompt: string): Promise<string | undefined>;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
interface AskQuestionOptions {
|
|
119
|
-
/** Timeout in milliseconds, null/undefined to disable */
|
|
120
|
-
timeout?: number | null;
|
|
118
|
+
input(prompt: string, options_?: { signal?: AbortSignal }): Promise<string | undefined>;
|
|
121
119
|
}
|
|
122
120
|
|
|
123
121
|
async function askSingleQuestion(
|
|
@@ -126,9 +124,8 @@ async function askSingleQuestion(
|
|
|
126
124
|
optionLabels: string[],
|
|
127
125
|
multi: boolean,
|
|
128
126
|
recommended?: number,
|
|
129
|
-
|
|
127
|
+
signal?: AbortSignal,
|
|
130
128
|
): Promise<SelectionResult> {
|
|
131
|
-
const timeout = options?.timeout ?? undefined;
|
|
132
129
|
const doneLabel = getDoneOptionLabel();
|
|
133
130
|
let selectedOptions: string[] = [];
|
|
134
131
|
let customInput: string | undefined;
|
|
@@ -152,22 +149,27 @@ async function askSingleQuestion(
|
|
|
152
149
|
opts.push(OTHER_OPTION);
|
|
153
150
|
|
|
154
151
|
const prefix = selected.size > 0 ? `(${selected.size} selected) ` : "";
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
152
|
+
const choice = signal
|
|
153
|
+
? await untilAborted(signal, () =>
|
|
154
|
+
ui.select(`${prefix}${question}`, opts, {
|
|
155
|
+
initialIndex: cursorIndex,
|
|
156
|
+
signal,
|
|
157
|
+
outline: true,
|
|
158
|
+
}),
|
|
159
|
+
)
|
|
160
|
+
: await ui.select(`${prefix}${question}`, opts, {
|
|
161
|
+
initialIndex: cursorIndex,
|
|
162
|
+
signal,
|
|
163
|
+
outline: true,
|
|
164
|
+
});
|
|
163
165
|
|
|
164
166
|
if (choice === undefined || choice === doneLabel) break;
|
|
165
167
|
|
|
166
168
|
if (choice === OTHER_OPTION) {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
const input = signal
|
|
170
|
+
? await untilAborted(signal, () => ui.input("Enter your response:", { signal }))
|
|
171
|
+
: await ui.input("Enter your response:", { signal });
|
|
172
|
+
if (input) customInput = input;
|
|
171
173
|
break;
|
|
172
174
|
}
|
|
173
175
|
|
|
@@ -192,21 +194,28 @@ async function askSingleQuestion(
|
|
|
192
194
|
selected.add(opt);
|
|
193
195
|
}
|
|
194
196
|
}
|
|
195
|
-
|
|
196
|
-
if (timedOut) {
|
|
197
|
-
break;
|
|
198
|
-
}
|
|
199
197
|
}
|
|
200
198
|
selectedOptions = Array.from(selected);
|
|
201
199
|
} else {
|
|
202
200
|
const displayLabels = addRecommendedSuffix(optionLabels, recommended);
|
|
203
|
-
const choice =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
201
|
+
const choice = signal
|
|
202
|
+
? await untilAborted(signal, () =>
|
|
203
|
+
ui.select(question, [...displayLabels, OTHER_OPTION], {
|
|
204
|
+
initialIndex: recommended,
|
|
205
|
+
signal,
|
|
206
|
+
outline: true,
|
|
207
|
+
}),
|
|
208
|
+
)
|
|
209
|
+
: await ui.select(question, [...displayLabels, OTHER_OPTION], {
|
|
210
|
+
initialIndex: recommended,
|
|
211
|
+
signal,
|
|
212
|
+
outline: true,
|
|
213
|
+
});
|
|
214
|
+
|
|
208
215
|
if (choice === OTHER_OPTION) {
|
|
209
|
-
const input =
|
|
216
|
+
const input = signal
|
|
217
|
+
? await untilAborted(signal, () => ui.input("Enter your response:", { signal }))
|
|
218
|
+
: await ui.input("Enter your response:", { signal });
|
|
210
219
|
if (input) customInput = input;
|
|
211
220
|
} else if (choice) {
|
|
212
221
|
selectedOptions = [stripRecommendedSuffix(choice)];
|
|
@@ -265,7 +274,7 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
265
274
|
async execute(
|
|
266
275
|
_toolCallId: string,
|
|
267
276
|
params: AskParams,
|
|
268
|
-
|
|
277
|
+
signal?: AbortSignal,
|
|
269
278
|
_onUpdate?: AgentToolUpdateCallback<AskToolDetails>,
|
|
270
279
|
context?: AgentToolContext,
|
|
271
280
|
): Promise<AgentToolResult<AskToolDetails>> {
|
|
@@ -277,7 +286,11 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
277
286
|
};
|
|
278
287
|
}
|
|
279
288
|
|
|
280
|
-
const
|
|
289
|
+
const extensionUi = context.ui;
|
|
290
|
+
const ui: UIContext = {
|
|
291
|
+
select: (prompt, options, dialogOptions) => extensionUi.select(prompt, options, dialogOptions),
|
|
292
|
+
input: (prompt, dialogOptions) => extensionUi.input(prompt, undefined, dialogOptions),
|
|
293
|
+
};
|
|
281
294
|
|
|
282
295
|
// Determine timeout based on settings and plan mode
|
|
283
296
|
const planModeEnabled = this.session.getPlanModeState?.()?.enabled ?? false;
|
|
@@ -296,18 +309,41 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
296
309
|
};
|
|
297
310
|
}
|
|
298
311
|
|
|
312
|
+
const askQuestion = async (q: AskParams["questions"][number]) => {
|
|
313
|
+
const optionLabels = q.options.map(o => o.label);
|
|
314
|
+
const timeoutSignal = timeout == null ? undefined : AbortSignal.timeout(timeout);
|
|
315
|
+
const questionSignal = ptree.combineSignals(signal, timeoutSignal);
|
|
316
|
+
try {
|
|
317
|
+
const { selectedOptions, customInput } = await askSingleQuestion(
|
|
318
|
+
ui,
|
|
319
|
+
q.question,
|
|
320
|
+
optionLabels,
|
|
321
|
+
q.multi ?? false,
|
|
322
|
+
q.recommended,
|
|
323
|
+
questionSignal,
|
|
324
|
+
);
|
|
325
|
+
return { optionLabels, selectedOptions, customInput, timedOut: false };
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
328
|
+
if (signal?.aborted) {
|
|
329
|
+
throw new ToolAbortError("Ask input was cancelled");
|
|
330
|
+
}
|
|
331
|
+
if (timeoutSignal?.aborted) {
|
|
332
|
+
return { optionLabels, selectedOptions: [], customInput: undefined, timedOut: true };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
|
|
299
339
|
if (params.questions.length === 1) {
|
|
300
340
|
const [q] = params.questions;
|
|
301
|
-
const optionLabels = q
|
|
302
|
-
const { selectedOptions, customInput } = await askSingleQuestion(
|
|
303
|
-
ui,
|
|
304
|
-
q.question,
|
|
305
|
-
optionLabels,
|
|
306
|
-
q.multi ?? false,
|
|
307
|
-
q.recommended,
|
|
308
|
-
{ timeout },
|
|
309
|
-
);
|
|
341
|
+
const { optionLabels, selectedOptions, customInput, timedOut } = await askQuestion(q);
|
|
310
342
|
|
|
343
|
+
if (!timedOut && selectedOptions.length === 0 && !customInput) {
|
|
344
|
+
context.abort();
|
|
345
|
+
throw new ToolAbortError("Ask tool was cancelled by the user");
|
|
346
|
+
}
|
|
311
347
|
const details: AskToolDetails = {
|
|
312
348
|
question: q.question,
|
|
313
349
|
options: optionLabels,
|
|
@@ -333,16 +369,12 @@ export class AskTool implements AgentTool<typeof askSchema, AskToolDetails> {
|
|
|
333
369
|
const results: QuestionResult[] = [];
|
|
334
370
|
|
|
335
371
|
for (const q of params.questions) {
|
|
336
|
-
const optionLabels = q
|
|
337
|
-
const { selectedOptions, customInput } = await askSingleQuestion(
|
|
338
|
-
ui,
|
|
339
|
-
q.question,
|
|
340
|
-
optionLabels,
|
|
341
|
-
q.multi ?? false,
|
|
342
|
-
q.recommended,
|
|
343
|
-
{ timeout },
|
|
344
|
-
);
|
|
372
|
+
const { optionLabels, selectedOptions, customInput, timedOut } = await askQuestion(q);
|
|
345
373
|
|
|
374
|
+
if (!timedOut && selectedOptions.length === 0 && !customInput) {
|
|
375
|
+
context.abort();
|
|
376
|
+
throw new ToolAbortError("Ask tool was cancelled by the user");
|
|
377
|
+
}
|
|
346
378
|
results.push({
|
|
347
379
|
id: q.id,
|
|
348
380
|
question: q.question,
|
package/src/tools/bash.ts
CHANGED
|
@@ -98,7 +98,11 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
|
|
|
98
98
|
constructor(private readonly session: ToolSession) {
|
|
99
99
|
this.#asyncEnabled = this.session.settings.get("async.enabled");
|
|
100
100
|
this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
|
|
101
|
-
this.description = renderPromptTemplate(bashDescription, {
|
|
101
|
+
this.description = renderPromptTemplate(bashDescription, {
|
|
102
|
+
asyncEnabled: this.#asyncEnabled,
|
|
103
|
+
hasAstGrep: this.session.settings.get("astGrep.enabled"),
|
|
104
|
+
hasAstEdit: this.session.settings.get("astEdit.enabled"),
|
|
105
|
+
});
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
#formatResultOutput(result: BashResult | BashInteractiveResult, headLines?: number, tailLines?: number): string {
|
package/src/tools/index.ts
CHANGED
|
@@ -282,6 +282,24 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
|
|
|
282
282
|
) {
|
|
283
283
|
requestedTools.push("bash");
|
|
284
284
|
}
|
|
285
|
+
|
|
286
|
+
// Auto-include AST counterparts when their text-based sibling is present
|
|
287
|
+
if (requestedTools) {
|
|
288
|
+
if (
|
|
289
|
+
requestedTools.includes("grep") &&
|
|
290
|
+
!requestedTools.includes("ast_grep") &&
|
|
291
|
+
session.settings.get("astGrep.enabled")
|
|
292
|
+
) {
|
|
293
|
+
requestedTools.push("ast_grep");
|
|
294
|
+
}
|
|
295
|
+
if (
|
|
296
|
+
requestedTools.includes("edit") &&
|
|
297
|
+
!requestedTools.includes("ast_edit") &&
|
|
298
|
+
session.settings.get("astEdit.enabled")
|
|
299
|
+
) {
|
|
300
|
+
requestedTools.push("ast_edit");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
285
303
|
const allTools: Record<string, ToolFactory> = { ...BUILTIN_TOOLS, ...HIDDEN_TOOLS };
|
|
286
304
|
const isToolAllowed = (name: string) => {
|
|
287
305
|
if (name === "lsp") return enableLsp;
|
|
@@ -86,9 +86,8 @@ export function formatPromptContent(content: string, options: PromptFormatOption
|
|
|
86
86
|
|
|
87
87
|
for (let i = 0; i < lines.length; i++) {
|
|
88
88
|
let line = lines[i].trimEnd();
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (CODE_FENCE.test(trimmed)) {
|
|
89
|
+
let trimmedStart = line.trimStart();
|
|
90
|
+
if (CODE_FENCE.test(trimmedStart)) {
|
|
92
91
|
inCodeBlock = !inCodeBlock;
|
|
93
92
|
result.push(line);
|
|
94
93
|
continue;
|
|
@@ -102,30 +101,29 @@ export function formatPromptContent(content: string, options: PromptFormatOption
|
|
|
102
101
|
if (replaceAsciiSymbols) {
|
|
103
102
|
line = replaceCommonAsciiSymbols(line);
|
|
104
103
|
}
|
|
104
|
+
trimmedStart = line.trimStart();
|
|
105
|
+
const trimmed = line.trim();
|
|
105
106
|
|
|
106
|
-
const isOpeningXml = OPENING_XML.test(
|
|
107
|
-
if (isOpeningXml && line.length ===
|
|
108
|
-
const match = OPENING_XML.exec(
|
|
107
|
+
const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>");
|
|
108
|
+
if (isOpeningXml && line.length === trimmedStart.length) {
|
|
109
|
+
const match = OPENING_XML.exec(trimmedStart);
|
|
109
110
|
if (match) topLevelTags.push(match[1]);
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
const closingMatch = CLOSING_XML.exec(
|
|
113
|
+
const closingMatch = CLOSING_XML.exec(trimmedStart);
|
|
113
114
|
if (closingMatch) {
|
|
114
115
|
const tagName = closingMatch[1];
|
|
115
116
|
if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
|
|
116
|
-
line = trimmed;
|
|
117
117
|
topLevelTags.pop();
|
|
118
|
-
} else {
|
|
119
|
-
line = line.trimEnd();
|
|
120
118
|
}
|
|
121
|
-
} else if (isPreRender &&
|
|
122
|
-
|
|
123
|
-
} else if (TABLE_SEP.test(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
line =
|
|
119
|
+
} else if (isPreRender && trimmedStart.startsWith("{{")) {
|
|
120
|
+
/* keep indentation as-is in pre-render for Handlebars markers */
|
|
121
|
+
} else if (TABLE_SEP.test(trimmedStart)) {
|
|
122
|
+
const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
|
|
123
|
+
line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`;
|
|
124
|
+
} else if (TABLE_ROW.test(trimmedStart)) {
|
|
125
|
+
const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
|
|
126
|
+
line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`;
|
|
129
127
|
}
|
|
130
128
|
|
|
131
129
|
if (shouldBoldRfc2119) {
|