From 11763d9f16fa9c115f90f7bcc9a8ad62a2eb643e Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 28 Apr 2026 22:46:21 +0100 Subject: [PATCH 1/2] fix(agent): improve handler edits and mcp gateway load --- README.md | 4 +- src/agent/index.ts | 163 ++++++++++++++++++++++++++++------- src/sandbox/tool.d.ts | 13 +++ src/sandbox/tool.js | 141 ++++++++++++++++++++++++++++++ tests/plugin-manager.test.ts | 13 +++ tests/sandbox-tool.test.ts | 37 ++++++++ 6 files changed, 337 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 11d2623..6521771 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ Most agent CLIs are powerful because they can touch your machine directly: shell HyperAgent takes a different route. The model acts by writing JavaScript handlers, and those handlers run inside a hardware-isolated Hyperlight micro-VM. By default there is no shell, no filesystem, no network, and no process access. When the task needs host capabilities, they are added deliberately through plugins, profiles, or MCP servers. +Because handlers can fetch, parse, filter, aggregate, and validate data before returning, the model does not have to read every raw API response, file, or plugin result itself. Well-written handlers can reduce large tool outputs into compact, relevant results, keeping more of the conversation focused on decisions instead of data plumbing and often dramatically reducing token consumption during research, repair, and analysis loops. + What that gets you: | Instead of | HyperAgent gives you | @@ -51,7 +53,7 @@ These are the kinds of jobs HyperAgent is designed to handle. ```bash hyperagent --skill pptx-expert --profile web-research \ - --prompt "Create a visually rich Artemis II mission briefing deck. Use NASA public imagery where available, include mission objectives, crew, Orion/SLS architecture, lunar flyby timeline, key risks, and why the mission matters. Make it dramatic but factual, with strong full-bleed image slides and clean diagrams. Save it as artemis-ii-briefing.pptx." + --prompt "Create a presentation on the NASA Artemis II mission include lots of statistics and data, use an appropriate theme and color scheme for the subject, you aim is to inspire the audience to find out more and get involved, make sure you go to the Internet to get the very latest mission information from https://www.nasa.gov/mission/artemis-ii and images/multimedia from https://www.nasa.gov/artemis-ii-multimedia/ , ensure you include photos taken by the crew during the mission, make it stunning" ``` The agent can use `ha:pptx`, `ha:pptx-charts`, and `ha:pptx-tables` to create editable PowerPoint files instead of screenshots glued into slides. diff --git a/src/agent/index.ts b/src/agent/index.ts index 5504fd0..2fc139a 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -1662,11 +1662,12 @@ const editHandlerTool = defineTool("edit_handler", { description: [ "Make a surgical edit to an existing handler without re-sending all the code.", "", - "Finds oldString exactly once in the handler and replaces it with newString.", - "Much faster and safer than re-registering the entire handler for small fixes.", + "Either replace oldString exactly once, or replace a line range from get_handler_source.", + "Much faster and safer than regenerating the entire handler for small fixes.", "", - "⚠️ oldString must match EXACTLY ONCE. If it matches 0 or 2+ times, the edit", - "fails. Add more surrounding context to make the match unique.", + "String mode: provide oldString and newString. oldString must match EXACTLY ONCE.", + "Line mode: provide startLine, optional endLine, and replacement. Use the line", + "numbers returned by get_handler_source, but do not include the 'N |' prefixes.", "", "Returns the edited region with surrounding context for verification.", ].join("\n"), @@ -1679,62 +1680,157 @@ const editHandlerTool = defineTool("edit_handler", { }, oldString: { type: "string", - description: - "Exact string to find and replace. Must occur exactly once.", + description: "String mode: exact string to find and replace once.", }, newString: { type: "string", - description: "Replacement string.", + description: "String mode: replacement string.", + }, + startLine: { + type: "number", + description: + "Line mode: 1-based start line to replace, from get_handler_source.", + }, + endLine: { + type: "number", + description: + "Line mode: optional 1-based end line to replace. Defaults to startLine.", + }, + replacement: { + type: "string", + description: "Line mode: replacement code for the selected line range.", }, }, - required: ["name", "oldString", "newString"], + required: ["name"], }, handler: async ({ name, oldString, newString, + startLine, + endLine, + replacement, }: { name: string; - oldString: string; - newString: string; + oldString?: string; + newString?: string; + startLine?: number; + endLine?: number; + replacement?: string; }) => { // ── Preview the edit and validate before applying ───────────── // Get current source to build the edited version const sourceResult = sandbox.getHandlerSource(name, { lineNumbers: false, - }) as { success: true; source: string } | { success: false; error: string }; + }); if (!sourceResult.success) { console.error(` ${C.err("❌ " + sourceResult.error)}`); return { success: false, error: sourceResult.error }; } - const currentSource = sourceResult.source; + const currentSource = sourceResult.code ?? ""; - // Check exact-once match - const firstIdx = currentSource.indexOf(oldString); - if (firstIdx === -1) { - const error = - "oldString not found in handler. Use get_handler_source to see current code, then copy the EXACT text to replace."; - console.error(` ${C.err("❌ " + error)}`); - return { success: false, error }; - } - const secondIdx = currentSource.indexOf( - oldString, - firstIdx + oldString.length, - ); - if (secondIdx !== -1) { + const hasStringEdit = oldString !== undefined || newString !== undefined; + const hasLineEdit = + startLine !== undefined || + endLine !== undefined || + replacement !== undefined; + + if (hasStringEdit && hasLineEdit) { const error = - "oldString matches multiple times. Add more surrounding context to make it unique."; + "Use either string mode (oldString + newString) or line mode (startLine/endLine + replacement), not both."; console.error(` ${C.err("❌ " + error)}`); return { success: false, error }; } - // Build the edited code - const editedCode = - currentSource.slice(0, firstIdx) + - newString + - currentSource.slice(firstIdx + oldString.length); + let editedCode: string; + let applyEdit: () => Promise<{ + success: boolean; + message?: string; + error?: string; + handlers?: string[]; + codeSize?: number; + contextAfter?: string; + }>; + + if (hasLineEdit) { + if (typeof replacement !== "string") { + const error = "Line mode requires replacement."; + console.error(` ${C.err("❌ " + error)}`); + return { success: false, error }; + } + if ( + typeof startLine !== "number" || + !Number.isInteger(startLine) || + startLine < 1 + ) { + const error = "Line mode requires startLine as a positive integer."; + console.error(` ${C.err("❌ " + error)}`); + return { success: false, error }; + } + + const lines = currentSource.split("\n"); + const rangeEnd = endLine ?? startLine; + if ( + typeof rangeEnd !== "number" || + !Number.isInteger(rangeEnd) || + rangeEnd < startLine + ) { + const error = + "endLine must be a positive integer greater than or equal to startLine."; + console.error(` ${C.err("❌ " + error)}`); + return { success: false, error }; + } + if (startLine > lines.length || rangeEnd > lines.length) { + const error = `Line range ${startLine}-${rangeEnd} is outside handler "${name}" (${lines.length} lines).`; + console.error(` ${C.err("❌ " + error)}`); + return { success: false, error }; + } + + const replacementLines = + replacement === "" ? [] : replacement.split("\n"); + const editedLines = [ + ...lines.slice(0, startLine - 1), + ...replacementLines, + ...lines.slice(rangeEnd), + ]; + editedCode = editedLines.join("\n"); + applyEdit = async () => + sandbox.editHandlerLines(name, startLine, rangeEnd, replacement); + } else { + if (typeof oldString !== "string" || typeof newString !== "string") { + const error = "String mode requires oldString and newString."; + console.error(` ${C.err("❌ " + error)}`); + return { success: false, error }; + } + + // Check exact-once match + const firstIdx = currentSource.indexOf(oldString); + if (firstIdx === -1) { + const error = + "oldString not found in handler. Use get_handler_source to see current code, then copy the EXACT text to replace, or use startLine/replacement line mode."; + console.error(` ${C.err("❌ " + error)}`); + return { success: false, error }; + } + const secondIdx = currentSource.indexOf( + oldString, + firstIdx + oldString.length, + ); + if (secondIdx !== -1) { + const error = + "oldString matches multiple times. Add more surrounding context to make it unique, or use startLine/replacement line mode."; + console.error(` ${C.err("❌ " + error)}`); + return { success: false, error }; + } + + // Build the edited code + editedCode = + currentSource.slice(0, firstIdx) + + newString + + currentSource.slice(firstIdx + oldString.length); + applyEdit = async () => sandbox.editHandler(name, oldString, newString); + } // Validate the edited code through the same pipeline as register_handler try { @@ -1762,7 +1858,7 @@ const editHandlerTool = defineTool("edit_handler", { } // Validation passed — apply the edit - const result = await sandbox.editHandler(name, oldString, newString); + const result = await applyEdit(); if (result.success) { console.error( ` ${C.ok("✅")} Edited handler "${name}" (${result.codeSize} bytes)`, @@ -5353,9 +5449,10 @@ async function main(): Promise { if (mcpManager) { const mcpPlugin = pluginManager.getPlugin("mcp"); if (mcpPlugin && mcpPlugin.state !== "enabled") { + const mcpSource = pluginManager.loadSource("mcp"); // Compute current content hash so the audit matches the source const mcpHash = computePluginHash(mcpPlugin.dir); - if (mcpHash) { + if (mcpSource && mcpHash) { pluginManager.setAuditResult("mcp", { contentHash: mcpHash, auditedAt: new Date().toISOString(), diff --git a/src/sandbox/tool.d.ts b/src/sandbox/tool.d.ts index c55986c..f108e1d 100644 --- a/src/sandbox/tool.d.ts +++ b/src/sandbox/tool.d.ts @@ -223,6 +223,19 @@ export type SandboxTool = { codeSize?: number; contextAfter?: string; }>; + editHandlerLines: ( + name: string, + startLine: number, + endLine: number, + replacement: string, + ) => Promise<{ + success: boolean; + message?: string; + error?: string; + handlers?: string[]; + codeSize?: number; + contextAfter?: string; + }>; registerModule: ( name: string, source: string, diff --git a/src/sandbox/tool.js b/src/sandbox/tool.js index e395c73..2d840ca 100644 --- a/src/sandbox/tool.js +++ b/src/sandbox/tool.js @@ -122,6 +122,7 @@ export function parsePositiveInt(raw, defaultVal) { * @property {() => {heapMb: number, scratchMb: number}} getEffectiveMemorySizes * @property {(name: string, code: string, options?: {isModule?: boolean}) => Promise<{success: boolean, message?: string, error?: string, handlers?: string[], codeSize?: number, mode?: string}>} registerHandler * @property {(name: string) => Promise<{success: boolean, message?: string, error?: string, handlers?: string[]}>} deleteHandler + * @property {(name: string, startLine: number, endLine: number, replacement: string) => Promise<{success: boolean, message?: string, error?: string, handlers?: string[], codeSize?: number, contextAfter?: string}>} editHandlerLines * @property {(name: string, event?: object) => Promise} execute * @property {() => string[]} getHandlers * @property {(name: string) => string | null} getHandlerSource @@ -1356,6 +1357,145 @@ export function createSandboxTool(options = {}) { } } + /** + * Edit a handler by replacing a 1-based inclusive line range. + * This is friendlier for LLM repair loops because getHandlerSource() + * already returns stable line numbers. + * + * @param {string} name — Handler name + * @param {number} startLine — 1-based start line (inclusive) + * @param {number} endLine — 1-based end line (inclusive) + * @param {string} replacement — Replacement code for the line range + * @returns {Promise<{success: boolean, message?: string, error?: string, handlers?: string[], codeSize?: number, contextAfter?: string}>} + */ + async function editHandlerLines(name, startLine, endLine, replacement) { + const release = await acquireLock(); + try { + if (!name || typeof name !== "string") { + return { + success: false, + error: "Handler name must be a non-empty string", + handlers: publicHandlerNames(), + }; + } + if (name.startsWith("_")) { + return { + success: false, + error: `Internal handler "${name}" cannot be edited`, + handlers: publicHandlerNames(), + }; + } + if (!Number.isInteger(startLine) || startLine < 1) { + return { + success: false, + error: "startLine must be a positive integer", + handlers: publicHandlerNames(), + }; + } + if (!Number.isInteger(endLine) || endLine < startLine) { + return { + success: false, + error: + "endLine must be a positive integer greater than or equal to startLine", + handlers: publicHandlerNames(), + }; + } + if (typeof replacement !== "string") { + return { + success: false, + error: "replacement must be a string", + handlers: publicHandlerNames(), + }; + } + + const entry = handlerCache.get(name); + if (!entry) { + return { + success: false, + error: `Handler "${name}" not found`, + handlers: publicHandlerNames(), + }; + } + + const lines = entry.code.split("\n"); + if (startLine > lines.length || endLine > lines.length) { + return { + success: false, + error: `Line range ${startLine}-${endLine} is outside handler "${name}" (${lines.length} lines)`, + handlers: publicHandlerNames(), + }; + } + + const replacementLines = + replacement === "" ? [] : replacement.split("\n"); + const newLines = [ + ...lines.slice(0, startLine - 1), + ...replacementLines, + ...lines.slice(endLine), + ]; + const newCode = newLines.join("\n"); + const newHash = sha256(newCode); + + const existing = findDuplicateCode(newHash, name); + if (existing) { + return { + success: false, + error: `This edit would create duplicate code — same as handler "${existing}".`, + handlers: publicHandlerNames(), + }; + } + + handlerCache.set(name, { code: newCode, hash: newHash }); + + if (loadedSandbox !== null) { + if (verbose) { + console.error( + `[sandbox] editHandlerLines("${name}"): loadedSandbox exists, calling autoSaveState before invalidating...`, + ); + } + await autoSaveState(); + try { + jsSandbox = await loadedSandbox.unload(); + loadedSandbox = null; + } catch { + invalidateSandbox(); + } + compiledHandlersHash = null; + currentSnapshot = null; + if (verbose) { + const stashSize = savedSharedState ? savedSharedState.size : 0; + console.error( + `[sandbox] editHandlerLines("${name}"): sandbox invalidated, stash preserved with ${stashSize} keys`, + ); + } + } + + const contextStart = Math.max(0, startLine - 3); + const contextEnd = Math.min(newLines.length, startLine + 3); + const contextLines = newLines.slice(contextStart, contextEnd); + const width = String(contextEnd).length; + const contextAfter = contextLines + .map((line, lineIndex) => { + const lineNumber = String(contextStart + lineIndex + 1).padStart( + width, + " ", + ); + return `${lineNumber} | ${line}`; + }) + .join("\n"); + + return { + success: true, + message: `Handler "${name}" edited (recompile pending — shared-state auto-preserved)`, + handlers: publicHandlerNames(), + codeSize: newCode.length, + contextAfter, + }; + } finally { + release(); + } + } + // ── User Module Management ─────────────────────────────────── // // Modules are ES modules that handlers (and other modules) can @@ -2199,6 +2339,7 @@ export function createSandboxTool(options = {}) { deleteHandler, getHandlerSource, editHandler, + editHandlerLines, registerModule, deleteModule: deleteModule, setModules, diff --git a/tests/plugin-manager.test.ts b/tests/plugin-manager.test.ts index d50e27a..3a06408 100644 --- a/tests/plugin-manager.test.ts +++ b/tests/plugin-manager.test.ts @@ -1105,6 +1105,19 @@ describe("approval management", () => { manager.discover(); expect(manager.getApprovalRecord("test-plugin")).toBeUndefined(); }); + + it("should verify auto-approved plugin source after loading source", () => { + const manager = createPluginManager(FIXTURES_DIR); + manager.discover(); + + manager.setAuditResult("test-plugin", makeAudit("synthetic")); + expect(manager.approve("test-plugin")).toBe(true); + manager.enable("test-plugin"); + + expect(manager.verifySourceHash("test-plugin")).toBe(false); + expect(manager.loadSource("test-plugin")).toBeTruthy(); + expect(manager.verifySourceHash("test-plugin")).toBe(true); + }); }); // ── applyInlineConfig ────────────────────────────────────────────── diff --git a/tests/sandbox-tool.test.ts b/tests/sandbox-tool.test.ts index c146195..0abe05a 100644 --- a/tests/sandbox-tool.test.ts +++ b/tests/sandbox-tool.test.ts @@ -485,6 +485,43 @@ describe("editHandler", () => { expect(r.contextAfter).toBeDefined(); expect(r.contextAfter).toContain("22"); }); + + it("should edit a handler by line range", async () => { + const code = [ + "function handler(event) {", + " const title = 'old';", + " const height = 0.4;", + " return { title, height };", + "}", + ].join("\n"); + await tool.registerHandler("edit-lines", code); + + const r = await tool.editHandlerLines( + "edit-lines", + 3, + 3, + " const height = 0.6;", + ); + + expect(r.success).toBe(true); + expect(r.contextAfter).toContain("0.6"); + + const source = tool.getHandlerSource("edit-lines", { lineNumbers: false }); + expect(source.code).toContain("const height = 0.6;"); + expect(source.code).not.toContain("const height = 0.4;"); + }); + + it("should reject line edits outside the handler", async () => { + await tool.registerHandler("edit-lines-range", "return 'range';"); + const r = await tool.editHandlerLines( + "edit-lines-range", + 20, + 20, + "return 2;", + ); + expect(r.success).toBe(false); + expect(r.error).toContain("outside handler"); + }); }); // ── Execution (Named Handlers) ─────────────────────────────────────── From 54a46d3d535d9cdc430f1e06548474934990442c Mon Sep 17 00:00:00 2001 From: Simon Davies Date: Tue, 28 Apr 2026 23:09:20 +0100 Subject: [PATCH 2/2] fix(agent): address handler edit review feedback --- README.md | 2 +- src/sandbox/tool.js | 40 ++++++++++++++++++++++++-------------- tests/sandbox-tool.test.ts | 28 ++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 6521771..1d0a573 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ These are the kinds of jobs HyperAgent is designed to handle. ```bash hyperagent --skill pptx-expert --profile web-research \ - --prompt "Create a presentation on the NASA Artemis II mission include lots of statistics and data, use an appropriate theme and color scheme for the subject, you aim is to inspire the audience to find out more and get involved, make sure you go to the Internet to get the very latest mission information from https://www.nasa.gov/mission/artemis-ii and images/multimedia from https://www.nasa.gov/artemis-ii-multimedia/ , ensure you include photos taken by the crew during the mission, make it stunning" + --prompt "Create a presentation on the NASA Artemis II mission include lots of statistics and data, use an appropriate theme and color scheme for the subject, your aim is to inspire the audience to find out more and get involved, make sure you go to the Internet to get the very latest mission information from https://www.nasa.gov/mission/artemis-ii and images/multimedia from https://www.nasa.gov/artemis-ii-multimedia/, ensure you include photos taken by the crew during the mission, make it stunning" ``` The agent can use `ha:pptx`, `ha:pptx-charts`, and `ha:pptx-tables` to create editable PowerPoint files instead of screenshots glued into slides. diff --git a/src/sandbox/tool.js b/src/sandbox/tool.js index 2d840ca..a2ea6f0 100644 --- a/src/sandbox/tool.js +++ b/src/sandbox/tool.js @@ -125,7 +125,7 @@ export function parsePositiveInt(raw, defaultVal) { * @property {(name: string, startLine: number, endLine: number, replacement: string) => Promise<{success: boolean, message?: string, error?: string, handlers?: string[], codeSize?: number, contextAfter?: string}>} editHandlerLines * @property {(name: string, event?: object) => Promise} execute * @property {() => string[]} getHandlers - * @property {(name: string) => string | null} getHandlerSource + * @property {(name: string, options?: {lineNumbers?: boolean, startLine?: number, endLine?: number}) => {success: boolean, code?: string, error?: string, totalLines?: number, startLine?: number, endLine?: number}} getHandlerSource * @property {() => string[]} getAvailableModules */ @@ -1436,6 +1436,30 @@ export function createSandboxTool(options = {}) { const newCode = newLines.join("\n"); const newHash = sha256(newCode); + const contextStart = Math.max(0, startLine - 3); + const contextEnd = Math.min(newLines.length, startLine + 3); + const contextLines = newLines.slice(contextStart, contextEnd); + const width = String(contextEnd).length; + const contextAfter = contextLines + .map((line, lineIndex) => { + const lineNumber = String(contextStart + lineIndex + 1).padStart( + width, + " ", + ); + return `${lineNumber} | ${line}`; + }) + .join("\n"); + + if (newHash === entry.hash) { + return { + success: true, + message: `Handler "${name}" unchanged (same code)`, + handlers: publicHandlerNames(), + codeSize: entry.code.length, + contextAfter, + }; + } + const existing = findDuplicateCode(newHash, name); if (existing) { return { @@ -1470,20 +1494,6 @@ export function createSandboxTool(options = {}) { } } - const contextStart = Math.max(0, startLine - 3); - const contextEnd = Math.min(newLines.length, startLine + 3); - const contextLines = newLines.slice(contextStart, contextEnd); - const width = String(contextEnd).length; - const contextAfter = contextLines - .map((line, lineIndex) => { - const lineNumber = String(contextStart + lineIndex + 1).padStart( - width, - " ", - ); - return `${lineNumber} | ${line}`; - }) - .join("\n"); - return { success: true, message: `Handler "${name}" edited (recompile pending — shared-state auto-preserved)`, diff --git a/tests/sandbox-tool.test.ts b/tests/sandbox-tool.test.ts index 0abe05a..2e31283 100644 --- a/tests/sandbox-tool.test.ts +++ b/tests/sandbox-tool.test.ts @@ -522,6 +522,34 @@ describe("editHandler", () => { expect(r.success).toBe(false); expect(r.error).toContain("outside handler"); }); + + it("should not invalidate loaded sandbox for no-op line edits", async () => { + const code = [ + "let count = 0;", + "function handler() {", + " count++;", + " return { count };", + "}", + ].join("\n"); + await tool.registerHandler("edit-lines-noop", code); + + const first = await tool.executeJavaScript("edit-lines-noop"); + expect(first.result).toEqual({ count: 1 }); + + const edit = await tool.editHandlerLines( + "edit-lines-noop", + 3, + 3, + " count++;", + ); + expect(edit.success).toBe(true); + expect(edit.message).toContain("unchanged"); + + const second = await tool.executeJavaScript("edit-lines-noop"); + expect(second.result).toEqual({ count: 2 }); + expect(second.timing!.compileMs).toBe(0); + expect(second.statePreserved).toBe(true); + }); }); // ── Execution (Named Handlers) ───────────────────────────────────────