diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index b4e5a60f4..2e0699c09 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -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). \ No newline at end of file +- `queueMessage()` is now synchronous (no longer returns a Promise). diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index b5f73507a..e9ba703fe 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -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; const propertiesValue = schemaRecord.properties; @@ -133,38 +133,46 @@ function injectIntentGoalIntoSchema(schema: unknown): unknown { propertiesValue && typeof propertiesValue === "object" && !Array.isArray(propertiesValue) ? (propertiesValue as Record) : {}; - 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): Record { - if (!(INTENT_FIELD in args)) return args; - const { [INTENT_FIELD]: _goal, ...rest } = args; - return rest; -} - -function extractIntentGoal(args: Record): 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): { intent?: string; strippedArgs: Record } { + 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, started: false, result: undefined as AgentToolResult | 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; - const intent = extractIntentGoal(toolArgs); + let argsForExecution = toolCall.arguments as Record; + 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; @@ -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", diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 52f2f6f60..98b5c9917 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -119,8 +119,8 @@ export interface AgentLoopConfig extends SimpleStreamOptions { transformToolCallArguments?: (args: Record, toolName: string) => Record; /** * 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 }; diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 89edd8cdd..2ddb6506b 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -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") { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ea761048d..a19fa8375 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 \ No newline at end of file +- HTML export with syntax highlighting and collapsible sections diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index d4ff1982f..3da2f0946 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -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", }, }, diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index 12a1b5450..f7c84fa1f 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -503,6 +503,7 @@ export interface ToolExecutionStartEvent { toolCallId: string; toolName: string; args: unknown; + intent?: string; } /** Fired during tool execution with partial/streaming output */ diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index cafd83b27..e21e87a9a 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -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(); + #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(); diff --git a/packages/coding-agent/src/patch/index.ts b/packages/coding-agent/src/patch/index.ts index 2e10102c5..132e21548 100644 --- a/packages/coding-agent/src/patch/index.ts +++ b/packages/coding-agent/src/patch/index.ts @@ -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; export type HashlineParams = Static; diff --git a/packages/coding-agent/src/prompts/tools/hashline.md b/packages/coding-agent/src/prompts/tools/hashline.md index 39fac2878..cec6f5e84 100644 --- a/packages/coding-agent/src/prompts/tools/hashline.md +++ b/packages/coding-agent/src/prompts/tools/hashline.md @@ -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. - **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. - + ```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;"] ``` - + ```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 ``` - + ```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: [""] ``` - + ```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;", " }"] ``` - + ```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 ``` - -Bad: -```json -{ after: "195#d3", inserted_lines: [" { id: \"nanogpt\", available: true },"] } + +```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. - + ```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. + + + +```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.** + + + +```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. {{#if allowReplaceText}} - -```json -{ old_text: "x = 42", new_text: "x = 99" } + ``` +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). {{/if}} + +``` +path: "src/deprecated/legacy.ts" +delete: true +``` + + + +``` +path: "src/utils.ts" +rename: "src/helpers/utils.ts" +edits: [..] +``` + + + +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() { ... }", ""] +``` + + - [ ] Payload shape is `{ "path": string, "edits": [operation, ...], "delete"?: true, "rename"?: string }` - [ ] Every operation matches exactly one variant diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index fa6b29363..dcac2658b 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -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") {