diff --git a/Cargo.lock b/Cargo.lock index 6301a7c08..c28da3648 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1245,9 +1245,9 @@ dependencies = [ [[package]] name = "html-to-markdown-rs" -version = "2.28.6" +version = "2.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6869b5e058b5ebb8c176269406b692d0695b4b19c36e532b56a2c355590978ae" +checksum = "9013679b8c3600142e5a8f742748c3c38c49d9fc50675dad62f8f1721090a85a" dependencies = [ "ahash", "astral-tl", @@ -2114,7 +2114,7 @@ dependencies = [ [[package]] name = "pi-natives" -version = "13.14.2" +version = "13.15.0" dependencies = [ "arboard", "ast-grep-core", diff --git a/Cargo.toml b/Cargo.toml index e2404e472..61fabde30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/brush-core-vendored", "crates/brush-builtins-vendored"] resolver = "3" [workspace.package] -version = "13.14.2" +version = "13.15.0" edition = "2024" license = "MIT" authors = ["Can Boluk"] diff --git a/biome.json b/biome.json index 47c5d9045..b7b152eb1 100644 --- a/biome.json +++ b/biome.json @@ -3,6 +3,10 @@ "enabled": true, "rules": { "recommended": true, + "correctness": { + "noUnusedImports": "error", + "noVoidTypeReturn": "off" + }, "style": { "noNonNullAssertion": "off", "useConst": "error", diff --git a/bun.lock b/bun.lock index 4ec2cc838..14bb9820e 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "packages/agent": { "name": "@oh-my-pi/pi-agent-core", - "version": "13.14.2", + "version": "13.15.0", "dependencies": { "@oh-my-pi/pi-ai": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", @@ -27,7 +27,7 @@ }, "packages/ai": { "name": "@oh-my-pi/pi-ai", - "version": "13.14.2", + "version": "13.15.0", "bin": { "pi-ai": "./src/cli.ts", }, @@ -51,7 +51,7 @@ }, "packages/coding-agent": { "name": "@oh-my-pi/pi-coding-agent", - "version": "13.14.2", + "version": "13.15.0", "bin": { "omp": "src/cli.ts", }, @@ -80,7 +80,7 @@ }, "packages/natives": { "name": "@oh-my-pi/pi-natives", - "version": "13.14.2", + "version": "13.15.0", "dependencies": { "@oh-my-pi/pi-utils": "workspace:*", }, @@ -114,7 +114,7 @@ }, "packages/stats": { "name": "@oh-my-pi/omp-stats", - "version": "13.14.2", + "version": "13.15.0", "bin": { "omp-stats": "./src/index.ts", }, @@ -139,7 +139,7 @@ }, "packages/swarm-extension": { "name": "@oh-my-pi/swarm-extension", - "version": "13.14.2", + "version": "13.15.0", "bin": { "omp-swarm": "src/cli.ts", }, @@ -152,7 +152,7 @@ }, "packages/tui": { "name": "@oh-my-pi/pi-tui", - "version": "13.14.2", + "version": "13.15.0", "dependencies": { "@oh-my-pi/pi-natives": "workspace:*", "@oh-my-pi/pi-utils": "workspace:*", @@ -165,7 +165,7 @@ }, "packages/utils": { "name": "@oh-my-pi/pi-utils", - "version": "13.14.2", + "version": "13.15.0", "dependencies": { "beautiful-mermaid": "^1.1", "winston": "^3.19", @@ -467,21 +467,21 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260321.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260321.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260321.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260321.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-uScJZRWRxyi1l4EWwOtuO88Gh8sUTi0itcI4oKlyNtXkqik4Y7EHfs1sfYPDuAEJO3cvW6bqohHjGx3mcXSZzQ=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260322.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260322.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260322.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260322.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-CmzQTKvesYHmz3g92G+XPDis25ocvHqa/gK8m98w+bML99KJLEWQKVlvkLrYA85JiJEK+XBIiz+6lCgUqRkWXA=="], - "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260321.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3LQP363bDCF/pmXqzhSCSkKXr1PpNl2elC167YFRPKRyJdrETiIwj3YAB8A6esn9D30pas5VLzfmeK/tUOf+6g=="], + "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260322.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5wSilxwLGX5fMKJgsUkCBwOfW9GMG3WF5j77CVBOdFI7miFaR3JQaPzTA+uyHDMNIIeSDo1KtV77GT48Y/d0Xg=="], - "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260321.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-gCoKiv415CROgl0K8hEV8Lw/zvbYriWWmD7VxvpiQiTRqQmHppVXhLtb2OrGaPcsqpoBdYeCJQHN4wnohAkNLA=="], + "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260322.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-G806SrfxkYNAgZ9Xk53+OvbmIg9iD5hjaiD2QhDQL2aZjzy10D4MhcdaZEOoMfw0OI/PoJPYOiPD+9/x2kw3Lg=="], - "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "arm" }, "sha512-QuAFR9eFQzuqtKTIaJ5XkNR4i5Q55b1SE7fUcIAS528aY9j+5P1cMpvJa8aOBCuRKxfMgV5UtamolZKGWWzaMw=="], + "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "arm" }, "sha512-0a12pp19ELiNHMqTglfQQQNMsxvtzpjAa4qf12oMJoGyy+UnguKEmaaaCHdp75KvBXGDzlssfDAdiy+NirN19A=="], - "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-15z7UWt0PG870ktcUbaa0NogAjXIYT4pSFWlsc95u8+1aITrBTMQgqRih5qUH8bHke3eeYwbpjfXaU4gNmexvw=="], + "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+FyomEEt3K8TBO//n3Ijr61SDM2F7cxZCVqGt+Wk3rLcOCQ2i+8+p64gdsZCmImy3CyP0hBnxPydEbyNkZLtvg=="], - "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260321.1", "", { "os": "linux", "cpu": "x64" }, "sha512-8yuzwkxQnNSpXjXK43Y5Pn6rBfNbJVIcd3Qh9n3Tzhgtr+lcoGgwgMvn8axnqaazkxIUB3PZuiGRcqr6XIq3LA=="], + "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260322.1", "", { "os": "linux", "cpu": "x64" }, "sha512-MviQe5x4WqQGv/Vhu4hcv2A0qTW/BTaZPbOLYCtvhuovNFO6D++ZmJAbHvA0h/bJEaNTgxKZdZPHMpCfSEKfjA=="], - "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260321.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-fCUk/VElUjMFmE6iFAtsy5r7kLxeLggEHOTWuR0HGYIUQze6EyAdDFqMPFFxvbzpUyFQFpRfUa0I/Fa5tqKh8g=="], + "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260322.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-ibnMaXDJPSgMXKC61NHiFlww/xjAEINgc1mcn2ntTfuGHwduU4P9Bi038TxXg95Wmu3v6xIPIorXXsBOdE+p3Q=="], - "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260321.1", "", { "os": "win32", "cpu": "x64" }, "sha512-CWGyck7+sbNwOhcL+ObHhtKZe2/+Y6OZlEdWX2mHjpv8ef7ohUbPCdS94p+e7jbVY56w8NAce2Xx7ppn/C1Ucg=="], + "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260322.1", "", { "os": "win32", "cpu": "x64" }, "sha512-O+r1RToWBbGkK7NXC7DpraLObSWyxvSqRiSfr/BlZ351Cdq1q3121zCGzVtqERGeRtVoEMRrzS5ITOd6On/pCw=="], "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], @@ -941,7 +941,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], diff --git a/crates/brush-core-vendored/src/commands.rs b/crates/brush-core-vendored/src/commands.rs index 7b8b9c856..e7426f1ae 100644 --- a/crates/brush-core-vendored/src/commands.rs +++ b/crates/brush-core-vendored/src/commands.rs @@ -576,12 +576,18 @@ pub(crate) async fn invoke_command_in_subshell_and_get_output( rt.block_on(run_substitution_command(subshell, params, s)) }); - // Extract output. - let output_str = std::io::read_to_string(reader)?; + // Read subshell output on a blocking thread to avoid stalling the + // async runtime when the pipe stays open (e.g. a hung child process). + let output_join_handle = tokio::task::spawn_blocking(move || { + std::io::read_to_string(reader) + }); - // Now observe the command's completion. - let run_result = cmd_join_handle.await?; - let cmd_result = run_result?; + // Wait for both the output reader and the command to complete. + let (output_result, cmd_result) = tokio::join!(output_join_handle, cmd_join_handle); + let output_str = output_result + .map_err(|e| std::io::Error::other(e))??; + let cmd_result = cmd_result + .map_err(|e| std::io::Error::other(e))??; // Store the status. *shell.last_exit_status_mut() = cmd_result.exit_code.into(); diff --git a/docs/porting-from-pi-mono.md b/docs/porting-from-pi-mono.md index fbc41b604..24a9f8704 100644 --- a/docs/porting-from-pi-mono.md +++ b/docs/porting-from-pi-mono.md @@ -5,15 +5,15 @@ Use it for any merge: single file, feature branch, or full release sync. ## Last Sync Point -**Commit:** `1feccfedcb1eeeca91be0b9d389e8e5a9daee505` -**Date:** 2026-03-14 +**Commit:** `b21b42d032919de2f2e6920a76fa9a37c3920c0a` +**Date:** 2026-03-22 Update this section after each sync; do not reuse the previous range. When starting a new sync, generate patches from this commit forward: ```bash -git format-patch 15e0957b045d9e0d49253b2285cb585cf3a75c55..HEAD --stdout > changes.patch +git format-patch b21b42d032919de2f2e6920a76fa9a37c3920c0a..HEAD --stdout > changes.patch ``` ## 0) Define the scope diff --git a/packages/agent/package.json b/packages/agent/package.json index a71b0b306..69ac86498 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-agent-core", - "version": "13.14.2", + "version": "13.15.0", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "homepage": "https://github.com/can1357/oh-my-pi", "author": "Can Boluk", diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 0eac54e36..e32feaea3 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -196,7 +196,6 @@ async function runLoop( // Outer loop: continues when queued follow-up messages arrive after agent would stop while (true) { let hasMoreToolCalls = true; - let steeringAfterTools: AgentMessage[] | null = null; // Inner loop: process tool calls and steering messages while (hasMoreToolCalls || pendingMessages.length > 0) { @@ -225,6 +224,7 @@ async function runLoop( // Stream assistant response const message = await streamAssistantResponse(currentContext, config, signal, stream, streamFn); newMessages.push(message); + let steeringMessagesFromExecution: AgentMessage[] | undefined; if (message.stopReason === "error" || message.stopReason === "aborted") { // Create placeholder tool results for any tool calls in the aborted message @@ -250,19 +250,20 @@ async function runLoop( const toolResults: ToolResultMessage[] = []; if (hasMoreToolCalls) { - const toolExecution = await executeToolCalls( + const executionResult = await executeToolCalls( currentContext.tools, message, signal, stream, config.getSteeringMessages, - config.getToolContext, config.interruptMode, + config.getToolContext, config.transformToolCallArguments, config.intentTracing, ); - toolResults.push(...toolExecution.toolResults); - steeringAfterTools = toolExecution.steeringMessages ?? null; + + toolResults.push(...executionResult.toolResults); + steeringMessagesFromExecution = executionResult.steeringMessages; for (const result of toolResults) { currentContext.messages.push(result); @@ -272,13 +273,7 @@ async function runLoop( stream.push({ type: "turn_end", message, toolResults }); - // Get steering messages after turn completes - if (steeringAfterTools && steeringAfterTools.length > 0) { - pendingMessages = steeringAfterTools; - steeringAfterTools = null; - } else { - pendingMessages = (await config.getSteeringMessages?.()) || []; - } + pendingMessages = steeringMessagesFromExecution ?? ((await config.getSteeringMessages?.()) || []); } // Agent would stop here. Check for follow-up messages. @@ -433,25 +428,37 @@ async function executeToolCalls( signal: AbortSignal | undefined, stream: EventStream, getSteeringMessages?: AgentLoopConfig["getSteeringMessages"], - getToolContext?: AgentLoopConfig["getToolContext"], interruptMode: AgentLoopConfig["interruptMode"] = "immediate", + getToolContext?: AgentLoopConfig["getToolContext"], transformToolCallArguments?: AgentLoopConfig["transformToolCallArguments"], intentTracing?: AgentLoopConfig["intentTracing"], ): Promise<{ toolResults: ToolResultMessage[]; steeringMessages?: AgentMessage[] }> { type ToolCallContent = Extract; const toolCalls = assistantMessage.content.filter((c): c is ToolCallContent => c.type === "toolCall"); const emittedToolResults: ToolResultMessage[] = []; - let steeringMessages: AgentMessage[] | undefined; - const shouldInterruptImmediately = interruptMode !== "wait"; const toolCallInfos = toolCalls.map(call => ({ id: call.id, name: call.name })); const batchId = `${assistantMessage.timestamp ?? Date.now()}_${toolCalls[0]?.id ?? "batch"}`; + const shouldInterruptImmediately = interruptMode !== "wait"; const steeringAbortController = new AbortController(); const toolSignal = signal ? AbortSignal.any([signal, steeringAbortController.signal]) : steeringAbortController.signal; const interruptState = { triggered: false }; + let steeringMessages: AgentMessage[] | undefined; let steeringCheck: Promise | null = null; + 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, + skipped: false, + toolResultMessage: undefined as ToolResultMessage | undefined, + resultEmitted: false, + })); + const checkSteering = async (): Promise => { if (!shouldInterruptImmediately || !getSteeringMessages || interruptState.triggered) { return; @@ -473,18 +480,6 @@ async function executeToolCalls( await steeringCheck; }; - 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, - skipped: false, - toolResultMessage: undefined as ToolResultMessage | undefined, - resultEmitted: false, - })); - const emitToolResult = (record: (typeof records)[number], result: AgentToolResult, isError: boolean): void => { if (record.resultEmitted) return; const { toolCall } = record; @@ -578,7 +573,6 @@ async function executeToolCalls( transformToolCallArguments ? transformToolCallArguments(effectiveArgs, toolCall.name) : effectiveArgs, tool.nonAbortable ? undefined : toolSignal, partialResult => { - if (interruptState.triggered) return; stream.push({ type: "tool_execution_update", toolCallId: toolCall.id, @@ -637,13 +631,6 @@ async function executeToolCalls( return { toolResults: emittedToolResults, steeringMessages }; } -function createSkippedToolResult(): AgentToolResult { - return { - content: [{ type: "text", text: "Skipped due to queued user message." }], - details: {}, - }; -} - /** * Create a tool result for a tool call that was aborted or errored before execution. * Maintains the tool_use/tool_result pairing required by the API. @@ -690,3 +677,10 @@ function createAbortedToolResult( return toolResultMessage; } + +function createSkippedToolResult(): AgentToolResult { + return { + content: [{ type: "text", text: "Skipped due to queued user message." }], + details: {}, + }; +} diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index ba73a7f81..da002c77b 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -603,33 +603,16 @@ describe("agentLoop with AgentMessage", () => { expect(text).not.toContain("Tool execution was aborted.:"); } }); - it("should inject queued messages and skip remaining tool calls", async () => { + it("should skip remaining tool calls when steering is queued", async () => { const toolSchema = Type.Object({ value: Type.String() }); const executed: string[] = []; - const { promise: allowSecond, resolve: allowSecondResolve } = Promise.withResolvers(); const tool: AgentTool = { name: "echo", label: "Echo", description: "Echo tool", parameters: toolSchema, - async execute(_toolCallId, params, signal) { - if (params.value === "second") { - await new Promise((resolve, reject) => { - if (signal?.aborted) { - reject(new Error("Tool aborted")); - return; - } - const onAbort = () => reject(new Error("Tool aborted")); - signal?.addEventListener("abort", onAbort, { once: true }); - allowSecond.then(() => { - signal?.removeEventListener("abort", onAbort); - resolve(); - }); - }); - if (signal?.aborted) { - throw new Error("Tool aborted"); - } - } + concurrency: "exclusive", + async execute(_toolCallId, params) { executed.push(params.value); return { content: [{ type: "text", text: `ok:${params.value}` }], @@ -654,11 +637,11 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, + interruptMode: "immediate", getSteeringMessages: async () => { - // Return queued message after first tool executes - if (executed.length === 1 && !queuedDelivered) { + // Return steering message after tool execution has started + if (executed.length >= 1 && !queuedDelivered) { queuedDelivered = true; - allowSecondResolve(); return [queuedUserMessage]; } return []; @@ -700,29 +683,31 @@ describe("agentLoop with AgentMessage", () => { events.push(event); } - // Only first tool should have executed + // Only the first tool should execute; the second is skipped after steering is queued. expect(executed).toEqual(["first"]); - // Second tool should be skipped const toolEnds = events.filter( (e): e is Extract => e.type === "tool_execution_end", ); expect(toolEnds.length).toBe(2); - expect(toolEnds[0].isError).toBeFalsy(); + expect(toolEnds[0].isError).toBe(false); expect(toolEnds[1].isError).toBe(true); if (toolEnds[1].result.content[0]?.type === "text") { expect(toolEnds[1].result.content[0].text).toContain("Skipped due to queued user message"); } - // Queued message should appear in events - const queuedMessageEvent = events.find( - e => - e.type === "message_start" && - e.message.role === "user" && - typeof e.message.content === "string" && - e.message.content === "interrupt", - ); - expect(queuedMessageEvent).toBeDefined(); + // Queued message should appear in events after the tool results and before the next model call. + const eventSequence = events.flatMap(event => { + if (event.type !== "message_start") return []; + if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`]; + if (event.message.role === "user" && typeof event.message.content === "string") { + return [event.message.content]; + } + return []; + }); + expect(eventSequence).toContain("interrupt"); + expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt")); + expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt")); // Interrupt message should be in context when second LLM call is made expect(sawInterruptInContext).toBe(true); diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index c3c83f93b..bf0471f8a 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,12 +1,17 @@ # Changelog ## [Unreleased] + +## [13.15.0] - 2026-03-23 + ### Added - Added `isUsageLimitError()` to `rate-limit-utils` as a single source of truth for detecting usage/quota limit errors across all providers ### Fixed +- Fixed lazy stream forwarding to properly handle final results from source streams with `result()` methods +- Fixed lazy stream error handling to convert iterator failures into terminal error results instead of silently failing - Fixed `parseRateLimitReason` to recognize "usage limit" in error messages and correctly classify them as `QUOTA_EXHAUSTED` - Fixed Codex `fetchWithRetry` retrying 429 responses for `usage_limit_reached` errors for up to 5 minutes instead of returning immediately for credential switching - Removed `usage.?limit` from `TRANSIENT_MESSAGE_PATTERN` in retry utils since usage limits are not transient and require credential rotation diff --git a/packages/ai/package.json b/packages/ai/package.json index 4b76c9ed4..3b7279ee8 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-ai", - "version": "13.14.2", + "version": "13.15.0", "description": "Unified LLM API with automatic model discovery and provider configuration", "homepage": "https://github.com/can1357/oh-my-pi", "author": "Can Boluk", diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index df719797a..7acc629dd 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -16,6 +16,7 @@ export * from "./providers/google"; export * from "./providers/google-gemini-cli"; export * from "./providers/google-vertex"; export * from "./providers/kimi"; +export type { OpenAICodexResponsesOptions } from "./providers/openai-codex-responses"; export * from "./providers/openai-completions"; export * from "./providers/openai-responses"; export * from "./providers/synthetic"; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index a036c5e79..d33a3fb99 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -360,6 +360,13 @@ function handleContentBlockStop( /** * Check if the model supports prompt caching. * Supported: Claude 3.5 Haiku, Claude 3.7 Sonnet, Claude 4.x+ models, Haiku 4.5+ + * + * For base models and system-defined inference profiles the model ID / ARN + * contains the model name, so we can decide locally. + * + * For application inference profiles (whose ARNs don't contain the model name), + * set AWS_BEDROCK_FORCE_CACHE=1 to enable cache points. Amazon Nova models + * have automatic caching and don't need explicit cache points. */ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean { if (model.cost.cacheRead || model.cost.cacheWrite) return true; @@ -370,6 +377,9 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean if (id.includes("claude-3-7-sonnet") || id.includes("claude-3-5-haiku")) return true; // Claude Haiku 4.5+ (new naming) if (id.includes("claude-haiku")) return true; + // Application inference profiles don't contain the model name in the ARN. + // Allow users to force cache points via environment variable. + if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true; return false; } diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 9d409a5c4..3d42663f7 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -376,6 +376,12 @@ export interface AnthropicOptions extends StreamOptions { betas?: string[] | string; /** Force OAuth bearer auth mode for proxy tokens that don't match Anthropic token prefixes. */ isOAuth?: boolean; + /** + * Pre-built Anthropic client instance. When provided, skips internal client + * construction entirely. Use this to inject alternative SDK clients such as + * `AnthropicVertex` that shares the same messaging API. + */ + client?: Anthropic; } export type AnthropicClientOptionsArgs = { @@ -611,19 +617,31 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( let rawRequestDump: RawHttpRequestDump | undefined; try { - const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; - const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com"; + let client: Anthropic; + let isOAuthToken: boolean; - const { client, isOAuthToken } = createClient(model, { - model, - apiKey, - extraBetas: normalizeExtraBetas(options?.betas), - stream: true, - interleavedThinking: options?.interleavedThinking ?? true, - headers: options?.headers, - dynamicHeaders: copilotDynamicHeaders?.headers, - isOAuth: options?.isOAuth, - }); + if (options?.client) { + client = options.client; + isOAuthToken = false; + } else { + const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? ""; + + const created = createClient(model, { + model, + apiKey, + extraBetas: normalizeExtraBetas(options?.betas), + stream: true, + interleavedThinking: options?.interleavedThinking ?? true, + headers: options?.headers, + dynamicHeaders: copilotDynamicHeaders?.headers, + isOAuth: options?.isOAuth, + }); + client = created.client; + isOAuthToken = created.isOAuthToken; + } + const baseUrl = + resolveAnthropicBaseUrl(model, options?.apiKey ?? getEnvApiKey(model.provider) ?? "") ?? + "https://api.anthropic.com"; let params = buildParams(model, baseUrl, context, isOAuthToken, options); const replacementPayload = await options?.onPayload?.(params, model); if (replacementPayload !== undefined) { @@ -661,6 +679,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = ( for await (const event of anthropicStream) { started = true; if (event.type === "message_start") { + output.responseId = event.message.id; // Capture initial token usage from message_start event // This ensures we have input token counts even if the stream is aborted early output.usage.input = event.message.usage.input_tokens || 0; diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 5a524486a..595aa29f0 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -68,6 +68,20 @@ export function requiresToolCallId(modelId: string): boolean { return modelId.startsWith("claude-"); } +function getGeminiMajorVersion(modelId: string): number | undefined { + const match = modelId.toLowerCase().match(/^gemini(?:-live)?-(\d+)/); + if (!match) return undefined; + return Number.parseInt(match[1], 10); +} + +function supportsMultimodalFunctionResponse(modelId: string): boolean { + const geminiMajorVersion = getGeminiMajorVersion(modelId); + if (geminiMajorVersion !== undefined) { + return geminiMajorVersion >= 3; + } + return true; +} + function isGemini3Model(modelId: string): boolean { return modelId.includes("gemini-3"); } @@ -189,10 +203,10 @@ export function convertMessages(model: Model, contex const hasText = textResult.length > 0; const hasImages = imageContent.length > 0; - // Gemini 3 supports multimodal function responses with images nested inside functionResponse.parts - // See: https://ai.google.dev/gemini-api/docs/function-calling#multimodal - // Older models don't support this, so we put images in a separate user message. - const supportsMultimodalFunctionResponse = model.id.includes("gemini-3"); + // Gemini 3+ models support multimodal function responses with images nested inside + // functionResponse.parts. Claude and other non-Gemini models behind Cloud Code Assist / + // Antigravity also accept this shape. Gemini < 3 still needs a separate user image turn. + const modelSupportsMultimodalFunctionResponse = supportsMultimodalFunctionResponse(model.id); // Use "output" key for success, "error" key for errors as per SDK documentation const responseValue = hasText ? textResult.toWellFormed() : hasImages ? "(see attached image)" : ""; @@ -209,8 +223,7 @@ export function convertMessages(model: Model, contex functionResponse: { name: msg.toolName, response: msg.isError ? { error: responseValue } : { output: responseValue }, - // Nest images inside functionResponse.parts for Gemini 3 - ...(hasImages && supportsMultimodalFunctionResponse && { parts: imageParts }), + ...(hasImages && modelSupportsMultimodalFunctionResponse && { parts: imageParts }), ...(includeId ? { id: msg.toolCallId } : {}), }, }; @@ -231,8 +244,8 @@ export function convertMessages(model: Model, contex }); } - // For older models, add images in a separate user message - if (hasImages && !supportsMultimodalFunctionResponse) { + // For Gemini < 3, add images in a separate user message + if (hasImages && !modelSupportsMultimodalFunctionResponse) { contents.push({ role: "user", parts: [{ text: "Tool result image:" }, ...imageParts], diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index f3f44b50b..fc5ace911 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -811,7 +811,7 @@ function handleCodexStreamEvent(args: { return handleResponseCreated(runtime, rawEvent); } - if (eventType === "response.completed" || eventType === "response.done") { + if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") { handleResponseCompleted(model, output, runtime, rawEvent); return firstTokenTime; } @@ -1046,6 +1046,9 @@ function handleResponseCompleted( cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } + if (typeof response?.id === "string" && response.id.length > 0) { + output.responseId = response.id; + } const state = runtime.websocketState; if (runtime.transport === "websocket" && state) { @@ -1764,6 +1767,7 @@ class CodexWebSocketConnection { if ( eventType === "response.completed" || eventType === "response.done" || + eventType === "response.incomplete" || eventType === "response.failed" || eventType === "error" ) { diff --git a/packages/ai/src/providers/openai-completions-compat.ts b/packages/ai/src/providers/openai-completions-compat.ts index f75ce4b47..d6ff51bcb 100644 --- a/packages/ai/src/providers/openai-completions-compat.ts +++ b/packages/ai/src/providers/openai-completions-compat.ts @@ -89,7 +89,13 @@ export function detectOpenAICompat(model: Model<"openai-completions">): Resolved requiresAssistantAfterToolResult: false, requiresThinkingAsText: isMistral, requiresMistralToolIds: isMistral, - thinkingFormat: isZai ? "zai" : isAlibaba || isQwen ? "qwen" : "openai", + thinkingFormat: isZai + ? "zai" + : provider === "openrouter" || baseUrl.includes("openrouter.ai") + ? "openrouter" + : isAlibaba || isQwen + ? "qwen" + : "openai", reasoningContentField: "reasoning_content", requiresReasoningContentForToolCalls: isKimiModel, requiresAssistantContentForToolCalls: isKimiModel, diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index cd8e12bc8..f26545224 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -337,11 +337,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( errorMessage: "OpenAI completions stream stalled while waiting for the next event", onIdle: () => requestAbortController.abort(), })) { + if (!chunk || typeof chunk !== "object") continue; + + // OpenAI documents ChatCompletionChunk.id as the unique chat completion identifier, + // and each chunk in a streamed completion carries the same id. + output.responseId ||= chunk.id; + if (chunk.usage) { output.usage = parseChunkUsage(chunk.usage, model, copilotPremiumRequests); } - const choice = chunk.choices[0]; + const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined; if (!choice) continue; if (!chunk.usage) { @@ -352,7 +358,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( } if (choice.finish_reason) { - output.stopReason = mapStopReason(choice.finish_reason); + const finishReasonResult = mapStopReason(choice.finish_reason); + output.stopReason = finishReasonResult.stopReason; + if (finishReasonResult.errorMessage) { + output.errorMessage = finishReasonResult.errorMessage; + } } if (choice.delta) { @@ -463,8 +473,11 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = ( throw new Error("Request was aborted"); } - if (output.stopReason === "aborted" || output.stopReason === "error") { - throw new Error("An unknown error occurred"); + if (output.stopReason === "aborted") { + throw new Error("Request was aborted"); + } + if (output.stopReason === "error") { + throw new Error(output.errorMessage || "Provider returned an error stop reason"); } output.duration = Date.now() - startTime; @@ -616,6 +629,12 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio Reflect.set(params, "enable_thinking", !!options?.reasoning); } else if (compat.thinkingFormat === "qwen-chat-template" && model.reasoning) { Reflect.set(params, "chat_template_kwargs", { enable_thinking: !!options?.reasoning }); + } else if (compat.thinkingFormat === "openrouter" && options?.reasoning && model.reasoning) { + // OpenRouter normalizes reasoning across providers via a nested reasoning object. + const openRouterParams = params as typeof params & { reasoning?: { effort?: string } }; + openRouterParams.reasoning = { + effort: mapReasoningEffort(options.reasoning, compat.reasoningEffortMap), + }; } else if (options?.reasoning && model.reasoning && compat.supportsReasoningEffort) { // OpenAI-style reasoning_effort Reflect.set(params, "reasoning_effort", mapReasoningEffort(options.reasoning, compat.reasoningEffortMap)); @@ -1061,21 +1080,29 @@ function convertTools(tools: Tool[], compat: ResolvedOpenAICompat): OpenAI.Chat. }); } -function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): StopReason { - if (reason === null) return "stop"; +function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): { + stopReason: StopReason; + errorMessage?: string; +} { + if (reason === null) return { stopReason: "stop" }; switch (reason) { case "stop": case "end": - return "stop"; + return { stopReason: "stop" }; case "length": - return "length"; + return { stopReason: "length" }; case "function_call": case "tool_calls": - return "toolUse"; + return { stopReason: "toolUse" }; case "content_filter": - return "error"; + return { stopReason: "error", errorMessage: "Provider finish_reason: content_filter" }; + case "network_error": + return { stopReason: "error", errorMessage: "Provider finish_reason: network_error" }; default: - throw new Error(`Unhandled stop reason: ${reason}`); + return { + stopReason: "error", + errorMessage: `Provider finish_reason: ${reason}`, + }; } } diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 3ef822262..74341fcc7 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -52,8 +52,26 @@ export function parseTextSignature( return { id: signature }; } -export function normalizeResponsesToolCallIdForTransform(id: string): string { +export function normalizeResponsesToolCallIdForTransform( + id: string, + model?: Model, + source?: AssistantMessage, +): string { if (!id.includes("|")) return id; + const isForeignToolCall = + source != null && model != null && (source.provider !== model.provider || source.api !== model.api); + if (isForeignToolCall) { + const [callId, itemId] = id.split("|"); + const normalizeIdPart = (part: string): string => { + const sanitized = part.replace(/[^a-zA-Z0-9_-]/g, "_"); + const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized; + return truncated.replace(/_+$/, ""); + }; + const normalizedCallId = normalizeIdPart(callId); + let normalizedItemId = `fc_${Bun.hash(itemId).toString(36)}`; + if (normalizedItemId.length > 64) normalizedItemId = normalizedItemId.slice(0, 64); + return `${normalizedCallId}|${normalizedItemId}`; + } const normalized = normalizeResponsesToolCallId(id); return `${normalized.callId}|${normalized.itemId}`; } @@ -221,7 +239,9 @@ export async function processResponsesStream( let sawFirstToken = false; for await (const event of openaiStream) { - if (event.type === "response.output_item.added") { + if (event.type === "response.created") { + output.responseId = event.response.id; + } else if (event.type === "response.output_item.added") { if (!sawFirstToken) { sawFirstToken = true; options?.onFirstToken?.(); @@ -376,6 +396,9 @@ export async function processResponsesStream( } } else if (event.type === "response.completed") { const response = event.response; + if (response?.id) { + output.responseId = response.id; + } if (response?.usage) { const cachedTokens = response.usage.input_tokens_details?.cached_tokens || 0; output.usage = { diff --git a/packages/ai/src/providers/register-builtins.ts b/packages/ai/src/providers/register-builtins.ts new file mode 100644 index 000000000..dc89cbd30 --- /dev/null +++ b/packages/ai/src/providers/register-builtins.ts @@ -0,0 +1,329 @@ +/** + * Lazy provider module loading. + * + * Each provider module is loaded only when its stream function is first called. + * This avoids eagerly importing heavy SDK dependencies (e.g., @anthropic-ai/sdk, + * openai) at startup. The loaded module promise is cached so subsequent calls + * reuse the same import. + * + * NOTE: stream.ts currently imports providers directly, so this file is not yet + * wired into the main streaming path. It provides the infrastructure for lazy + * loading that can be integrated when stream.ts is refactored. + */ +import type { + Api, + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStream, + Context, + Model, + OptionsForApi, +} from "../types"; +import { AssistantMessageEventStream as EventStreamImpl } from "../utils/event-stream"; +import type { BedrockOptions } from "./amazon-bedrock"; +import type { AnthropicOptions } from "./anthropic"; +import type { AzureOpenAIResponsesOptions } from "./azure-openai-responses"; +import type { CursorOptions } from "./cursor"; +import type { GoogleOptions } from "./google"; +import type { GoogleGeminiCliOptions } from "./google-gemini-cli"; +import type { GoogleVertexOptions } from "./google-vertex"; +import type { OpenAICodexResponsesOptions } from "./openai-codex-responses"; +import type { OpenAICompletionsOptions } from "./openai-completions"; +import type { OpenAIResponsesOptions } from "./openai-responses"; + +// --------------------------------------------------------------------------- +// Lazy provider module shape +// --------------------------------------------------------------------------- + +interface LazyProviderModule { + stream: (model: Model, context: Context, options: OptionsForApi) => AsyncIterable; +} + +interface AnthropicProviderModule { + streamAnthropic: ( + model: Model<"anthropic-messages">, + context: Context, + options: AnthropicOptions, + ) => AssistantMessageEventStream; +} + +interface AzureOpenAIResponsesProviderModule { + streamAzureOpenAIResponses: ( + model: Model<"azure-openai-responses">, + context: Context, + options: AzureOpenAIResponsesOptions, + ) => AssistantMessageEventStream; +} + +interface GoogleProviderModule { + streamGoogle: ( + model: Model<"google-generative-ai">, + context: Context, + options: GoogleOptions, + ) => AssistantMessageEventStream; +} + +interface GoogleGeminiCliProviderModule { + streamGoogleGeminiCli: ( + model: Model<"google-gemini-cli">, + context: Context, + options: GoogleGeminiCliOptions, + ) => AssistantMessageEventStream; +} + +interface GoogleVertexProviderModule { + streamGoogleVertex: ( + model: Model<"google-vertex">, + context: Context, + options: GoogleVertexOptions, + ) => AssistantMessageEventStream; +} + +interface OpenAICodexResponsesProviderModule { + streamOpenAICodexResponses: ( + model: Model<"openai-codex-responses">, + context: Context, + options: OpenAICodexResponsesOptions, + ) => AssistantMessageEventStream; +} + +interface OpenAICompletionsProviderModule { + streamOpenAICompletions: ( + model: Model<"openai-completions">, + context: Context, + options: OpenAICompletionsOptions, + ) => AssistantMessageEventStream; +} + +interface OpenAIResponsesProviderModule { + streamOpenAIResponses: ( + model: Model<"openai-responses">, + context: Context, + options: OpenAIResponsesOptions, + ) => AssistantMessageEventStream; +} + +interface CursorProviderModule { + streamCursor: ( + model: Model<"cursor-agent">, + context: Context, + options: CursorOptions, + ) => AssistantMessageEventStream; +} + +interface BedrockProviderModule { + streamBedrock: ( + model: Model<"bedrock-converse-stream">, + context: Context, + options: BedrockOptions, + ) => AssistantMessageEventStream; +} + +// --------------------------------------------------------------------------- +// Module-level lazy promise caches +// --------------------------------------------------------------------------- + +const importNodeOnlyProvider = (specifier: string): Promise => import(specifier); + +let anthropicProviderModulePromise: Promise> | undefined; +let azureOpenAIResponsesProviderModulePromise: Promise> | undefined; +let googleProviderModulePromise: Promise> | undefined; +let googleGeminiCliProviderModulePromise: Promise> | undefined; +let googleVertexProviderModulePromise: Promise> | undefined; +let openAICodexResponsesProviderModulePromise: Promise> | undefined; +let openAICompletionsProviderModulePromise: Promise> | undefined; +let openAIResponsesProviderModulePromise: Promise> | undefined; +let cursorProviderModulePromise: Promise> | undefined; +let bedrockProviderModuleOverride: LazyProviderModule<"bedrock-converse-stream"> | undefined; +let bedrockProviderModulePromise: Promise> | undefined; + +export function setBedrockProviderModule(module: BedrockProviderModule): void { + bedrockProviderModuleOverride = { + stream: module.streamBedrock, + }; +} + +// --------------------------------------------------------------------------- +// Stream forwarding / error helpers +// --------------------------------------------------------------------------- + +function hasFinalResult( + source: AsyncIterable, +): source is AsyncIterable & { result(): Promise } { + return typeof (source as { result?: unknown }).result === "function"; +} + +function forwardStream( + target: EventStreamImpl, + source: AsyncIterable, + model: Model, +): void { + (async () => { + try { + for await (const event of source) { + target.push(event); + } + if (hasFinalResult(source)) { + target.end(await source.result()); + } else { + target.end(); + } + } catch (error) { + const message = createLazyLoadErrorMessage(model, error); + target.push({ type: "error", reason: "error", error: message }); + target.end(message); + } + })(); +} + +function createLazyLoadErrorMessage(model: Model, error: unknown): AssistantMessage { + return { + role: "assistant", + content: [], + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "error", + errorMessage: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + }; +} + +// --------------------------------------------------------------------------- +// Generic lazy stream factory +// --------------------------------------------------------------------------- + +function createLazyStream( + loadModule: () => Promise>, +): (model: Model, context: Context, options: OptionsForApi) => EventStreamImpl { + return (model, context, options) => { + const outer = new EventStreamImpl(); + + loadModule() + .then(module => { + const inner = module.stream(model, context, options); + forwardStream(outer, inner, model); + }) + .catch(error => { + const message = createLazyLoadErrorMessage(model, error); + outer.push({ type: "error", reason: "error", error: message }); + outer.end(message); + }); + + return outer; + }; +} + +// --------------------------------------------------------------------------- +// Module loaders (one per provider, cached via ||=) +// --------------------------------------------------------------------------- + +function loadAnthropicProviderModule(): Promise> { + anthropicProviderModulePromise ||= import("./anthropic").then(module => { + const provider = module as AnthropicProviderModule; + return { stream: provider.streamAnthropic }; + }); + return anthropicProviderModulePromise; +} + +function loadAzureOpenAIResponsesProviderModule(): Promise> { + azureOpenAIResponsesProviderModulePromise ||= import("./azure-openai-responses").then(module => { + const provider = module as AzureOpenAIResponsesProviderModule; + return { stream: provider.streamAzureOpenAIResponses }; + }); + return azureOpenAIResponsesProviderModulePromise; +} + +function loadGoogleProviderModule(): Promise> { + googleProviderModulePromise ||= import("./google").then(module => { + const provider = module as GoogleProviderModule; + return { stream: provider.streamGoogle }; + }); + return googleProviderModulePromise; +} + +function loadGoogleGeminiCliProviderModule(): Promise> { + googleGeminiCliProviderModulePromise ||= import("./google-gemini-cli").then(module => { + const provider = module as GoogleGeminiCliProviderModule; + return { stream: provider.streamGoogleGeminiCli }; + }); + return googleGeminiCliProviderModulePromise; +} + +function loadGoogleVertexProviderModule(): Promise> { + googleVertexProviderModulePromise ||= import("./google-vertex").then(module => { + const provider = module as GoogleVertexProviderModule; + return { stream: provider.streamGoogleVertex }; + }); + return googleVertexProviderModulePromise; +} + +function loadOpenAICodexResponsesProviderModule(): Promise> { + openAICodexResponsesProviderModulePromise ||= import("./openai-codex-responses").then(module => { + const provider = module as OpenAICodexResponsesProviderModule; + return { stream: provider.streamOpenAICodexResponses }; + }); + return openAICodexResponsesProviderModulePromise; +} + +function loadOpenAICompletionsProviderModule(): Promise> { + openAICompletionsProviderModulePromise ||= import("./openai-completions").then(module => { + const provider = module as OpenAICompletionsProviderModule; + return { stream: provider.streamOpenAICompletions }; + }); + return openAICompletionsProviderModulePromise; +} + +function loadOpenAIResponsesProviderModule(): Promise> { + openAIResponsesProviderModulePromise ||= import("./openai-responses").then(module => { + const provider = module as OpenAIResponsesProviderModule; + return { stream: provider.streamOpenAIResponses }; + }); + return openAIResponsesProviderModulePromise; +} + +function loadCursorProviderModule(): Promise> { + cursorProviderModulePromise ||= import("./cursor").then(module => { + const provider = module as CursorProviderModule; + return { stream: provider.streamCursor }; + }); + return cursorProviderModulePromise; +} + +function loadBedrockProviderModule(): Promise> { + if (bedrockProviderModuleOverride) { + return Promise.resolve(bedrockProviderModuleOverride); + } + bedrockProviderModulePromise ||= importNodeOnlyProvider("./amazon-bedrock").then(module => { + const provider = module as BedrockProviderModule; + return { stream: provider.streamBedrock }; + }); + return bedrockProviderModulePromise; +} + +// --------------------------------------------------------------------------- +// Lazy stream function exports +// +// These use the same names as the direct provider stream functions. When +// stream.ts is updated to import from this module instead of individual +// providers, the lazy loading will take effect on the main code path. +// --------------------------------------------------------------------------- + +export const streamAnthropic = createLazyStream(loadAnthropicProviderModule); +export const streamAzureOpenAIResponses = createLazyStream(loadAzureOpenAIResponsesProviderModule); +export const streamGoogle = createLazyStream(loadGoogleProviderModule); +export const streamGoogleGeminiCli = createLazyStream(loadGoogleGeminiCliProviderModule); +export const streamGoogleVertex = createLazyStream(loadGoogleVertexProviderModule); +export const streamOpenAICodexResponses = createLazyStream(loadOpenAICodexResponsesProviderModule); +export const streamOpenAICompletions = createLazyStream(loadOpenAICompletionsProviderModule); +export const streamOpenAIResponses = createLazyStream(loadOpenAIResponsesProviderModule); +export const streamCursor = createLazyStream(loadCursorProviderModule); +export const streamBedrock = createLazyStream(loadBedrockProviderModule); diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index de610c366..e38224e60 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -338,6 +338,7 @@ export interface AssistantMessage { api: Api; provider: Provider; model: string; + responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one usage: Usage; stopReason: StopReason; errorMessage?: string; @@ -444,8 +445,8 @@ export interface OpenAICompat { requiresThinkingAsText?: boolean; /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */ requiresMistralToolIds?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ - thinkingFormat?: "openai" | "zai" | "qwen" | "qwen-chat-template"; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "zai" uses thinking: { type: "enabled" }, "qwen" uses top-level enable_thinking, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */ + thinkingFormat?: "openai" | "openrouter" | "zai" | "qwen" | "qwen-chat-template"; /** Which reasoning content field to emit on assistant messages. Default: auto-detected. */ reasoningContentField?: "reasoning_content" | "reasoning" | "reasoning_text"; /** Whether assistant tool-call messages must include reasoning content. Default: false. */ diff --git a/packages/ai/test/register-builtins.test.ts b/packages/ai/test/register-builtins.test.ts new file mode 100644 index 000000000..f3911edcb --- /dev/null +++ b/packages/ai/test/register-builtins.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "bun:test"; +import { setBedrockProviderModule, streamBedrock } from "../src/providers/register-builtins"; +import type { AssistantMessage, Context, Model } from "../src/types"; +import type { AssistantMessageEventStream } from "../src/utils/event-stream"; + +function createModel(): Model<"bedrock-converse-stream"> { + return { + id: "mock-bedrock", + name: "Mock Bedrock", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://example.invalid", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 2048, + }; +} + +function createAssistantMessage( + stopReason: AssistantMessage["stopReason"] = "stop", + errorMessage?: string, +): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text: errorMessage ? `error: ${errorMessage}` : "ok" }], + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + model: "mock-bedrock", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + errorMessage, + timestamp: Date.now(), + }; +} + +const baseContext: Context = { messages: [] }; + +describe("register-builtins lazy streams", () => { + it("resolves the outer stream result from source.result() when no terminal event is iterated", async () => { + const finalMessage = createAssistantMessage("stop"); + const partialMessage = createAssistantMessage("stop"); + const source = { + async *[Symbol.asyncIterator]() { + yield { type: "start", partial: partialMessage } as const; + }, + result: async () => finalMessage, + } as unknown as AssistantMessageEventStream; + + setBedrockProviderModule({ + streamBedrock: () => source, + }); + + const stream = streamBedrock(createModel(), baseContext, {}); + const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]); + + expect(result).not.toBe("timeout"); + if (result === "timeout") { + throw new Error("Timed out waiting for forwarded stream result"); + } + expect(result).toEqual(finalMessage); + }); + + it("turns iterator failures into terminal error results", async () => { + const partialMessage = createAssistantMessage("stop"); + const source = { + async *[Symbol.asyncIterator]() { + yield { type: "start", partial: partialMessage } as const; + throw new Error("bedrock exploded"); + }, + } as unknown as AssistantMessageEventStream; + + setBedrockProviderModule({ + streamBedrock: () => source, + }); + + const stream = streamBedrock(createModel(), baseContext, {}); + const result = await Promise.race([stream.result(), Bun.sleep(100).then(() => "timeout" as const)]); + + expect(result).not.toBe("timeout"); + if (result === "timeout") { + throw new Error("Timed out waiting for forwarded error result"); + } + expect(result.stopReason).toBe("error"); + expect(result.errorMessage).toContain("bedrock exploded"); + }); +}); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2df0fc50f..987c250b3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,8 +1,70 @@ # Changelog ## [Unreleased] + +## [13.15.0] - 2026-03-23 +### Breaking Changes + +- Changed hashline edit schema from flat `op`/`pos`/`end`/`lines` fields to structured `loc`/`content` format with location-specific objects +- Renamed hashline edit operations: `replace_line` → `{ line: anchor }`, `replace_range` → `{ block: { pos, end } }`, `append_at` → `{ append: anchor }`, `prepend_at` → `{ prepend: anchor }`, `append_file` → `"append"`, `prepend_file` → `"prepend"` +- Changed `lines` parameter to `content` in hashline edit entries +- Renamed hashline edit operation types: `append` → `append_at`, `prepend` → `prepend_at`, `append_eof` → `append_file`, `prepend_bof` → `prepend_file` +- Changed hashline edit operation types from `replace` (with optional `end`) to explicit `replace_line` and `replace_range` operations +- Added required `append_eof` and `prepend_bof` operations for file-level edits; `append` and `prepend` now require an anchor position +- Made `pos` parameter required for `replace_line`, `append`, and `prepend` operations; `append_eof` and `prepend_bof` no longer accept anchors + ### Added +- Added prompt for tradeoff metrics during autoresearch setup to collect secondary metrics alongside primary metric +- Added validation of contract path specifications to reject absolute paths and parent directory references +- Added stricter benchmark command validation in `isAutoresearchShCommand()` to reject chained commands, pipes, and redirects +- Added protection against prototype pollution in ASI data and metric cloning by filtering `__proto__`, `constructor`, and `prototype` keys +- Added `autoResumeArmed` flag to track when autoresearch should automatically resume pending runs +- Added `lastAutoResumePendingRunNumber` to prevent duplicate auto-resume prompts for the same pending run +- Added `git clean -X` invocation during failed experiment rollback to remove ignored build artifacts +- Added validation to reject `init_experiment` when a previous run is still pending and unlogged +- Added autoresearch contract system for validating benchmark commands, metrics, scope paths, off-limits paths, and constraints with fingerprint tracking to detect configuration drift +- Added `autoresearch.program.md` support for repo-local playbook overlays that guide session strategy while preserving `autoresearch.md` as source of truth +- Added pending run artifact tracking and recovery to resume incomplete experiments from `.autoresearch/runs/` directory with run numbers and benchmark logs +- Added run directory organization with numbered run artifacts, benchmark logs, and optional checks logs for experiment traceability +- Added segment fingerprinting to detect when benchmark configuration changes between runs and warn about potential incomparability +- Added support for secondary metrics tracking alongside primary metric with configurable direction (lower/higher is better) +- Added `getCurrentAutoresearchBranch()` helper to detect and validate existing autoresearch branches for session resumption +- Added `PendingRunSummary` type to track unlogged run state including parsed metrics, ASI data, and pass/fail status +- Added hidden next-turn message delivery via `deliverAs: 'nextTurn'` with optional `triggerTurn` to queue context for next LLM call without exposing in editable queue +- Added `#queueHiddenNextTurnMessage()` and `#promptQueuedHiddenNextTurnMessages()` to AgentSession for autonomous tool reactions +- Added resume context support in `command-resume.md` template for user-provided guidance when resuming sessions +- Added current segment snapshot display in autoresearch prompt showing recent runs, baseline metrics, and best results +- Added pending run indicator in autoresearch prompt to guide users to complete unlogged experiments before starting new benchmarks +- Added local playbook section in autoresearch prompt when `autoresearch.program.md` exists +- Added tab replacement in dashboard and tool output rendering to prevent display corruption from shell commands with tabs +- Added boundary duplication warning when replace_range or replace_line operations include a last inserted line that matches the next surviving line, helping detect off-by-one range errors +- Added git branch isolation for autoresearch sessions via `ensureAutoresearchBranch()` to safely revert failed experiments +- Added branch status line to autoresearch initialization and resume prompts showing created or reused branch name +- Added `Files in Scope`, `Off Limits`, and `Constraints` sections to autoresearch.md template for explicit scope definition +- Added validation of ASI metadata requirements in `log_experiment` tool, requiring hypothesis for all runs and rollback context for failed runs +- Added keybinding matcher utilities `matchesAppInterrupt()` and `matchesSelectCancel()` for consistent escape key handling across components +- Added support for customizable `app.interrupt` and `tui.select.cancel` keybindings in interactive components +- Added `defaultInactive` property to `ToolDefinition` to allow tools to be registered but excluded from the initial active set, with extension responsibility for activation/deactivation +- Added dynamic tool activation/deactivation in autoresearch mode via `setActiveTools()` API +- Added separate initialization and resume workflows for autoresearch with `command-initialize.md` and `command-resume.md` prompts +- Added intent dialog to prompt users for autoresearch optimization goals when starting fresh +- Added automatic detection of existing `autoresearch.md` to resume from previous sessions without re-prompting for intent +- Added autoresearch extension with autonomous experiment loop capabilities +- Added `init_experiment` tool to initialize and reset autoresearch sessions with configurable metrics +- Added `log_experiment` tool to record experiment results with metric parsing and confidence tracking +- Added `run_experiment` tool to execute commands and capture metrics with timeout and crash detection +- Added autoresearch dashboard controller for displaying experiment results and optimization progress +- Added support for secondary metrics tracking alongside primary metric +- Added `ExtensionWidgetContent` and `ExtensionUiComponentFactory` types for flexible widget configuration +- Added `ExtensionWidgetOptions` interface with `placement` parameter to position widgets above or below editor +- Added `WidgetPlacement` type supporting 'aboveEditor' and 'belowEditor' placement options +- Added `hookWidgetContainerAbove` and `hookWidgetContainerBelow` containers to InteractiveMode for separate widget management +- Added autoresearch mode for autonomous experiment loops with init_experiment, log_experiment, and run_experiment tools +- Added autoresearch dashboard widget displaying experiment results, metrics, and optimization progress +- Added support for metric tracking with configurable direction (lower/higher is better) and secondary metrics +- Added widget placement options to position extensions above or below the editor via `placement` parameter +- Added `ExtensionWidgetContent` and `ExtensionWidgetOptions` types for flexible widget configuration - Added ACP (Agent Client Protocol) mode for headless agent operation via `--mode acp` - Added support for Agent Client Protocol SDK integration with session management, MCP server configuration, and streaming communication - Added `ensureOnDisk()` method to SessionManager to persist sessions immediately for ACP discovery @@ -10,8 +72,76 @@ ### Changed +- Changed `isAutoresearchShCommand()` to use proper command-line argument parsing instead of regex, improving accuracy for complex shell invocations +- Changed autoresearch initialization prompt to display collected tradeoff metrics in the setup summary +- Changed `command-initialize.md` template to include guidance on preflight requirements, comparability invariants, and marking measurement-critical files as off-limits +- Changed `command-initialize.md` to instruct users to write or update `autoresearch.program.md` with durable heuristics and repo-specific strategy +- Changed autoresearch resume guidance to emphasize continuing on the current protected branch rather than switching branches +- Changed autoresearch prompt to clarify that `autoresearch.md` holds durable conclusions while `autoresearch.ideas.md` is the scratch backlog +- Changed autoresearch prompt guidance to require stable measurement harness and fixed benchmark inputs unless intentionally starting a new segment +- Changed autoresearch prompt to recommend keeping equal or near-equal results when they materially simplify implementation +- Changed `init_experiment` to reset pending run state (checks, duration, ASI, artifact directory) when initializing a new segment +- Changed `log_experiment` to set `autoResumeArmed` flag after successfully logging a run to enable auto-resume on next agent turn +- Changed `run_experiment` to set `autoResumeArmed` flag and update dashboard after completing a run +- Changed auto-resume logic to only prompt when a new pending run exists or when `autoResumeArmed` is explicitly set, preventing duplicate prompts +- Changed path normalization in contract validation to use `path.posix.normalize()` for consistent path handling +- Changed autoresearch initialization to collect and validate benchmark command, metric definition, scope paths, off-limits list, and constraints before `init_experiment` +- Changed `init_experiment` to require exact benchmark command, metric definition, scope, off-limits, and constraints matching collected contract +- Changed `log_experiment` to record run number, benchmark command, scope paths, off-limits list, constraints, and segment fingerprint with each result +- Changed `run_experiment` to organize output in numbered run directories with separate benchmark and checks logs for artifact preservation +- Changed autoresearch dashboard to show pending run indicator when unlogged experiment exists +- Changed autoresearch resume workflow to detect and offer recovery of pending run artifacts before continuing experiment loop +- Changed `ExperimentResult` to include `runNumber`, `benchmarkCommand`, `scopePaths`, `offLimits`, `constraints`, and `segmentFingerprint` fields +- Changed `RunningExperiment` to track `runDirectory` and `runNumber` for artifact organization +- Changed `AutoresearchRuntime` to include `lastRunArtifactDir`, `lastRunNumber`, `lastRunSummary`, `benchmarkCommand`, `secondaryMetrics`, `scopePaths`, `offLimits`, `constraints`, and `segmentFingerprint` +- Changed autoresearch prompts to emphasize `autoresearch.md` as source of truth for benchmark, scope, and constraints +- Changed `command-initialize.md` to display collected setup (benchmark command, metric, direction, scope, off-limits, constraints) before initialization +- Changed `resume-message.md` to reference pending run artifacts and guide completion of unlogged experiments +- Changed `sendMessage()` API documentation to clarify `deliverAs: 'nextTurn'` behavior for hidden context delivery +- Changed `SendMessageHandler` type documentation to explain hidden next-turn message queuing during prompt teardown +- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop +- Changed autoresearch to refuse startup when unrelated worktree changes would make auto-reverts unsafe +- Changed autoresearch prompts to emphasize scope and constraints as source of truth for session direction +- Changed component escape key handling to use keybinding manager for `app.interrupt` and `tui.select.cancel` with fallback to raw Escape matching +- Updated autoresearch prompt guidance to require explicit files in scope, off-limits paths, and session constraints +- Changed autoresearch command to use intent-based initialization instead of goal parameter, with user input dialog for new sessions +- Changed autoresearch startup to create or reuse a dedicated `autoresearch/...` git branch before enabling the experiment loop, and to refuse startup when unrelated worktree changes would make auto-reverts unsafe +- Changed autoresearch startup to activate experiment tools (`init_experiment`, `run_experiment`, `log_experiment`) only when autoresearch mode is enabled +- Changed autoresearch shutdown to deactivate experiment tools when mode is disabled or cleared +- Changed autoresearch session rehydration to dynamically manage experiment tool activation based on session state +- Changed autoresearch prompts and notes guidance to require explicit files in scope, off-limits paths, and session constraints +- Refactored hashline edit validation to enforce stricter anchor requirements per operation type +- Updated edit application logic to handle explicit file-level operations (`append_eof`, `prepend_bof`) separately from anchor-based operations +- Changed `setWidget` API to accept `ExtensionWidgetOptions` parameter for placement control +- Changed widget placement logic to manage widgets above and below editor separately +- Changed hashline edit application to preserve duplicated boundary lines exactly as provided instead of auto-correcting them +- Updated RPC mode to support widget placement option in `setWidget` requests +- Changed hashline edit application to preserve duplicated boundary lines exactly as provided instead of auto-correcting them +- Changed widget API to support placement options and component factories in addition to string arrays +- Updated extension UI controller to manage widgets above and below the editor separately +- Updated ask tool rendering to support markdown formatting in questions and option labels +- Refactored hook input and selector components to render titles as markdown for richer text formatting - Changed session collection to include sessions with zero messages, enabling ACP mode to create discoverable sessions immediately - Changed session persistence logic to use atomic file rewrite when flushing unflushed sessions to prevent duplication +- Removed hashline edit autocorrection for duplicated boundary lines; escaped-tab autocorrection remains available for leading `\\t` sequences + +### Removed + +- Removed `command-start.md` prompt template in favor of separate initialize and resume workflows +- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines +- Removed `shouldAutocorrect` function and related boundary line deduplication logic from hashline editor +- Removed auto-correction of off-by-one range edits that duplicated closing braces or boundary lines + +### Fixed + +- Fixed boundary duplication warnings to always display when replacement lines match the next surviving line, even when auto-correction is disabled +- Fixed secondary metrics validation to properly reject missing configured metrics and new metrics without force flag +- Fixed ASI data cloning to prevent prototype pollution attacks by filtering reserved property names +- Fixed autoresearch resume to detect and recover pending run artifacts that were left unlogged from previous sessions +- Fixed dashboard overlay to display when running experiment even with zero completed results +- Fixed tab character rendering in dashboard command display and tool output summaries +- Fixed autoresearch logging to require durable ASI metadata (hypothesis, rollback_reason, next_action_hint) for every run including rollback context for discarded, crashed, and checks-failed experiments +- Fixed autoresearch logging to require durable ASI metadata for every run, including rollback context for discarded, crashed, and checks-failed experiments ## [13.14.0] - 2026-03-20 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 8c01dd0c1..df4387af1 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "@oh-my-pi/pi-coding-agent", - "version": "13.14.2", + "version": "13.15.0", "description": "Coding agent CLI with read, bash, edit, write tools and session management", "homepage": "https://github.com/can1357/oh-my-pi", "author": "Can Boluk", diff --git a/packages/coding-agent/src/autoresearch/command-initialize.md b/packages/coding-agent/src/autoresearch/command-initialize.md new file mode 100644 index 000000000..1e7939d42 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/command-initialize.md @@ -0,0 +1,34 @@ +Set up autoresearch for this intent: + +{{intent}} + +{{branch_status_line}} + +Collected setup: + +- benchmark command: `{{benchmark_command}}` +- primary metric: `{{metric_name}}` +- metric unit: `{{metric_unit}}` +- direction: `{{direction}}` +- tradeoff metrics: +{{{secondary_metrics_block}}} +- files in scope: +{{{scope_paths_block}}} +- off limits: +{{{off_limits_block}}} +- constraints: +{{{constraints_block}}} + +Explain briefly what autoresearch will do in this repository, then initialize the workspace. + +Your first actions: +- write `autoresearch.md` +- record the collected benchmark command, primary metric, metric unit, direction, tradeoff metrics, scope, off-limits list, and constraints in `autoresearch.md` +- add a short preflight section in `autoresearch.md` covering prerequisites, one-time setup, and the comparability invariant that must stay fixed across runs +- explicitly mark the ground-truth evaluator, fixed datasets, and other measurement-critical files as off-limits or hard constraints when they define the benchmark contract +- write or update `autoresearch.program.md` when you learn durable heuristics, failure patterns, or repo-specific strategy that future resume turns should inherit +- define the benchmark entrypoint in `autoresearch.sh` +- optionally add `autoresearch.checks.sh` if correctness or quality needs a hard gate +- run `init_experiment` with the exact collected benchmark command, metric definition, scope paths, off-limits list, and constraints +- run and log the baseline +- keep iterating until interrupted or until the configured iteration cap is reached diff --git a/packages/coding-agent/src/autoresearch/command-resume.md b/packages/coding-agent/src/autoresearch/command-resume.md new file mode 100644 index 000000000..e71543cb1 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/command-resume.md @@ -0,0 +1,17 @@ +Resume autoresearch from the attached notes. + +@{{autoresearch_md_path}} + +{{branch_status_line}} +{{#if has_resume_context}} + +Additional context from the user: + +{{resume_context}} +{{/if}} + +Use the notes as the source of truth for the current direction, scope, and constraints. +- inspect recent git history for context +- inspect `autoresearch.jsonl` if it exists +- continue the most promising unfinished direction on the current protected branch +- keep iterating until interrupted or until the configured iteration cap is reached diff --git a/packages/coding-agent/src/autoresearch/contract.ts b/packages/coding-agent/src/autoresearch/contract.ts new file mode 100644 index 000000000..c5b8b87c9 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/contract.ts @@ -0,0 +1,332 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { AutoresearchBenchmarkContract, AutoresearchContract, MetricDirection } from "./types"; + +export interface AutoresearchContractLoadResult { + contract: AutoresearchContract; + errors: string[]; + path: string; +} + +export interface AutoresearchScriptSnapshot { + benchmarkScript: string; + benchmarkScriptPath: string; + checksScript: string | null; + checksScriptPath: string; + errors: string[]; +} + +const HEADING_REGEX = /^##\s+(.+?)\s*$/; +const LIST_ITEM_REGEX = /^\s*[-*]\s+(.*)$/; +const KEY_VALUE_REGEX = /^\s*[-*]\s+([^:]+):\s*(.*)$/; + +export function readAutoresearchContract(workDir: string): AutoresearchContractLoadResult { + const contractPath = path.join(workDir, "autoresearch.md"); + let content = ""; + try { + content = fs.readFileSync(contractPath, "utf8"); + } catch { + return { + contract: createEmptyAutoresearchContract(), + errors: [`${contractPath} does not exist. Create it before initializing autoresearch.`], + path: contractPath, + }; + } + + const contract = parseAutoresearchContract(content); + const errors = validateAutoresearchContract(contract); + return { contract, errors, path: contractPath }; +} + +export function parseAutoresearchContract(markdown: string): AutoresearchContract { + const sections = extractSections(markdown); + return { + benchmark: parseBenchmarkSection(sections.get("benchmark") ?? ""), + scopePaths: parseListSection(sections.get("files in scope") ?? "", normalizeContractPathSpec), + offLimits: parseListSection(sections.get("off limits") ?? "", normalizeContractPathSpec), + constraints: parseListSection(sections.get("constraints") ?? ""), + }; +} + +export function validateAutoresearchContract(contract: AutoresearchContract): string[] { + const errors: string[] = []; + if (!contract.benchmark.command) { + errors.push("Benchmark.command is required in autoresearch.md."); + } + if (!contract.benchmark.primaryMetric) { + errors.push("Benchmark.primary metric is required in autoresearch.md."); + } + if (!contract.benchmark.direction) { + errors.push("Benchmark.direction must be `lower` or `higher` in autoresearch.md."); + } + if (contract.scopePaths.length === 0) { + errors.push("Files in Scope must contain at least one path in autoresearch.md."); + } + for (const scopePath of contract.scopePaths) { + if (isUnsafeContractPathSpec(scopePath)) { + errors.push(`Files in Scope contains an invalid path: ${scopePath}`); + } + } + for (const offLimitsPath of contract.offLimits) { + if (isUnsafeContractPathSpec(offLimitsPath)) { + errors.push(`Off Limits contains an invalid path: ${offLimitsPath}`); + } + } + return errors; +} + +export function buildAutoresearchSegmentFingerprint( + contract: AutoresearchContract, + scripts: { + benchmarkScript: string; + checksScript: string | null; + }, +): string { + const payload = { + benchmark: contract.benchmark, + scopePaths: contract.scopePaths, + offLimits: contract.offLimits, + constraints: contract.constraints, + scripts, + }; + return crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex"); +} + +export function getAutoresearchFingerprintMismatchError( + stateFingerprint: string | null, + workDir: string, +): string | null { + if (!stateFingerprint) { + return "The current segment has no fingerprint metadata. Re-run init_experiment before continuing."; + } + + const contractResult = readAutoresearchContract(workDir); + const scriptSnapshot = loadAutoresearchScriptSnapshot(workDir); + const errors = [...contractResult.errors, ...scriptSnapshot.errors]; + if (errors.length > 0) { + return `${errors.join(" ")} Re-run init_experiment after fixing the workspace contract.`; + } + + const currentFingerprint = buildAutoresearchSegmentFingerprint(contractResult.contract, { + benchmarkScript: scriptSnapshot.benchmarkScript, + checksScript: scriptSnapshot.checksScript, + }); + if (currentFingerprint === stateFingerprint) { + return null; + } + + return "autoresearch.md, autoresearch.sh, or autoresearch.checks.sh changed since the current segment was initialized. Re-run init_experiment before continuing."; +} + +export function loadAutoresearchScriptSnapshot(workDir: string): AutoresearchScriptSnapshot { + const benchmarkScriptPath = path.join(workDir, "autoresearch.sh"); + const checksScriptPath = path.join(workDir, "autoresearch.checks.sh"); + const errors: string[] = []; + + let benchmarkScript = ""; + try { + benchmarkScript = fs.readFileSync(benchmarkScriptPath, "utf8"); + } catch { + errors.push(`${benchmarkScriptPath} does not exist. Create it before initializing autoresearch.`); + } + + let checksScript: string | null = null; + try { + checksScript = fs.readFileSync(checksScriptPath, "utf8"); + } catch { + checksScript = null; + } + + return { + benchmarkScript, + benchmarkScriptPath, + checksScript, + checksScriptPath, + errors, + }; +} + +export function normalizeAutoresearchList(values: readonly string[]): string[] { + const normalized: string[] = []; + const seen = new Set(); + for (const value of values) { + const trimmed = value.trim(); + if (trimmed.length === 0) continue; + if (seen.has(trimmed)) continue; + seen.add(trimmed); + normalized.push(trimmed); + } + return normalized; +} + +export function normalizeContractPathSpec(value: string): string { + const normalized = path.posix.normalize(value.trim().replaceAll("\\", "/")); + if (normalized === "." || normalized === "./") return "."; + return normalized.replace(/^\.\/+/, "").replace(/\/+$/, ""); +} + +export function pathMatchesContractPath(pathValue: string, specValue: string): boolean { + const normalizedPath = normalizeContractPathSpec(pathValue); + const normalizedSpec = normalizeContractPathSpec(specValue); + if (normalizedSpec === ".") return true; + return normalizedPath === normalizedSpec || normalizedPath.startsWith(`${normalizedSpec}/`); +} + +export function contractListsEqual(left: readonly string[], right: readonly string[]): boolean { + const normalizedLeft = normalizeAutoresearchList(left); + const normalizedRight = normalizeAutoresearchList(right); + if (normalizedLeft.length !== normalizedRight.length) return false; + return normalizedLeft.every((value, index) => value === normalizedRight[index]); +} + +export function contractPathListsEqual(left: readonly string[], right: readonly string[]): boolean { + const normalizedLeft = normalizeContractPathList(left); + const normalizedRight = normalizeContractPathList(right); + if (normalizedLeft.length !== normalizedRight.length) return false; + return normalizedLeft.every((value, index) => value === normalizedRight[index]); +} + +function createEmptyAutoresearchContract(): AutoresearchContract { + return { + benchmark: { + command: null, + primaryMetric: null, + metricUnit: "", + direction: null, + secondaryMetrics: [], + }, + scopePaths: [], + offLimits: [], + constraints: [], + }; +} + +function normalizeContractPathList(values: readonly string[]): string[] { + return normalizeAutoresearchList(values.map(normalizeContractPathSpec)).sort((left, right) => + left.localeCompare(right), + ); +} + +function extractSections(markdown: string): Map { + const sections = new Map(); + const lines = markdown.split("\n"); + let currentHeading: string | null = null; + let currentLines: string[] = []; + + for (const line of lines) { + const headingMatch = line.match(HEADING_REGEX); + if (headingMatch) { + if (currentHeading) { + sections.set(currentHeading, currentLines.join("\n").trim()); + } + currentHeading = headingMatch[1]?.trim().toLowerCase() ?? null; + currentLines = []; + continue; + } + if (currentHeading) { + currentLines.push(line); + } + } + + if (currentHeading) { + sections.set(currentHeading, currentLines.join("\n").trim()); + } + return sections; +} + +function parseBenchmarkSection(section: string): AutoresearchBenchmarkContract { + const entries = new Map(); + const lines = section.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const rawLine = lines[index] ?? ""; + const match = rawLine.match(KEY_VALUE_REGEX); + if (!match) continue; + const key = normalizeKey(match[1] ?? ""); + let value = (match[2] ?? "").trim(); + if (key === "secondarymetrics") { + const nestedItems: string[] = []; + for (let nestedIndex = index + 1; nestedIndex < lines.length; nestedIndex += 1) { + const nestedLine = lines[nestedIndex] ?? ""; + if (nestedLine.match(KEY_VALUE_REGEX)) break; + const nestedMatch = nestedLine.match(/^\s{2,}[-*]\s+(.*)$/); + if (!nestedMatch) { + if (nestedLine.trim().length > 0) break; + continue; + } + nestedItems.push((nestedMatch[1] ?? "").trim()); + index = nestedIndex; + } + if (nestedItems.length > 0) { + value = [value, ...nestedItems].filter(Boolean).join(", "); + } + } + entries.set(key, value); + } + + const direction = parseDirection(entries.get("direction")); + return { + command: readNullableEntry(entries.get("command")), + primaryMetric: readNullableEntry(entries.get("primarymetric")), + metricUnit: entries.get("metricunit")?.trim() ?? "", + direction, + secondaryMetrics: parseSecondaryMetrics(entries.get("secondarymetrics")), + }; +} + +function parseListSection(section: string, normalizeItem?: (value: string) => string): string[] { + const items: string[] = []; + let activeItem: string | null = null; + for (const rawLine of section.split("\n")) { + const line = rawLine.trimEnd(); + if (line.trim().length === 0) continue; + const match = rawLine.match(LIST_ITEM_REGEX); + if (match) { + if (activeItem) items.push(activeItem); + activeItem = (match[1] ?? "").trim(); + continue; + } + if (activeItem && /^\s{2,}\S/.test(rawLine)) { + activeItem = `${activeItem} ${line.trim()}`; + continue; + } + if (activeItem) { + items.push(activeItem); + activeItem = null; + } + items.push(line.trim()); + } + if (activeItem) { + items.push(activeItem); + } + const normalizedItems = normalizeAutoresearchList(items); + return normalizeItem ? normalizedItems.map(normalizeItem) : normalizedItems; +} + +function normalizeKey(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]+/g, ""); +} + +function parseDirection(value: string | undefined): MetricDirection | null { + if (value === "lower" || value === "higher") return value; + return null; +} + +function readNullableEntry(value: string | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +function parseSecondaryMetrics(value: string | undefined): string[] { + if (!value) return []; + return normalizeAutoresearchList( + value + .split(",") + .map(entry => entry.trim()) + .filter(Boolean), + ); +} + +function isUnsafeContractPathSpec(value: string): boolean { + return path.posix.isAbsolute(value) || value === ".." || value.startsWith("../"); +} diff --git a/packages/coding-agent/src/autoresearch/dashboard.ts b/packages/coding-agent/src/autoresearch/dashboard.ts new file mode 100644 index 000000000..2f46cb268 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/dashboard.ts @@ -0,0 +1,447 @@ +import { matchesKey, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui"; +import type { Theme } from "../modes/theme/theme"; +import { replaceTabs } from "../tools/render-utils"; +import { formatElapsed, formatNum, isBetter } from "./helpers"; +import { currentResults, findBaselineMetric, findBaselineRunNumber, findBaselineSecondary } from "./state"; +import type { AutoresearchRuntime, DashboardController, ExperimentResult, ExperimentState } from "./types"; + +export function createDashboardController(): DashboardController { + let overlayTui: { requestRender(): void } | null = null; + let spinnerTimer: NodeJS.Timeout | undefined; + let spinnerFrame = 0; + + const requestRender = (): void => { + overlayTui?.requestRender(); + }; + + const clear = (): void => { + overlayTui = null; + if (spinnerTimer) { + clearInterval(spinnerTimer); + spinnerTimer = undefined; + } + }; + + return { + clear(ctx): void { + clear(); + if (ctx.hasUI) { + ctx.ui.setWidget("autoresearch", undefined); + } + }, + requestRender, + updateWidget(ctx, runtime): void { + if (!ctx.hasUI) return; + const state = runtime.state; + if (!shouldShowDashboard(runtime, state)) { + ctx.ui.setWidget("autoresearch", undefined); + return; + } + + ctx.ui.setWidget("autoresearch", (_tui, theme) => { + if (state.results.length === 0 && runtime.runningExperiment) { + return new Text(renderRunningOnly(runtime, state, theme), 0, 0); + } + if (runtime.dashboardExpanded) { + const width = process.stdout.columns ?? 120; + const lines = [ + renderExpandedHeader(runtime, width, theme), + ...renderDashboardLines(runtime, width, theme, 8), + ]; + return new Text(lines.join("\n"), 0, 0); + } + return new Text(renderCollapsedLine(runtime, state, theme), 0, 0); + }); + }, + async showOverlay(ctx, runtime): Promise { + if (!ctx.hasUI || !shouldShowDashboard(runtime, runtime.state)) return; + await ctx.ui.custom( + (tui, theme, _keybindings, done) => { + overlayTui = tui; + if (!spinnerTimer) { + spinnerTimer = setInterval(() => { + spinnerFrame += 1; + requestRender(); + }, 80); + } + + let scrollOffset = 0; + return { + render(width: number): string[] { + const terminalRows = process.stdout.rows ?? 40; + const header = renderExpandedHeader(runtime, width, theme); + const body = renderDashboardLines(runtime, width, theme, 0); + if (runtime.runningExperiment) { + body.push(renderOverlayRunningLine(runtime, theme, width, spinnerFrame)); + } + const viewportRows = Math.max(4, terminalRows - 4); + const maxScroll = Math.max(0, body.length - viewportRows); + if (scrollOffset > maxScroll) scrollOffset = maxScroll; + const visible = body.slice(scrollOffset, scrollOffset + viewportRows); + const footer = renderOverlayFooter(width, scrollOffset, viewportRows, body.length, theme); + return [ + header, + ...visible, + ...Array.from({ length: Math.max(0, viewportRows - visible.length) }, () => ""), + footer, + ]; + }, + handleInput(data: string): void { + const totalRows = + renderDashboardLines(runtime, process.stdout.columns ?? 120, theme, 0).length + + (runtime.runningExperiment ? 1 : 0); + const viewportRows = Math.max(4, (process.stdout.rows ?? 40) - 4); + const maxScroll = Math.max(0, totalRows - viewportRows); + if (matchesKey(data, "escape") || matchesKey(data, "esc") || data === "q") { + done(undefined); + return; + } + if (matchesKey(data, "up") || data === "k") { + scrollOffset = Math.max(0, scrollOffset - 1); + } else if (matchesKey(data, "down") || data === "j") { + scrollOffset = Math.min(maxScroll, scrollOffset + 1); + } else if (matchesKey(data, "pageUp")) { + scrollOffset = Math.max(0, scrollOffset - viewportRows); + } else if (matchesKey(data, "pageDown")) { + scrollOffset = Math.min(maxScroll, scrollOffset + viewportRows); + } else if (data === "g") { + scrollOffset = 0; + } else if (data === "G") { + scrollOffset = maxScroll; + } + tui.requestRender(); + }, + invalidate(): void {}, + dispose(): void { + clear(); + }, + }; + }, + { overlay: true }, + ); + }, + }; +} + +function renderRunningOnly(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string { + const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", " running...")]; + if (state.name) { + parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`)); + } + if (runtime.runningExperiment) { + parts.push(theme.fg("dim", ` | ${replaceTabs(runtime.runningExperiment.command)}`)); + } + return parts.join(""); +} + +function shouldShowDashboard(runtime: AutoresearchRuntime, state: ExperimentState): boolean { + return ( + runtime.autoresearchMode || + state.results.length > 0 || + runtime.runningExperiment !== null || + runtime.lastRunSummary !== null + ); +} + +function renderExpandedHeader(runtime: AutoresearchRuntime, width: number, theme: Theme): string { + const state = runtime.state; + const status = renderModeStatus(runtime, state); + const label = state.name ? ` autoresearch: ${replaceTabs(state.name)} ` : " autoresearch "; + const hint = theme.fg("dim", ` ctrl+x collapse ctrl+shift+x overlay${status ? ` ${status}` : ""} `); + const fillWidth = Math.max(0, width - visibleWidth(label) - visibleWidth(hint)); + return truncateToWidth(theme.fg("accent", label) + theme.fg("borderMuted", "-".repeat(fillWidth)) + hint, width); +} + +function renderCollapsedLine(runtime: AutoresearchRuntime, state: ExperimentState, theme: Theme): string { + if (runtime.lastRunSummary) { + const parts = [ + theme.fg("accent", "autoresearch"), + theme.fg("warning", ` pending run #${runtime.lastRunSummary.runNumber}`), + theme.fg("dim", runtime.lastRunSummary.passed ? " pass" : " fail"), + ]; + if (runtime.lastRunSummary.parsedPrimary !== null) { + parts.push( + theme.fg( + "muted", + ` | ${state.metricName}=${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}`, + ), + ); + } + parts.push(theme.fg("warning", " | log_experiment required")); + if (!runtime.autoresearchMode) { + parts.push(theme.fg("dim", " | mode off")); + } + return parts.join(""); + } + if (state.results.length === 0) { + const modeStatus = runtime.autoresearchMode ? "baseline pending" : "mode off"; + const parts = [theme.fg("accent", "autoresearch"), theme.fg("warning", ` ${modeStatus}`)]; + if (state.name) { + parts.push(theme.fg("dim", ` | ${replaceTabs(state.name)}`)); + } + if (runtime.autoresearchMode) { + parts.push(theme.fg("dim", " | run the baseline")); + } + return parts.join(""); + } + const current = currentResults(state.results, state.currentSegment); + const kept = current.filter(result => result.status === "keep").length; + const crashed = current.filter(result => result.status === "crash").length; + const checksFailed = current.filter(result => result.status === "checks_failed").length; + const best = findBestResult(state); + const archivedRuns = Math.max(0, state.results.length - current.length); + const parts = [ + theme.fg("accent", "autoresearch"), + theme.fg("muted", ` ${current.length} runs`), + theme.fg("success", ` ${kept} kept`), + ]; + if (archivedRuns > 0) parts.push(theme.fg("dim", ` +${archivedRuns} archived`)); + if (crashed > 0) parts.push(theme.fg("error", ` ${crashed} crash`)); + if (checksFailed > 0) parts.push(theme.fg("error", ` ${checksFailed} checks_failed`)); + parts.push(theme.fg("dim", " | ")); + if (best && state.bestMetric !== null && best.result.metric !== state.bestMetric) { + parts.push(theme.fg("warning", `best ${formatNum(best.result.metric, state.metricUnit)}`)); + parts.push(theme.fg("dim", ` baseline ${formatNum(state.bestMetric, state.metricUnit)}`)); + } else if (state.bestMetric !== null) { + parts.push(theme.fg("warning", `baseline ${formatNum(state.bestMetric, state.metricUnit)}`)); + } else { + parts.push(theme.fg("warning", `no kept runs yet`)); + } + if (state.confidence !== null) { + const confidenceColor = state.confidence >= 2 ? "success" : state.confidence >= 1 ? "warning" : "error"; + parts.push(theme.fg("dim", " | ")); + parts.push(theme.fg(confidenceColor, `conf ${state.confidence.toFixed(1)}x`)); + } + if (runtime.runningExperiment) { + parts.push(theme.fg("dim", ` | running ${formatElapsed(Date.now() - runtime.runningExperiment.startedAt)}`)); + } else if (!runtime.autoresearchMode) { + parts.push(theme.fg("dim", ` | ${renderModeStatus(runtime, state)}`)); + } + parts.push(theme.fg("dim", " | ctrl+x expand")); + return parts.join(""); +} + +export function renderDashboardLines( + runtime: AutoresearchRuntime, + width: number, + theme: Theme, + maxRows: number, +): string[] { + const state = runtime.state; + if (state.results.length === 0) { + if (runtime.lastRunSummary) { + const lines = [ + truncateToWidth(`Pending run: #${runtime.lastRunSummary.runNumber}`, width), + truncateToWidth( + `Result: ${runtime.lastRunSummary.passed ? "passed" : "failed"}${runtime.lastRunSummary.parsedPrimary !== null ? ` ${state.metricName} ${formatNum(runtime.lastRunSummary.parsedPrimary, state.metricUnit)}` : ""}`, + width, + ), + truncateToWidth("Next action: finish log_experiment before starting another run.", width), + ]; + if (!runtime.autoresearchMode) { + lines.push(truncateToWidth("Mode: off", width)); + } + return lines; + } + if (runtime.autoresearchMode) { + return [ + truncateToWidth("Current segment: 0 runs", width), + truncateToWidth("Baseline: pending", width), + truncateToWidth("Next action: run and log the baseline experiment.", width), + ]; + } + return [theme.fg("dim", "No experiments logged yet.")]; + } + + const current = currentResults(state.results, state.currentSegment); + const kept = current.filter(result => result.status === "keep").length; + const discarded = current.filter(result => result.status === "discard").length; + const crashed = current.filter(result => result.status === "crash").length; + const checksFailed = current.filter(result => result.status === "checks_failed").length; + const baseline = findBaselineMetric(state.results, state.currentSegment); + const baselineRunNumber = findBaselineRunNumber(state.results, state.currentSegment); + const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics); + const best = findBestResult(state); + const lines = [ + truncateToWidth( + `Current segment: ${current.length} runs ${kept} kept ${discarded} discarded ${crashed} crashed ${checksFailed} checks_failed`, + width, + ), + truncateToWidth( + `Baseline: ${formatNum(baseline, state.metricUnit)}${baselineRunNumber ? ` (#${baselineRunNumber})` : ""}`, + width, + ), + ]; + if (state.results.length > current.length) { + lines.push( + truncateToWidth(`Archived from earlier segments: ${state.results.length - current.length} runs`, width), + ); + } + if (runtime.lastRunSummary) { + lines.push( + truncateToWidth( + `Pending run: #${runtime.lastRunSummary.runNumber} (${runtime.lastRunSummary.passed ? "passed" : "failed"}) — log_experiment required`, + width, + ), + ); + } + if (!runtime.autoresearchMode) { + lines.push(truncateToWidth(`Mode: ${renderModeStatus(runtime, state)}`, width)); + } + if (best) { + const bestRunNumber = best.result.runNumber ?? best.index + 1; + let progress = `Best: ${formatNum(best.result.metric, state.metricUnit)} (#${bestRunNumber})`; + if (baseline !== null && baseline !== 0 && best.result.metric !== baseline) { + const delta = ((best.result.metric - baseline) / baseline) * 100; + const sign = delta > 0 ? "+" : ""; + progress += ` ${sign}${delta.toFixed(1)}%`; + } + if (state.confidence !== null) { + progress += ` conf ${state.confidence.toFixed(1)}x`; + } + lines.push(truncateToWidth(progress, width)); + if (state.secondaryMetrics.length > 0) { + const details = state.secondaryMetrics + .map(metric => + renderSecondarySummary( + metric.name, + best.result.metrics[metric.name], + baselineSecondary[metric.name], + metric.unit, + ), + ) + .filter((value): value is string => Boolean(value)); + if (details.length > 0) { + lines.push(truncateToWidth(`Secondary: ${details.join(" ")}`, width)); + } + } + } + lines.push(""); + lines.push(renderTableHeader(state, width, theme)); + lines.push(theme.fg("borderMuted", "-".repeat(Math.max(0, width - 1)))); + + const visible = maxRows > 0 ? current.slice(-maxRows) : current; + if (visible.length < current.length) { + lines.push(theme.fg("dim", `... ${current.length - visible.length} earlier runs hidden ...`)); + } + for (const result of visible) { + lines.push(renderResultRow(result, state, baselineSecondary, width, theme)); + } + return lines; +} + +function renderTableHeader(state: ExperimentState, width: number, theme: Theme): string { + const secondaryHeader = state.secondaryMetrics.map(metric => truncateToWidth(metric.name, 10)).join(" "); + return truncateToWidth( + `${theme.fg("muted", "#".padEnd(4))}${theme.fg("muted", "commit".padEnd(10))}${theme.fg("warning", state.metricName.padEnd(12))}${secondaryHeader ? `${theme.fg("muted", secondaryHeader)} ` : ""}${theme.fg("muted", "status".padEnd(14))}${theme.fg("muted", "description")}`, + width, + ); +} + +function renderResultRow( + result: ExperimentResult, + state: ExperimentState, + baselineSecondary: { [key: string]: number }, + width: number, + theme: Theme, +): string { + const runNumber = result.runNumber ?? state.results.indexOf(result) + 1; + const secondary = state.secondaryMetrics + .map(metric => + truncateToWidth( + renderSecondaryCell(result.metrics[metric.name], metric.unit, baselineSecondary[metric.name]), + 10, + ).padEnd(11), + ) + .join(""); + const statusColor = result.status === "keep" ? "success" : result.status === "discard" ? "warning" : "error"; + const line = + `${theme.fg("dim", String(runNumber).padEnd(4))}` + + `${theme.fg("accent", (result.commit || "-").padEnd(10))}` + + `${theme.fg(statusColor, formatNum(result.metric, state.metricUnit).padEnd(12))}` + + `${secondary}` + + `${theme.fg(statusColor, result.status.padEnd(14))}` + + `${theme.fg("muted", replaceTabs(result.description))}`; + return truncateToWidth(line, width); +} + +function renderSecondaryCell(value: number | undefined, unit: string, baseline: number | undefined): string { + if (value === undefined) return "-"; + const formatted = formatNum(value, unit); + if (baseline === undefined || baseline === 0 || baseline === value) return formatted; + const delta = ((value - baseline) / baseline) * 100; + const sign = delta > 0 ? "+" : ""; + return `${formatted} ${sign}${delta.toFixed(1)}%`; +} + +function renderSecondarySummary( + name: string, + value: number | undefined, + baseline: number | undefined, + unit: string, +): string | null { + if (value === undefined) return null; + if (baseline === undefined || baseline === 0 || baseline === value) { + return `${name} ${formatNum(value, unit)}`; + } + const delta = ((value - baseline) / baseline) * 100; + const sign = delta > 0 ? "+" : ""; + return `${name} ${formatNum(value, unit)} ${sign}${delta.toFixed(1)}%`; +} + +function renderOverlayRunningLine( + runtime: AutoresearchRuntime, + theme: Theme, + width: number, + spinnerFrame: number, +): string { + const spinner = theme.spinnerFrames[spinnerFrame % theme.spinnerFrames.length] ?? "*"; + return truncateToWidth( + theme.fg( + "warning", + `${spinner} running ${formatElapsed(Date.now() - (runtime.runningExperiment?.startedAt ?? Date.now()))} ${replaceTabs( + runtime.runningExperiment?.command ?? "", + )}`, + ), + width, + ); +} + +function renderOverlayFooter( + width: number, + scrollOffset: number, + viewportRows: number, + totalRows: number, + theme: Theme, +): string { + const position = + totalRows > viewportRows + ? ` ${scrollOffset + 1}-${Math.min(totalRows, scrollOffset + viewportRows)}/${totalRows}` + : ""; + const hint = theme.fg("dim", ` up/down j/k pageup pagedown g G esc${position} `); + const fill = Math.max(0, width - visibleWidth(hint)); + return theme.fg("borderMuted", "-".repeat(fill)) + hint; +} + +function renderModeStatus(runtime: AutoresearchRuntime, state: ExperimentState): string { + if (runtime.autoresearchMode) { + return state.results.length === 0 ? "baseline pending" : "mode on"; + } + const current = currentResults(state.results, state.currentSegment); + if (state.maxExperiments !== null && current.length >= state.maxExperiments) { + return "segment complete"; + } + return "mode off"; +} + +function findBestResult(state: ExperimentState): { index: number; result: ExperimentResult } | null { + let best: { index: number; result: ExperimentResult } | null = null; + for (let index = 0; index < state.results.length; index += 1) { + const result = state.results[index]; + if (result.segment !== state.currentSegment || result.status !== "keep" || result.metric <= 0) continue; + if (!best || isBetter(result.metric, best.result.metric, state.bestDirection)) { + best = { index, result }; + } + } + return best; +} diff --git a/packages/coding-agent/src/autoresearch/git.ts b/packages/coding-agent/src/autoresearch/git.ts new file mode 100644 index 000000000..e22ea4976 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/git.ts @@ -0,0 +1,243 @@ +import type { ExtensionAPI } from "../extensibility/extensions"; +import { isAutoresearchLocalStatePath, normalizeAutoresearchPath } from "./helpers"; + +const AUTORESEARCH_BRANCH_PREFIX = "autoresearch/"; +const BRANCH_NAME_MAX_LENGTH = 48; + +export interface EnsureAutoresearchBranchFailure { + error: string; + ok: false; +} + +export interface EnsureAutoresearchBranchSuccess { + branchName: string; + created: boolean; + ok: true; +} + +export type EnsureAutoresearchBranchResult = EnsureAutoresearchBranchFailure | EnsureAutoresearchBranchSuccess; + +export async function getCurrentAutoresearchBranch(api: ExtensionAPI, workDir: string): Promise { + const currentBranchResult = await api.exec("git", ["branch", "--show-current"], { cwd: workDir, timeout: 5_000 }); + const currentBranch = currentBranchResult.stdout.trim(); + return currentBranch.startsWith(AUTORESEARCH_BRANCH_PREFIX) ? currentBranch : null; +} + +export async function ensureAutoresearchBranch( + api: ExtensionAPI, + workDir: string, + goal: string | null, +): Promise { + const repoRootResult = await api.exec("git", ["rev-parse", "--show-toplevel"], { cwd: workDir, timeout: 5_000 }); + if (repoRootResult.code !== 0) { + return { + error: "Autoresearch requires a git repository so it can isolate experiments and revert failed runs safely.", + ok: false, + }; + } + const repoRoot = repoRootResult.stdout.trim() || workDir; + + const dirtyPathsResult = await api.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { + cwd: repoRoot, + timeout: 5_000, + }); + if (dirtyPathsResult.code !== 0) { + return { + error: `Unable to inspect git status before starting autoresearch: ${mergeStdoutStderr(dirtyPathsResult).trim() || `exit ${dirtyPathsResult.code}`}`, + ok: false, + }; + } + + const workDirPrefix = await readGitWorkDirPrefix(api, workDir); + const unsafeDirtyPaths = collectUnsafeDirtyPaths(dirtyPathsResult.stdout, workDirPrefix); + const currentBranch = await getCurrentAutoresearchBranch(api, workDir); + if (currentBranch) { + if (unsafeDirtyPaths.length > 0) { + return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths); + } + return { + branchName: currentBranch, + created: false, + ok: true, + }; + } + if (unsafeDirtyPaths.length > 0) { + return buildUnsafeDirtyPathsFailure(unsafeDirtyPaths); + } + + const branchName = await allocateBranchName(api, workDir, goal); + const checkoutResult = await api.exec("git", ["checkout", "-b", branchName], { cwd: workDir, timeout: 10_000 }); + if (checkoutResult.code !== 0) { + return { + error: + `Failed to create autoresearch branch ${branchName}: ` + + `${mergeStdoutStderr(checkoutResult).trim() || `exit ${checkoutResult.code}`}`, + ok: false, + }; + } + + return { + branchName, + created: true, + ok: true, + }; +} + +export function parseWorkDirDirtyPaths(statusOutput: string, workDirPrefix: string): string[] { + const relativePaths: string[] = []; + for (const dirtyPath of parseDirtyPaths(statusOutput)) { + const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix); + if (relativePath === null) continue; + relativePaths.push(relativePath); + } + return relativePaths; +} + +export function relativizeGitPathToWorkDir(repoRelativePath: string, workDirPrefix: string): string | null { + const normalizedPath = normalizeStatusPath(repoRelativePath); + const normalizedPrefix = normalizeAutoresearchPath(workDirPrefix); + if (normalizedPrefix === "" || normalizedPrefix === ".") { + return normalizedPath; + } + if (normalizedPath === normalizedPrefix) { + return "."; + } + if (!normalizedPath.startsWith(`${normalizedPrefix}/`)) { + return null; + } + return normalizeAutoresearchPath(normalizedPath.slice(normalizedPrefix.length + 1)); +} + +async function readGitWorkDirPrefix(api: ExtensionAPI, workDir: string): Promise { + const prefixResult = await api.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 }); + if (prefixResult.code !== 0) { + return ""; + } + return prefixResult.stdout.trim(); +} + +export function parseDirtyPaths(statusOutput: string): string[] { + if (statusOutput.includes("\0")) { + return parseDirtyPathsNul(statusOutput); + } + return parseDirtyPathsLines(statusOutput); +} + +function parseDirtyPathsNul(statusOutput: string): string[] { + const unsafePaths = new Set(); + let index = 0; + while (index + 3 <= statusOutput.length) { + const statusToken = statusOutput.slice(index, index + 3); + index += 3; + const pathEnd = statusOutput.indexOf("\0", index); + if (pathEnd < 0) break; + const firstPath = statusOutput.slice(index, pathEnd); + index = pathEnd + 1; + addDirtyPath(unsafePaths, firstPath); + if (isRenameOrCopy(statusToken)) { + const secondPathEnd = statusOutput.indexOf("\0", index); + if (secondPathEnd < 0) break; + const secondPath = statusOutput.slice(index, secondPathEnd); + index = secondPathEnd + 1; + addDirtyPath(unsafePaths, secondPath); + } + } + return [...unsafePaths]; +} + +function parseDirtyPathsLines(statusOutput: string): string[] { + const unsafePaths = new Set(); + for (const line of statusOutput.split("\n")) { + const trimmedLine = line.trimEnd(); + if (trimmedLine.length < 4) continue; + const rawPath = trimmedLine.slice(3).trim(); + if (rawPath.length === 0) continue; + const renameParts = rawPath.split(" -> "); + for (const renamePart of renameParts) { + addDirtyPath(unsafePaths, renamePart); + } + } + return [...unsafePaths]; +} + +export function normalizeStatusPath(path: string): string { + let normalized = path.trim(); + if (normalized.startsWith('"') && normalized.endsWith('"')) { + normalized = normalized.slice(1, -1); + } + return normalizeAutoresearchPath(normalized); +} + +async function allocateBranchName(api: ExtensionAPI, workDir: string, goal: string | null): Promise { + const baseName = `${AUTORESEARCH_BRANCH_PREFIX}${slugifyGoal(goal)}-${currentDateStamp()}`; + let candidate = baseName; + let suffix = 2; + while (await branchExists(api, workDir, candidate)) { + candidate = `${baseName}-${suffix}`; + suffix += 1; + } + return candidate; +} + +async function branchExists(api: ExtensionAPI, workDir: string, branchName: string): Promise { + const result = await api.exec("git", ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { + cwd: workDir, + timeout: 5_000, + }); + return result.code === 0; +} + +function slugifyGoal(goal: string | null): string { + const normalized = (goal ?? "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + const trimmed = normalized.slice(0, BRANCH_NAME_MAX_LENGTH).replace(/-+$/g, ""); + return trimmed || "session"; +} + +function currentDateStamp(): string { + const now = new Date(); + const year = String(now.getFullYear()); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + return `${year}${month}${day}`; +} + +function mergeStdoutStderr(result: { stderr: string; stdout: string }): string { + return `${result.stdout}${result.stderr}`; +} + +function addDirtyPath(paths: Set, rawPath: string): void { + const normalizedPath = normalizeStatusPath(rawPath); + if (normalizedPath.length === 0) return; + paths.add(normalizedPath); +} + +function buildUnsafeDirtyPathsFailure(unsafeDirtyPaths: string[]): EnsureAutoresearchBranchFailure { + const preview = unsafeDirtyPaths.slice(0, 5).join(", "); + const suffix = unsafeDirtyPaths.length > 5 ? ` (+${unsafeDirtyPaths.length - 5} more)` : ""; + return { + error: + "Autoresearch needs a clean git worktree before it can create or reuse an isolated branch. " + + `Commit or stash these paths first: ${preview}${suffix}`, + ok: false, + }; +} + +function isRenameOrCopy(statusToken: string): boolean { + const trimmed = statusToken.trim(); + return trimmed.startsWith("R") || trimmed.startsWith("C"); +} + +function collectUnsafeDirtyPaths(statusOutput: string, workDirPrefix: string): string[] { + const unsafeDirtyPaths: string[] = []; + for (const dirtyPath of parseDirtyPaths(statusOutput)) { + const relativePath = relativizeGitPathToWorkDir(dirtyPath, workDirPrefix); + if (relativePath && isAutoresearchLocalStatePath(relativePath)) { + continue; + } + unsafeDirtyPaths.push(relativePath ?? normalizeStatusPath(dirtyPath)); + } + return unsafeDirtyPaths; +} diff --git a/packages/coding-agent/src/autoresearch/helpers.ts b/packages/coding-agent/src/autoresearch/helpers.ts new file mode 100644 index 000000000..e278d3631 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/helpers.ts @@ -0,0 +1,458 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { isEnoent } from "@oh-my-pi/pi-utils"; +import { parseCommandArgs } from "../utils/command-args"; +import type { + ASIData, + ASIValue, + AutoresearchConfig, + MetricDirection, + NumericMetricMap, + PendingRunSummary, +} from "./types"; + +export const METRIC_LINE_PREFIX = "METRIC"; +export const ASI_LINE_PREFIX = "ASI"; +export const EXPERIMENT_MAX_LINES = 10; +export const EXPERIMENT_MAX_BYTES = 4 * 1024; +export const AUTORESEARCH_COMMITTABLE_FILES = [ + "autoresearch.md", + "autoresearch.program.md", + "autoresearch.sh", + "autoresearch.checks.sh", + "autoresearch.ideas.md", +] as const; +export const AUTORESEARCH_LOCAL_STATE_FILES = ["autoresearch.jsonl"] as const; +export const AUTORESEARCH_LOCAL_STATE_DIRECTORIES = [".autoresearch"] as const; + +const DENIED_KEY_NAMES = new Set(["__proto__", "constructor", "prototype"]); + +export function parseMetricLines(output: string): Map { + const metrics = new Map(); + const regex = new RegExp(`^${METRIC_LINE_PREFIX}\\s+([\\w.µ-]+)=(\\S+)\\s*$`, "gm"); + let match = regex.exec(output); + while (match !== null) { + const name = match[1]; + if (!DENIED_KEY_NAMES.has(name)) { + const value = Number(match[2]); + if (Number.isFinite(value)) { + metrics.set(name, value); + } + } + match = regex.exec(output); + } + return metrics; +} + +export function parseAsiLines(output: string): ASIData | null { + const asi: ASIData = {}; + const regex = new RegExp(`^${ASI_LINE_PREFIX}\\s+([\\w.-]+)=(.+)\\s*$`, "gm"); + let match = regex.exec(output); + while (match !== null) { + const key = match[1]; + if (!DENIED_KEY_NAMES.has(key)) { + asi[key] = parseAsiValue(match[2]); + } + match = regex.exec(output); + } + return Object.keys(asi).length > 0 ? asi : null; +} + +function parseAsiValue(raw: string): ASIValue { + const value = raw.trim(); + if (value === "true") return true; + if (value === "false") return false; + if (value === "null") return null; + if (/^-?\d+(?:\.\d+)?$/.test(value)) { + const numberValue = Number(value); + if (Number.isFinite(numberValue)) return numberValue; + } + if (value.startsWith("{") || value.startsWith("[") || value.startsWith('"')) { + try { + const parsed = JSON.parse(value) as ASIValue; + return parsed; + } catch { + return value; + } + } + return value; +} + +export function mergeAsi(base: ASIData | null, override: ASIData | undefined): ASIData | undefined { + if (!base && !override) return undefined; + return { + ...(base ?? {}), + ...(override ?? {}), + }; +} + +export function commas(value: number): string { + const sign = value < 0 ? "-" : ""; + const digits = String(Math.trunc(Math.abs(value))); + const groups: string[] = []; + for (let index = digits.length; index > 0; index -= 3) { + groups.unshift(digits.slice(Math.max(0, index - 3), index)); + } + return sign + groups.join(","); +} + +export function fmtNum(value: number, decimals: number = 0): string { + if (decimals <= 0) return commas(Math.round(value)); + const absolute = Math.abs(value); + const whole = Math.floor(absolute); + const fraction = (absolute - whole).toFixed(decimals).slice(1); + return `${value < 0 ? "-" : ""}${commas(whole)}${fraction}`; +} + +export function formatNum(value: number | null, unit: string): string { + if (value === null) return "-"; + if (Number.isInteger(value)) return `${fmtNum(value)}${unit}`; + return `${fmtNum(value, 2)}${unit}`; +} + +export function formatElapsed(milliseconds: number): string { + const totalSeconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes > 0) { + return `${minutes}m ${String(seconds).padStart(2, "0")}s`; + } + return `${seconds}s`; +} + +export function getAutoresearchRunDirectory(workDir: string, runNumber: number): string { + return path.join(workDir, ".autoresearch", "runs", String(runNumber).padStart(4, "0")); +} + +export function getNextAutoresearchRunNumber(workDir: string, lastRunNumber: number | null): number { + const runsDirectory = path.join(workDir, ".autoresearch", "runs"); + let maxRunNumber = lastRunNumber ?? 0; + try { + for (const entry of fs.readdirSync(runsDirectory, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const runNumber = Number.parseInt(entry.name, 10); + if (Number.isFinite(runNumber)) { + maxRunNumber = Math.max(maxRunNumber, runNumber); + } + } + } catch (error) { + if (!isEnoent(error)) { + throw error; + } + } + return maxRunNumber + 1; +} + +export function normalizeAutoresearchPath(relativePath: string): string { + const normalized = relativePath.replaceAll("\\", "/").trim(); + if (normalized === "." || normalized === "./") return "."; + return normalized.replace(/^\.\/+/, "").replace(/\/+$/, ""); +} + +export function isAutoresearchCommittableFile(relativePath: string): boolean { + const normalized = normalizeAutoresearchPath(relativePath); + return AUTORESEARCH_COMMITTABLE_FILES.some(candidate => candidate === normalized); +} + +export function isAutoresearchLocalStatePath(relativePath: string): boolean { + const normalized = normalizeAutoresearchPath(relativePath); + if (AUTORESEARCH_LOCAL_STATE_FILES.some(candidate => candidate === normalized)) { + return true; + } + return AUTORESEARCH_LOCAL_STATE_DIRECTORIES.some(candidate => { + const normalizedCandidate = normalizeAutoresearchPath(candidate); + return normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`); + }); +} + +export function killTree(pid: number, signal: NodeJS.Signals | number = "SIGTERM"): void { + try { + process.kill(-pid, signal); + } catch { + try { + process.kill(pid, signal); + } catch { + // Process already exited. + } + } +} + +export function isAutoresearchShCommand(command: string): boolean { + let normalized = command.trim(); + normalized = normalized.replace(/^(?:\w+=\S*\s+)+/, ""); + + let previous = ""; + while (previous !== normalized) { + previous = normalized; + normalized = normalized.replace(/^(?:env|time|nice|nohup)(?:\s+-\S+(?:\s+\d+)?)?\s+/, ""); + } + if (/[;&|<>]/.test(normalized)) { + return false; + } + + const tokens = parseCommandArgs(normalized); + if (tokens.length === 0) return false; + + let index = 0; + if (tokens[index] === "bash" || tokens[index] === "sh") { + index += 1; + while (index < tokens.length && tokens[index]?.startsWith("-")) { + if (tokens[index]?.includes("c")) { + return false; + } + index += 1; + } + } + + const scriptToken = tokens[index]; + if (!scriptToken || !/^(?:\.\/|\/[\w/.-]*\/)?autoresearch\.sh$/.test(scriptToken)) { + return false; + } + + for (const token of tokens.slice(index + 1)) { + if (token === "&&" || token === "||" || token === ";" || token === "|" || token === ">" || token === "<") { + return false; + } + } + + return true; +} + +export function isBetter(current: number, best: number, direction: MetricDirection): boolean { + return direction === "lower" ? current < best : current > best; +} + +export function inferMetricUnitFromName(name: string): string { + if (name.endsWith("µs") || name.endsWith("_µs")) return "µs"; + if (name.endsWith("ms") || name.endsWith("_ms")) return "ms"; + if (name.endsWith("_s") || name.endsWith("_sec") || name.endsWith("_secs")) return "s"; + if (name.endsWith("_kb") || name.endsWith("kb")) return "kb"; + if (name.endsWith("_mb") || name.endsWith("mb")) return "mb"; + return ""; +} + +export async function readPendingRunSummary( + workDir: string, + loggedRunNumbers: ReadonlySet = new Set(), +): Promise { + const runsDir = path.join(workDir, ".autoresearch", "runs"); + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(runsDir, { withFileTypes: true }); + } catch (error) { + if (isEnoent(error)) return null; + throw error; + } + + const runDirectories = entries + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort((left, right) => right.localeCompare(left)); + + for (const directoryName of runDirectories) { + const runDirectory = path.join(runsDir, directoryName); + const runJsonPath = path.join(runDirectory, "run.json"); + let parsed: unknown; + try { + parsed = await Bun.file(runJsonPath).json(); + } catch (error) { + if (isEnoent(error)) continue; + throw error; + } + + const pendingRun = parsePendingRunSummary(parsed, runDirectory, directoryName, loggedRunNumbers); + if (pendingRun) { + return pendingRun; + } + } + + return null; +} + +export function readConfig(cwd: string): AutoresearchConfig { + const configPath = path.join(cwd, "autoresearch.config.json"); + try { + const raw = fs.readFileSync(configPath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (typeof parsed !== "object" || parsed === null) return {}; + const candidate = parsed as { maxIterations?: unknown; workingDir?: unknown }; + const config: AutoresearchConfig = {}; + if (typeof candidate.maxIterations === "number" && Number.isFinite(candidate.maxIterations)) { + config.maxIterations = candidate.maxIterations; + } + if (typeof candidate.workingDir === "string" && candidate.workingDir.trim().length > 0) { + config.workingDir = candidate.workingDir; + } + return config; + } catch (error) { + if (isEnoent(error)) return {}; + return {}; + } +} + +export function readMaxExperiments(cwd: string): number | null { + const value = readConfig(cwd).maxIterations; + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return null; + return Math.floor(value); +} + +export function resolveWorkDir(cwd: string): string { + const configured = readConfig(cwd).workingDir; + if (!configured) return cwd; + return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured); +} + +export function validateWorkDir(cwd: string): string | null { + const workDir = resolveWorkDir(cwd); + try { + const stat = fs.statSync(workDir); + if (!stat.isDirectory()) { + return `workingDir ${workDir} is not a directory.`; + } + return null; + } catch (error) { + if (isEnoent(error)) { + return `workingDir ${workDir} does not exist.`; + } + return `workingDir ${workDir} is unavailable.`; + } +} + +function parsePendingRunSummary( + value: unknown, + runDirectory: string, + directoryName: string, + loggedRunNumbers: ReadonlySet, +): PendingRunSummary | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as { + checks?: { durationSeconds?: unknown; passed?: unknown; timedOut?: unknown }; + completedAt?: unknown; + command?: unknown; + durationSeconds?: unknown; + exitCode?: unknown; + loggedAt?: unknown; + parsedAsi?: unknown; + parsedMetrics?: unknown; + parsedPrimary?: unknown; + runNumber?: unknown; + status?: unknown; + timedOut?: unknown; + }; + if (candidate.loggedAt !== undefined || candidate.status !== undefined) { + return null; + } + + const command = typeof candidate.command === "string" ? candidate.command : ""; + const runNumber = + typeof candidate.runNumber === "number" && Number.isFinite(candidate.runNumber) + ? candidate.runNumber + : parseInt(directoryName, 10); + if (!Number.isFinite(runNumber)) return null; + if (loggedRunNumbers.has(runNumber)) return null; + + const hasCompletedMetadata = + typeof candidate.completedAt === "string" || + candidate.exitCode !== undefined || + candidate.timedOut !== undefined || + candidate.durationSeconds !== undefined || + candidate.checks !== undefined || + candidate.parsedPrimary !== undefined || + candidate.parsedMetrics !== undefined || + candidate.parsedAsi !== undefined; + if (!hasCompletedMetadata) { + return null; + } + + const checksPass = + typeof candidate.checks?.passed === "boolean" + ? candidate.checks.passed + : typeof candidate.checks?.timedOut === "boolean" && candidate.checks.timedOut + ? false + : null; + const exitCode = + typeof candidate.exitCode === "number" && Number.isFinite(candidate.exitCode) ? candidate.exitCode : null; + const timedOut = candidate.timedOut === true; + const durationSeconds = + typeof candidate.durationSeconds === "number" && Number.isFinite(candidate.durationSeconds) + ? candidate.durationSeconds + : null; + const parsedPrimary = + typeof candidate.parsedPrimary === "number" && Number.isFinite(candidate.parsedPrimary) + ? candidate.parsedPrimary + : null; + const parsedAsi = cloneAsiData(candidate.parsedAsi); + const parsedMetrics = cloneNumericMetricMap(candidate.parsedMetrics); + const checksDurationSeconds = + typeof candidate.checks?.durationSeconds === "number" && Number.isFinite(candidate.checks.durationSeconds) + ? candidate.checks.durationSeconds + : null; + const checksTimedOut = candidate.checks?.timedOut === true; + + return { + checksDurationSeconds, + checksPass, + checksTimedOut, + command, + durationSeconds, + parsedAsi, + parsedMetrics, + parsedPrimary, + passed: exitCode === 0 && !timedOut && checksPass !== false, + runDirectory, + runNumber, + }; +} + +function cloneNumericMetricMap(value: unknown): NumericMetricMap | null { + if (typeof value !== "object" || value === null) return null; + const metrics = value as { [key: string]: unknown }; + const clone: NumericMetricMap = {}; + for (const [key, entryValue] of Object.entries(metrics)) { + if (DENIED_KEY_NAMES.has(key)) continue; + if (typeof entryValue === "number" && Number.isFinite(entryValue)) { + clone[key] = entryValue; + } + } + return Object.keys(clone).length > 0 ? clone : null; +} + +function cloneAsiData(value: unknown): ASIData | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as { [key: string]: unknown }; + const clone: ASIData = {}; + for (const [key, entryValue] of Object.entries(candidate)) { + if (DENIED_KEY_NAMES.has(key)) continue; + const sanitized = clonePendingAsiValue(entryValue); + if (sanitized !== undefined) { + clone[key] = sanitized; + } + } + return Object.keys(clone).length > 0 ? clone : null; +} + +function clonePendingAsiValue(value: unknown): ASIValue | undefined { + if (value === null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + if (Array.isArray(value)) { + const items = value + .map(entry => clonePendingAsiValue(entry)) + .filter((entry): entry is NonNullable => entry !== undefined); + return items; + } + if (typeof value === "object") { + const candidate = value as { [key: string]: unknown }; + const clone: { [key: string]: ASIValue } = {}; + for (const [key, entryValue] of Object.entries(candidate)) { + if (DENIED_KEY_NAMES.has(key)) continue; + const sanitized = clonePendingAsiValue(entryValue); + if (sanitized !== undefined) { + clone[key] = sanitized; + } + } + return clone; + } + return undefined; +} diff --git a/packages/coding-agent/src/autoresearch/index.ts b/packages/coding-agent/src/autoresearch/index.ts new file mode 100644 index 000000000..8173b12d3 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/index.ts @@ -0,0 +1,693 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { AutocompleteItem } from "@oh-my-pi/pi-tui"; +import { renderPromptTemplate } from "../config/prompt-templates"; +import type { ExtensionContext, ExtensionFactory } from "../extensibility/extensions"; +import commandInitializeTemplate from "./command-initialize.md" with { type: "text" }; +import commandResumeTemplate from "./command-resume.md" with { type: "text" }; +import { pathMatchesContractPath } from "./contract"; +import { createDashboardController } from "./dashboard"; +import { ensureAutoresearchBranch } from "./git"; +import { + formatNum, + isAutoresearchCommittableFile, + isAutoresearchLocalStatePath, + isAutoresearchShCommand, + normalizeAutoresearchPath, + readMaxExperiments, + readPendingRunSummary, + resolveWorkDir, + validateWorkDir, +} from "./helpers"; +import promptTemplate from "./prompt.md" with { type: "text" }; +import resumeMessageTemplate from "./resume-message.md" with { type: "text" }; +import { + cloneExperimentState, + createExperimentState, + createRuntimeStore, + currentResults, + findBaselineMetric, + reconstructControlState, + reconstructStateFromJsonl, +} from "./state"; +import { createInitExperimentTool } from "./tools/init-experiment"; +import { createLogExperimentTool } from "./tools/log-experiment"; +import { createRunExperimentTool } from "./tools/run-experiment"; +import type { AutoresearchRuntime, ChecksResult, ExperimentResult, PendingRunSummary } from "./types"; + +const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment"]; + +interface AutoresearchSetupInput { + intent: string; + benchmarkCommand: string; + metricName: string; + metricUnit: string; + direction: "lower" | "higher"; + secondaryMetrics: string[]; + scopePaths: string[]; + offLimits: string[]; + constraints: string[]; +} + +export const createAutoresearchExtension: ExtensionFactory = api => { + const runtimeStore = createRuntimeStore(); + const dashboard = createDashboardController(); + + const getSessionKey = (ctx: ExtensionContext): string => ctx.sessionManager.getSessionId(); + const getRuntime = (ctx: ExtensionContext): AutoresearchRuntime => runtimeStore.ensure(getSessionKey(ctx)); + + const rehydrate = async (ctx: ExtensionContext): Promise => { + const runtime = getRuntime(ctx); + const workDir = resolveWorkDir(ctx.cwd); + const reconstructed = reconstructStateFromJsonl(workDir); + const control = reconstructControlState(ctx.sessionManager.getBranch()); + const loggedRunNumbers = collectLoggedRunNumbers(reconstructed.state.results); + runtime.state = cloneExperimentState(reconstructed.state); + runtime.state.maxExperiments = readMaxExperiments(ctx.cwd); + runtime.goal = control.goal; + runtime.autoresearchMode = control.autoresearchMode; + runtime.autoResumeArmed = false; + runtime.lastAutoResumePendingRunNumber = null; + runtime.lastRunSummary = await readPendingRunSummary(workDir, loggedRunNumbers); + runtime.lastRunChecks = summaryToChecks(runtime.lastRunSummary); + runtime.lastRunDuration = runtime.lastRunSummary?.durationSeconds ?? null; + runtime.lastRunAsi = runtime.lastRunSummary?.parsedAsi ?? null; + runtime.lastRunArtifactDir = runtime.lastRunSummary?.runDirectory ?? null; + runtime.lastRunNumber = runtime.lastRunSummary?.runNumber ?? null; + runtime.runningExperiment = null; + dashboard.updateWidget(ctx, runtime); + const activeTools = api.getActiveTools(); + const experimentTools = new Set(EXPERIMENT_TOOL_NAMES); + const nextActiveTools = runtime.autoresearchMode + ? [...new Set([...activeTools, ...EXPERIMENT_TOOL_NAMES])] + : activeTools.filter(name => !experimentTools.has(name)); + const toolsChanged = + nextActiveTools.length !== activeTools.length || + nextActiveTools.some((name, index) => name !== activeTools[index]); + if (toolsChanged) { + await api.setActiveTools(nextActiveTools); + } + }; + + const setMode = ( + ctx: ExtensionContext, + enabled: boolean, + goal: string | null, + mode: "on" | "off" | "clear", + ): void => { + const runtime = getRuntime(ctx); + runtime.autoresearchMode = enabled; + runtime.autoResumeArmed = false; + runtime.goal = goal; + runtime.lastAutoResumePendingRunNumber = null; + api.appendEntry("autoresearch-control", goal ? { mode, goal } : { mode }); + }; + + api.registerTool(createInitExperimentTool({ dashboard, getRuntime, pi: api })); + api.registerTool(createRunExperimentTool({ dashboard, getRuntime, pi: api })); + api.registerTool(createLogExperimentTool({ dashboard, getRuntime, pi: api })); + api.on("tool_call", (event, ctx) => { + const runtime = getRuntime(ctx); + if (!runtime.autoresearchMode) return; + if (event.toolName === "bash") { + const command = typeof event.input.command === "string" ? event.input.command : ""; + const validationError = validateAutoresearchBashCommand(command); + if (validationError) { + return { + block: true, + reason: validationError, + }; + } + return; + } + if (event.toolName !== "write" && event.toolName !== "edit" && event.toolName !== "ast_edit") return; + + const rawPaths = getGuardedToolPaths(event.toolName, event.input); + if (rawPaths === null) { + return { + block: true, + reason: + "Autoresearch requires an explicit target path for this editing tool so it can enforce Files in Scope and Off Limits before changes are made.", + }; + } + + const workDir = resolveWorkDir(ctx.cwd); + for (const rawPath of rawPaths) { + const relativePath = resolveAutoresearchRelativePath(workDir, rawPath); + if (!relativePath.ok) { + return { + block: true, + reason: relativePath.reason, + }; + } + const validationError = validateEditableAutoresearchPath(relativePath.relativePath, runtime); + if (validationError) { + return { + block: true, + reason: `Autoresearch blocked edits to ${relativePath.relativePath}: ${validationError}`, + }; + } + } + }); + + api.registerCommand("autoresearch", { + description: "Start, stop, or clear builtin autoresearch mode.", + getArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + if (argumentPrefix.includes(" ")) return null; + const completions: AutocompleteItem[] = [ + { label: "off", value: "off", description: "Leave autoresearch mode" }, + { label: "clear", value: "clear", description: "Delete autoresearch.jsonl and leave autoresearch mode" }, + ]; + const normalized = argumentPrefix.trim().toLowerCase(); + const filtered = completions.filter(item => item.label.startsWith(normalized)); + return filtered.length > 0 ? filtered : null; + }, + async handler(args, ctx): Promise { + const trimmed = args.trim(); + const runtime = getRuntime(ctx); + const workDirError = validateWorkDir(ctx.cwd); + if (workDirError) { + ctx.ui.notify(workDirError, "error"); + return; + } + + if (trimmed === "off") { + setMode(ctx, false, runtime.goal, "off"); + dashboard.updateWidget(ctx, runtime); + const experimentTools = new Set(EXPERIMENT_TOOL_NAMES); + await api.setActiveTools(api.getActiveTools().filter(name => !experimentTools.has(name))); + ctx.ui.notify("Autoresearch mode disabled", "info"); + return; + } + if (trimmed === "clear") { + const workDir = resolveWorkDir(ctx.cwd); + const jsonlPath = path.join(workDir, "autoresearch.jsonl"); + const localStatePath = path.join(workDir, ".autoresearch"); + if (fs.existsSync(jsonlPath)) { + fs.rmSync(jsonlPath); + } + if (fs.existsSync(localStatePath)) { + fs.rmSync(localStatePath, { force: true, recursive: true }); + } + runtime.state = createExperimentState(); + runtime.state.maxExperiments = readMaxExperiments(ctx.cwd); + runtime.goal = null; + runtime.lastRunChecks = null; + runtime.lastRunDuration = null; + runtime.lastRunAsi = null; + runtime.lastRunArtifactDir = null; + runtime.lastRunNumber = null; + runtime.lastRunSummary = null; + setMode(ctx, false, null, "clear"); + dashboard.updateWidget(ctx, runtime); + const experimentTools = new Set(EXPERIMENT_TOOL_NAMES); + await api.setActiveTools(api.getActiveTools().filter(name => !experimentTools.has(name))); + ctx.ui.notify("Autoresearch local state cleared", "info"); + return; + } + + const workDir = resolveWorkDir(ctx.cwd); + const autoresearchMdPath = path.join(workDir, "autoresearch.md"); + const hasAutoresearchMd = fs.existsSync(autoresearchMdPath); + const controlState = reconstructControlState(ctx.sessionManager.getBranch()); + const shouldResumeExistingNotes = + hasAutoresearchMd && + (hasLocalAutoresearchState(workDir) || (controlState.lastMode !== "clear" && trimmed.length === 0)); + + if (shouldResumeExistingNotes) { + const resumeContext = trimmed; + const resumeGoal = runtime.goal ?? runtime.state.name ?? null; + const branchResult = await ensureAutoresearchBranch(api, workDir, resumeGoal); + if (!branchResult.ok) { + ctx.ui.notify(branchResult.error, "error"); + return; + } + + setMode(ctx, true, resumeGoal, "on"); + dashboard.updateWidget(ctx, runtime); + await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]); + api.sendUserMessage( + renderPromptTemplate(commandResumeTemplate, { + autoresearch_md_path: autoresearchMdPath, + branch_status_line: branchResult.created + ? `Created and checked out dedicated git branch \`${branchResult.branchName}\` before resuming.` + : `Using dedicated git branch \`${branchResult.branchName}\`.`, + has_resume_context: resumeContext.length > 0, + resume_context: resumeContext, + }), + ); + return; + } + + const setup = await promptForAutoresearchSetup( + ctx, + trimmed || runtime.goal || "what should autoresearch improve?", + ); + if (!setup) return; + + const branchResult = await ensureAutoresearchBranch(api, workDir, setup.intent); + if (!branchResult.ok) { + ctx.ui.notify(branchResult.error, "error"); + return; + } + + setMode(ctx, true, setup.intent, "on"); + runtime.state.name = setup.intent; + runtime.state.metricName = setup.metricName; + runtime.state.metricUnit = setup.metricUnit; + runtime.state.bestDirection = setup.direction; + runtime.state.secondaryMetrics = setup.secondaryMetrics.map(name => ({ name, unit: "" })); + runtime.state.benchmarkCommand = setup.benchmarkCommand; + runtime.state.scopePaths = [...setup.scopePaths]; + runtime.state.offLimits = [...setup.offLimits]; + runtime.state.constraints = [...setup.constraints]; + dashboard.updateWidget(ctx, runtime); + await api.setActiveTools([...new Set([...api.getActiveTools(), ...EXPERIMENT_TOOL_NAMES])]); + api.sendUserMessage( + renderPromptTemplate(commandInitializeTemplate, { + branch_status_line: branchResult.created + ? `Created and checked out dedicated git branch \`${branchResult.branchName}\`.` + : `Using dedicated git branch \`${branchResult.branchName}\`.`, + intent: setup.intent, + benchmark_command: setup.benchmarkCommand, + metric_name: setup.metricName, + metric_unit: setup.metricUnit, + direction: setup.direction, + has_secondary_metrics: setup.secondaryMetrics.length > 0, + secondary_metrics: setup.secondaryMetrics, + secondary_metrics_block: formatBulletBlock( + setup.secondaryMetrics, + value => ` - \`${value}\``, + " - `(none)`", + ), + scope_paths: setup.scopePaths, + scope_paths_block: formatBulletBlock(setup.scopePaths, value => ` - \`${value}\``), + has_off_limits: setup.offLimits.length > 0, + off_limits: setup.offLimits, + off_limits_block: formatBulletBlock(setup.offLimits, value => ` - \`${value}\``, " - `(none)`"), + has_constraints: setup.constraints.length > 0, + constraints: setup.constraints, + constraints_block: formatBulletBlock(setup.constraints, value => ` - ${value}`, " - `(none)`"), + }), + ); + }, + }); + + api.registerShortcut("ctrl+x", { + description: "Toggle autoresearch dashboard", + handler(ctx): void { + const runtime = getRuntime(ctx); + if (runtime.state.results.length === 0 && !runtime.runningExperiment) { + ctx.ui.notify("No autoresearch results yet", "info"); + return; + } + runtime.dashboardExpanded = !runtime.dashboardExpanded; + dashboard.updateWidget(ctx, runtime); + }, + }); + + api.registerShortcut("ctrl+shift+x", { + description: "Show autoresearch dashboard overlay", + handler(ctx): Promise { + return dashboard.showOverlay(ctx, getRuntime(ctx)); + }, + }); + + api.on("session_start", (_event, ctx) => rehydrate(ctx)); + api.on("session_switch", (_event, ctx) => rehydrate(ctx)); + api.on("session_branch", (_event, ctx) => rehydrate(ctx)); + api.on("session_tree", (_event, ctx) => rehydrate(ctx)); + api.on("session_shutdown", (_event, ctx) => { + dashboard.clear(ctx); + runtimeStore.clear(getSessionKey(ctx)); + }); + + api.on("agent_end", async (_event, ctx) => { + const runtime = getRuntime(ctx); + runtime.runningExperiment = null; + dashboard.updateWidget(ctx, runtime); + dashboard.requestRender(); + if (!runtime.autoresearchMode) return; + if (ctx.hasPendingMessages()) { + runtime.autoResumeArmed = false; + return; + } + const workDir = resolveWorkDir(ctx.cwd); + const pendingRun = + runtime.lastRunSummary ?? + (await readPendingRunSummary(workDir, collectLoggedRunNumbers(runtime.state.results))); + runtime.lastRunSummary = pendingRun; + runtime.lastRunChecks = summaryToChecks(pendingRun); + runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration; + runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi; + const shouldResumePendingRun = + pendingRun !== null && runtime.lastAutoResumePendingRunNumber !== pendingRun.runNumber; + if (!shouldResumePendingRun && !runtime.autoResumeArmed) { + return; + } + runtime.autoResumeArmed = false; + runtime.lastAutoResumePendingRunNumber = pendingRun?.runNumber ?? null; + const autoresearchMdPath = path.join(workDir, "autoresearch.md"); + const ideasPath = path.join(workDir, "autoresearch.ideas.md"); + api.sendMessage( + { + customType: "autoresearch-resume", + content: renderPromptTemplate(resumeMessageTemplate, { + autoresearch_md_path: autoresearchMdPath, + has_ideas: fs.existsSync(ideasPath), + has_pending_run: Boolean(pendingRun), + }), + display: false, + attribution: "agent", + }, + { deliverAs: "nextTurn", triggerTurn: true }, + ); + }); + + api.on("before_agent_start", async (event, ctx) => { + const runtime = getRuntime(ctx); + if (!runtime.autoresearchMode) return; + const workDir = resolveWorkDir(ctx.cwd); + const autoresearchMdPath = path.join(workDir, "autoresearch.md"); + const checksPath = path.join(workDir, "autoresearch.checks.sh"); + const ideasPath = path.join(workDir, "autoresearch.ideas.md"); + const programPath = path.join(workDir, "autoresearch.program.md"); + const pendingRun = + runtime.lastRunSummary ?? + (await readPendingRunSummary(workDir, collectLoggedRunNumbers(runtime.state.results))); + runtime.lastRunSummary = pendingRun; + runtime.lastRunChecks = summaryToChecks(pendingRun); + runtime.lastRunDuration = pendingRun?.durationSeconds ?? runtime.lastRunDuration; + runtime.lastRunAsi = pendingRun?.parsedAsi ?? runtime.lastRunAsi; + const currentSegmentResults = currentResults(runtime.state.results, runtime.state.currentSegment); + const baselineMetric = findBaselineMetric(runtime.state.results, runtime.state.currentSegment); + const bestResult = findBestResult(runtime); + const goal = runtime.goal ?? runtime.state.name ?? ""; + const recentResults = currentSegmentResults.slice(-3).map(result => { + const asiSummary = summarizeExperimentAsi(result); + return { + asi_summary: asiSummary, + description: result.description, + has_asi_summary: Boolean(asiSummary), + metric_display: formatNum(result.metric, runtime.state.metricUnit), + run_number: result.runNumber ?? runtime.state.results.indexOf(result) + 1, + status: result.status, + }; + }); + return { + systemPrompt: renderPromptTemplate(promptTemplate, { + base_system_prompt: event.systemPrompt, + has_goal: goal.trim().length > 0, + goal, + working_dir: workDir, + default_metric_name: runtime.state.metricName, + metric_name: runtime.state.metricName, + has_autoresearch_md: fs.existsSync(autoresearchMdPath), + autoresearch_md_path: autoresearchMdPath, + has_checks: fs.existsSync(checksPath), + checks_path: checksPath, + has_ideas: fs.existsSync(ideasPath), + ideas_path: ideasPath, + has_program: fs.existsSync(programPath), + program_path: programPath, + current_segment: runtime.state.currentSegment + 1, + current_segment_run_count: currentSegmentResults.length, + has_baseline_metric: baselineMetric !== null, + baseline_metric_display: formatNum(baselineMetric, runtime.state.metricUnit), + has_best_result: Boolean(bestResult), + best_metric_display: bestResult + ? formatNum(bestResult.metric, runtime.state.metricUnit) + : formatNum(baselineMetric, runtime.state.metricUnit), + best_run_number: bestResult + ? (bestResult.runNumber ?? runtime.state.results.indexOf(bestResult) + 1) + : null, + has_recent_results: recentResults.length > 0, + recent_results: recentResults, + has_pending_run: Boolean(pendingRun), + pending_run_number: pendingRun?.runNumber, + pending_run_command: pendingRun?.command, + pending_run_directory: pendingRun?.runDirectory, + pending_run_passed: pendingRun?.passed ?? false, + has_pending_run_metric: pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined, + pending_run_metric_display: + pendingRun?.parsedPrimary !== null && pendingRun?.parsedPrimary !== undefined + ? formatNum(pendingRun.parsedPrimary, runtime.state.metricUnit) + : null, + }), + }; + }); +}; + +async function promptForAutoresearchSetup( + ctx: ExtensionContext, + defaultIntent: string, +): Promise { + const intentInput = await ctx.ui.input("Autoresearch Intent", defaultIntent); + if (intentInput === undefined) return undefined; + const intent = intentInput.trim(); + if (intent.length === 0) { + ctx.ui.notify("Autoresearch intent is required", "info"); + return undefined; + } + + const benchmarkCommandInput = await ctx.ui.input("Benchmark Command", "bash autoresearch.sh"); + if (benchmarkCommandInput === undefined) return undefined; + const benchmarkCommand = benchmarkCommandInput.trim(); + if (benchmarkCommand.length === 0) { + ctx.ui.notify("Benchmark command is required", "info"); + return undefined; + } + if (!isAutoresearchShCommand(benchmarkCommand)) { + ctx.ui.notify("Benchmark command must invoke `autoresearch.sh` directly", "info"); + return undefined; + } + + const metricNameInput = await ctx.ui.input("Primary Metric Name", "runtime_ms"); + if (metricNameInput === undefined) return undefined; + const metricName = metricNameInput.trim(); + if (metricName.length === 0) { + ctx.ui.notify("Primary metric name is required", "info"); + return undefined; + } + + const metricUnitInput = await ctx.ui.input("Metric Unit", "ms"); + if (metricUnitInput === undefined) return undefined; + const metricUnit = metricUnitInput.trim(); + + const directionInput = await ctx.ui.input("Metric Direction", "lower"); + if (directionInput === undefined) return undefined; + const normalizedDirection = directionInput.trim().toLowerCase(); + if (normalizedDirection !== "lower" && normalizedDirection !== "higher") { + ctx.ui.notify("Metric direction must be `lower` or `higher`", "info"); + return undefined; + } + + const secondaryMetricsInput = await ctx.ui.input("Tradeoff Metrics", ""); + if (secondaryMetricsInput === undefined) return undefined; + + const scopePathsInput = await ctx.ui.input("Files in Scope", "packages/coding-agent/src/autoresearch"); + if (scopePathsInput === undefined) return undefined; + const scopePaths = splitSetupList(scopePathsInput); + if (scopePaths.length === 0) { + ctx.ui.notify("Files in Scope must include at least one path", "info"); + return undefined; + } + + const offLimitsInput = await ctx.ui.input("Off Limits", ""); + if (offLimitsInput === undefined) return undefined; + const constraintsInput = await ctx.ui.input("Constraints", ""); + if (constraintsInput === undefined) return undefined; + + return { + intent, + benchmarkCommand, + metricName, + metricUnit, + direction: normalizedDirection, + secondaryMetrics: splitSetupList(secondaryMetricsInput), + scopePaths, + offLimits: splitSetupList(offLimitsInput), + constraints: splitSetupList(constraintsInput), + }; +} + +function splitSetupList(value: string): string[] { + return value + .split(/\r?\n|,/) + .map(entry => entry.trim()) + .filter((entry, index, values) => entry.length > 0 && values.indexOf(entry) === index); +} + +function formatBulletBlock(values: string[], renderValue: (value: string) => string, emptyValue = ""): string { + if (values.length === 0) { + return emptyValue; + } + return values.map(renderValue).join("\n"); +} + +function hasLocalAutoresearchState(workDir: string): boolean { + return fs.existsSync(path.join(workDir, "autoresearch.jsonl")) || fs.existsSync(path.join(workDir, ".autoresearch")); +} + +function summarizeExperimentAsi(result: ExperimentResult): string | null { + const hypothesis = typeof result.asi?.hypothesis === "string" ? result.asi.hypothesis.trim() : ""; + const rollbackReason = typeof result.asi?.rollback_reason === "string" ? result.asi.rollback_reason.trim() : ""; + const nextActionHint = typeof result.asi?.next_action_hint === "string" ? result.asi.next_action_hint.trim() : ""; + const summary = [hypothesis, rollbackReason, nextActionHint].filter(part => part.length > 0).join(" | "); + return summary.length > 0 ? summary.slice(0, 220) : null; +} + +function getGuardedToolPaths(toolName: string, input: Record): string[] | null { + if (toolName === "write") { + return typeof input.path === "string" ? [input.path] : null; + } + if (toolName === "ast_edit") { + return typeof input.path === "string" ? [input.path] : null; + } + if (toolName !== "edit") { + return []; + } + + const paths: string[] = []; + if (typeof input.path === "string") { + paths.push(input.path); + } + if (typeof input.rename === "string") { + paths.push(input.rename); + } + if (typeof input.move === "string") { + paths.push(input.move); + } + return paths; +} + +function resolveAutoresearchRelativePath( + workDir: string, + rawPath: string, +): { ok: false; reason: string } | { ok: true; relativePath: string } { + if (looksLikeInternalUrl(rawPath)) { + return { + ok: false, + reason: `Autoresearch cannot validate internal URL paths during scoped editing: ${rawPath}`, + }; + } + const resolvedPath = path.isAbsolute(rawPath) ? path.resolve(rawPath) : path.resolve(workDir, rawPath); + const canonicalWorkDir = canonicalizeExistingPath(workDir); + const canonicalTargetPath = canonicalizeTargetPath(resolvedPath); + const relativePath = path.relative(canonicalWorkDir, canonicalTargetPath); + if (relativePath === ".." || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) { + return { + ok: false, + reason: `Autoresearch blocked edits outside the working tree: ${rawPath}`, + }; + } + return { + ok: true, + relativePath: relativePath.length === 0 ? "." : normalizeAutoresearchPath(relativePath), + }; +} + +function validateEditableAutoresearchPath(relativePath: string, runtime: AutoresearchRuntime): string | null { + if (isAutoresearchLocalStatePath(relativePath)) { + return "autoresearch local state files are managed by the experiment tools and cannot be edited directly"; + } + if (runtime.state.offLimits.some(spec => pathMatchesContractPath(relativePath, spec))) { + return "this path is listed under Off Limits in autoresearch.md"; + } + if (isAutoresearchCommittableFile(relativePath)) { + return null; + } + if (runtime.state.scopePaths.length === 0) { + return "Files in Scope is not initialized yet; only autoresearch control files may be edited before init_experiment runs"; + } + if (!runtime.state.scopePaths.some(spec => pathMatchesContractPath(relativePath, spec))) { + return "this path is outside Files in Scope in autoresearch.md"; + } + return null; +} + +function findBestResult(runtime: AutoresearchRuntime): ExperimentResult | null { + let best: ExperimentResult | null = null; + for (const result of runtime.state.results) { + if (result.segment !== runtime.state.currentSegment || result.status !== "keep") continue; + if (!best) { + best = result; + continue; + } + if (runtime.state.bestDirection === "lower" ? result.metric < best.metric : result.metric > best.metric) { + best = result; + } + } + return best; +} + +function collectLoggedRunNumbers(results: ExperimentResult[]): Set { + const runNumbers = new Set(); + for (const result of results) { + if (result.runNumber !== null) { + runNumbers.add(result.runNumber); + } + } + return runNumbers; +} + +function summaryToChecks(summary: PendingRunSummary | null): ChecksResult | null { + if (!summary || summary.checksPass === null) { + return null; + } + return { + pass: summary.checksPass, + output: "", + duration: summary.checksDurationSeconds ?? 0, + }; +} + +function looksLikeInternalUrl(value: string): boolean { + return /^[a-z][a-z0-9+.-]*:\/\//i.test(value); +} + +function canonicalizeExistingPath(targetPath: string): string { + try { + return fs.realpathSync.native(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +function canonicalizeTargetPath(targetPath: string): string { + const pendingSegments: string[] = []; + let currentPath = path.resolve(targetPath); + while (!fs.existsSync(currentPath)) { + const parentPath = path.dirname(currentPath); + if (parentPath === currentPath) { + return currentPath; + } + pendingSegments.unshift(path.basename(currentPath)); + currentPath = parentPath; + } + return path.resolve(canonicalizeExistingPath(currentPath), ...pendingSegments); +} + +function validateAutoresearchBashCommand(command: string): string | null { + const trimmed = command.trim(); + if (trimmed.length === 0) { + return null; + } + const mutationPatterns = [ + /(^|[;&|()]\s*)(?:bash|sh)\b/, + /(^|[;&|()]\s*)(?:python|python3|node|perl|ruby|php)\b/, + /(^|[;&|()]\s*)(?:mv|cp|rm|mkdir|touch|chmod|chown|ln|install|patch)\b/, + /(^|[;&|()]\s*)sed\s+-i\b/, + /(^|[;&|()]\s*)git\s+(?:add|apply|checkout|clean|commit|merge|rebase|reset|restore|revert|stash|switch|worktree)\b/, + /(^|[^<])>>?/, + /\|\s*tee\b/, + /<< pattern.test(trimmed))) { + return ( + "Autoresearch only allows read-only shell inspection. " + + "Use write/edit/ast_edit for file changes and run_experiment for benchmark execution." + ); + } + return null; +} diff --git a/packages/coding-agent/src/autoresearch/prompt.md b/packages/coding-agent/src/autoresearch/prompt.md new file mode 100644 index 000000000..185edcbfe --- /dev/null +++ b/packages/coding-agent/src/autoresearch/prompt.md @@ -0,0 +1,227 @@ +{{{base_system_prompt}}} + +## Autoresearch Mode + +Autoresearch mode is active. + +{{#if has_goal}} +Primary goal: +{{goal}} +{{else}} +Primary goal is documented in `autoresearch.md` for this session. +{{/if}} + +Working directory: +`{{working_dir}}` + +You are running an autonomous experiment loop. Keep iterating until the user interrupts you or the configured maximum iteration count is reached. +{{#if has_program}} + +### Local Playbook + +`autoresearch.program.md` exists at `{{program_path}}`. + +Use it as a repo-local strategy overlay for this session. `autoresearch.md` remains the source of truth for benchmark, scope, and constraints. +{{/if}} +{{#if has_recent_results}} + +### Current Segment Snapshot + +- segment: `{{current_segment}}` +- runs in current segment: `{{current_segment_run_count}}` +{{#if has_baseline_metric}} +- baseline `{{metric_name}}`: `{{baseline_metric_display}}` +{{/if}} +{{#if has_best_result}} +- best kept `{{metric_name}}`: `{{best_metric_display}}`{{#if best_run_number}} from run `#{{best_run_number}}`{{/if}} +{{/if}} + +Recent runs: +{{#each recent_results}} +- run `#{{run_number}}`: `{{status}}` `{{metric_display}}` — {{description}} +{{#if has_asi_summary}} + ASI: {{asi_summary}} +{{/if}} +{{/each}} +{{/if}} +{{#if has_pending_run}} + +### Pending Run + +An unlogged run artifact exists at `{{pending_run_directory}}`. + +- run: `#{{pending_run_number}}` +- command: `{{pending_run_command}}` +{{#if has_pending_run_metric}} +- parsed `{{metric_name}}`: `{{pending_run_metric_display}}` +{{/if}} +- result status: {{#if pending_run_passed}}passed{{else}}failed{{/if}} +- finish the `log_experiment` step before starting another benchmark +{{/if}} + +### Available tools + +- `init_experiment` — initialize or reset the experiment session for the current optimization target. +- `run_experiment` — run a benchmark or experiment command with timing, output capture, structured metric parsing, and optional backpressure checks. +- `log_experiment` — record the result, update the dashboard, persist JSONL history, auto-commit kept experiments, and auto-revert discarded or failed experiments. + +### Operating protocol + +1. Understand the target before touching code. + - Read the relevant source files. + - Identify the true bottleneck or quality constraint. + - Check existing scripts, benchmark harnesses, and config files. + - Verify prerequisites, one-time setup, and benchmark inputs before the first run of a segment. +2. Keep your notes in `autoresearch.md`. + - Record the goal, the benchmark command, the primary metric, important secondary metrics, the files in scope, hard constraints, preflight requirements, and the benchmark comparability invariant. + - Update the notes whenever the strategy changes. + - Keep durable conclusions in `autoresearch.md`. + - Use `autoresearch.ideas.md` for deferred experiment ideas that are promising but not active yet. +3. Use `autoresearch.sh` as the canonical benchmark entrypoint. + - If it does not exist yet, create it. + - Make it print structured metric lines in the form `METRIC name=value`. + - Use the same workload every run unless you intentionally re-initialize with a new segment. + - Keep the measurement harness, evaluator, and fixed benchmark inputs stable unless you intentionally start a new segment and document the change. +4. Initialize the loop with `init_experiment` before the first logged run of a segment. +5. Run a baseline first. + - Establish the baseline metric before attempting optimizations. + - Track secondary metrics only when they matter to correctness, quality, or obvious regressions. +6. Iterate. + - Make one coherent experiment at a time. + - Run `run_experiment`. + - Interpret the result honestly. + - Call `log_experiment` after every run. +7. Keep the primary metric as the decision maker. + - `keep` when the primary metric improves. + - `discard` when it regresses or stays flat. + - `crash` when the run fails. + - `checks_failed` when the benchmark passes but backpressure checks fail. +8. Record ASI on every `log_experiment` call. + - At minimum include `hypothesis`. + - On `discard`, `crash`, or `checks_failed`, also include `rollback_reason` and `next_action_hint`. + - Use ASI to capture what you learned, not just what you changed. +9. Prefer simpler wins. + - Remove dead ends. + - Keep equal or near-equal results when they materially simplify the implementation. + - Do not keep ugly complexity for tiny gains unless the payoff is clearly worth it. + - Do not thrash between unrelated ideas without writing down the conclusion. +10. When confidence is low, confirm. + - The dashboard confidence score compares the best observed improvement against the observed noise floor. + - Below `1.0x` usually means the improvement is within noise. + - Re-run promising changes when needed before keeping them. + +### Benchmark harness guidance + +Your benchmark script SHOULD: + +- live at `autoresearch.sh` +- run from `{{working_dir}}` +- fail with a non-zero exit status on invalid runs +- print the primary metric as `METRIC {{default_metric_name}}=` or another explicit metric name chosen during initialization +- print secondary metrics as additional `METRIC name=value` lines +- avoid extra randomness when possible +- use repeated samples and median-style summaries for fast benchmarks +- preserve the comparability invariant for the current segment +- keep the ground-truth evaluator and fixed benchmark inputs unchanged unless the segment is explicitly re-initialized + +### Notes file template + +Keep `autoresearch.md` concise and current. + +Suggested structure: + +```md +# Autoresearch + +## Goal +{{#if has_goal}} +- {{goal}} +{{else}} +- document the active target here before the first benchmark +{{/if}} + +## Benchmark + - command: + - primary metric: + - metric unit: + - direction: + - secondary metrics: memory_mb, rss_mb + +## Files in Scope +- path: + +## Off Limits +- path: + +## Constraints +- rule: + +## Baseline +- metric: +- notes: + +## Current best +- metric: +- why it won: + +## What's Been Tried +- experiment: +- lesson: +``` + +### Guardrails + +- Do not game the benchmark. +- Do not overfit to synthetic inputs if the real workload is broader. +- Preserve correctness. +- Only modify files that are explicitly in scope for the current session. +- Do not use the general shell tool for file mutations during autoresearch. Use `write`, `edit`, or `ast_edit` for scoped code changes and `run_experiment` for benchmark execution. +- If you create `autoresearch.checks.sh`, treat it as a hard gate for `keep`. +- If the user sends another message while a run is in progress, finish the current run and logging cycle first, then address the new input in the next iteration. + +{{#if has_autoresearch_md}} +### Resume mode + +`autoresearch.md` already exists at `{{autoresearch_md_path}}`. + +Resume from the existing notes: + +- read `autoresearch.md` +- inspect recent git history +- inspect `autoresearch.jsonl` +- continue from the most promising unfinished direction on the current protected branch + +{{else}} +### Initial setup + +`autoresearch.md` does not exist yet. + +Create the experiment workspace before the first benchmark: + +- write `autoresearch.md` +- write `autoresearch.sh` +- optionally write `autoresearch.checks.sh` +- run `init_experiment` +- run and log the baseline + +{{/if}} +{{#if has_checks}} +### Backpressure checks + +`autoresearch.checks.sh` exists at `{{checks_path}}` and runs automatically after passing benchmark runs. + +Treat failing checks as a failed experiment: + +- do not `keep` a run when checks fail +- log it as `checks_failed` +- diagnose the regression before continuing + +{{/if}} +{{#if has_ideas}} +### Ideas backlog + +`autoresearch.ideas.md` exists at `{{ideas_path}}`. + +Use it to keep promising but deferred experiments. `autoresearch.md` should hold durable conclusions; `autoresearch.ideas.md` is the scratch backlog. Prune stale ideas when they are disproven or superseded. + +{{/if}} diff --git a/packages/coding-agent/src/autoresearch/resume-message.md b/packages/coding-agent/src/autoresearch/resume-message.md new file mode 100644 index 000000000..31052bb78 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/resume-message.md @@ -0,0 +1,16 @@ +Continue the autoresearch loop now. + +@{{autoresearch_md_path}} + +- Read `autoresearch.md` and `autoresearch.jsonl`. +- Treat `autoresearch.md` as the source of truth for the current direction, scope, and constraints. +- Inspect recent git history for context. +{{#if has_pending_run}} +- Inspect the latest unlogged `run.json` under `.autoresearch/runs/` and finish the pending `log_experiment` step before starting a new benchmark. +{{/if}} +- Continue from the most promising unfinished direction. +{{#if has_ideas}} +- Review `autoresearch.ideas.md` for deferred next steps and prune stale items. +{{/if}} +- Keep iterating until interrupted or until the configured iteration cap is reached. +- Preserve correctness and do not game the benchmark. diff --git a/packages/coding-agent/src/autoresearch/state.ts b/packages/coding-agent/src/autoresearch/state.ts new file mode 100644 index 000000000..9ab05a60d --- /dev/null +++ b/packages/coding-agent/src/autoresearch/state.ts @@ -0,0 +1,386 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { SessionEntry } from "../session/session-manager"; +import { normalizeAutoresearchList, normalizeContractPathSpec } from "./contract"; +import { inferMetricUnitFromName, isBetter } from "./helpers"; +import type { + AutoresearchControlEntryData, + AutoresearchJsonConfigEntry, + AutoresearchJsonRunEntry, + AutoresearchRuntime, + ExperimentResult, + ExperimentState, + MetricDef, + MetricDirection, + NumericMetricMap, + ReconstructedControlState, + ReconstructedExperimentData, + RuntimeStore, +} from "./types"; + +export function createExperimentState(): ExperimentState { + return { + results: [], + bestMetric: null, + bestDirection: "lower", + metricName: "metric", + metricUnit: "", + secondaryMetrics: [], + name: null, + currentSegment: 0, + maxExperiments: null, + confidence: null, + benchmarkCommand: null, + scopePaths: [], + offLimits: [], + constraints: [], + segmentFingerprint: null, + }; +} + +export function createSessionRuntime(): AutoresearchRuntime { + return { + autoresearchMode: false, + autoResumeArmed: false, + dashboardExpanded: false, + lastAutoResumePendingRunNumber: null, + lastRunChecks: null, + lastRunDuration: null, + lastRunAsi: null, + lastRunArtifactDir: null, + lastRunNumber: null, + lastRunSummary: null, + runningExperiment: null, + state: createExperimentState(), + goal: null, + }; +} + +export function cloneExperimentState(state: ExperimentState): ExperimentState { + return { + ...state, + results: state.results.map(result => ({ + ...result, + metrics: { ...result.metrics }, + asi: result.asi ? structuredClone(result.asi) : undefined, + })), + secondaryMetrics: state.secondaryMetrics.map(metric => ({ ...metric })), + scopePaths: [...state.scopePaths], + offLimits: [...state.offLimits], + constraints: [...state.constraints], + }; +} + +export function currentResults(results: ExperimentResult[], segment: number): ExperimentResult[] { + return results.filter(result => result.segment === segment); +} + +export function findBaselineResult(results: ExperimentResult[], segment: number): ExperimentResult | null { + return currentResults(results, segment).find(result => result.status === "keep") ?? null; +} + +export function findBaselineMetric(results: ExperimentResult[], segment: number): number | null { + const baseline = findBaselineResult(results, segment); + return baseline ? baseline.metric : null; +} + +export function findBestKeptMetric( + results: ExperimentResult[], + segment: number, + direction: MetricDirection, +): number | null { + let best: number | null = null; + for (const result of currentResults(results, segment)) { + if (result.status !== "keep") continue; + if (best === null || isBetter(result.metric, best, direction)) { + best = result.metric; + } + } + return best; +} + +export function findBaselineRunNumber(results: ExperimentResult[], segment: number): number | null { + const baseline = findBaselineResult(results, segment); + if (!baseline) return null; + if (baseline.runNumber !== null) return baseline.runNumber; + const index = results.indexOf(baseline); + return index >= 0 ? index + 1 : null; +} + +export function findBaselineSecondary( + results: ExperimentResult[], + segment: number, + knownMetrics: MetricDef[], +): NumericMetricMap { + const baseline = findBaselineResult(results, segment); + const values: NumericMetricMap = baseline ? { ...baseline.metrics } : {}; + for (const metric of knownMetrics) { + if (values[metric.name] !== undefined) continue; + for (const result of currentResults(results, segment)) { + const value = result.metrics[metric.name]; + if (value !== undefined) { + values[metric.name] = value; + break; + } + } + } + return values; +} + +export function sortedMedian(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + const midpoint = Math.floor(sorted.length / 2); + if (sorted.length % 2 === 0) { + return (sorted[midpoint - 1] + sorted[midpoint]) / 2; + } + return sorted[midpoint]; +} + +export function computeConfidence( + results: ExperimentResult[], + segment: number, + direction: MetricDirection, +): number | null { + const current = currentResults(results, segment).filter(result => result.metric > 0); + if (current.length < 3) return null; + + const values = current.map(result => result.metric); + const median = sortedMedian(values); + const mad = sortedMedian(values.map(value => Math.abs(value - median))); + if (mad === 0) return null; + + const baseline = findBaselineMetric(results, segment); + if (baseline === null) return null; + + let bestKept: number | null = null; + for (const result of current) { + if (result.status !== "keep" || result.metric <= 0) continue; + if (bestKept === null || isBetter(result.metric, bestKept, direction)) { + bestKept = result.metric; + } + } + if (bestKept === null || bestKept === baseline) return null; + + return Math.abs(bestKept - baseline) / mad; +} + +export function reconstructStateFromJsonl(workDir: string): ReconstructedExperimentData { + const state = createExperimentState(); + const jsonlPath = path.join(workDir, "autoresearch.jsonl"); + if (!fs.existsSync(jsonlPath)) { + return { hasLog: false, state }; + } + + const content = fs.readFileSync(jsonlPath, "utf8"); + const lines = content + .split("\n") + .map(line => line.trim()) + .filter(line => line.length > 0); + + let segment = 0; + let sawConfig = false; + for (const line of lines) { + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch { + continue; + } + + const configEntry = parseConfigEntry(parsed); + if (configEntry) { + if (sawConfig || state.results.length > 0) { + segment += 1; + } + sawConfig = true; + state.currentSegment = segment; + if (configEntry.name) state.name = configEntry.name; + if (configEntry.metricName) state.metricName = configEntry.metricName; + if (configEntry.metricUnit !== undefined) state.metricUnit = configEntry.metricUnit; + if (configEntry.bestDirection) state.bestDirection = configEntry.bestDirection; + if (configEntry.benchmarkCommand !== undefined) state.benchmarkCommand = configEntry.benchmarkCommand; + state.scopePaths = cloneStringArray(configEntry.scopePaths); + state.offLimits = cloneStringArray(configEntry.offLimits); + state.constraints = cloneStringArray(configEntry.constraints); + state.segmentFingerprint = + typeof configEntry.segmentFingerprint === "string" ? configEntry.segmentFingerprint : null; + state.secondaryMetrics = hydrateMetricDefs(configEntry.secondaryMetrics); + continue; + } + + if (!isRunEntry(parsed)) continue; + const result: ExperimentResult = { + runNumber: typeof parsed.run === "number" && Number.isFinite(parsed.run) ? parsed.run : null, + commit: typeof parsed.commit === "string" ? parsed.commit : "", + metric: typeof parsed.metric === "number" && Number.isFinite(parsed.metric) ? parsed.metric : 0, + metrics: cloneNumericMetrics(parsed.metrics), + status: isExperimentStatus(parsed.status) ? parsed.status : "keep", + description: typeof parsed.description === "string" ? parsed.description : "", + timestamp: typeof parsed.timestamp === "number" && Number.isFinite(parsed.timestamp) ? parsed.timestamp : 0, + segment, + confidence: + typeof parsed.confidence === "number" && Number.isFinite(parsed.confidence) ? parsed.confidence : null, + asi: cloneAsi(parsed.asi), + }; + state.results.push(result); + if (segment !== state.currentSegment) continue; + registerSecondaryMetrics(state.secondaryMetrics, result.metrics); + } + + state.bestMetric = findBaselineMetric(state.results, state.currentSegment); + state.confidence = computeConfidence(state.results, state.currentSegment, state.bestDirection); + return { hasLog: true, state }; +} + +export function reconstructControlState(entries: SessionEntry[]): ReconstructedControlState { + let autoresearchMode = false; + let goal: string | null = null; + let lastMode: ReconstructedControlState["lastMode"] = null; + for (const entry of entries) { + if (entry.type !== "custom" || entry.customType !== "autoresearch-control") continue; + const data = parseControlEntry(entry.data); + if (!data) continue; + lastMode = data.mode; + autoresearchMode = data.mode === "on"; + goal = data.goal ?? goal; + if (data.mode === "clear") { + goal = null; + } + } + return { autoresearchMode, goal, lastMode }; +} + +export function createRuntimeStore(): RuntimeStore { + const runtimes = new Map(); + return { + clear(sessionKey: string): void { + runtimes.delete(sessionKey); + }, + ensure(sessionKey: string): AutoresearchRuntime { + const existing = runtimes.get(sessionKey); + if (existing) return existing; + const runtime = createSessionRuntime(); + runtimes.set(sessionKey, runtime); + return runtime; + }, + }; +} + +function registerSecondaryMetrics(metrics: MetricDef[], values: NumericMetricMap): void { + for (const name of Object.keys(values)) { + if (metrics.some(metric => metric.name === name)) continue; + metrics.push({ + name, + unit: inferMetricUnitFromName(name), + }); + } +} + +function isConfigEntry(value: unknown): value is AutoresearchJsonConfigEntry { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { type?: unknown }; + return candidate.type === "config"; +} + +function parseConfigEntry(value: unknown): AutoresearchJsonConfigEntry | null { + if (!isConfigEntry(value)) return null; + const candidate = value as AutoresearchJsonConfigEntry; + const config: AutoresearchJsonConfigEntry = { type: "config" }; + if (typeof candidate.name === "string" && candidate.name.trim().length > 0) { + config.name = candidate.name; + } + if (typeof candidate.metricName === "string" && candidate.metricName.trim().length > 0) { + config.metricName = candidate.metricName; + } + if (typeof candidate.metricUnit === "string") { + config.metricUnit = candidate.metricUnit; + } + if (candidate.bestDirection === "lower" || candidate.bestDirection === "higher") { + config.bestDirection = candidate.bestDirection; + } + if (typeof candidate.benchmarkCommand === "string" && candidate.benchmarkCommand.trim().length > 0) { + config.benchmarkCommand = candidate.benchmarkCommand; + } + if (Array.isArray(candidate.secondaryMetrics)) { + config.secondaryMetrics = normalizeAutoresearchList( + candidate.secondaryMetrics.filter((item): item is string => typeof item === "string"), + ); + } + if (Array.isArray(candidate.scopePaths)) { + config.scopePaths = normalizeAutoresearchList( + candidate.scopePaths.filter((item): item is string => typeof item === "string").map(normalizeContractPathSpec), + ); + } + if (Array.isArray(candidate.offLimits)) { + config.offLimits = normalizeAutoresearchList( + candidate.offLimits.filter((item): item is string => typeof item === "string").map(normalizeContractPathSpec), + ); + } + if (Array.isArray(candidate.constraints)) { + config.constraints = normalizeAutoresearchList( + candidate.constraints.filter((item): item is string => typeof item === "string"), + ); + } + if (typeof candidate.segmentFingerprint === "string" && candidate.segmentFingerprint.trim().length > 0) { + config.segmentFingerprint = candidate.segmentFingerprint; + } + return config; +} + +function isRunEntry(value: unknown): value is AutoresearchJsonRunEntry { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { type?: unknown }; + return candidate.type === undefined || candidate.type === "run"; +} + +function isExperimentStatus(value: unknown): value is ExperimentResult["status"] { + return value === "keep" || value === "discard" || value === "crash" || value === "checks_failed"; +} + +function cloneNumericMetrics(value: unknown): NumericMetricMap { + if (typeof value !== "object" || value === null) return {}; + const metrics = value as { [key: string]: unknown }; + const clone: NumericMetricMap = {}; + for (const [key, entryValue] of Object.entries(metrics)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") continue; + if (typeof entryValue === "number" && Number.isFinite(entryValue)) { + clone[key] = entryValue; + } + } + return clone; +} + +function cloneStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function hydrateMetricDefs(metricNames: string[] | undefined): MetricDef[] { + if (!metricNames) return []; + return metricNames.map(name => ({ + name, + unit: inferMetricUnitFromName(name), + })); +} + +function cloneAsi(value: unknown): ExperimentResult["asi"] { + if (typeof value !== "object" || value === null) return undefined; + const clone: { [key: string]: unknown } = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") continue; + clone[key] = structuredClone(entryValue); + } + return clone as ExperimentResult["asi"]; +} + +function parseControlEntry(value: unknown): AutoresearchControlEntryData | null { + if (typeof value !== "object" || value === null) return null; + const candidate = value as { goal?: unknown; mode?: unknown }; + if (candidate.mode !== "on" && candidate.mode !== "off" && candidate.mode !== "clear") return null; + const data: AutoresearchControlEntryData = { mode: candidate.mode }; + if (typeof candidate.goal === "string" && candidate.goal.trim().length > 0) { + data.goal = candidate.goal; + } + return data; +} diff --git a/packages/coding-agent/src/autoresearch/tools/init-experiment.ts b/packages/coding-agent/src/autoresearch/tools/init-experiment.ts new file mode 100644 index 000000000..cf8a0f63f --- /dev/null +++ b/packages/coding-agent/src/autoresearch/tools/init-experiment.ts @@ -0,0 +1,310 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { StringEnum } from "@oh-my-pi/pi-ai"; +import { Text } from "@oh-my-pi/pi-tui"; +import { Type } from "@sinclair/typebox"; +import type { ToolDefinition } from "../../extensibility/extensions"; +import type { Theme } from "../../modes/theme/theme"; +import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; +import { + buildAutoresearchSegmentFingerprint, + contractListsEqual, + contractPathListsEqual, + loadAutoresearchScriptSnapshot, + readAutoresearchContract, +} from "../contract"; +import { + inferMetricUnitFromName, + isAutoresearchShCommand, + readMaxExperiments, + readPendingRunSummary, + resolveWorkDir, + validateWorkDir, +} from "../helpers"; +import { cloneExperimentState } from "../state"; +import type { AutoresearchToolFactoryOptions, ExperimentState } from "../types"; + +const initExperimentSchema = Type.Object({ + name: Type.String({ + description: "Human-readable experiment name.", + }), + metric_name: Type.String({ + description: "Primary metric name shown in the dashboard.", + }), + metric_unit: Type.Optional( + Type.String({ + description: "Unit for the primary metric, for example µs, ms, s, kb, or empty.", + }), + ), + direction: Type.Optional( + StringEnum(["lower", "higher"], { + description: "Whether lower or higher values are better. Defaults to lower.", + }), + ), + benchmark_command: Type.String({ + description: "Benchmark command recorded in autoresearch.md.", + }), + scope_paths: Type.Array(Type.String(), { + description: "Files in Scope from autoresearch.md. Must be non-empty.", + minItems: 1, + }), + off_limits: Type.Optional( + Type.Array(Type.String(), { + description: "Off Limits paths from autoresearch.md.", + }), + ), + constraints: Type.Optional( + Type.Array(Type.String(), { + description: "Constraints from autoresearch.md.", + }), + ), +}); + +interface InitExperimentDetails { + state: ExperimentState; +} + +export function createInitExperimentTool( + options: AutoresearchToolFactoryOptions, +): ToolDefinition { + return { + name: "init_experiment", + label: "Init Experiment", + description: + "Initialize or reset the autoresearch session for the current optimization target before the first logged run of a segment.", + parameters: initExperimentSchema, + defaultInactive: true, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const workDirError = validateWorkDir(ctx.cwd); + if (workDirError) { + return { + content: [{ type: "text", text: `Error: ${workDirError}` }], + }; + } + + const runtime = options.getRuntime(ctx); + const state = runtime.state; + const isReinitializing = state.results.length > 0; + const workDir = resolveWorkDir(ctx.cwd); + const pendingRun = await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results)); + if (pendingRun) { + return { + content: [ + { + type: "text", + text: + `Error: run #${pendingRun.runNumber} has not been logged yet. ` + + "Call log_experiment before re-initializing the current segment.", + }, + ], + }; + } + const contractResult = readAutoresearchContract(workDir); + const scriptSnapshot = loadAutoresearchScriptSnapshot(workDir); + const errors = [...contractResult.errors, ...scriptSnapshot.errors]; + if (errors.length > 0) { + return { + content: [{ type: "text", text: `Error: ${errors.join(" ")}` }], + }; + } + + const benchmarkContract = contractResult.contract.benchmark; + const expectedDirection = benchmarkContract.direction ?? "lower"; + const expectedMetricUnit = benchmarkContract.metricUnit; + if (benchmarkContract.command && !isAutoresearchShCommand(benchmarkContract.command)) { + return { + content: [ + { + type: "text", + text: + "Error: Benchmark.command in autoresearch.md must invoke `autoresearch.sh` directly. " + + "Move the real workload into `autoresearch.sh` and re-run init_experiment.", + }, + ], + }; + } + if (benchmarkContract.command !== params.benchmark_command.trim()) { + return { + content: [ + { + type: "text", + text: + "Error: benchmark_command does not match autoresearch.md. " + + `Expected: ${benchmarkContract.command ?? "(missing)"}\nReceived: ${params.benchmark_command}`, + }, + ], + }; + } + if (benchmarkContract.primaryMetric !== params.metric_name.trim()) { + return { + content: [ + { + type: "text", + text: + "Error: metric_name does not match autoresearch.md. " + + `Expected: ${benchmarkContract.primaryMetric ?? "(missing)"}\nReceived: ${params.metric_name}`, + }, + ], + }; + } + if ((params.metric_unit ?? "") !== expectedMetricUnit) { + return { + content: [ + { + type: "text", + text: + "Error: metric_unit does not match autoresearch.md. " + + `Expected: ${expectedMetricUnit || "(empty)"}\nReceived: ${params.metric_unit ?? "(empty)"}`, + }, + ], + }; + } + if ((params.direction ?? "lower") !== expectedDirection) { + return { + content: [ + { + type: "text", + text: + "Error: direction does not match autoresearch.md. " + + `Expected: ${expectedDirection}\nReceived: ${params.direction ?? "lower"}`, + }, + ], + }; + } + if (!contractPathListsEqual(params.scope_paths, contractResult.contract.scopePaths)) { + return { + content: [ + { + type: "text", + text: + "Error: scope_paths do not match autoresearch.md. " + + `Expected: ${contractResult.contract.scopePaths.join(", ")}`, + }, + ], + }; + } + if (!contractPathListsEqual(params.off_limits ?? [], contractResult.contract.offLimits)) { + return { + content: [ + { + type: "text", + text: + "Error: off_limits do not match autoresearch.md. " + + `Expected: ${contractResult.contract.offLimits.join(", ") || "(empty)"}`, + }, + ], + }; + } + if (!contractListsEqual(params.constraints ?? [], contractResult.contract.constraints)) { + return { + content: [ + { + type: "text", + text: + "Error: constraints do not match autoresearch.md. " + + `Expected: ${contractResult.contract.constraints.join(", ") || "(empty)"}`, + }, + ], + }; + } + + const segmentFingerprint = buildAutoresearchSegmentFingerprint(contractResult.contract, { + benchmarkScript: scriptSnapshot.benchmarkScript, + checksScript: scriptSnapshot.checksScript, + }); + + state.name = params.name; + state.metricName = params.metric_name; + state.metricUnit = params.metric_unit ?? ""; + state.bestDirection = params.direction ?? "lower"; + state.maxExperiments = readMaxExperiments(ctx.cwd); + state.bestMetric = null; + state.confidence = null; + state.secondaryMetrics = benchmarkContract.secondaryMetrics.map(name => ({ + name, + unit: inferMetricUnitFromName(name), + })); + state.benchmarkCommand = params.benchmark_command.trim(); + state.scopePaths = [...contractResult.contract.scopePaths]; + state.offLimits = [...contractResult.contract.offLimits]; + state.constraints = [...contractResult.contract.constraints]; + state.segmentFingerprint = segmentFingerprint; + if (isReinitializing) { + state.currentSegment += 1; + } + + const jsonlPath = path.join(workDir, "autoresearch.jsonl"); + const configLine = JSON.stringify({ + type: "config", + name: state.name, + metricName: state.metricName, + metricUnit: state.metricUnit, + bestDirection: state.bestDirection, + benchmarkCommand: state.benchmarkCommand, + secondaryMetrics: state.secondaryMetrics.map(metric => metric.name), + scopePaths: state.scopePaths, + offLimits: state.offLimits, + constraints: state.constraints, + segmentFingerprint, + }); + + if (isReinitializing) { + fs.appendFileSync(jsonlPath, `${configLine}\n`); + } else { + fs.writeFileSync(jsonlPath, `${configLine}\n`); + } + + runtime.autoresearchMode = true; + runtime.autoResumeArmed = true; + runtime.lastAutoResumePendingRunNumber = null; + runtime.lastRunChecks = null; + runtime.lastRunDuration = null; + runtime.lastRunAsi = null; + runtime.lastRunArtifactDir = null; + runtime.lastRunNumber = null; + runtime.lastRunSummary = null; + options.dashboard.updateWidget(ctx, runtime); + options.dashboard.requestRender(); + + const lines = [ + `Experiment initialized: ${state.name}`, + `Metric: ${state.metricName} (${state.metricUnit || "unitless"}, ${state.bestDirection} is better)`, + `Benchmark command: ${state.benchmarkCommand}`, + `Working directory: ${workDir}`, + `Files in Scope: ${state.scopePaths.join(", ")}`, + isReinitializing + ? "Previous results remain in history. This starts a new segment and requires a fresh baseline." + : "Now run the baseline experiment and log it.", + ]; + if (state.maxExperiments !== null) { + lines.push(`Max iterations: ${state.maxExperiments}`); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { state: cloneExperimentState(state) }, + }; + }, + renderCall(args, _options, theme): Text { + return new Text(renderInitCall(args.name, theme), 0, 0); + }, + renderResult(result): Text { + const text = replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""); + return new Text(text, 0, 0); + }, + }; +} + +function renderInitCall(name: string, theme: Theme): string { + return `${theme.fg("toolTitle", theme.bold("init_experiment"))} ${theme.fg("accent", truncateToWidth(replaceTabs(name), 100))}`; +} + +function collectLoggedRunNumbers(results: ExperimentState["results"]): Set { + const runNumbers = new Set(); + for (const result of results) { + if (result.runNumber !== null) { + runNumbers.add(result.runNumber); + } + } + return runNumbers; +} diff --git a/packages/coding-agent/src/autoresearch/tools/log-experiment.ts b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts new file mode 100644 index 000000000..ec9f6caee --- /dev/null +++ b/packages/coding-agent/src/autoresearch/tools/log-experiment.ts @@ -0,0 +1,833 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { StringEnum } from "@oh-my-pi/pi-ai"; +import { Text } from "@oh-my-pi/pi-tui"; +import { logger } from "@oh-my-pi/pi-utils"; +import { Type } from "@sinclair/typebox"; +import type { ToolDefinition } from "../../extensibility/extensions"; +import type { Theme } from "../../modes/theme/theme"; +import { replaceTabs, truncateToWidth } from "../../tools/render-utils"; +import { getAutoresearchFingerprintMismatchError, pathMatchesContractPath } from "../contract"; +import { getCurrentAutoresearchBranch, parseWorkDirDirtyPaths } from "../git"; +import { + AUTORESEARCH_COMMITTABLE_FILES, + formatNum, + inferMetricUnitFromName, + isAutoresearchCommittableFile, + isAutoresearchLocalStatePath, + isBetter, + mergeAsi, + readPendingRunSummary, + resolveWorkDir, + validateWorkDir, +} from "../helpers"; +import { + cloneExperimentState, + computeConfidence, + currentResults, + findBaselineMetric, + findBaselineSecondary, + findBestKeptMetric, +} from "../state"; +import type { + ASIData, + AutoresearchToolFactoryOptions, + ExperimentResult, + ExperimentState, + LogDetails, + NumericMetricMap, +} from "../types"; + +const EXPERIMENT_TOOL_NAMES = ["init_experiment", "run_experiment", "log_experiment"]; + +const logExperimentSchema = Type.Object({ + commit: Type.String({ + description: "Current git commit hash or placeholder.", + }), + metric: Type.Number({ + description: "Primary metric value for this run.", + }), + status: StringEnum(["keep", "discard", "crash", "checks_failed"], { + description: "Outcome for this run.", + }), + description: Type.String({ + description: "Short description of the experiment.", + }), + metrics: Type.Optional( + Type.Record(Type.String(), Type.Number(), { + description: "Secondary metrics for this run.", + }), + ), + force: Type.Optional( + Type.Boolean({ + description: "Allow introducing new secondary metrics.", + }), + ), + asi: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: "Actionable side information captured for this run.", + }), + ), +}); + +interface PreservedFile { + content: Buffer; + path: string; +} + +interface KeepCommitResult { + error?: string; + note?: string; +} + +export function createLogExperimentTool( + options: AutoresearchToolFactoryOptions, +): ToolDefinition { + return { + name: "log_experiment", + label: "Log Experiment", + description: + "Log the experiment result, update dashboard state, persist JSONL history, and apply git keep or revert behavior.", + parameters: logExperimentSchema, + defaultInactive: true, + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const workDirError = validateWorkDir(ctx.cwd); + if (workDirError) { + return { + content: [{ type: "text", text: `Error: ${workDirError}` }], + }; + } + + const runtime = options.getRuntime(ctx); + const state = runtime.state; + const workDir = resolveWorkDir(ctx.cwd); + const fingerprintError = getAutoresearchFingerprintMismatchError(state.segmentFingerprint, workDir); + if (fingerprintError) { + return { + content: [{ type: "text", text: `Error: ${fingerprintError}` }], + }; + } + + const pendingRun = + runtime.lastRunSummary ?? (await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results))); + if (!pendingRun) { + return { + content: [{ type: "text", text: "Error: no unlogged run is available. Run run_experiment first." }], + }; + } + runtime.lastRunSummary = pendingRun; + runtime.lastRunAsi = pendingRun.parsedAsi; + runtime.lastRunChecks = + pendingRun.checksPass === null + ? null + : { + pass: pendingRun.checksPass, + output: "", + duration: pendingRun.checksDurationSeconds ?? 0, + }; + runtime.lastRunDuration = pendingRun.durationSeconds; + + if (pendingRun.parsedPrimary !== null && params.metric !== pendingRun.parsedPrimary) { + return { + content: [ + { + type: "text", + text: + "Error: metric does not match the parsed primary metric from the pending run.\n" + + `Expected: ${pendingRun.parsedPrimary}\nReceived: ${params.metric}`, + }, + ], + }; + } + + if (params.status === "keep" && !pendingRun.passed) { + return { + content: [ + { + type: "text", + text: "Error: cannot keep this run because the pending benchmark did not pass. Log it as crash or checks_failed instead.", + }, + ], + }; + } + + if (params.status === "keep" && runtime.lastRunChecks && !runtime.lastRunChecks.pass) { + return { + content: [ + { + type: "text", + text: "Error: cannot keep this run because autoresearch.checks.sh failed. Log it as checks_failed instead.", + }, + ], + }; + } + + const observedStatusError = validateObservedStatus(params.status, pendingRun); + if (observedStatusError) { + return { + content: [{ type: "text", text: `Error: ${observedStatusError}` }], + }; + } + + const secondaryMetrics = buildSecondaryMetrics(params.metrics, pendingRun.parsedMetrics, state.metricName); + const validationError = validateSecondaryMetrics(state, secondaryMetrics, params.force ?? false); + if (validationError) { + return { + content: [{ type: "text", text: `Error: ${validationError}` }], + }; + } + + const mergedAsi = mergeAsi(runtime.lastRunAsi, sanitizeAsi(params.asi)); + const asiValidationError = validateAsiRequirements(mergedAsi, params.status); + if (asiValidationError) { + return { + content: [{ type: "text", text: `Error: ${asiValidationError}` }], + }; + } + + let keepScopeValidation: { committablePaths: string[] } | undefined; + if (params.status === "keep") { + const scopeValidation = await validateKeepPaths(options, workDir, state); + if (typeof scopeValidation === "string") { + return { + content: [{ type: "text", text: `Error: ${scopeValidation}` }], + }; + } + const currentBestMetric = findBestKeptMetric(state.results, state.currentSegment, state.bestDirection); + if ( + currentBestMetric !== null && + params.metric !== currentBestMetric && + !isBetter(params.metric, currentBestMetric, state.bestDirection) + ) { + return { + content: [ + { + type: "text", + text: + "Error: cannot keep this run because the primary metric regressed.\n" + + `Current best: ${currentBestMetric}\nReceived: ${params.metric}`, + }, + ], + }; + } + keepScopeValidation = scopeValidation; + } + + const experiment: ExperimentResult = { + runNumber: runtime.lastRunNumber ?? pendingRun.runNumber, + commit: params.commit.slice(0, 7), + metric: params.metric, + metrics: secondaryMetrics, + status: params.status, + description: params.description, + timestamp: Date.now(), + segment: state.currentSegment, + confidence: null, + asi: mergedAsi, + }; + + const activeBranch = await getCurrentAutoresearchBranch(options.pi, workDir); + if (!activeBranch) { + return { + content: [ + { + type: "text", + text: + "Error: autoresearch keep/discard actions require an active `autoresearch/...` branch. " + + "Run `/autoresearch` again to restore the protected branch before logging this run.", + }, + ], + }; + } + + let gitNote: string | null = null; + if (params.status === "keep") { + const commitResult = await commitKeptExperiment(options, workDir, state, experiment, keepScopeValidation); + if (commitResult.error) { + return { + content: [{ type: "text", text: `Error: ${commitResult.error}` }], + }; + } + gitNote = commitResult.note ?? null; + } else { + const revertResult = await revertFailedExperiment(options, workDir); + if (revertResult.error) { + return { + content: [{ type: "text", text: `Error: ${revertResult.error}` }], + }; + } + gitNote = revertResult.note ?? null; + } + + const previousState = cloneExperimentState(state); + state.results.push(experiment); + registerSecondaryMetrics(state, secondaryMetrics); + state.bestMetric = findBaselineMetric(state.results, state.currentSegment); + state.confidence = computeConfidence(state.results, state.currentSegment, state.bestDirection); + experiment.confidence = state.confidence; + + const wallClockSeconds = runtime.lastRunDuration; + try { + persistRun(workDir, experiment); + } catch (error) { + runtime.state = previousState; + options.dashboard.updateWidget(ctx, runtime); + options.dashboard.requestRender(); + throw error; + } + try { + await updateRunMetadata(runtime.lastRunArtifactDir ?? pendingRun.runDirectory, { + commit: experiment.commit, + confidence: experiment.confidence, + description: experiment.description, + gitNote, + loggedAt: new Date(experiment.timestamp).toISOString(), + loggedAsi: experiment.asi, + loggedMetric: experiment.metric, + loggedMetrics: experiment.metrics, + runNumber: runtime.lastRunNumber ?? pendingRun.runNumber, + status: experiment.status, + wallClockSeconds, + }); + } catch (error) { + logger.warn("Failed to update autoresearch run metadata after persisting JSONL history", { + error: error instanceof Error ? error.message : String(error), + runDirectory: runtime.lastRunArtifactDir ?? pendingRun.runDirectory, + runNumber: runtime.lastRunNumber ?? pendingRun.runNumber, + }); + } + + runtime.runningExperiment = null; + runtime.lastRunChecks = null; + runtime.lastRunDuration = null; + runtime.lastRunAsi = null; + runtime.lastRunArtifactDir = null; + runtime.lastRunNumber = null; + runtime.lastRunSummary = null; + runtime.autoResumeArmed = true; + runtime.lastAutoResumePendingRunNumber = null; + + const currentSegmentRuns = currentResults(state.results, state.currentSegment).length; + const text = buildLogText(state, experiment, currentSegmentRuns, wallClockSeconds, gitNote); + if (state.maxExperiments !== null && currentSegmentRuns >= state.maxExperiments) { + runtime.autoresearchMode = false; + options.pi.appendEntry( + "autoresearch-control", + runtime.goal ? { mode: "off", goal: runtime.goal } : { mode: "off" }, + ); + await options.pi.setActiveTools( + options.pi.getActiveTools().filter(name => !EXPERIMENT_TOOL_NAMES.includes(name)), + ); + } + options.dashboard.updateWidget(ctx, runtime); + options.dashboard.requestRender(); + + return { + content: [{ type: "text", text }], + details: { + experiment: { + ...experiment, + metrics: { ...experiment.metrics }, + asi: experiment.asi ? structuredClone(experiment.asi) : undefined, + }, + state: cloneExperimentState(state), + wallClockSeconds, + }, + }; + }, + renderCall(args, _options, theme): Text { + const color = args.status === "keep" ? "success" : args.status === "discard" ? "warning" : "error"; + const description = truncateToWidth(replaceTabs(args.description), 100); + return new Text( + `${theme.fg("toolTitle", theme.bold("log_experiment"))} ${theme.fg(color, args.status)} ${theme.fg("muted", description)}`, + 0, + 0, + ); + }, + renderResult(result, _options, theme): Text { + const details = result.details; + if (!details) { + return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0); + } + const summary = renderSummary(details, theme); + return new Text(summary, 0, 0); + }, + }; +} + +function cloneMetrics(value: NumericMetricMap | undefined): NumericMetricMap { + return value ? { ...value } : {}; +} + +function buildSecondaryMetrics( + overrides: NumericMetricMap | undefined, + parsedMetrics: NumericMetricMap | null, + primaryMetricName: string, +): NumericMetricMap { + const merged: NumericMetricMap = {}; + for (const [name, value] of Object.entries(parsedMetrics ?? {})) { + if (name === "__proto__" || name === "constructor" || name === "prototype") continue; + if (name === primaryMetricName) continue; + merged[name] = value; + } + for (const [name, value] of Object.entries(cloneMetrics(overrides))) { + if (name === "__proto__" || name === "constructor" || name === "prototype") continue; + merged[name] = value; + } + return merged; +} + +function sanitizeAsi(value: { [key: string]: unknown } | undefined): ASIData | undefined { + if (!value) return undefined; + const result: ASIData = {}; + for (const [key, entryValue] of Object.entries(value)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") continue; + const sanitized = sanitizeAsiValue(entryValue); + if (sanitized !== undefined) { + result[key] = sanitized; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function sanitizeAsiValue(value: unknown): ASIData[string] | undefined { + if (value === null) return null; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value; + if (Array.isArray(value)) { + const items = value + .map(item => sanitizeAsiValue(item)) + .filter((item): item is NonNullable => item !== undefined); + return items; + } + if (typeof value === "object") { + const objectValue = value as { [key: string]: unknown }; + const result: ASIData = {}; + for (const [key, entryValue] of Object.entries(objectValue)) { + if (key === "__proto__" || key === "constructor" || key === "prototype") continue; + const sanitized = sanitizeAsiValue(entryValue); + if (sanitized !== undefined) { + result[key] = sanitized; + } + } + return result; + } + return undefined; +} + +export function validateAsiRequirements(asi: ASIData | undefined, status: ExperimentResult["status"]): string | null { + if (!asi) { + return "asi is required. Include at minimum a non-empty hypothesis."; + } + if (typeof asi.hypothesis !== "string" || asi.hypothesis.trim().length === 0) { + return "asi.hypothesis is required and must be a non-empty string."; + } + if (status === "keep") return null; + if (typeof asi.rollback_reason !== "string" || asi.rollback_reason.trim().length === 0) { + return "asi.rollback_reason is required for discard, crash, and checks_failed results."; + } + if (typeof asi.next_action_hint !== "string" || asi.next_action_hint.trim().length === 0) { + return "asi.next_action_hint is required for discard, crash, and checks_failed results."; + } + return null; +} + +function validateSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap, force: boolean): string | null { + if (state.secondaryMetrics.length === 0) return null; + const knownNames = new Set(state.secondaryMetrics.map(metric => metric.name)); + const providedNames = new Set(Object.keys(metrics)); + + const missing = [...knownNames].filter(name => !providedNames.has(name)); + if (missing.length > 0) { + return `missing secondary metrics: ${missing.join(", ")}`; + } + + const newMetrics = [...providedNames].filter(name => !knownNames.has(name)); + if (newMetrics.length > 0 && !force) { + return `new secondary metrics require force=true: ${newMetrics.join(", ")}`; + } + return null; +} + +function registerSecondaryMetrics(state: ExperimentState, metrics: NumericMetricMap): void { + for (const name of Object.keys(metrics)) { + if (state.secondaryMetrics.some(metric => metric.name === name)) continue; + state.secondaryMetrics.push({ + name, + unit: inferMetricUnitFromName(name), + }); + } +} + +function persistRun(workDir: string, experiment: ExperimentResult): void { + const entry = { + run: experiment.runNumber, + ...experiment, + }; + const jsonlPath = path.join(workDir, "autoresearch.jsonl"); + fs.appendFileSync(jsonlPath, `${JSON.stringify(entry)}\n`); +} + +function collectLoggedRunNumbers(results: ExperimentResult[]): Set { + const runNumbers = new Set(); + for (const result of results) { + if (result.runNumber !== null) { + runNumbers.add(result.runNumber); + } + } + return runNumbers; +} + +function validateObservedStatus( + status: ExperimentResult["status"], + pendingRun: { checksPass: boolean | null; passed: boolean }, +): string | null { + if (pendingRun.checksPass === false) { + return status === "checks_failed" + ? null + : "benchmark checks failed for the pending run. Log it as checks_failed."; + } + if (!pendingRun.passed) { + return status === "crash" ? null : "the pending benchmark failed. Log it as crash."; + } + return status === "keep" || status === "discard" ? null : "the pending benchmark passed. Log it as keep or discard."; +} + +async function commitKeptExperiment( + options: AutoresearchToolFactoryOptions, + workDir: string, + state: ExperimentState, + experiment: ExperimentResult, + scopeValidation: { committablePaths: string[] } | undefined, +): Promise { + if (!scopeValidation || scopeValidation.committablePaths.length === 0) { + return { note: "nothing to commit" }; + } + + const addResult = await options.pi.exec("git", ["add", "--all", "--", ...scopeValidation.committablePaths], { + cwd: workDir, + timeout: 10_000, + }); + if (addResult.code !== 0) { + return { + error: `git add failed: ${mergeStdoutStderr(addResult).trim() || `exit ${addResult.code}`}`, + }; + } + + const diffResult = await options.pi.exec( + "git", + ["diff", "--cached", "--quiet", "--", ...scopeValidation.committablePaths], + { + cwd: workDir, + timeout: 10_000, + }, + ); + if (diffResult.code === 0) { + return { note: "nothing to commit" }; + } + + const payload: { [key: string]: string | number } = { + status: experiment.status, + [state.metricName]: experiment.metric, + }; + for (const [name, value] of Object.entries(experiment.metrics)) { + payload[name] = value; + } + const commitMessage = `${experiment.description}\n\nResult: ${JSON.stringify(payload)}`; + const commitResult = await options.pi.exec( + "git", + ["commit", "-m", commitMessage, "--", ...scopeValidation.committablePaths], + { + cwd: workDir, + timeout: 10_000, + }, + ); + if (commitResult.code !== 0) { + return { + error: `git commit failed: ${mergeStdoutStderr(commitResult).trim() || `exit ${commitResult.code}`}`, + }; + } + + const revParseResult = await options.pi.exec("git", ["rev-parse", "--short=7", "HEAD"], { + cwd: workDir, + timeout: 5_000, + }); + const newCommit = revParseResult.stdout.trim(); + if (newCommit.length >= 7) { + experiment.commit = newCommit; + } + const summaryLine = + mergeStdoutStderr(commitResult) + .split("\n") + .find(line => line.trim().length > 0) ?? "committed"; + return { note: summaryLine.trim() }; +} + +async function revertFailedExperiment( + options: AutoresearchToolFactoryOptions, + workDir: string, +): Promise { + const preservedFiles = preserveAutoresearchFiles(workDir); + const restoreResult = await options.pi.exec( + "git", + ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."], + { cwd: workDir, timeout: 10_000 }, + ); + const cleanResult = await options.pi.exec("git", ["clean", "-fd", "--", "."], { cwd: workDir, timeout: 10_000 }); + const cleanIgnoredResult = await options.pi.exec("git", ["clean", "-fdX", "--", "."], { + cwd: workDir, + timeout: 10_000, + }); + restoreAutoresearchFiles(preservedFiles); + if (restoreResult.code !== 0) { + return { + error: `git restore failed: ${mergeStdoutStderr(restoreResult).trim() || `exit ${restoreResult.code}`}`, + }; + } + if (cleanResult.code !== 0) { + return { + error: `git clean failed: ${mergeStdoutStderr(cleanResult).trim() || `exit ${cleanResult.code}`}`, + }; + } + if (cleanIgnoredResult.code !== 0) { + return { + error: `git clean -X failed: ${mergeStdoutStderr(cleanIgnoredResult).trim() || `exit ${cleanIgnoredResult.code}`}`, + }; + } + const dirtyCheckResult = await options.pi.exec( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."], + { cwd: workDir, timeout: 10_000 }, + ); + if (dirtyCheckResult.code !== 0) { + return { + error: `git status failed after cleanup: ${mergeStdoutStderr(dirtyCheckResult).trim() || `exit ${dirtyCheckResult.code}`}`, + }; + } + const workDirPrefix = await readGitWorkDirPrefix(options, workDir); + const remainingDirtyPaths = parseWorkDirDirtyPaths(dirtyCheckResult.stdout, workDirPrefix).filter( + relativePath => !isAutoresearchLocalStatePath(relativePath), + ); + if (remainingDirtyPaths.length > 0) { + return { + error: + "Autoresearch cleanup left the worktree dirty. Resolve these paths before continuing: " + + remainingDirtyPaths.join(", "), + }; + } + return { note: "reverted changes" }; +} + +function preserveAutoresearchFiles(workDir: string): PreservedFile[] { + const files: PreservedFile[] = []; + for (const relativePath of [...AUTORESEARCH_COMMITTABLE_FILES, "autoresearch.jsonl"]) { + const absolutePath = path.join(workDir, relativePath); + if (!fs.existsSync(absolutePath)) continue; + files.push({ + content: fs.readFileSync(absolutePath), + path: absolutePath, + }); + } + const localStateDir = path.join(workDir, ".autoresearch"); + if (fs.existsSync(localStateDir)) { + collectDirectoryFiles(localStateDir, files); + } + return files; +} + +function restoreAutoresearchFiles(files: PreservedFile[]): void { + for (const file of files) { + fs.mkdirSync(path.dirname(file.path), { recursive: true }); + fs.writeFileSync(file.path, file.content); + } +} + +function mergeStdoutStderr(result: { stderr: string; stdout: string }): string { + return `${result.stdout}${result.stderr}`; +} + +async function validateKeepPaths( + options: AutoresearchToolFactoryOptions, + workDir: string, + state: ExperimentState, +): Promise<{ committablePaths: string[] } | string> { + if (state.scopePaths.length === 0) { + return "Files in Scope is empty for the current segment. Re-run init_experiment after fixing autoresearch.md."; + } + + const statusResult = await options.pi.exec( + "git", + ["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", "."], + { + cwd: workDir, + timeout: 10_000, + }, + ); + if (statusResult.code !== 0) { + return `git status failed: ${mergeStdoutStderr(statusResult).trim() || `exit ${statusResult.code}`}`; + } + + const workDirPrefix = await readGitWorkDirPrefix(options, workDir); + const committablePaths: string[] = []; + for (const normalizedPath of parseWorkDirDirtyPaths(statusResult.stdout, workDirPrefix)) { + if (isAutoresearchLocalStatePath(normalizedPath)) { + continue; + } + if (isAutoresearchCommittableFile(normalizedPath)) { + committablePaths.push(normalizedPath); + continue; + } + if (state.offLimits.some(spec => pathMatchesContractPath(normalizedPath, spec))) { + return `cannot keep this run because ${normalizedPath} is listed under Off Limits in autoresearch.md`; + } + if (!state.scopePaths.some(spec => pathMatchesContractPath(normalizedPath, spec))) { + return `cannot keep this run because ${normalizedPath} is outside Files in Scope`; + } + committablePaths.push(normalizedPath); + } + + return { committablePaths }; +} + +function collectDirectoryFiles(directory: string, files: PreservedFile[]): void { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, entry.name); + if (entry.isDirectory()) { + collectDirectoryFiles(absolutePath, files); + continue; + } + files.push({ + content: fs.readFileSync(absolutePath), + path: absolutePath, + }); + } +} + +async function updateRunMetadata( + runDirectory: string | null, + metadata: { + commit: string; + confidence: number | null; + description: string; + gitNote: string | null; + loggedAt: string; + loggedAsi: ASIData | undefined; + loggedMetric: number; + loggedMetrics: NumericMetricMap; + runNumber: number | null; + status: ExperimentResult["status"]; + wallClockSeconds: number | null; + }, +): Promise { + if (!runDirectory) return; + const runJsonPath = path.join(runDirectory, "run.json"); + let existing: Record = {}; + try { + existing = (await Bun.file(runJsonPath).json()) as Record; + } catch { + existing = {}; + } + await Bun.write( + runJsonPath, + JSON.stringify( + { + ...existing, + loggedRunNumber: metadata.runNumber, + loggedAt: metadata.loggedAt, + loggedAsi: metadata.loggedAsi, + loggedMetric: metadata.loggedMetric, + loggedMetrics: metadata.loggedMetrics, + status: metadata.status, + description: metadata.description, + commit: metadata.commit, + gitNote: metadata.gitNote, + confidence: metadata.confidence, + wallClockSeconds: metadata.wallClockSeconds, + }, + null, + 2, + ), + ); +} + +function buildLogText( + state: ExperimentState, + experiment: ExperimentResult, + currentSegmentRuns: number, + wallClockSeconds: number | null, + gitNote: string | null, +): string { + const displayRunNumber = experiment.runNumber ?? state.results.length; + const lines = [`Logged run #${displayRunNumber}: ${experiment.status} - ${experiment.description}`]; + if (wallClockSeconds !== null) { + lines.push(`Wall clock: ${wallClockSeconds.toFixed(1)}s`); + } + if (state.bestMetric !== null) { + lines.push(`Baseline ${state.metricName}: ${formatNum(state.bestMetric, state.metricUnit)}`); + } + if (currentSegmentRuns > 1 && state.bestMetric !== null && experiment.metric !== state.bestMetric) { + const delta = ((experiment.metric - state.bestMetric) / state.bestMetric) * 100; + const sign = delta > 0 ? "+" : ""; + lines.push(`This run: ${formatNum(experiment.metric, state.metricUnit)} (${sign}${delta.toFixed(1)}%)`); + } else { + lines.push(`This run: ${formatNum(experiment.metric, state.metricUnit)}`); + } + if (Object.keys(experiment.metrics).length > 0) { + const baselineSecondary = findBaselineSecondary(state.results, state.currentSegment, state.secondaryMetrics); + const parts = Object.entries(experiment.metrics).map(([name, value]) => { + const unit = state.secondaryMetrics.find(metric => metric.name === name)?.unit ?? ""; + const baseline = baselineSecondary[name]; + if (baseline === undefined || baseline === 0 || currentSegmentRuns === 1) { + return `${name}: ${formatNum(value, unit)}`; + } + const delta = ((value - baseline) / baseline) * 100; + const sign = delta > 0 ? "+" : ""; + return `${name}: ${formatNum(value, unit)} (${sign}${delta.toFixed(1)}%)`; + }); + lines.push(`Secondary metrics: ${parts.join(" ")}`); + } + if (experiment.asi) { + const asiSummary = Object.entries(experiment.asi) + .map(([key, value]) => `${key}: ${truncateAsiValue(value)}`) + .join(" | "); + lines.push(`ASI: ${asiSummary}`); + } + if (state.confidence !== null) { + const status = state.confidence >= 2 ? "likely real" : state.confidence >= 1 ? "marginal" : "within noise"; + lines.push(`Confidence: ${state.confidence.toFixed(1)}x noise floor (${status})`); + } + if (gitNote) { + lines.push(`Git: ${gitNote}`); + } + if (state.maxExperiments !== null) { + lines.push(`Progress: ${currentSegmentRuns}/${state.maxExperiments} runs in current segment`); + if (currentSegmentRuns >= state.maxExperiments) { + lines.push(`Maximum experiments reached (${state.maxExperiments}). Autoresearch mode is now off.`); + } + } + return lines.join("\n"); +} + +async function readGitWorkDirPrefix(options: AutoresearchToolFactoryOptions, workDir: string): Promise { + const prefixResult = await options.pi.exec("git", ["rev-parse", "--show-prefix"], { cwd: workDir, timeout: 5_000 }); + if (prefixResult.code !== 0) return ""; + return prefixResult.stdout.trim(); +} + +function truncateAsiValue(value: ASIData[string]): string { + const text = typeof value === "string" ? value : JSON.stringify(value); + return text.length > 120 ? `${text.slice(0, 117)}...` : text; +} + +function renderSummary(details: LogDetails, theme: Theme): string { + const { experiment, state } = details; + const color = experiment.status === "keep" ? "success" : experiment.status === "discard" ? "warning" : "error"; + let summary = `${theme.fg(color, experiment.status.toUpperCase())} ${theme.fg("muted", truncateToWidth(replaceTabs(experiment.description), 100))}`; + summary += ` ${theme.fg("accent", `${state.metricName}=${formatNum(experiment.metric, state.metricUnit)}`)}`; + if (state.bestMetric !== null) { + summary += ` ${theme.fg("dim", `baseline ${formatNum(state.bestMetric, state.metricUnit)}`)}`; + } + if (state.confidence !== null) { + summary += ` ${theme.fg("dim", `conf ${state.confidence.toFixed(1)}x`)}`; + } + return summary; +} diff --git a/packages/coding-agent/src/autoresearch/tools/run-experiment.ts b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts new file mode 100644 index 000000000..a6281a29f --- /dev/null +++ b/packages/coding-agent/src/autoresearch/tools/run-experiment.ts @@ -0,0 +1,640 @@ +import * as childProcess from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Text } from "@oh-my-pi/pi-tui"; +import { formatBytes } from "@oh-my-pi/pi-utils"; +import { Type } from "@sinclair/typebox"; +import type { ToolDefinition } from "../../extensibility/extensions"; +import type { Theme } from "../../modes/theme/theme"; +import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../../session/streaming-output"; +import { replaceTabs, shortenPath, truncateToWidth } from "../../tools/render-utils"; +import { getAutoresearchFingerprintMismatchError } from "../contract"; +import { + EXPERIMENT_MAX_BYTES, + EXPERIMENT_MAX_LINES, + formatElapsed, + formatNum, + getAutoresearchRunDirectory, + getNextAutoresearchRunNumber, + isAutoresearchShCommand, + killTree, + parseAsiLines, + parseMetricLines, + readPendingRunSummary, + resolveWorkDir, + validateWorkDir, +} from "../helpers"; +import type { AutoresearchToolFactoryOptions, RunDetails, RunExperimentProgressDetails } from "../types"; + +const runExperimentSchema = Type.Object({ + command: Type.String({ + description: "Shell command to run for this experiment.", + }), + timeout_seconds: Type.Optional( + Type.Number({ + description: "Timeout in seconds. Defaults to 600.", + }), + ), + checks_timeout_seconds: Type.Optional( + Type.Number({ + description: "Timeout in seconds for autoresearch.checks.sh. Defaults to 300.", + }), + ), +}); + +interface ProcessExecutionResult { + exitCode: number | null; + killed: boolean; + logPath: string; + output: string; +} + +interface ChecksExecutionResult { + code: number | null; + killed: boolean; + logPath: string; + output: string; +} + +interface ProgressSnapshot { + elapsed: string; + runDirectory: string; + fullOutputPath: string; + tailOutput: string; + truncation?: RunExperimentProgressDetails["truncation"]; +} + +export function createRunExperimentTool( + options: AutoresearchToolFactoryOptions, +): ToolDefinition { + return { + name: "run_experiment", + label: "Run Experiment", + description: + "Run an experiment command with timing, output capture, structured metric parsing, durable run artifacts, and optional autoresearch.checks.sh validation.", + parameters: runExperimentSchema, + defaultInactive: true, + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const workDirError = validateWorkDir(ctx.cwd); + if (workDirError) { + return { + content: [{ type: "text", text: `Error: ${workDirError}` }], + }; + } + + const runtime = options.getRuntime(ctx); + const state = runtime.state; + const workDir = resolveWorkDir(ctx.cwd); + const checksPath = path.join(workDir, "autoresearch.checks.sh"); + const autoresearchScriptPath = path.join(workDir, "autoresearch.sh"); + const fingerprintError = getAutoresearchFingerprintMismatchError(state.segmentFingerprint, workDir); + if (fingerprintError) { + return { + content: [{ type: "text", text: `Error: ${fingerprintError}` }], + }; + } + + if (state.benchmarkCommand && params.command.trim() !== state.benchmarkCommand) { + return { + content: [ + { + type: "text", + text: + "Error: command does not match the benchmark command recorded for this segment.\n" + + `Expected: ${state.benchmarkCommand}\nReceived: ${params.command}`, + }, + ], + }; + } + + if (fs.existsSync(autoresearchScriptPath) && !isAutoresearchShCommand(params.command)) { + return { + content: [ + { + type: "text", + text: + `Error: autoresearch.sh exists. Run it directly instead of using a different command.\n` + + `Expected something like: bash autoresearch.sh\n` + + `Received: ${params.command}`, + }, + ], + }; + } + + if (state.maxExperiments !== null) { + const segmentRuns = state.results.filter(result => result.segment === state.currentSegment).length; + if (segmentRuns >= state.maxExperiments) { + return { + content: [ + { + type: "text", + text: `Maximum experiments reached (${state.maxExperiments}). Re-initialize to start a new segment.`, + }, + ], + }; + } + } + + const pendingRun = + runtime.lastRunSummary ?? (await readPendingRunSummary(workDir, collectLoggedRunNumbers(state.results))); + if (pendingRun) { + return { + content: [ + { + type: "text", + text: + `Error: run #${pendingRun.runNumber} has not been logged yet. ` + + "Call log_experiment before starting another benchmark run.", + }, + ], + }; + } + + const runNumber = getNextAutoresearchRunNumber(workDir, runtime.lastRunNumber); + const runDirectory = getAutoresearchRunDirectory(workDir, runNumber); + const benchmarkLogPath = path.join(runDirectory, "benchmark.log"); + const checksLogPath = path.join(runDirectory, "checks.log"); + const runJsonPath = path.join(runDirectory, "run.json"); + await fs.promises.mkdir(runDirectory, { recursive: true }); + runtime.lastRunChecks = null; + runtime.lastRunDuration = null; + runtime.lastRunAsi = null; + runtime.lastRunArtifactDir = runDirectory; + runtime.lastRunNumber = runNumber; + runtime.lastRunSummary = null; + await Bun.write( + runJsonPath, + JSON.stringify( + { + runNumber, + runDirectory, + benchmarkLogPath, + checksLogPath, + command: params.command, + startedAt: new Date().toISOString(), + }, + null, + 2, + ), + ); + + runtime.runningExperiment = { + startedAt: Date.now(), + command: params.command, + runDirectory, + runNumber, + }; + options.dashboard.updateWidget(ctx, runtime); + options.dashboard.requestRender(); + + const timeoutMs = Math.max(0, Math.floor((params.timeout_seconds ?? 600) * 1000)); + const startedAt = Date.now(); + let execution: ProcessExecutionResult; + try { + execution = await executeProcess({ + command: ["bash", "-lc", params.command], + cwd: workDir, + logPath: benchmarkLogPath, + timeoutMs, + signal, + onProgress: details => { + onUpdate?.({ + content: [{ type: "text", text: details.tailOutput }], + details: { + phase: "running", + elapsed: details.elapsed, + truncation: details.truncation, + fullOutputPath: details.fullOutputPath, + runDirectory: details.runDirectory, + }, + }); + }, + }); + } finally { + runtime.runningExperiment = null; + options.dashboard.updateWidget(ctx, runtime); + options.dashboard.requestRender(); + } + + const durationSeconds = (Date.now() - startedAt) / 1000; + runtime.lastRunDuration = durationSeconds; + + const benchmarkPassed = execution.exitCode === 0 && !execution.killed; + let checksPass: boolean | null = null; + let checksTimedOut = false; + let checksOutput = ""; + let checksDuration = 0; + let checksLogPathValue: string | undefined; + + if (benchmarkPassed && fs.existsSync(checksPath)) { + const checksStartedAt = Date.now(); + const checksResult = await runChecks({ + cwd: workDir, + pathToChecks: checksPath, + logPath: checksLogPath, + timeoutMs: Math.max(0, Math.floor((params.checks_timeout_seconds ?? 300) * 1000)), + signal, + }); + checksDuration = (Date.now() - checksStartedAt) / 1000; + checksTimedOut = checksResult.killed; + checksPass = checksResult.code === 0 && !checksResult.killed; + checksOutput = checksResult.output; + checksLogPathValue = checksResult.logPath; + } + + runtime.lastRunChecks = + checksPass === null + ? null + : { + pass: checksPass, + output: checksOutput, + duration: checksDuration, + }; + + const llmTruncation = truncateTail(execution.output, { + maxBytes: EXPERIMENT_MAX_BYTES, + maxLines: EXPERIMENT_MAX_LINES, + }); + const displayTruncation = truncateTail(execution.output, { + maxBytes: DEFAULT_MAX_BYTES, + maxLines: DEFAULT_MAX_LINES, + }); + + const parsedMetricsMap = parseMetricLines(execution.output); + const parsedMetrics = parsedMetricsMap.size > 0 ? Object.fromEntries(parsedMetricsMap.entries()) : null; + const parsedPrimary = parsedMetricsMap.get(state.metricName) ?? null; + const parsedAsi = parseAsiLines(execution.output); + runtime.lastRunAsi = parsedAsi; + + const resultDetails: RunDetails = { + runNumber, + runDirectory, + benchmarkLogPath, + checksLogPath: checksLogPathValue, + command: params.command, + exitCode: execution.exitCode, + durationSeconds, + passed: benchmarkPassed && (checksPass === null || checksPass), + crashed: execution.exitCode !== 0 || execution.killed || checksPass === false, + timedOut: execution.killed, + tailOutput: displayTruncation.content, + checksPass, + checksTimedOut, + checksOutput: checksOutput.split("\n").slice(-80).join("\n"), + checksDuration, + parsedMetrics, + parsedPrimary, + parsedAsi, + metricName: state.metricName, + metricUnit: state.metricUnit, + truncation: llmTruncation.truncated ? llmTruncation : undefined, + fullOutputPath: execution.logPath, + }; + runtime.lastRunSummary = { + checksDurationSeconds: checksDuration, + checksPass, + checksTimedOut, + command: params.command, + durationSeconds, + parsedAsi, + parsedMetrics, + parsedPrimary, + passed: resultDetails.passed, + runDirectory, + runNumber, + }; + runtime.autoResumeArmed = true; + runtime.lastAutoResumePendingRunNumber = null; + options.dashboard.updateWidget(ctx, runtime); + options.dashboard.requestRender(); + + await Bun.write( + runJsonPath, + JSON.stringify( + { + runNumber, + runDirectory, + benchmarkLogPath, + checksLogPath: checksLogPathValue, + command: params.command, + completedAt: new Date().toISOString(), + durationSeconds, + exitCode: execution.exitCode, + timedOut: execution.killed, + checks: { + durationSeconds: checksDuration, + passed: checksPass, + timedOut: checksTimedOut, + }, + parsedMetrics, + parsedPrimary, + parsedAsi, + truncation: resultDetails.truncation, + fullOutputPath: resultDetails.fullOutputPath, + }, + null, + 2, + ), + ); + + return { + content: [{ type: "text", text: buildRunText(resultDetails, llmTruncation.content, state.bestMetric) }], + details: resultDetails, + }; + }, + renderCall(args, _options, theme): Text { + const commandPreview = truncateToWidth(replaceTabs(args.command), 100); + return new Text( + `${theme.fg("toolTitle", theme.bold("run_experiment"))} ${theme.fg("muted", commandPreview)}`, + 0, + 0, + ); + }, + renderResult(result, options, theme): Text { + if (isProgressDetails(result.details)) { + const header = theme.fg("warning", `Running ${result.details.elapsed}...`); + const preview = replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""); + return new Text(preview ? `${header}\n${theme.fg("dim", preview)}` : header, 0, 0); + } + + const details = result.details; + if (!details || !isRunDetails(details)) { + return new Text(replaceTabs(result.content.find(part => part.type === "text")?.text ?? ""), 0, 0); + } + + const statusText = renderStatus(details, theme); + if (!options.expanded && details.tailOutput.trim().length === 0) { + return new Text(statusText, 0, 0); + } + + const preview = replaceTabs( + options.expanded ? details.tailOutput : details.tailOutput.split("\n").slice(-5).join("\n"), + ); + const suffix = + options.expanded && details.truncation && details.fullOutputPath + ? `\n${theme.fg("warning", `Full output: ${shortenPath(details.fullOutputPath)}`)}` + : ""; + return new Text(preview ? `${statusText}\n${theme.fg("dim", preview)}${suffix}` : statusText, 0, 0); + }, + }; +} + +async function executeProcess(options: { + command: string[]; + cwd: string; + logPath: string; + timeoutMs: number; + signal?: AbortSignal; + onProgress?(details: ProgressSnapshot): void; +}): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const child = childProcess.spawn(options.command[0] ?? "bash", options.command.slice(1), { + cwd: options.cwd, + detached: true, + stdio: ["ignore", "pipe", "pipe"], + }); + + const tailChunks: Buffer[] = []; + let chunksBytes = 0; + let killedByTimeout = false; + let resolved = false; + let writeStream: fs.WriteStream | undefined = fs.createWriteStream(options.logPath); + let forceKillTimeout: NodeJS.Timeout | undefined; + + const closeWriteStream = (): Promise => { + if (!writeStream) return Promise.resolve(); + const stream = writeStream; + writeStream = undefined; + return new Promise((resolveClose, rejectClose) => { + stream.end((error?: Error | null) => { + if (error) { + rejectClose(error); + return; + } + resolveClose(); + }); + }); + }; + + const cleanup = (): void => { + if (progressTimer) clearInterval(progressTimer); + if (timeoutHandle) clearTimeout(timeoutHandle); + if (forceKillTimeout) clearTimeout(forceKillTimeout); + options.signal?.removeEventListener("abort", abortHandler); + }; + + const finish = (callback: () => void): void => { + if (resolved) return; + resolved = true; + cleanup(); + callback(); + }; + + const appendChunk = (data: Buffer): void => { + writeStream?.write(data); + tailChunks.push(data); + chunksBytes += data.length; + while (chunksBytes > DEFAULT_MAX_BYTES * 2 && tailChunks.length > 1) { + const removed = tailChunks.shift(); + if (removed) chunksBytes -= removed.length; + } + }; + + const snapshot = (): ProgressSnapshot => { + const tail = truncateTail(Buffer.concat(tailChunks).toString("utf8"), { + maxBytes: DEFAULT_MAX_BYTES, + maxLines: DEFAULT_MAX_LINES, + }); + return { + elapsed: formatElapsed(Date.now() - startedAt), + runDirectory: path.dirname(options.logPath), + fullOutputPath: options.logPath, + tailOutput: tail.content, + truncation: tail.truncated ? tail : undefined, + }; + }; + + const killTreeWithEscalation = (): void => { + if (!child.pid) return; + killTree(child.pid); + forceKillTimeout = setTimeout(() => { + if (child.pid) killTree(child.pid, "SIGKILL"); + }, 1_000); + forceKillTimeout.unref?.(); + }; + + const startedAt = Date.now(); + const progressTimer = options.onProgress + ? setInterval(() => { + options.onProgress?.(snapshot()); + }, 1000) + : undefined; + const timeoutHandle = + options.timeoutMs > 0 + ? setTimeout(() => { + killedByTimeout = true; + killTreeWithEscalation(); + }, options.timeoutMs) + : undefined; + + const abortHandler = (): void => { + killTreeWithEscalation(); + }; + if (options.signal?.aborted) { + abortHandler(); + } else { + options.signal?.addEventListener("abort", abortHandler, { once: true }); + } + + child.stdout?.on("data", data => { + appendChunk(data); + }); + child.stderr?.on("data", data => { + appendChunk(data); + }); + child.on("error", error => { + void closeWriteStream().finally(() => { + finish(() => reject(error)); + }); + }); + child.on("close", async code => { + try { + await closeWriteStream(); + if (options.signal?.aborted) { + finish(() => reject(new Error("aborted"))); + return; + } + const output = await fs.promises.readFile(options.logPath, "utf8"); + finish(() => + resolve({ + exitCode: code, + killed: killedByTimeout, + logPath: options.logPath, + output, + }), + ); + } catch (error) { + finish(() => reject(error)); + } + }); + + return promise; +} + +async function runChecks(options: { + cwd: string; + pathToChecks: string; + logPath: string; + timeoutMs: number; + signal?: AbortSignal; +}): Promise { + const result = await executeProcess({ + command: ["bash", options.pathToChecks], + cwd: options.cwd, + logPath: options.logPath, + timeoutMs: options.timeoutMs, + signal: options.signal, + }); + return { + code: result.exitCode, + killed: result.killed, + logPath: result.logPath, + output: result.output.trim(), + }; +} + +function buildRunText(details: RunDetails, outputPreview: string, bestMetric: number | null): string { + const lines: string[] = []; + lines.push(`Run directory: ${details.runDirectory}`); + if (details.timedOut) { + lines.push(`TIMEOUT after ${details.durationSeconds.toFixed(1)}s`); + } else if (details.exitCode !== 0) { + lines.push(`FAILED with exit code ${details.exitCode} in ${details.durationSeconds.toFixed(1)}s`); + } else { + lines.push(`PASSED in ${details.durationSeconds.toFixed(1)}s`); + } + if (details.checksTimedOut) { + lines.push(`Checks timed out after ${details.checksDuration.toFixed(1)}s`); + } else if (details.checksPass === false) { + lines.push(`Checks failed in ${details.checksDuration.toFixed(1)}s`); + } else if (details.checksPass === true) { + lines.push(`Checks passed in ${details.checksDuration.toFixed(1)}s`); + } + if (bestMetric !== null) { + lines.push(`Current baseline ${details.metricName}: ${formatNum(bestMetric, details.metricUnit)}`); + } + if (details.parsedPrimary !== null) { + lines.push(`Parsed ${details.metricName}: ${details.parsedPrimary}`); + lines.push(`Next log_experiment metric: ${details.parsedPrimary}`); + } + if (details.parsedMetrics) { + const secondaryEntries = Object.entries(details.parsedMetrics) + .filter(([name]) => name !== details.metricName) + .map(([name, value]) => [name, value] as const); + const secondary = secondaryEntries.map(([name, value]) => `${name}=${value}`); + if (secondary.length > 0) { + lines.push(`Parsed metrics: ${secondary.join(", ")}`); + lines.push(`Next log_experiment metrics: ${JSON.stringify(Object.fromEntries(secondaryEntries))}`); + } + } + if (details.parsedAsi) { + lines.push(`Parsed ASI keys: ${Object.keys(details.parsedAsi).join(", ")}`); + } + lines.push(""); + lines.push(outputPreview); + if (details.truncation && details.fullOutputPath) { + lines.push(""); + lines.push( + `Output truncated (${formatBytes(EXPERIMENT_MAX_BYTES)} limit). Full output: ${details.fullOutputPath}`, + ); + } + if (details.checksLogPath) { + lines.push(`Checks log: ${details.checksLogPath}`); + } + if (details.checksPass === false && details.checksOutput.length > 0) { + lines.push(""); + lines.push("Checks output:"); + lines.push(details.checksOutput); + } + return lines.join("\n").trimEnd(); +} + +function renderStatus(details: RunDetails, theme: Theme): string { + if (details.timedOut) { + return theme.fg("error", `TIMEOUT ${details.durationSeconds.toFixed(1)}s`); + } + if (details.checksTimedOut) { + return theme.fg("warning", `Checks timeout ${details.checksDuration.toFixed(1)}s`); + } + if (details.checksPass === false) { + return theme.fg("error", `Checks failed ${details.checksDuration.toFixed(1)}s`); + } + if (details.exitCode !== 0) { + return theme.fg("error", `FAIL exit=${details.exitCode} ${details.durationSeconds.toFixed(1)}s`); + } + const metric = + details.parsedPrimary !== null + ? ` ${details.metricName}=${formatNum(details.parsedPrimary, details.metricUnit)}` + : ""; + return theme.fg("success", `PASS ${details.durationSeconds.toFixed(1)}s${metric}`); +} + +function isRunDetails(value: unknown): value is RunDetails { + if (typeof value !== "object" || value === null) return false; + return "command" in value && "durationSeconds" in value; +} + +function isProgressDetails(value: unknown): value is RunExperimentProgressDetails { + if (typeof value !== "object" || value === null) return false; + return "phase" in value && value.phase === "running"; +} + +function collectLoggedRunNumbers(results: Array<{ runNumber: number | null }>): Set { + const runNumbers = new Set(); + for (const result of results) { + if (result.runNumber !== null) { + runNumbers.add(result.runNumber); + } + } + return runNumbers; +} diff --git a/packages/coding-agent/src/autoresearch/types.ts b/packages/coding-agent/src/autoresearch/types.ts new file mode 100644 index 000000000..e14fadac9 --- /dev/null +++ b/packages/coding-agent/src/autoresearch/types.ts @@ -0,0 +1,218 @@ +import type { AgentToolResult } from "@oh-my-pi/pi-agent-core"; +import type { ExtensionAPI, ExtensionContext } from "../extensibility/extensions"; +import type { SessionEntry } from "../session/session-manager"; +import type { TruncationResult } from "../session/streaming-output"; + +export type MetricDirection = "lower" | "higher"; +export type ExperimentStatus = "keep" | "discard" | "crash" | "checks_failed"; + +export type ASIValue = string | number | boolean | null | ASIValue[] | { [key: string]: ASIValue }; + +export interface ASIData { + [key: string]: ASIValue; +} + +export interface NumericMetricMap { + [key: string]: number; +} + +export interface MetricDef { + name: string; + unit: string; +} + +export interface AutoresearchBenchmarkContract { + command: string | null; + primaryMetric: string | null; + metricUnit: string; + direction: MetricDirection | null; + secondaryMetrics: string[]; +} + +export interface AutoresearchContract { + benchmark: AutoresearchBenchmarkContract; + scopePaths: string[]; + offLimits: string[]; + constraints: string[]; +} + +export interface ExperimentResult { + runNumber: number | null; + commit: string; + metric: number; + metrics: NumericMetricMap; + status: ExperimentStatus; + description: string; + timestamp: number; + segment: number; + confidence: number | null; + asi?: ASIData; +} + +export interface ExperimentState { + results: ExperimentResult[]; + bestMetric: number | null; + bestDirection: MetricDirection; + metricName: string; + metricUnit: string; + secondaryMetrics: MetricDef[]; + name: string | null; + currentSegment: number; + maxExperiments: number | null; + confidence: number | null; + benchmarkCommand: string | null; + scopePaths: string[]; + offLimits: string[]; + constraints: string[]; + segmentFingerprint: string | null; +} + +export interface RunExperimentProgressDetails { + phase: "running"; + elapsed: string; + truncation?: TruncationResult; + fullOutputPath?: string; + runDirectory?: string; +} + +export interface RunDetails { + runNumber: number; + runDirectory: string; + benchmarkLogPath: string; + checksLogPath?: string; + command: string; + exitCode: number | null; + durationSeconds: number; + passed: boolean; + crashed: boolean; + timedOut: boolean; + tailOutput: string; + checksPass: boolean | null; + checksTimedOut: boolean; + checksOutput: string; + checksDuration: number; + parsedMetrics: NumericMetricMap | null; + parsedPrimary: number | null; + parsedAsi: ASIData | null; + metricName: string; + metricUnit: string; + truncation?: TruncationResult; + fullOutputPath?: string; +} + +export interface LogDetails { + experiment: ExperimentResult; + state: ExperimentState; + wallClockSeconds: number | null; +} + +export interface ChecksResult { + pass: boolean; + output: string; + duration: number; +} + +export interface PendingRunSummary { + checksDurationSeconds: number | null; + checksPass: boolean | null; + checksTimedOut: boolean; + command: string; + durationSeconds: number | null; + parsedAsi: ASIData | null; + parsedMetrics: NumericMetricMap | null; + parsedPrimary: number | null; + passed: boolean; + runDirectory: string; + runNumber: number; +} + +export interface RunningExperiment { + startedAt: number; + command: string; + runDirectory: string; + runNumber: number; +} + +export interface AutoresearchRuntime { + autoresearchMode: boolean; + autoResumeArmed: boolean; + dashboardExpanded: boolean; + lastAutoResumePendingRunNumber: number | null; + lastRunChecks: ChecksResult | null; + lastRunDuration: number | null; + lastRunAsi: ASIData | null; + lastRunArtifactDir: string | null; + lastRunNumber: number | null; + lastRunSummary: PendingRunSummary | null; + runningExperiment: RunningExperiment | null; + state: ExperimentState; + goal: string | null; +} + +export interface AutoresearchConfig { + maxIterations?: number; + workingDir?: string; +} + +export interface AutoresearchJsonConfigEntry { + type: "config"; + name?: string; + metricName?: string; + metricUnit?: string; + bestDirection?: MetricDirection; + benchmarkCommand?: string; + secondaryMetrics?: string[]; + scopePaths?: string[]; + offLimits?: string[]; + constraints?: string[]; + segmentFingerprint?: string; +} + +export interface AutoresearchJsonRunEntry { + run?: number; + commit?: string; + metric?: number; + metrics?: NumericMetricMap; + status?: ExperimentStatus; + description?: string; + timestamp?: number; + confidence?: number | null; + asi?: ASIData; +} + +export interface ReconstructedExperimentData { + hasLog: boolean; + state: ExperimentState; +} + +export interface AutoresearchControlEntryData { + mode: "on" | "off" | "clear"; + goal?: string; +} + +export interface ReconstructedControlState { + autoresearchMode: boolean; + goal: string | null; + lastMode: AutoresearchControlEntryData["mode"] | null; +} + +export interface RuntimeStore { + clear(sessionKey: string): void; + ensure(sessionKey: string): AutoresearchRuntime; +} + +export interface DashboardController { + clear(ctx: ExtensionContext): void; + requestRender(): void; + showOverlay(ctx: ExtensionContext, runtime: AutoresearchRuntime): Promise; + updateWidget(ctx: ExtensionContext, runtime: AutoresearchRuntime): void; +} + +export interface AutoresearchToolFactoryOptions { + dashboard: DashboardController; + getRuntime(ctx: ExtensionContext): AutoresearchRuntime; + pi: ExtensionAPI; +} + +export type AutoresearchToolResult = AgentToolResult; +export type SessionEntries = SessionEntry[]; diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 3b5b1392e..a7adea1e7 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -28,6 +28,8 @@ export interface Args { mode?: Mode; noSession?: boolean; sessionDir?: string; + providerSessionId?: string; + fork?: string; models?: string[]; tools?: string[]; noTools?: boolean; @@ -79,6 +81,8 @@ export function parseArgs(args: string[], extensionFlags?: Map 0; + if (!hasInitialContext) { + return { + initialImages: undefined, + }; + } + + let body = ""; + if (fileText !== undefined) { + body += fileText; + } + + if (parsed.messages.length > 0) { + body += parsed.messages[0]; + parsed.messages.shift(); + } + + const initialMessage = + stdinContent !== undefined + ? body.length > 0 + ? `${stdinContent}\n${body}` + : stdinContent + : body.length > 0 + ? body + : fileImages && fileImages.length > 0 + ? "" + : undefined; + + return { + initialMessage, + initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined, + }; +} diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index 6fe4ee578..44d954e4a 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -1,120 +1,438 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { - DEFAULT_EDITOR_KEYBINDINGS, - type EditorAction, - type EditorKeybindingsConfig, - EditorKeybindingsManager, + type Keybinding, + type KeybindingDefinitions, + type KeybindingsConfig, type KeyId, - matchesKey, - setEditorKeybindings, + setKeybindings, + TUI_KEYBINDINGS, + KeybindingsManager as TuiKeybindingsManager, } from "@oh-my-pi/pi-tui"; import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils"; /** - * Application-level actions (coding agent specific). + * Application-level keybindings (coding agent specific). + * Values are always `true` — used for declaration merging. */ -export type AppAction = - | "interrupt" - | "clear" - | "exit" - | "suspend" - | "cycleThinkingLevel" - | "cycleModelForward" - | "cycleModelBackward" - | "selectModel" - | "togglePlanMode" - | "expandTools" - | "toggleThinking" - | "externalEditor" - | "historySearch" - | "followUp" - | "dequeue" - | "pasteImage" - | "copyLine" - | "copyPrompt" - | "newSession" - | "tree" - | "fork" - | "resume" - | "toggleSTT"; +interface AppKeybindings { + "app.interrupt": true; + "app.clear": true; + "app.exit": true; + "app.suspend": true; + "app.thinking.cycle": true; + "app.thinking.toggle": true; + "app.model.cycleForward": true; + "app.model.cycleBackward": true; + "app.model.select": true; + "app.tools.expand": true; + "app.editor.external": true; + "app.message.followUp": true; + "app.message.dequeue": true; + "app.clipboard.pasteImage": true; + "app.clipboard.copyLine": true; + "app.clipboard.copyPrompt": true; + "app.session.new": true; + "app.session.tree": true; + "app.session.fork": true; + "app.session.resume": true; + "app.session.togglePath": true; + "app.session.toggleSort": true; + "app.session.rename": true; + "app.session.delete": true; + "app.session.deleteNoninvasive": true; + "app.tree.foldOrUp": true; + "app.tree.unfoldOrDown": true; + "app.plan.toggle": true; + "app.history.search": true; + "app.stt.toggle": true; +} + +export type AppKeybinding = keyof AppKeybindings; + +declare module "@oh-my-pi/pi-tui" { + interface Keybindings extends AppKeybindings {} +} /** - * All configurable actions. + * All keybindings definitions: TUI + app-specific. */ -export type KeyAction = AppAction | EditorAction; +export const KEYBINDINGS = { + ...TUI_KEYBINDINGS, + "app.interrupt": { + defaultKeys: "escape", + description: "Interrupt current operation", + }, + "app.clear": { + defaultKeys: "ctrl+c", + description: "Clear screen or cancel", + }, + "app.exit": { + defaultKeys: "ctrl+d", + description: "Exit application", + }, + "app.suspend": { + defaultKeys: "ctrl+z", + description: "Suspend application", + }, + "app.thinking.cycle": { + defaultKeys: "shift+tab", + description: "Cycle thinking level", + }, + "app.thinking.toggle": { + defaultKeys: "ctrl+t", + description: "Toggle thinking mode", + }, + "app.model.cycleForward": { + defaultKeys: "ctrl+p", + description: "Cycle to next model", + }, + "app.model.cycleBackward": { + defaultKeys: "shift+ctrl+p", + description: "Cycle to previous model", + }, + "app.model.select": { + defaultKeys: "ctrl+l", + description: "Select model", + }, + "app.tools.expand": { + defaultKeys: "ctrl+o", + description: "Expand tools", + }, + "app.editor.external": { + defaultKeys: "ctrl+g", + description: "Open external editor", + }, + "app.message.followUp": { + defaultKeys: "ctrl+enter", + description: "Send follow-up message", + }, + "app.message.dequeue": { + defaultKeys: "alt+up", + description: "Dequeue message", + }, + "app.clipboard.pasteImage": { + defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v", + description: "Paste image from clipboard", + }, + "app.clipboard.copyLine": { + defaultKeys: "alt+shift+l", + description: "Copy current line", + }, + "app.clipboard.copyPrompt": { + defaultKeys: "alt+shift+c", + description: "Copy prompt", + }, + "app.session.new": { + defaultKeys: [], + description: "Create new session", + }, + "app.session.tree": { + defaultKeys: [], + description: "Show session tree", + }, + "app.session.fork": { + defaultKeys: [], + description: "Fork session", + }, + "app.session.resume": { + defaultKeys: [], + description: "Resume session", + }, + "app.session.togglePath": { + defaultKeys: "ctrl+p", + description: "Toggle session path display", + }, + "app.session.toggleSort": { + defaultKeys: "ctrl+s", + description: "Toggle session sort order", + }, + "app.session.rename": { + defaultKeys: "ctrl+r", + description: "Rename session", + }, + "app.session.delete": { + defaultKeys: "ctrl+d", + description: "Delete session", + }, + "app.session.deleteNoninvasive": { + defaultKeys: "ctrl+backspace", + description: "Delete session (non-invasive)", + }, + "app.tree.foldOrUp": { + defaultKeys: ["ctrl+left", "alt+left"], + description: "Fold or move up", + }, + "app.tree.unfoldOrDown": { + defaultKeys: ["ctrl+right", "alt+right"], + description: "Unfold or move down", + }, + "app.plan.toggle": { + defaultKeys: "alt+shift+p", + description: "Toggle plan mode", + }, + "app.history.search": { + defaultKeys: "ctrl+r", + description: "Search history", + }, + "app.stt.toggle": { + defaultKeys: "alt+h", + description: "Toggle speech-to-text", + }, +} as const satisfies KeybindingDefinitions; /** - * Full keybindings configuration (app + editor actions). + * Migration map from old keybinding names to new namespaced IDs. */ -export type KeybindingsConfig = { - [K in KeyAction]?: KeyId | KeyId[]; -}; +const KEYBINDING_NAME_MIGRATIONS = { + // App-specific (old names) + interrupt: "app.interrupt", + clear: "app.clear", + exit: "app.exit", + suspend: "app.suspend", + cycleThinkingLevel: "app.thinking.cycle", + cycleModelForward: "app.model.cycleForward", + cycleModelBackward: "app.model.cycleBackward", + selectModel: "app.model.select", + togglePlanMode: "app.plan.toggle", + historySearch: "app.history.search", + expandTools: "app.tools.expand", + toggleThinking: "app.thinking.toggle", + externalEditor: "app.editor.external", + followUp: "app.message.followUp", + dequeue: "app.message.dequeue", + pasteImage: "app.clipboard.pasteImage", + copyLine: "app.clipboard.copyLine", + copyPrompt: "app.clipboard.copyPrompt", + newSession: "app.session.new", + tree: "app.session.tree", + fork: "app.session.fork", + resume: "app.session.resume", + toggleSTT: "app.stt.toggle", + // TUI editor (old names for backward compatibility) + cursorUp: "tui.editor.cursorUp", + cursorDown: "tui.editor.cursorDown", + cursorLeft: "tui.editor.cursorLeft", + cursorRight: "tui.editor.cursorRight", + cursorWordLeft: "tui.editor.cursorWordLeft", + cursorWordRight: "tui.editor.cursorWordRight", + cursorLineStart: "tui.editor.cursorLineStart", + cursorLineEnd: "tui.editor.cursorLineEnd", + jumpForward: "tui.editor.jumpForward", + jumpBackward: "tui.editor.jumpBackward", + pageUp: "tui.editor.pageUp", + pageDown: "tui.editor.pageDown", + deleteCharBackward: "tui.editor.deleteCharBackward", + deleteCharForward: "tui.editor.deleteCharForward", + deleteWordBackward: "tui.editor.deleteWordBackward", + deleteWordForward: "tui.editor.deleteWordForward", + deleteToLineStart: "tui.editor.deleteToLineStart", + deleteToLineEnd: "tui.editor.deleteToLineEnd", + yank: "tui.editor.yank", + yankPop: "tui.editor.yankPop", + undo: "tui.editor.undo", + // TUI input (old names for backward compatibility) + newLine: "tui.input.newLine", + submit: "tui.input.submit", + tab: "tui.input.tab", + copy: "tui.input.copy", + // TUI select (old names for backward compatibility) + selectUp: "tui.select.up", + selectDown: "tui.select.down", + selectPageUp: "tui.select.pageUp", + selectPageDown: "tui.select.pageDown", + selectConfirm: "tui.select.confirm", + selectCancel: "tui.select.cancel", + // Upstream additional migrations + toggleSessionNamedFilter: "app.session.togglePath", +} as const satisfies Record; /** - * Default application keybindings. + * Check if a key is a legacy keybinding name. */ -export const DEFAULT_APP_KEYBINDINGS: Record = { - interrupt: "escape", - clear: "ctrl+c", - exit: "ctrl+d", - suspend: "ctrl+z", - cycleThinkingLevel: "shift+tab", - cycleModelForward: "ctrl+p", - cycleModelBackward: "shift+ctrl+p", - selectModel: "ctrl+l", - togglePlanMode: "alt+shift+p", - historySearch: "ctrl+r", - expandTools: "ctrl+o", - toggleThinking: "ctrl+t", - externalEditor: "ctrl+g", - followUp: "ctrl+enter", - dequeue: "alt+up", - pasteImage: "ctrl+v", - copyLine: "alt+shift+l", - copyPrompt: "alt+shift+c", - newSession: [], - tree: [], - fork: [], - resume: [], - toggleSTT: "alt+h", -}; +function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS { + return key in KEYBINDING_NAME_MIGRATIONS; +} + /** - * All default keybindings (app + editor). + * Normalize input to KeybindingsConfig, validating types. */ -export const DEFAULT_KEYBINDINGS: Required = { - ...DEFAULT_EDITOR_KEYBINDINGS, - ...DEFAULT_APP_KEYBINDINGS, -}; +function toKeybindingsConfig(value: unknown): KeybindingsConfig { + if (typeof value !== "object" || value === null) { + return {}; + } -// App actions list for type checking -const APP_ACTIONS: AppAction[] = [ - "interrupt", - "clear", - "exit", - "suspend", - "cycleThinkingLevel", - "cycleModelForward", - "cycleModelBackward", - "selectModel", - "togglePlanMode", - "historySearch", - "expandTools", - "toggleThinking", - "externalEditor", - "followUp", - "dequeue", - "pasteImage", - "copyLine", - "copyPrompt", - "newSession", - "tree", - "fork", - "resume", - "toggleSTT", -]; + const config: KeybindingsConfig = {}; + for (const [key, val] of Object.entries(value)) { + // Allow undefined, string (KeyId), or array of strings + if (val === undefined) { + config[key] = undefined; + } else if (typeof val === "string") { + config[key] = val as KeyId; + } else if (Array.isArray(val) && val.every(v => typeof v === "string")) { + config[key] = val as string[] as KeyId[]; + } + // Silently skip invalid entries + } + return config; +} -function isAppAction(action: string): action is AppAction { - return APP_ACTIONS.includes(action as AppAction); +/** + * Migrate old keybinding names to new namespaced IDs. + * Returns both the migrated config and a flag indicating if migration occurred. + */ +function migrateKeybindingNames(rawConfig: unknown): { + config: KeybindingsConfig; + migrated: boolean; +} { + const config = toKeybindingsConfig(rawConfig); + const migrated: KeybindingsConfig = {}; + let didMigrate = false; + + for (const [key, value] of Object.entries(config)) { + if (isLegacyKeybindingName(key)) { + const newKey = KEYBINDING_NAME_MIGRATIONS[key]; + migrated[newKey] = value; + didMigrate = true; + } else { + // Already a new-style key + migrated[key] = value; + } + } + + return { config: migrated, migrated: didMigrate }; +} + +/** + * Order keybindings config to match KEYBINDINGS key order. + */ +function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig { + const ordered: KeybindingsConfig = {}; + for (const key of Object.keys(KEYBINDINGS)) { + const value = config[key]; + if (value !== undefined) { + ordered[key] = value; + } + } + // Add any remaining keys that aren't in KEYBINDINGS + for (const key of Object.keys(config)) { + if (!(key in ordered)) { + ordered[key] = config[key]; + } + } + return ordered; +} + +/** + * Load raw config from a file synchronously. + * Returns parsed JSON or null if file doesn't exist or is invalid. + */ +function loadRawConfig(filePath: string): unknown { + try { + if (!existsSync(filePath)) { + return null; + } + const content = readFileSync(filePath, "utf-8"); + return JSON.parse(content); + } catch (error) { + if (isEnoent(error)) { + return null; + } + logger.warn("Failed to parse keybindings config", { path: filePath, error: String(error) }); + return null; + } +} + +/** + * Migrate keybindings config file from old format to new. + * Reads from agentDir/keybindings.json, migrates old names, and writes back. + */ +function loadKeybindingsConfig(filePath: string, writeBack: boolean): KeybindingsConfig { + const rawConfig = loadRawConfig(filePath); + + if (rawConfig === null) { + return {}; + } + + const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig); + if (writeBack && migrated) { + const ordered = orderKeybindingsConfig(migratedConfig); + try { + writeFileSync(filePath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8"); + logger.debug("Migrated keybindings config", { path: filePath }); + } catch (error) { + logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) }); + } + } + + return migratedConfig; +} + +function migrateKeybindingsConfigFile(agentDir: string): void { + const configPath = path.join(agentDir, "keybindings.json"); + loadKeybindingsConfig(configPath, true); +} + +/** + * Manages all keybindings (app + TUI). + * Extends the TUI KeybindingsManager with app-specific functionality. + */ +export class KeybindingsManager extends TuiKeybindingsManager { + #configPath: string | undefined; + + constructor(userBindings: KeybindingsConfig = {}, configPath?: string) { + super(KEYBINDINGS, userBindings); + this.#configPath = configPath; + } + + /** + * Create from config file at agentDir/keybindings.json. + */ + static create(agentDir: string = getAgentDir()): KeybindingsManager { + const configPath = path.join(agentDir, "keybindings.json"); + const userBindings = KeybindingsManager.#loadFromFile(configPath); + const manager = new KeybindingsManager(userBindings, configPath); + // Set globally so getKeybindings() returns this manager + setKeybindings(manager); + return manager; + } + + /** + * Create an in-memory keybindings manager without file persistence. + */ + static inMemory(userBindings: KeybindingsConfig = {}): KeybindingsManager { + return new KeybindingsManager(userBindings); + } + + /** + * Reload keybindings from the config file. + */ + reload(): void { + if (!this.#configPath) return; + this.setUserBindings(KeybindingsManager.#loadFromFile(this.#configPath)); + } + + /** + * Get the effective resolved bindings (defaults + user overrides). + */ + getEffectiveConfig(): KeybindingsConfig { + return this.getResolvedBindings(); + } + + /** + * Get display string for a keybinding (e.g., "ctrl+c/escape"). + */ + getDisplayString(keybinding: Keybinding): string { + const keys = this.getKeys(keybinding); + return formatKeyHints(keys.length === 0 ? [] : keys); + } + + /** + * Load user bindings from a file, migrating old names if needed. + */ + static #loadFromFile(filePath: string): KeybindingsConfig { + return loadKeybindingsConfig(filePath, true); + } } /** @@ -145,8 +463,6 @@ const KEY_LABELS: Record = { right: "Right", }; -const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId; - function formatKeyPart(part: string): string { const lower = part.toLowerCase(); const modifier = MODIFIER_LABELS[lower]; @@ -166,116 +482,5 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string { return list.map(formatKeyHint).join("/"); } -/** - * Manages all keybindings (app + editor). - */ -export class KeybindingsManager { - #appActionToKeys: Map; - - private constructor(private readonly config: KeybindingsConfig) { - this.#appActionToKeys = new Map(); - this.#buildMaps(); - } - - /** - * Create from config file and set up editor keybindings. - */ - static async create(agentDir: string = getAgentDir()): Promise { - const configPath = path.join(agentDir, "keybindings.json"); - const config = await KeybindingsManager.#loadFromFile(configPath); - const manager = new KeybindingsManager(config); - - // Set up editor keybindings globally - const editorConfig: EditorKeybindingsConfig = {}; - for (const [action, keys] of Object.entries(config)) { - if (!isAppAction(action)) { - editorConfig[action as EditorAction] = keys; - } - } - setEditorKeybindings(new EditorKeybindingsManager(editorConfig)); - - return manager; - } - - /** - * Create in-memory. - */ - static inMemory(config: KeybindingsConfig = {}): KeybindingsManager { - return new KeybindingsManager(config); - } - - static async #loadFromFile(path: string): Promise { - try { - return await Bun.file(path).json(); - } catch (error) { - if (isEnoent(error)) return {}; - logger.warn("Failed to parse keybindings config", { path, error: String(error) }); - return {}; - } - } - - #buildMaps(): void { - this.#appActionToKeys.clear(); - - // Set defaults for app actions - for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) { - const keyArray = Array.isArray(keys) ? keys : [keys]; - this.#appActionToKeys.set( - action as AppAction, - keyArray.map(key => normalizeKeyId(key as KeyId)), - ); - } - - // Override with user config (app actions only) - for (const [action, keys] of Object.entries(this.config)) { - if (keys === undefined || !isAppAction(action)) continue; - const keyArray = Array.isArray(keys) ? keys : [keys]; - this.#appActionToKeys.set( - action, - keyArray.map(key => normalizeKeyId(key as KeyId)), - ); - } - } - - /** - * Check if input matches an app action. - */ - matches(data: string, action: AppAction): boolean { - const keys = this.#appActionToKeys.get(action); - if (!keys) return false; - for (const key of keys) { - if (matchesKey(data, key)) return true; - } - return false; - } - - /** - * Get keys bound to an app action. - */ - getKeys(action: AppAction): KeyId[] { - return this.#appActionToKeys.get(action) ?? []; - } - - /** - * Get display string for an action. - */ - getDisplayString(action: AppAction): string { - return formatKeyHints(this.getKeys(action)); - } - - /** - * Get the full effective config. - */ - getEffectiveConfig(): Required { - const result = { ...DEFAULT_KEYBINDINGS }; - for (const [action, keys] of Object.entries(this.config)) { - if (keys !== undefined) { - (result as KeybindingsConfig)[action as KeyAction] = keys; - } - } - return result; - } -} - -// Re-export for convenience -export type { EditorAction, KeyId }; +export type { Keybinding, KeybindingsConfig, KeyId }; +export { migrateKeybindingsConfigFile }; diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 251dc2964..29a49a7d8 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -90,6 +90,7 @@ const OpenAICompatSchema = Type.Object({ thinkingFormat: Type.Optional( Type.Union([ Type.Literal("openai"), + Type.Literal("openrouter"), Type.Literal("zai"), Type.Literal("qwen"), Type.Literal("qwen-chat-template"), diff --git a/packages/coding-agent/src/config/model-resolver.ts b/packages/coding-agent/src/config/model-resolver.ts index 157b8a118..82d20b3b5 100644 --- a/packages/coding-agent/src/config/model-resolver.ts +++ b/packages/coding-agent/src/config/model-resolver.ts @@ -141,6 +141,55 @@ function isAlias(id: string): boolean { return !datePattern.test(id); } +/** + * Find an exact model reference match. + * Supports either a bare model id or a canonical provider/modelId reference. + * When matching by bare id, ambiguous matches across providers are rejected. + */ +export function findExactModelReferenceMatch( + modelReference: string, + availableModels: Model[], +): Model | undefined { + const trimmedReference = modelReference.trim(); + if (!trimmedReference) { + return undefined; + } + + const normalizedReference = trimmedReference.toLowerCase(); + + const canonicalMatches = availableModels.filter( + model => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference, + ); + if (canonicalMatches.length === 1) { + return canonicalMatches[0]; + } + if (canonicalMatches.length > 1) { + return undefined; + } + + const slashIndex = trimmedReference.indexOf("/"); + if (slashIndex !== -1) { + const provider = trimmedReference.substring(0, slashIndex).trim(); + const modelId = trimmedReference.substring(slashIndex + 1).trim(); + if (provider && modelId) { + const providerMatches = availableModels.filter( + model => + model.provider.toLowerCase() === provider.toLowerCase() && + model.id.toLowerCase() === modelId.toLowerCase(), + ); + if (providerMatches.length === 1) { + return providerMatches[0]; + } + if (providerMatches.length > 1) { + return undefined; + } + } + } + + const idMatches = availableModels.filter(model => model.id.toLowerCase() === normalizedReference); + return idMatches.length === 1 ? idMatches[0] : undefined; +} + /** * Try to match a pattern to a model from the available models list. * Returns the matched model or undefined if no match found. @@ -150,17 +199,17 @@ function tryMatchModel( availableModels: Model[], context: ModelPreferenceContext, ): Model | undefined { - // Check for provider/modelId format (provider is everything before the first /) + // Try exact reference match first (handles provider/modelId and bare id with ambiguity rejection) + const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels); + if (exactRefMatch) { + return exactRefMatch; + } + + // Check for provider/modelId format — fuzzy match within provider const slashIndex = modelPattern.indexOf("/"); if (slashIndex !== -1) { const provider = modelPattern.substring(0, slashIndex); const modelId = modelPattern.substring(slashIndex + 1); - const providerMatch = availableModels.find( - m => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(), - ); - if (providerMatch) { - return providerMatch; - } const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase()); if (providerModels.length > 0) { @@ -187,10 +236,9 @@ function tryMatchModel( return scored[0]?.model; } } - // No exact provider/model match - fall through to other matching } - // Check for exact ID match (case-insensitive) + // Exact ID match (case-insensitive) — with ambiguity across providers handled by preference const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase()); if (exactMatches.length > 0) { return pickPreferredModel(exactMatches, context); diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 09ceb4274..260855051 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -139,6 +139,43 @@ type SettingDef = // under `as const` while still letting SettingValue infer the correct element type. const EMPTY_STRING_ARRAY: string[] = []; const EMPTY_STRING_RECORD: Record = {}; +export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [ + { + pattern: "^\\s*(cat|head|tail|less|more)\\s+", + tool: "read", + message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.", + }, + { + pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+", + tool: "grep", + message: "Use the `grep` tool instead of grep/rg. It respects .gitignore and provides structured output.", + }, + { + pattern: "^\\s*(find|fd|locate)\\s+.*(-name|-iname|-type|--type|-glob)", + tool: "find", + message: "Use the `find` tool instead of find/fd. It respects .gitignore and is faster for glob patterns.", + }, + { + pattern: "^\\s*sed\\s+(-i|--in-place)", + tool: "edit", + message: "Use the `edit` tool instead of sed -i. It provides diff preview and fuzzy matching.", + }, + { + pattern: "^\\s*perl\\s+.*-[pn]?i", + tool: "edit", + message: "Use the `edit` tool instead of perl -i. It provides diff preview and fuzzy matching.", + }, + { + pattern: "^\\s*awk\\s+.*-i\\s+inplace", + tool: "edit", + message: "Use the `edit` tool instead of awk -i inplace. It provides diff preview and fuzzy matching.", + }, + { + pattern: "^\\s*(echo|printf|cat\\s*<<)\\s+.*[^|]>\\s*\\S", + tool: "write", + message: "Use the `write` tool instead of echo/cat redirection. It handles encoding and provides confirmation.", + }, +]; export const SETTINGS_SCHEMA = { // ──────────────────────────────────────────────────────────────────────── @@ -943,16 +980,7 @@ export const SETTINGS_SCHEMA = { default: false, ui: { tab: "editing", label: "Bash Interceptor", description: "Block shell commands that have dedicated tools" }, }, - - "bashInterceptor.simpleLs": { - type: "boolean", - default: true, - ui: { - tab: "editing", - label: "Intercept `ls`", - description: "Intercept bare ls commands (when interceptor is enabled)", - }, - }, + "bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES }, // Python "python.toolMode": { diff --git a/packages/coding-agent/src/config/settings.ts b/packages/coding-agent/src/config/settings.ts index 1ecb255c3..3740ecc23 100644 --- a/packages/coding-agent/src/config/settings.ts +++ b/packages/coding-agent/src/config/settings.ts @@ -341,10 +341,7 @@ export class Settings { * Get bash interceptor rules (typed accessor for complex array config). */ getBashInterceptorRules(): BashInterceptorRule[] { - const patterns = (this.#merged.bashInterceptor as { patterns?: unknown[] })?.patterns; - if (!Array.isArray(patterns)) return []; - - return patterns.filter((p): p is BashInterceptorRule => typeof p === "object" && p !== null && "pattern" in p); + return this.get("bashInterceptor.patterns"); } /** diff --git a/packages/coding-agent/src/export/html/template.css b/packages/coding-agent/src/export/html/template.css index d1d40b63f..4a5287052 100644 --- a/packages/coding-agent/src/export/html/template.css +++ b/packages/coding-agent/src/export/html/template.css @@ -2,6 +2,10 @@ :root { --line-height: 18px; /* 12px font * 1.5 */ + --sidebar-width: 400px; + --sidebar-min-width: 240px; + --sidebar-max-width: 840px; + --sidebar-resizer-width: 6px; } body { @@ -12,6 +16,11 @@ background: var(--body-bg); } + body.sidebar-resizing { + cursor: col-resize; + user-select: none; + } + #app { display: flex; min-height: 100vh; @@ -19,7 +28,9 @@ /* Sidebar */ #sidebar { - width: 400px; + width: var(--sidebar-width); + min-width: var(--sidebar-width); + max-width: var(--sidebar-width); background: var(--container-bg); flex-shrink: 0; display: flex; @@ -203,8 +214,28 @@ flex-shrink: 0; } + #sidebar-resizer { + width: var(--sidebar-resizer-width); + flex-shrink: 0; + position: sticky; + top: 0; + height: 100vh; + cursor: col-resize; + touch-action: none; + background: transparent; + border-right: 1px solid transparent; + } + + #sidebar-resizer:hover, + body.sidebar-resizing #sidebar-resizer { + background: var(--selectedBg); + border-right-color: var(--dim); + } + /* Main content */ #content { + flex: 1; + min-width: 0; flex: 1; overflow-y: auto; padding: var(--line-height) calc(var(--line-height) * 2); @@ -841,17 +872,19 @@ @media (max-width: 900px) { #sidebar { position: fixed; - left: -400px; - width: 400px; + transform: translateX(-100%); + width: min(var(--sidebar-width), 100vw); + min-width: 0; + max-width: 100vw; top: 0; bottom: 0; height: 100vh; z-index: 99; - transition: left 0.3s; + transition: transform 0.3s; } #sidebar.open { - left: 0; + transform: translateX(0); } #sidebar-overlay.open { @@ -866,6 +899,10 @@ display: block; } + #sidebar-resizer { + display: none; + } + #content { padding: var(--line-height) 16px; } @@ -875,15 +912,8 @@ } } - @media (max-width: 500px) { - #sidebar { - width: 100vw; - left: -100vw; - } - } - @media print { - #sidebar, #sidebar-toggle { display: none !important; } + #sidebar, #sidebar-toggle, #sidebar-resizer { display: none !important; } body { background: white; color: black; } #content { max-width: none; } } diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts index 159081fa8..8be39c480 100644 --- a/packages/coding-agent/src/export/html/template.generated.ts +++ b/packages/coding-agent/src/export/html/template.generated.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/generate-template.ts - DO NOT EDIT -export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; +export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; diff --git a/packages/coding-agent/src/export/html/template.html b/packages/coding-agent/src/export/html/template.html index 3afb4beb3..0330e1307 100644 --- a/packages/coding-agent/src/export/html/template.html +++ b/packages/coding-agent/src/export/html/template.html @@ -28,6 +28,7 @@
+
diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index 6c3991c9f..06a9a4406 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -1279,6 +1279,113 @@ const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); const hamburger = document.getElementById('hamburger'); + const sidebarResizer = document.getElementById('sidebar-resizer'); + const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width'; + const MIN_CONTENT_WIDTH = 320; + + function isMobileLayout() { + return window.matchMedia('(max-width: 900px)').matches; + } + + function getSidebarBounds() { + const rootStyles = getComputedStyle(document.documentElement); + const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240; + const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720; + const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH; + return { + minWidth, + maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth)) + }; + } + + function clampSidebarWidth(width) { + const { minWidth, maxWidth } = getSidebarBounds(); + return Math.max(minWidth, Math.min(maxWidth, width)); + } + + function applySidebarWidth(width) { + document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`); + } + + function loadSidebarWidth() { + try { + const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY); + if (raw === null) return null; + const width = Number(raw); + return Number.isFinite(width) ? width : null; + } catch { + return null; + } + } + + function saveSidebarWidth(width) { + try { + localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width)))); + } catch { + // Ignore storage failures (e.g. private browsing restrictions) + } + } + + function setupSidebarResize() { + const savedWidth = loadSidebarWidth(); + if (savedWidth !== null) { + applySidebarWidth(savedWidth); + } + + if (!sidebarResizer) return; + + let cleanupDrag = null; + + const stopDrag = (pointerId) => { + if (cleanupDrag) { + cleanupDrag(pointerId); + cleanupDrag = null; + } + }; + + sidebarResizer.addEventListener('pointerdown', (e) => { + if (isMobileLayout()) return; + + e.preventDefault(); + const startX = e.clientX; + const startWidth = sidebar.getBoundingClientRect().width; + document.body.classList.add('sidebar-resizing'); + sidebarResizer.setPointerCapture?.(e.pointerId); + + const onPointerMove = (event) => { + applySidebarWidth(startWidth + (event.clientX - startX)); + }; + + cleanupDrag = (pointerIdToRelease) => { + document.body.classList.remove('sidebar-resizing'); + sidebarResizer.releasePointerCapture?.(pointerIdToRelease); + window.removeEventListener('pointermove', onPointerMove); + window.removeEventListener('pointerup', onPointerUp); + window.removeEventListener('pointercancel', onPointerCancel); + saveSidebarWidth(sidebar.getBoundingClientRect().width); + }; + + const onPointerUp = (event) => stopDrag(event.pointerId); + const onPointerCancel = (event) => stopDrag(event.pointerId); + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp); + window.addEventListener('pointercancel', onPointerCancel); + }); + + sidebarResizer.addEventListener('dblclick', () => { + if (isMobileLayout()) return; + applySidebarWidth(400); + saveSidebarWidth(400); + }); + + window.addEventListener('resize', () => { + if (isMobileLayout()) return; + applySidebarWidth(sidebar.getBoundingClientRect().width); + }); + } + + setupSidebarResize(); hamburger.addEventListener('click', () => { sidebar.classList.add('open'); diff --git a/packages/coding-agent/src/extensibility/extensions/types.ts b/packages/coding-agent/src/extensibility/extensions/types.ts index ae6bd21ee..9cfdae574 100644 --- a/packages/coding-agent/src/extensibility/extensions/types.ts +++ b/packages/coding-agent/src/extensibility/extensions/types.ts @@ -56,7 +56,7 @@ import type { TodoItem } from "../../tools/todo-write"; import type { EventBus } from "../../utils/event-bus"; import type { SlashCommandInfo } from "../slash-commands"; -export type { AppAction, KeybindingsManager } from "../../config/keybindings"; +export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings"; export type { ExecOptions, ExecResult } from "../../exec/exec"; export type { AgentToolResult, AgentToolUpdateCallback }; @@ -87,6 +87,16 @@ export interface ExtensionUIDialogOptions { /** Raw terminal input listener for extensions. */ export type TerminalInputHandler = (data: string) => { consume?: boolean; data?: string } | undefined; +export type WidgetPlacement = "aboveEditor" | "belowEditor"; + +export interface ExtensionWidgetOptions { + placement?: WidgetPlacement; +} + +export type ExtensionUiComponent = Component & { dispose?(): void }; +export type ExtensionUiComponentFactory = (tui: TUI, theme: Theme) => ExtensionUiComponent; +export type ExtensionWidgetContent = string[] | ExtensionUiComponentFactory | undefined; + /** * UI context for extensions to request interactive UI. * Each mode (interactive, RPC, print) provides its own implementation. @@ -113,15 +123,14 @@ export interface ExtensionUIContext { /** Set the working/loading message shown during streaming. Call with no argument to restore default. */ setWorkingMessage(message?: string): void; - /** Set a widget to display above the editor. Accepts string array or component factory. */ - setWidget(key: string, content: string[] | undefined): void; - setWidget(key: string, content: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; + /** Set a widget to display above or below the editor. Accepts string array or component factory. */ + setWidget(key: string, content: ExtensionWidgetContent, options?: ExtensionWidgetOptions): void; /** Set a custom footer component, or undefined to restore the built-in footer. */ - setFooter(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; + setFooter(factory: ExtensionUiComponentFactory | undefined): void; /** Set a custom header component, or undefined to restore the built-in header. */ - setHeader(factory: ((tui: TUI, theme: Theme) => Component & { dispose?(): void }) | undefined): void; + setHeader(factory: ExtensionUiComponentFactory | undefined): void; /** Set the terminal window/tab title. */ setTitle(title: string): void; @@ -133,7 +142,7 @@ export interface ExtensionUIContext { theme: Theme, keybindings: KeybindingsManager, done: (result: T) => void, - ) => (Component & { dispose?(): void }) | Promise, + ) => ExtensionUiComponent | Promise, options?: { overlay?: boolean }, ): Promise; @@ -303,6 +312,9 @@ export interface ToolDefinition