Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

154 lines
6.3 KiB
TypeScript

/**
* `edit_interactive_html` agent tool
*
* Surgically fixes bugs in an INTERACTIVE scene's HTML with exact-text
* ("str_replace") edits — the coding-agent pattern. The model reads the page
* (via `read_scene_content`, which returns the full HTML for interactive scenes)
* and emits one or more `{ oldText, newText }` edits as the tool arguments; this
* tool applies them DETERMINISTICALLY against the trusted current HTML. There is
* no second LLM call here — the agent's own turn authored the edits — so it is
* instant and can't truncate a large page (unlike a whole-page rewrite).
*
* Trust boundary (carries the v0 rule): the model supplies only `sceneId` + the
* edits; the page's current HTML comes from the trusted client-injected
* `SceneContext` (`getSceneContext`). interactive-only.
*
* On any unappliable edit (oldText not found / not unique / overlapping / no-op)
* the vendored applier throws a model-actionable message; we surface it as a
* tool error so the agent retries with a better anchor (the pi loop supports the
* multi-turn retry). Returns `{ sceneId, html }` for the client to apply.
*/
import { Type, type Static } from 'typebox';
import type { AgentTool } from '@earendil-works/pi-agent-core';
import { applyHtmlEdits, type Edit } from '@/lib/edit/html-edit';
import type { RegenerateActionsDeps, SceneContext } from './regenerate-scene-actions';
// ── Params (trust boundary: only id + edits; html comes from deps) ───────────
export const EditInteractiveHtmlParams = Type.Object({
sceneId: Type.String({
description:
'The id of the interactive scene to edit. Use the id of the current scene shown in the system prompt.',
}),
edits: Type.Array(
Type.Object({
oldText: Type.String({
description:
'Exact text to find in the current page HTML — copied verbatim, including whitespace. ' +
'Must be UNIQUE in the page; keep it as small as possible while still unique. Do not overlap edits.',
}),
newText: Type.String({
description:
'The replacement for this region. Preserve the surrounding structure: keep all HTML tags, ' +
'attributes and ids that appear in oldText — to change only a visible label, include the ' +
'enclosing tags in BOTH oldText and newText and change just the text between them (e.g. ' +
'oldText "<button id="go">Start</button>" → newText "<button id="go">Begin</button>"). ' +
'Never drop an element or its id when you only mean to change its text or one attribute.',
}),
}),
{
minItems: 1,
description:
'One or more targeted str_replace edits applied to the current HTML. Each oldText is matched ' +
'against the original page (not after earlier edits); merge nearby changes into one edit.',
},
),
});
export type EditInteractiveHtmlParams = Static<typeof EditInteractiveHtmlParams>;
// ── Details returned to the client ───────────────────────────────────────────
export interface EditInteractiveHtmlDetails {
sceneId: string;
/** The edited HTML, or null when the scene was refused / the edits didn't apply. */
html: string | null;
/** Number of edits applied (0 on failure). */
editCount: number;
}
// ── Factory ──────────────────────────────────────────────────────────────────
export function makeEditInteractiveHtmlTool(
deps: RegenerateActionsDeps,
): AgentTool<typeof EditInteractiveHtmlParams, EditInteractiveHtmlDetails> {
return {
name: 'edit_interactive_html',
label: 'Edit interactive page',
description:
'Fixes bugs in an INTERACTIVE scene (an interactive web page / widget) by applying exact-text ' +
'edits to its HTML — e.g. a button that does nothing, a control with no effect, a layout glitch. ' +
'First call read_scene_content to see the page HTML, then supply the sceneId and one or more ' +
'{ oldText, newText } edits (oldText must be a unique exact substring of the current HTML). ' +
'Works on interactive scenes only; only the matched regions change, everything else is preserved.',
parameters: EditInteractiveHtmlParams,
execute: async (_toolCallId, params) => {
const { sceneId, edits } = params;
const ctxData: SceneContext | undefined = deps.getSceneContext(sceneId);
if (!ctxData) {
return {
content: [
{
type: 'text',
text: `Error: scene context not found for sceneId ${JSON.stringify(String(sceneId).slice(0, 200))}. Cannot edit the page.`,
},
],
details: { sceneId, html: null, editCount: 0 },
isError: true,
};
}
const { content } = ctxData;
if (content.type !== 'interactive' || !content.html) {
return {
content: [
{
type: 'text',
text:
'Cannot edit this scene: editing is only supported for interactive scenes that have ' +
'embedded HTML. Suggest the user edits this scene on the canvas instead.',
},
],
details: { sceneId, html: null, editCount: 0 },
isError: true,
};
}
let newHtml: string;
try {
newHtml = applyHtmlEdits(content.html, edits as Edit[], 'the interactive page');
} catch (err) {
// Anchor not found / not unique / overlapping / no-op: surface the
// actionable message so the agent retries with a better oldText.
return {
content: [
{
type: 'text',
text: `Edit failed: ${err instanceof Error ? err.message : String(err)} The page has NOT been changed.`,
},
],
details: { sceneId, html: null, editCount: 0 },
isError: true,
};
}
return {
content: [
{
type: 'text',
text: `Applied ${edits.length} edit(s) to the interactive page.`,
},
],
details: {
sceneId,
html: newHtml,
editCount: edits.length,
},
};
},
};
}