Skip to content

Commit 24a50cf

Browse files
authored
feat(agent): add runtime code asset tools (CoreBunch#76)
1 parent cc1b050 commit 24a50cf

13 files changed

Lines changed: 874 additions & 58 deletions

File tree

docs/features/agent.md

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ The agent runs on a provider-agnostic AI runtime (`server/ai/`) that can drive a
1010

1111
- **Structure via HTML.** `insertHtml` and `replaceNodeHtml` accept semantic HTML strings; the browser executor calls `importHtml` (the same pipeline as the paste-HTML UI) to convert them into first-class, editable `PageNode`s.
1212
- **Styling via CSS.** The agent emits CSS the same way a human pastes it: a `<style>` block and/or `class=` attributes inside the `insertHtml`/`replaceNodeHtml` payload, or the standalone `applyCss` tool. The importer (`cssToStyleRules`) classifies every selector — a bare `.foo {}` rule becomes a reusable Selectors-panel class bound to `class="foo"`; any other selector (`.hero a`, `a:hover`, `nav > li`) becomes an ambient rule; `style=` attributes land on the node's inline styles. There is no structured `classes` parameter — the agent never hand-builds classes node-by-node at insert time. `applyCss` is the single tool for authoring/editing CSS on its own; it **upserts**, so re-applying a selector edits the existing rule (the way descendant/pseudo rules get restyled).
13-
- **29 tools total.** 7 server-side read tools (resolved server-side from the posted snapshot / DB) + 22 browser-bridged write tools.
14-
- **Two-endpoint bridge.** `POST /admin/api/ai/chat/site` opens an NDJSON stream. When the model calls a write tool, the server emits `toolRequest`; the browser executor applies it to the editor store and POSTs the `AiToolOutput` result to `POST /admin/api/ai/tool-result`.
13+
- **35 tools total.** 6 server-side catalog read tools (resolved server-side from the posted snapshot / DB) + 29 browser-bridged tools.
14+
- **Two-endpoint bridge.** `POST /admin/api/ai/chat/site` opens an NDJSON stream. When the model calls a browser-bridged tool, the server emits `toolRequest`; the browser executor reads or mutates the editor store and POSTs the `AiToolOutput` result to `POST /admin/api/ai/tool-result`.
1515
- **Provider-agnostic.** The runtime selects a driver (Anthropic, OpenAI, OpenRouter, Ollama) from the conversation's configured credential.
1616
- **Tool input schemas are a single source of truth** in `@core/ai` (`src/core/ai/toolSchemas.ts`). The server tool registry (`server/ai/tools/site/writeTools.ts`) and the browser executor (`executor.ts` + `tokenRunners.ts`) import the exact same schema objects — a constraint added once is enforced on both sides at build time. Gated by `ai-tool-schema-ssot.test.ts` and `ai-tools-typebox-only.test.ts`.
1717
- **Capabilities.** `ai.chat` required to stream; `ai.tools.write` required for write tools. Gated by `ai-handlers-capability-gated.test.ts`.
@@ -165,7 +165,7 @@ Server: chat.ts
165165
│ → browser reads or opens the target page/template/visual component
166166
│ → result returned to model
167167
168-
└─→ write tool (e.g. insertHtml)
168+
└─→ browser-bridged mutating tool (e.g. insertHtml)
169169
→ bridge.callBrowser(toolName, input)
170170
→ emit { type: 'toolRequest', requestId, toolName, input }
171171
→ driver loop pauses; awaits tool-result POST
@@ -174,7 +174,7 @@ NDJSON stream events (one JSON object + \n per line):
174174
{ type: 'bridgeReady', bridgeId }
175175
{ type: 'text', text: '…' }
176176
{ type: 'toolCall', toolCallId, toolName, input, status: 'pending' }
177-
{ type: 'toolRequest', requestId, toolName, input } ← write tools only
177+
{ type: 'toolRequest', requestId, toolName, input } ← browser-bridged tools only
178178
{ type: 'toolResult', toolCallId, toolName, ok, error? }
179179
{ type: 'usage', promptTokens, completionTokens, costUsd?, cacheReadTokens?, cacheCreationTokens? }
180180
{ type: 'context', contextTokens } ← per-round meter update
@@ -192,7 +192,7 @@ Browser: processStreamEvent(event) in streamEvents.ts
192192
└─→ 'text' / 'toolCall' / 'toolResult' / 'done' → update agentSlice.agentMessages
193193
```
194194

195-
The two-endpoint design keeps the **browser as editor-store authority** (write tools mutate the live Zustand store in the browser) while the **server runs the model** (driver + tool routing live server-side).
195+
The two-endpoint design keeps the **browser as editor-store authority** (browser-bridged tools read or mutate the live Zustand store in the browser) while the **server runs the model** (driver + tool routing live server-side).
196196

197197
---
198198

@@ -301,9 +301,9 @@ Resolved server-side from the posted `SiteAgentSnapshot` or the data repositorie
301301
| `list_loop_sources` | Loop source ids, source fields, order/filter options, and data-table field catalogs with valid `{currentEntry.field}` tokens. For post/custom table loops, use source id `data.rows`, the returned table `id` as `<instatic-loop data-table-id>`, and the returned tokens inside the loop body |
302302
| `list_tokens` | Design tokens: colors (with shades/tints), typography/spacing scale steps, font tokens — each with CSS variable + utility classes; optional `family` filter (`colors`\|`typography`\|`spacing`\|`fonts`) |
303303

304-
### Browser tools — 24, browser-bridged
304+
### Browser tools — 29, browser-bridged
305305

306-
All 24 tools carry `execution: 'browser'` in their `AiTool` definition. The server emits `toolRequest`; the browser executor validates input with TypeBox, runs the store action or read helper, and POSTs the canonical `AiToolOutput` result back.
306+
All 29 tools carry `execution: 'browser'` in their `AiTool` definition. The server emits `toolRequest`; the browser executor validates input with TypeBox, runs the store action or read helper, and POSTs the canonical `AiToolOutput` result back.
307307

308308
**Documents**
309309

@@ -366,6 +366,20 @@ The agent calls `list_loop_sources` first to get the valid source id, data table
366366
| `assignClass` | `{ nodeId, classId }` | none | Attach an existing class to a node; `classId` accepts id or name|
367367
| `removeClass` | `{ nodeId, classId }` | none | Detach a class from a node (the class itself remains) |
368368

369+
**Code assets**
370+
371+
Scripts and user stylesheets live in `site.files[]`; runtime targeting and loading options live in `site.runtime.scripts` / `site.runtime.styles`. These tools expose that existing Code Editor storage to the agent, so behavior such as theme toggles, tabs, menus, filters, and DOM-ready interactions is authored as a real runtime script instead of attempted through HTML import.
372+
373+
| Tool | Input | Success `data` | What it does |
374+
|------------------------|--------------------------------------------|-----------------------------------------|-------------------------------------------------------|
375+
| `list_code_assets` | `{ type?: 'script' \| 'style' }` | `{ assets }` | List runtime code assets with file ids, paths, full-content hashes, sizes, timestamps, and runtime config |
376+
| `read_code_asset` | `{ fileId? \| path?, part?, maxChars? }` | `{ fileId, path, type, content, hash, runtime, pageInfo }` | Read an exact script/stylesheet content slice. The `hash` is for the full file; page through with `pageInfo.nextPart` |
377+
| `write_code_asset` | `{ path, type, content, runtime? }` | asset summary + `{ action }` | Create or replace a runtime script/stylesheet and normalize its runtime config. Existing paths are updated, new paths are created |
378+
| `patch_code_asset` | `{ fileId? \| path?, expectedHash, replacements }` | asset summary + `{ replacements }` | Apply exact text replacements only when `expectedHash` matches the latest content. Ambiguous matches require a wider `oldText` or explicit `replaceAll:true` |
379+
| `inspect_code_runtime` | `{ document?: { type, id } }` | `{ pageId, document, scripts, styles }` | Report which runtime scripts/stylesheets apply to the current page/template or supplied page/template document ref |
380+
381+
`insertHtml` / `replaceNodeHtml` intentionally strip `<script>` elements and inline event handlers (`onclick`, `onload`, etc.). When a request needs behavior, the agent should use `write_code_asset({ type: "script", ... })` and then `inspect_code_runtime`, not raw `<script>` tags or event attributes in HTML.
382+
369383
**Pages**
370384

371385
| Tool | Input | Success `data` | What it does |
@@ -610,7 +624,7 @@ When `POST /admin/api/ai/credentials` creates a new credential, `seedEmptyDefaul
610624
- `src/core/ai/documentRefs.ts` — document refs/descriptors for pages, templates, and visual components
611625
- `src/core/ai/readSurface.ts` — runtime-agnostic `renderAgentDocument` annotated HTML + compact CSS renderer
612626
- `src/core/ai/index.ts` — barrel re-exporting the above
613-
- `server/ai/tools/site/writeTools.ts`24 browser-bridged site tool definitions (uses `@core/ai` input schemas)
627+
- `server/ai/tools/site/writeTools.ts`29 browser-bridged site tool definitions (uses `@core/ai` input schemas)
614628
- `server/ai/tools/site/readTools.ts` — 6 server-side catalog tool definitions
615629
- `server/ai/tools/site/render.ts``describeAgentModules`, `describeAgentTokens`, `filterTokenFamily`
616630
- `server/ai/tools/site/systemPrompt.ts` — HTML-native system prompt

docs/features/html-import.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ Callers splice the fragment into the page tree via `insertImportedNodes(parentId
142142
| `style="…"` attributes | Declarations harvested onto `node.inlineStyles`; the attribute is removed |
143143
| HTML comments and processing instructions | Stripped silently — no count |
144144

145+
The AI agent should not use stripped constructs for behavior. If an edit needs JavaScript, it writes a real runtime script with `write_code_asset({ type: "script", ... })` and verifies targeting with `inspect_code_runtime` instead of embedding `<script>` or `onclick` in an HTML import.
146+
145147
After insert, `ImportHtmlModal` builds a toast body from the added-selector count plus the non-zero stripped counts, e.g. `"3 CSS selectors, stripped 2 <script>"`. If nothing notable happened, the toast shows only the node count.
146148

147149
**Inline `style="…"``node.inlineStyles`.** Before `stripUnsafe` removes a `style` attribute, `harvestInlineStyles` (`inlineStyle.ts`) reads the element's parsed CSSOM declaration and copies **every** declaration into a camelCase bag, dropping only property names rejected by `isEmittableProperty` (the publisher's security denylist — the same gate `cssToStyleRules` uses). A `url(…)` background is canonicalised to `url('payload')` form so the Super Import asset rewriter and the editor's `BackgroundImageControl` recognise it. The bag is attached to the produced node as `node.inlineStyles` — the editor's first-class per-node `style=""` layer — which the publisher emits verbatim and the user edits via the Properties panel's inline-style mode. In Super Import any `url(…)` is uploaded to the media library and rewritten to its media URL.

server/ai/tools/site/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ const READ_ONLY_NAMES_IN_WRITE_FILE = new Set([
1818
'getNodeHtml',
1919
'read_document',
2020
'open_document',
21+
'list_code_assets',
22+
'read_code_asset',
23+
'inspect_code_runtime',
2124
'render_snapshot',
2225
])
2326

server/ai/tools/site/systemPrompt.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@ Structure as HTML, styling as CSS:
3535
- applyCss is the ONE tool for authoring or editing CSS on its own — after insertion, or for any selector a class= can't express. Pass real CSS text: a bare \`.foo { … }\` selector creates/edits a reusable class; ANY other selector (\`.hero a\`, \`a:hover\`, \`nav > li\`, \`.card::before\`, \`h1\`) creates/edits an ambient rule that attaches by matching. Re-applying a selector MERGES onto it, so applyCss both creates AND edits — that is how you restyle an existing descendant/pseudo rule (e.g. \`applyCss(".hero a:hover { color: var(--primary) }")\`). There is no class-by-id patch tool; just write the CSS, referencing tokens via var(--…).
3636
- Per-breakpoint variation: use @media queries — in the <style> block of an insert, or inside applyCss — with min/max-width queries that line up with the breakpoint widths in the dynamic suffix. Don't invent "mobile"/"tablet"/"desktop".
3737
38+
Behavior and runtime code:
39+
- insertHtml/replaceNodeHtml deliberately strip <script> and inline event handlers (onclick/onload/etc). NEVER try to add behavior with <script>, onclick, or custom inline JS in HTML.
40+
- To add behavior such as theme toggles, tabs, menus, filters, or DOM-ready interactions, use write_code_asset({ type:"script", path:"src/scripts/...", content, runtime }). The script file is stored in the site file layer and loaded through site.runtime.
41+
- Before changing existing scripts or user stylesheets, call list_code_assets/read_code_asset. Patch exact spans with patch_code_asset using the latest hash; if the text occurs multiple times, use a larger oldText span or replaceAll:true intentionally.
42+
- Use inspect_code_runtime after writing code to confirm scripts/styles apply to the current page/template, are enabled, and have the intended priority/placement/timing.
43+
3844
Responsive:
3945
- Design for every breakpoint in the suffix from the start. All variation is CSS via @media (in an insert's <style> block or applyCss), matched against the suffix breakpoint widths.
4046

server/ai/tools/site/writeTools.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@ import {
3333
ApplyCssInputSchema,
3434
AssignClassInputSchema,
3535
RemoveClassInputSchema,
36+
ListCodeAssetsInputSchema,
37+
ReadCodeAssetInputSchema,
38+
WriteCodeAssetInputSchema,
39+
PatchCodeAssetInputSchema,
40+
InspectCodeRuntimeInputSchema,
3641
AddPageInputSchema,
3742
DeletePageInputSchema,
3843
RenamePageInputSchema,
@@ -210,6 +215,60 @@ const removeClassTool: AiTool = {
210215
inputSchema: RemoveClassInputSchema,
211216
}
212217

218+
// ---------------------------------------------------------------------------
219+
// Code asset tools — scripts and user stylesheets in site.files + site.runtime
220+
// ---------------------------------------------------------------------------
221+
222+
const listCodeAssetsTool: AiTool = {
223+
name: 'list_code_assets',
224+
scope: 'site',
225+
execution: 'browser',
226+
requiredCapabilities: ['site.read'],
227+
description:
228+
'List user-authored runtime code assets stored in the site file layer. Optional `type` filters to scripts or styles. Returns file ids, paths, content hashes, size metadata, and current runtime config. Use before read_code_asset / patch_code_asset when modifying existing scripts or stylesheets.',
229+
inputSchema: ListCodeAssetsInputSchema,
230+
}
231+
232+
const readCodeAssetTool: AiTool = {
233+
name: 'read_code_asset',
234+
scope: 'site',
235+
execution: 'browser',
236+
requiredCapabilities: ['site.read'],
237+
description:
238+
'Read one script or stylesheet by fileId or path. Returns the exact content slice, full-file SHA-256 hash, runtime config, and pageInfo for pagination. If pageInfo.nextPart is not null, call read_code_asset again with the same asset and part.',
239+
inputSchema: ReadCodeAssetInputSchema,
240+
}
241+
242+
const writeCodeAssetTool: AiTool = {
243+
name: 'write_code_asset',
244+
scope: 'site',
245+
execution: 'browser',
246+
requiredCapabilities: SITE_STRUCTURE_CAPS,
247+
description:
248+
'Create or replace a runtime script/style file in site.files and attach normalized site.runtime config. Use `type:"script"` for behavior such as theme toggles, menus, tabs, analytics hooks, and DOM-ready interactions; use `type:"style"` for global user stylesheets that should load as files. `path` is a safe site-relative path such as src/scripts/theme-toggle.js or src/styles/theme.css. `runtime` is optional and merges with existing/default config.',
249+
inputSchema: WriteCodeAssetInputSchema,
250+
}
251+
252+
const patchCodeAssetTool: AiTool = {
253+
name: 'patch_code_asset',
254+
scope: 'site',
255+
execution: 'browser',
256+
requiredCapabilities: SITE_STRUCTURE_CAPS,
257+
description:
258+
'Patch an existing script or stylesheet by exact text replacement. Requires the latest `expectedHash` from read_code_asset/list_code_assets to prevent stale edits. Each replacement must match exactly; if oldText occurs multiple times, either make oldText more specific or set replaceAll:true.',
259+
inputSchema: PatchCodeAssetInputSchema,
260+
}
261+
262+
const inspectCodeRuntimeTool: AiTool = {
263+
name: 'inspect_code_runtime',
264+
scope: 'site',
265+
execution: 'browser',
266+
requiredCapabilities: ['site.read'],
267+
description:
268+
'Inspect which runtime scripts and user stylesheets apply to the current page/template, or to a supplied page/template document ref. Returns each asset path, enabled state, scope applicability, priority, and script placement/timing. Use after write_code_asset to confirm a script/style is targeted correctly.',
269+
inputSchema: InspectCodeRuntimeInputSchema,
270+
}
271+
213272
// ---------------------------------------------------------------------------
214273
// Page-level write tools
215274
// ---------------------------------------------------------------------------
@@ -363,6 +422,11 @@ export const siteWriteTools: AiTool[] = [
363422
applyCssTool,
364423
assignClassTool,
365424
removeClassTool,
425+
listCodeAssetsTool,
426+
readCodeAssetTool,
427+
writeCodeAssetTool,
428+
patchCodeAssetTool,
429+
inspectCodeRuntimeTool,
366430
addPageTool,
367431
deletePageTool,
368432
renamePageTool,

0 commit comments

Comments
 (0)