feat(coding-agent): added streamed tool intent display and hashline edit operations

- Added streamed tool intent display in working message to show real-time intent tracking during agent execution.
- Changed intent tracing field name from `$intent` to `_intent` across tool schemas and agent core for consistency.
- Added support for file deletion and renaming operations in hashline edit mode.
- Renamed hashline edit operation fields: `set` to `target`/`new_content`, `set_range` to `first`/`last`/`new_content`, `insert` to `inserted_lines`.
This commit is contained in:
can1357
2026-02-19 18:05:35 +01:00
parent a80d00abfe
commit 7f3079bf2f
11 changed files with 258 additions and 111 deletions
+17 -3
View File
@@ -1,9 +1,10 @@
# Changelog
## [Unreleased]
### Added
- Added `intentTracing` option to enable intent goal extraction from tool calls, allowing models to specify high-level goals via a `$intent` field that is automatically injected into tool schemas and stripped from arguments before execution
- Added `intentTracing` option to enable intent goal extraction from tool calls, allowing models to specify high-level goals via a required `_intent` field that is automatically injected into tool schemas and stripped from arguments before execution
## [12.11.0] - 2026-02-19
@@ -29,6 +30,7 @@
- Added `preferWebsockets` option to hint that websocket transport should be preferred when supported by the provider implementation
## [11.10.0] - 2026-02-10
### Added
- Added `temperature` option to `AgentOptions` to control LLM sampling temperature
@@ -47,6 +49,7 @@
- Added `skipInitialSteeringPoll` option to `_runLoop()` for correct queue resume ordering
## [11.3.0] - 2026-02-06
### Added
- Added `maxRetryDelayMs` option to AgentOptions to cap server-requested retry delays, allowing higher-level retry logic to handle long waits with user visibility
@@ -56,11 +59,13 @@
- Updated ThinkingLevel documentation to include support for gpt-5.3 and gpt-5.3-codex models with 'xhigh' thinking level
## [11.2.0] - 2026-02-05
### Fixed
- Fixed handling of aborted requests to properly throw abort errors when stream terminates without a terminal event
## [10.5.0] - 2026-02-04
### Added
- Added `concurrency` option to `AgentTool` to control tool scheduling: "shared" (default, runs in parallel) or "exclusive" (runs alone)
@@ -73,34 +78,42 @@
## [9.2.2] - 2026-01-31
### Added
- Added toolChoice option to AgentPromptOptions for controlling tool selection
## [8.2.0] - 2026-01-24
### Changed
- Updated TypeScript configuration for better publish-time configuration handling with tsconfig.publish.json
## [8.0.0] - 2026-01-23
### Added
- Added `nonAbortable` option to tools to ignore abort signals during execution
## [6.8.0] - 2026-01-20
### Changed
- Updated proxy stream processing to use utility function for reading lines
## [6.2.0] - 2026-01-19
### Added
- Enhanced getToolContext to receive tool call batch information including batchId, index, total count, and tool call details
## [5.6.7] - 2026-01-18
### Fixed
- Added proper tool result messages for tool calls that are aborted or error out
- Ensured tool_use/tool_result pairing is maintained when tool execution fails
## [4.6.0] - 2026-01-12
### Changed
- Modified assistant message handling to split messages around tool results for improved readability when using Cursor tools
@@ -110,12 +123,14 @@
- Fixed tool result ordering in Cursor mode by buffering results and emitting them at the correct position within assistant messages
## [4.3.0] - 2026-01-11
### Added
- Added `cursorExecHandlers` and `cursorOnToolResult` options for local tool execution with cursor-based streaming
- Added `emitExternalEvent` method to allow external event injection into the agent state
## [4.0.0] - 2026-01-10
### Added
- Added `popLastSteer()` and `popLastFollowUp()` methods to remove and return the last queued message (LIFO) for dequeue operations
@@ -197,7 +212,6 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
- **Transport abstraction removed**: `ProviderTransport`, `AppTransport`, and `AgentTransport` interface have been removed. Use the `streamFn` option directly for custom streaming implementations.
- **Agent options renamed**:
- `transport` → removed (use `streamFn` instead)
- `messageTransformer` → `convertToLlm`
- `preprocessor` → `transformContext`
@@ -226,4 +240,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
- `Agent` constructor now has all options optional (empty options use defaults).
- `queueMessage()` is now synchronous (no longer returns a Promise).
- `queueMessage()` is now synchronous (no longer returns a Promise).
+41 -33
View File
@@ -123,9 +123,9 @@ function normalizeMessagesForProvider(
return changed ? normalized : messages;
}
export const INTENT_FIELD = "$intent";
export const INTENT_FIELD = "_intent";
function injectIntentGoalIntoSchema(schema: unknown): unknown {
function injectIntentIntoSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return schema;
const schemaRecord = schema as Record<string, unknown>;
const propertiesValue = schemaRecord.properties;
@@ -133,38 +133,46 @@ function injectIntentGoalIntoSchema(schema: unknown): unknown {
propertiesValue && typeof propertiesValue === "object" && !Array.isArray(propertiesValue)
? (propertiesValue as Record<string, unknown>)
: {};
if (INTENT_FIELD in properties) return schema;
const requiredValue = schemaRecord.required;
const required = Array.isArray(requiredValue)
? requiredValue.filter((item): item is string => typeof item === "string")
: [];
if (INTENT_FIELD in properties) {
if (required.includes(INTENT_FIELD)) return schema;
return {
...schemaRecord,
required: [...required, INTENT_FIELD],
};
}
return {
...schemaRecord,
properties: {
...properties,
[INTENT_FIELD]: {
type: "string",
description: "High-level goal for this tool call.",
description:
"Describe intent as one sentence in present participle form (e.g., Inserting comment before the function) with no trailing period",
},
},
required: [...required, INTENT_FIELD],
};
}
function injectIntentGoalIntoTools(tools: Context["tools"]): Context["tools"] {
if (!tools || tools.length === 0) return tools;
return tools.map(tool => ({
function injectIntentIntoTools(tools: Context["tools"]): Context["tools"] {
return tools?.map(tool => ({
...tool,
parameters: injectIntentGoalIntoSchema(tool.parameters) as typeof tool.parameters,
parameters: injectIntentIntoSchema(tool.parameters) as typeof tool.parameters,
}));
}
function stripIntentGoalFromArgs(args: Record<string, unknown>): Record<string, unknown> {
if (!(INTENT_FIELD in args)) return args;
const { [INTENT_FIELD]: _goal, ...rest } = args;
return rest;
}
function extractIntentGoal(args: Record<string, unknown>): string | undefined {
const goal = args[INTENT_FIELD];
if (typeof goal !== "string") return undefined;
const trimmed = goal.trim();
return trimmed.length > 0 ? trimmed : undefined;
function extractIntent(args: Record<string, unknown>): { intent?: string; strippedArgs: Record<string, unknown> } {
const intent = args[INTENT_FIELD];
if (typeof intent !== "string") {
return { strippedArgs: args };
}
const { [INTENT_FIELD]: _ignored, ...strippedArgs } = args;
const trimmed = intent.trim();
return { intent: trimmed.length > 0 ? trimmed : undefined, strippedArgs };
}
/**
@@ -306,7 +314,7 @@ async function streamAssistantResponse(
const llmContext: Context = {
systemPrompt: context.systemPrompt,
messages: normalizedMessages,
tools: config.intentTracing ? injectIntentGoalIntoTools(context.tools) : context.tools,
tools: config.intentTracing ? injectIntentIntoTools(context.tools) : context.tools,
};
const streamFunction = streamFn || streamSimple;
@@ -458,6 +466,7 @@ async function executeToolCalls(
const records = toolCalls.map(toolCall => ({
toolCall,
tool: tools?.find(t => t.name === toolCall.name),
args: toolCall.arguments as Record<string, unknown>,
started: false,
result: undefined as AgentToolResult<any> | undefined,
isError: false,
@@ -471,25 +480,22 @@ async function executeToolCalls(
}
const { toolCall, tool } = record;
if (
intentTracing &&
toolCall.arguments &&
typeof toolCall.arguments === "object" &&
!Array.isArray(toolCall.arguments)
) {
const toolArgs = toolCall.arguments as Record<string, unknown>;
const intent = extractIntentGoal(toolArgs);
let argsForExecution = toolCall.arguments as Record<string, unknown>;
if (intentTracing) {
const { intent, strippedArgs } = extractIntent(toolCall.arguments);
argsForExecution = strippedArgs;
if (intent) {
toolCall.intent = intent;
}
toolCall.arguments = stripIntentGoalFromArgs(toolArgs);
}
record.args = argsForExecution;
record.started = true;
stream.push({
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
args: argsForExecution,
intent: toolCall.intent,
});
let result: AgentToolResult<any>;
@@ -498,7 +504,7 @@ async function executeToolCalls(
try {
if (!tool) throw new Error(`Tool ${toolCall.name} not found`);
const validatedArgs = validateToolArguments(tool, toolCall);
const validatedArgs = validateToolArguments(tool, { ...toolCall, arguments: argsForExecution });
const toolContext = getToolContext
? getToolContext({
batchId,
@@ -517,7 +523,7 @@ async function executeToolCalls(
type: "tool_execution_update",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
args: argsForExecution,
partialResult,
});
},
@@ -571,7 +577,8 @@ async function executeToolCalls(
type: "tool_execution_start",
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
args: record.args,
intent: toolCall.intent,
});
}
stream.push({
@@ -627,6 +634,7 @@ function createAbortedToolResult(
toolCallId: toolCall.id,
toolName: toolCall.name,
args: toolCall.arguments,
intent: toolCall.intent,
});
stream.push({
type: "tool_execution_end",
+3 -3
View File
@@ -119,8 +119,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions {
transformToolCallArguments?: (args: Record<string, unknown>, toolName: string) => Record<string, unknown>;
/**
* Enable intent tracing for tool calls.
* When enabled, the harness injects a `$intent: string` field into tool schemas sent to the model,
* then strips `$intent` from arguments before executing tools.
* When enabled, the harness injects a `_intent: string` field into tool schemas sent to the model,
* then strips `_intent` from arguments before executing tools.
*/
intentTracing?: boolean;
}
@@ -271,6 +271,6 @@ export type AgentEvent =
| { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
| { type: "message_end"; message: AgentMessage }
// Tool execution lifecycle
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
| { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any; intent?: string }
| { type: "tool_execution_update"; toolCallId: string; toolName: string; args: any; partialResult: any }
| { type: "tool_execution_end"; toolCallId: string; toolName: string; result: any; isError?: boolean };
+1
View File
@@ -444,6 +444,7 @@ describe("agentLoop with AgentMessage", () => {
value: { type: "string" },
[INTENT_FIELD]: { type: "string" },
});
expect(firstRequestToolSchema?.required).toEqual(expect.arrayContaining([INTENT_FIELD]));
expect(executedParams).toEqual([{ value: "hello" }]);
expect(tracedToolCall?.type).toBe("toolCall");
if (tracedToolCall?.type === "toolCall") {
+31 -2
View File
@@ -1,9 +1,11 @@
# Changelog
## [Unreleased]
### Added
- Added `tools.intentTracing` setting to enable intent tracing, which injects `$intent` into all tool schemas and strips it before tool execution
- Display streamed tool intent in working message during agent execution
- Added `tools.intentTracing` setting to enable intent tracing, which asks the agent to describe the intent of each tool call before executing it
- Support for file deletion in hashline edit mode via `delete: true` parameter
- Support for file renaming/moving in hashline edit mode via `rename` parameter
- Optional content-replace edit variant in hashline mode (enabled via `PI_HL_REPLACETXT=1` environment variable)
@@ -11,6 +13,7 @@
### Changed
- Intent tracing now uses `_intent` field name in tool schemas
- Hashline edit API: renamed `set` operation to `target`/`new_content` for clearer semantics
- Hashline edit API: renamed `set_range` operation to `first`/`last`/`new_content`
- Hashline edit API: renamed `insert` operation fields from `body` to `inserted_lines` and made `inserted_lines` required non-empty
@@ -20,8 +23,10 @@
### Fixed
- Grep tool now properly handles internal URL resolution when searching artifact paths
- Working message intent updates now fall back to tool execution events when streamed tool arguments omit the intent field
## [12.13.0] - 2026-02-19
### Breaking Changes
- Removed automatic line relocation when hash references become stale; edits with mismatched line hashes now fail with an error instead of silently relocating to matching lines elsewhere in the file
@@ -190,6 +195,7 @@
- Refactored session directory naming to use single-dash format for home-relative paths and double-dash format for absolute paths, with automatic migration of legacy session directories on first access
## [12.8.2] - 2026-02-17
### Changed
- Changed system environment context to use built-in `os` values for distro, kernel, and CPU model instead of native system-info data
@@ -202,11 +208,14 @@
## [12.8.0] - 2026-02-16
### Changed
- Improved `/changelog` performance by displaying only the most recent 3 versions by default, with a `--full` flag for the complete history ([#85](https://github.com/can1357/oh-my-pi/pull/85) by [@tctev](https://github.com/tctev))
- Centralized builtin slash command definitions and handlers into a shared registry, replacing the large input-controller if-chain dispatch
## [12.7.0] - 2026-02-16
### Added
- Added abort signal support to LSP file operations (`ensureFileOpen`, `refreshFile`) for cancellable file synchronization
- Added abort signal propagation through LSP request handlers (definition, references, hover, symbols, rename) enabling operation cancellation
- Added `shouldBypassAutocompleteOnEscape` callback to custom editor for context-aware escape key handling during active operations
@@ -221,7 +230,9 @@
- Added secret obfuscation: env vars matching secret patterns and `secrets.json` entries are replaced with placeholders before sending to LLM providers, deobfuscated in tool call arguments
- Added `secrets.enabled` setting to toggle secret obfuscation
- Added full regex literal support for `secrets.json` entries (`"/pattern/flags"` syntax with escaped `/` handling, automatic `g` flag enforcement)
### Changed
- Changed context promotion to trigger on context overflow instead of a configurable threshold, promoting to a larger model before attempting compaction
- Changed context promotion behavior to retry immediately on the promoted model without compacting, providing faster recovery from context limits
- Changed default grep context lines from 1 before/3 after to 0 before/0 after for more focused search results
@@ -236,20 +247,25 @@
- Updated web search provider priority order to include Brave (Exa → Brave → Jina → Perplexity → Anthropic → Gemini → Codex → Z.AI)
- Extended recency filter support to Brave provider alongside Perplexity
- Changed GitHub issue comment fetching to use paginated API requests with 100 comments per page instead of single request with 50-comment limit
### Removed
- Removed `contextPromotion.thresholdPercent` setting as context promotion now triggers only on overflow
### Fixed
- Fixed LSP operations to properly respect abort signals and throw `ToolAbortError` when cancelled
- Fixed workspace diagnostics process cleanup to remove abort event listeners in finally block
- Fixed PTY-backed bash execution to enforce timeout completion when detached child processes keep the PTY stream open ([#88](https://github.com/can1357/oh-my-pi/issues/88))
## [12.5.1] - 2026-02-15
### Added
- Added `repeatToolDescriptions` setting to render full tool descriptions in the system prompt instead of a tool name list
## [12.5.0] - 2026-02-15
### Breaking Changes
- Replaced `theme` setting with `theme.dark` and `theme.light` (auto-migrated)
@@ -300,6 +316,7 @@
- Sanitized debug log display to strip control codes, normalize tabs, and trim width
## [12.4.0] - 2026-02-14
### Changed
- Moved `sanitizeText` function from `@oh-my-pi/pi-utils` to `@oh-my-pi/pi-natives` for better code organization
@@ -315,6 +332,7 @@
- Fixed Cloudflare returning corrupted bytes when compression is negotiated in web scraper requests
## [12.3.0] - 2026-02-14
### Added
- Added autonomous memory extraction and consolidation system with configurable settings
@@ -360,6 +378,7 @@
- Fixed fetch tool to preserve actual response metadata (finalUrl, contentType) instead of defaults when requests fail
||||||| parent of a70a34c8b (fix(coding-agent/debug): Sanitized debug log rendering)
## [12.1.0] - 2026-02-13
### Added
@@ -411,6 +430,7 @@
- Removed @types/jsdom dependency
## [11.14.1] - 2026-02-12
### Changed
- Improved Bun binary detection to check `Bun.env.PI_COMPILED` environment variable
@@ -422,6 +442,7 @@
- Fixed Bun update process to properly handle version pinning and report installation mismatches
## [11.14.0] - 2026-02-12
### Added
- Added SwiftLint linter client with JSON reporter support for Swift file linting
@@ -474,6 +495,7 @@
- Refactored browser/file opening across multiple modules to use unified `openPath` utility for improved maintainability
## [11.12.0] - 2026-02-11
### Added
- Added `resolveFileDisplayMode` utility to centralize file display mode resolution across tools (read, grep, file mentions)
@@ -562,6 +584,7 @@
- Refactored hash line formatting to use async `streamHashLinesFromLines` for better performance
## [11.10.3] - 2026-02-10
### Added
- Exported `./patch/*` subpath for direct access to patch utilities
@@ -587,6 +610,7 @@
- Removed AggregateError unwrapping from console.warn in CLI initialization
## [11.10.1] - 2026-02-10
### Changed
- Migrated CLI framework from oclif to lightweight pi-utils CLI runner
@@ -601,6 +625,7 @@
- Removed custom oclif help renderer (oclif-help.ts)
## [11.10.0] - 2026-02-10
### Breaking Changes
- Changed `HashlineEdit.src` from string format (e.g., `"5:ab"`, `"5:ab..9:ef"`) to structured `SrcSpec` object with discriminated union types (`{ kind: "single", ref: "..." }`, `{ kind: "range", start: "...", end: "..." }`, etc.)
@@ -751,6 +776,7 @@
- Improved bash tool output draining after foreground completion to reduce tail output truncation
## [11.8.0] - 2026-02-10
### Added
- Added `ctx.reload()` method to extension command context to reload extensions, skills, prompts, and themes from disk
@@ -773,6 +799,7 @@
- Fixed archive extraction error handling to provide clear error messages on failure
## [11.7.0] - 2026-02-07
### Changed
- Enhanced error messages for failed Python cells to include full combined output context instead of just the error message
@@ -784,6 +811,7 @@
- Fixed tab character rendering in Python tool output display to properly format whitespace in cell output and status events
## [11.6.1] - 2026-02-07
### Fixed
- Fixed potential crash when rendering results with undefined details.results
@@ -832,6 +860,7 @@
- Removed ability to save screenshots to custom paths or artifacts directory
## [11.4.1] - 2026-02-06
### Fixed
- Fixed tab character display in error messages and bash tool output by properly replacing tabs with spaces
@@ -4805,4 +4834,4 @@ Initial public release.
- Git branch display in footer
- Message queueing during streaming responses
- OAuth integration for Gmail and Google Calendar access
- HTML export with syntax highlighting and collapsible sections
- HTML export with syntax highlighting and collapsible sections
@@ -508,7 +508,7 @@ export const SETTINGS_SCHEMA = {
ui: {
tab: "tools",
label: "Intent tracing",
description: "Inject `$intent` into all tool schemas and strip it before tool execution",
description: "Ask the agent to describe the intent of each tool call before executing it",
},
},
@@ -503,6 +503,7 @@ export interface ToolExecutionStartEvent {
toolCallId: string;
toolName: string;
args: unknown;
intent?: string;
}
/** Fired during tool execution with partial/streaming output */
@@ -1,3 +1,4 @@
import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
import { Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
import { settings } from "../../config/settings";
import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -14,6 +15,7 @@ export class EventController {
#lastReadGroup: ReadToolGroupComponent | undefined = undefined;
#lastThinkingCount = 0;
#renderedCustomMessages = new Set<string>();
#lastIntent: string | undefined = undefined;
constructor(private ctx: InteractiveModeContext) {}
@@ -32,6 +34,13 @@ export class EventController {
return this.#lastReadGroup;
}
#updateWorkingMessageFromIntent(intent: string | undefined): void {
const trimmed = intent?.trim();
if (!trimmed || trimmed === this.#lastIntent) return;
this.#lastIntent = trimmed;
this.ctx.setWorkingMessage(`${trimmed} (esc to interrupt)`);
}
subscribeToAgent(): void {
this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
await this.handleEvent(event);
@@ -48,6 +57,7 @@ export class EventController {
switch (event.type) {
case "agent_start":
this.#lastIntent = undefined;
if (this.ctx.retryEscapeHandler) {
this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
this.ctx.retryEscapeHandler = undefined;
@@ -155,6 +165,15 @@ export class EventController {
}
}
}
// Update working message with intent from streamed tool arguments
for (const content of this.ctx.streamingMessage.content) {
if (content.type !== "toolCall") continue;
const args = content.arguments;
if (!args || typeof args !== "object" || !(INTENT_FIELD in args)) continue;
this.#updateWorkingMessageFromIntent(args[INTENT_FIELD] as string | undefined);
}
this.ctx.ui.requestRender();
}
break;
@@ -196,6 +215,7 @@ export class EventController {
break;
case "tool_execution_start": {
this.#updateWorkingMessageFromIntent(event.intent);
if (!this.ctx.pendingTools.has(event.toolCallId)) {
if (event.toolName === "read") {
const group = this.#getReadGroup();
+30 -28
View File
@@ -140,8 +140,8 @@ export type HashlineEdit =
const hashlineContentFormat = (kind: string) =>
Type.Union([
Type.Null(),
Type.Array(Type.String(), { minItems: 1, description: `${kind} lines` }),
Type.String({ minLength: 1, description: `${kind} text, \\n delimited if multiple lines` }),
Type.Array(Type.String(), { description: `${kind} lines` }),
Type.String({ description: `${kind} line` }),
]);
function hashlineParseContent(edit: string | string[] | null): string[] {
@@ -153,6 +153,12 @@ function hashlineParseContent(edit: string | string[] | null): string[] {
return lines;
}
function hashlineParseContentString(edit: string | string[] | null): string {
if (edit === null) return "";
if (Array.isArray(edit)) return edit.join("\n");
return edit;
}
const hashlineTargetEditSchema = Type.Object({
target: Type.String({ description: 'Line reference "LINE#ID"' }),
new_content: hashlineContentFormat("Replacement"),
@@ -190,18 +196,19 @@ function hashlineParseRangeEdit(edit: HashlineEditUnion): HashlineEdit | null {
}
const hashlineInsertEditSchema = Type.Object({
before: Type.Optional(Type.String({ minLength: 1, description: 'Insert before this line "LINE#ID"' })),
after: Type.Optional(Type.String({ minLength: 1, description: 'Insert after this line "LINE#ID"' })),
before: Type.Optional(Type.String({ description: 'Insert before this line "LINE#ID"' })),
after: Type.Optional(Type.String({ description: 'Insert after this line "LINE#ID"' })),
inserted_lines: hashlineContentFormat("Inserted"),
});
function hashlineParseInsertEdit(edit: HashlineEditUnion): HashlineEdit | null {
if ("inserted_lines" in edit) {
const { before = "", after = "", inserted_lines } = edit;
return {
insert: {
before: edit.before,
after: edit.after,
body: hashlineParseContent(edit.inserted_lines),
before: before.length > 0 ? before : undefined,
after: after.length > 0 ? after : undefined,
body: hashlineParseContent(inserted_lines),
},
};
}
@@ -216,11 +223,12 @@ const hashlineReplaceTextEditSchema = Type.Object({
function hashlineParseReplaceTextEdit(edit: HashlineEditUnion): HashlineReplaceEdit | null {
if ("old_text" in edit && "new_text" in edit) {
const { old_text, new_text, all } = edit;
return {
replace: {
old_text: edit.old_text,
new_text: hashlineParseContent(edit.new_text).join("\n"),
all: edit.all,
old_text: old_text,
new_text: hashlineParseContentString(new_text),
all: all,
},
};
}
@@ -239,24 +247,18 @@ const hashlineEditItemSchemaWithReplace = Type.Union([
hashlineReplaceTextEditSchema,
]);
const hashlineEditSchema = Type.Object(
{
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
},
{ additionalProperties: true },
);
const hashlineEditSchemaWithReplace = Type.Object(
{
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchemaWithReplace, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
},
{ additionalProperties: true },
);
const hashlineEditSchema = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchema, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
});
const hashlineEditSchemaWithReplace = Type.Object({
path: Type.String({ description: "File path (relative or absolute)" }),
edits: Type.Array(hashlineEditItemSchemaWithReplace, { description: "Array of edit operations" }),
delete: Type.Optional(Type.Literal(true, { description: "Delete the file" })),
rename: Type.Optional(Type.String({ description: "New path for move" })),
});
export type HashlineToolEdit = Static<typeof hashlineEditItemSchemaWithReplace>;
export type HashlineParams = Static<typeof hashlineEditSchemaWithReplace>;
@@ -1,4 +1,4 @@
# Edit (Hash Anchored)
# Edit
Apply precise file edits using `LINE#ID` anchors from `read` output.
**CRITICAL:** anchors are `LINE#ID` only. Copy verbatim from the prefix (example: `{{hlineref 42 "const x = 1"}}`). Never include `|content`.
@@ -15,10 +15,10 @@ Apply precise file edits using `LINE#ID` anchors from `read` output.
<operations>
- **Single line replace/delete**
- `{ target: "LINE#ID", new_content: ["..."] }`
- `new_content: []` deletes the line; `new_content: [""]` keeps a blank line.
- `new_content: null` deletes the line; `new_content: [""]` keeps a blank line.
- **Range replace/delete**
- `{ first: "LINE#ID", last: "LINE#ID", new_content: ["..."] }`
- Use for swaps, block rewrites, or deleting a full span (`new_content: []`).
- Use for swaps, block rewrites, or deleting a full span (`new_content: null`).
- **Insert** (new content)
- `{ before: "LINE#ID", inserted_lines: ["..."] }`
- `{ after: "LINE#ID", inserted_lines: ["..."] }`
@@ -67,83 +67,154 @@ Apply precise file edits using `LINE#ID` anchors from `read` output.
- After two no-ops on same area, re-read the full function/block before retry.
</recovery>
<example name="single-line token fix (set)">
<example name="single line replace — fix a value or type">
```ts
{{hlinefull 41 " return record != null && record.status === 'fulfilled';"}}
{{hlinefull 23 " const timeout: number = 5000;"}}
```
```json
{ target: "{{hlineref 41 " return record != null && record.status === 'fulfilled';"}}", new_content: [" return record != null && record?.status === 'fulfilled';"] }
```
target: "{{hlineref 23 " const timeout: number = 5000;"}}"
new_content: [" const timeout: number = 30_000;"]
```
</example>
<example name="restore missing declaration (insert before)">
<example name="single line delete — remove a line entirely">
```ts
{{hlinefull 15 "export function useX(...): boolean {"}}
{{hlinefull 16 " useEffect(() => {"}}
{{hlinefull 7 "// @ts-ignore"}}
{{hlinefull 8 "const data = fetchSync(url);"}}
```
```json
{ before: "{{hlineref 16 " useEffect(() => {"}}", inserted_lines: [" const [isVisible, setIsVisible] = useState(true);"] }
```
target: "{{hlineref 7 "// @ts-ignore"}}"
new_content: null
```
</example>
<example name="insert between siblings (after+before)">
<example name="single line blank — clear content but keep the line break">
```ts
{{hlinefull 120 " doFirst();"}}
{{hlinefull 121 " doThird();"}}
{{hlinefull 14 " placeholder: \"DO NOT SHIP\","}}
```
```json
{ after: "{{hlineref 120 " doFirst();"}}", before: "{{hlineref 121 " doThird();"}}", inserted_lines: [" doSecond();"] }
```
target: "{{hlineref 14 " placeholder: \"DO NOT SHIP\","}}"
new_content: [""]
```
</example>
<example name="swap adjacent lines atomically (set_range)">
<example name="range replace — rewrite a block of logic">
```ts
{{hlinefull 190 " thenable.then(resolve, ignoreReject);"}}
{{hlinefull 191 " chunkCache.set(chunkId, thenable);"}}
{{hlinefull 60 " } catch (err) {"}}
{{hlinefull 61 " console.error(err);"}}
{{hlinefull 62 " return null;"}}
{{hlinefull 63 " }"}}
```
```json
{ first: "{{hlineref 190 " thenable.then(resolve, ignoreReject);"}}", last: "{{hlineref 191 " chunkCache.set(chunkId, thenable);"}}", new_content: [" chunkCache.set(chunkId, thenable);", " thenable.then(resolve, ignoreReject);"] }
```
first: "{{hlineref 60 " } catch (err) {"}}"
last: "{{hlineref 63 " }"}}"
new_content: [" } catch (err) {", " if (isEnoent(err)) return null;", " throw err;", " }"]
```
</example>
<example name="insert guard before comment">
<example name="range delete — remove a full block">
```ts
{{hlinefull 188 ""}}
{{hlinefull 189 " // If we don't find a Fiber on the comment..."}}
{{hlinefull 80 " // TODO: remove after migration"}}
{{hlinefull 81 " if (legacy) {"}}
{{hlinefull 82 " legacyHandler(req);"}}
{{hlinefull 83 " }"}}
```
```json
{ after: "{{hlineref 188 ""}}", inserted_lines: [" if (targetFiber) {", " targetInst = targetFiber;", " }"] }
```
first: "{{hlineref 80 " // TODO: remove after migration"}}"
last: "{{hlineref 83 " }"}}"
new_content: null
```
</example>
<example name="anti-pattern: interior anchor vs boundary anchor">
Bad:
```json
{ after: "195#d3", inserted_lines: [" { id: \"nanogpt\", available: true },"] }
<example name="insert with before — add an import above the first import">
```ts
{{hlinefull 1 "import * as fs from \"node:fs/promises\";"}}
{{hlinefull 2 "import * as path from \"node:path\";"}}
```
Good:
```json
{ after: "196#f6", before: "197#fc", inserted_lines: [" { id: \"nanogpt\", available: true },"] }
```
before: "{{hlineref 1 "import * as fs from \"node:fs/promises\";"}}"
inserted_lines: ["import * as os from \"node:os\";"]
```
Use `before` when prepending at the top of a block or file — there is no meaningful anchor above.
</example>
<example name="explicit EOF append">
<example name="insert with after — append at end of file">
```ts
{{hlinefull 260 "// last existing line"}}
{{hlinefull 260 "export { serialize, deserialize };"}}
```
```json
{ after: "{{hlineref 260 "// last existing line"}}", inserted_lines: ["// end marker"] }
```
after: "{{hlineref 260 "export { serialize, deserialize };"}}"
inserted_lines: ["export { validate };"]
```
Use `after` when appending at the bottom — there is no anchor below.
</example>
<example name="insert with after + before (dual anchor) — add an entry between known siblings">
```ts
{{hlinefull 44 " \"build\": \"bun run compile\","}}
{{hlinefull 45 " \"test\": \"bun test\""}}
```
```
after: "{{hlineref 44 " \"build\": \"bun run compile\","}}"
before: "{{hlineref 45 " \"test\": \"bun test\""}}"
inserted_lines: [" \"lint\": \"biome check\","]
```
Dual anchors pin the insert to exactly one gap, preventing drift from edits elsewhere in the file. **Always prefer dual anchors when both boundaries are content lines.**
</example>
<example name="insert a function before another function — anchor to the target, not whitespace">
```ts
{{hlinefull 100 " return buf.toString(\"hex\");"}}
{{hlinefull 101 "}"}}
{{hlinefull 102 ""}}
{{hlinefull 103 "export function serialize(data: unknown): string {"}}
```
```
before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}"
inserted_lines: ["function validate(data: unknown): boolean {", " return data != null && typeof data === \"object\";", "}", ""]
```
The trailing `""` in `inserted_lines` preserves the blank-line separator. **Anchor to the structural line (`export function ...`), not the blank line above it** — blank lines are ambiguous and may be added or removed by other edits.
</example>
{{#if allowReplaceText}}
<example name="replace fallback only">
```json
{ old_text: "x = 42", new_text: "x = 99" }
<example name="content replace (fallback) — when anchors are unavailable">
```
old_text: "x = 42"
new_text: "x = 99"
```
Use only when line anchors aren't available. `old_text` must match exactly one location in the file (or set `"all": true` for all occurrences).
</example>
{{/if}}
<example name="file delete">
```
path: "src/deprecated/legacy.ts"
delete: true
```
</example>
<example name="file rename with edits — move and modify in one atomic call">
```
path: "src/utils.ts"
rename: "src/helpers/utils.ts"
edits: [..]
```
</example>
<example name="anti-pattern: anchoring to whitespace">
Bad — anchors to a blank line; fragile if blank lines shift:
```
after: "{{hlineref 102 ""}}"
inserted_lines: ["function validate() { ... }"]
```
Good — anchors to the structural target:
```
before: "{{hlineref 103 "export function serialize(data: unknown): string {"}}"
inserted_lines: ["function validate() { ... }", ""]
```
</example>
<validation>
- [ ] Payload shape is `{ "path": string, "edits": [operation, ...], "delete"?: true, "rename"?: string }`
- [ ] Every operation matches exactly one variant
@@ -1140,6 +1140,7 @@ export class AgentSession {
toolCallId: event.toolCallId,
toolName: event.toolName,
args: event.args,
intent: event.intent,
};
await this.#extensionRunner.emit(extensionEvent);
} else if (event.type === "tool_execution_update") {