From cbcd34adca0820e010a41fb600652d24d00b47ec Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 01:43:00 -0700 Subject: [PATCH] feat(web,myra): Deploy card reads a package from fenced blocks in the message body (CL-8514) @intx/tools-mail's mail_send has no attachments parameter, so a mailed agent package has to travel as two labelled fenced code blocks in the message body instead. Add a body parser beside the existing attachment path (attachments still win when present), strip the recognized blocks from the rendered transcript, and update Myra's prompt to ask for fenced blocks rather than attachments. --- agents/myra/src/system-prompt.ts | 5 +- apps/web/src/chat/deployable-package.test.ts | 61 +++++++++ apps/web/src/chat/deployable-package.ts | 133 ++++++++++++++++++- apps/web/src/chat/message-attachments.tsx | 9 +- apps/web/src/pages/chat-thread-page.tsx | 36 +++-- apps/web/src/pages/workbench-room-page.tsx | 6 +- 6 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/chat/deployable-package.test.ts diff --git a/agents/myra/src/system-prompt.ts b/agents/myra/src/system-prompt.ts index aac2d7ffb..ce16238b3 100644 --- a/agents/myra/src/system-prompt.ts +++ b/agents/myra/src/system-prompt.ts @@ -21,8 +21,9 @@ export const ASSISTANT_SYSTEM_PROMPT = "\n" + "You have mail and a working tree, and nothing else reaches the " + "workbench itself. To stand up a new agent or workflow, write the " + - "package in your working tree, then reply with its files attached — " + - 'a "package.json" plus a "definition.json" holding {"name", ' + + "package in your working tree, then reply with its two files as " + + "fenced code blocks, each labelled with its filename on the line " + + 'above the fence — a "package.json" plus a "definition.json" holding {"name", ' + '"description", "systemPrompt", and an optional five-field cron ' + '"schedule" for a routine} — a one-line summary of what it ' + "does, and a note to press Deploy. Workbench renders and deploys the " + diff --git a/apps/web/src/chat/deployable-package.test.ts b/apps/web/src/chat/deployable-package.test.ts new file mode 100644 index 000000000..a16ab655d --- /dev/null +++ b/apps/web/src/chat/deployable-package.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import { deployablePackageFromBody, resolveMessagePackage } from "./deployable-package"; + +const PACKAGE_JSON = `{"name": "echo", "version": "1.0.0"}`; +const DEFINITION_JSON = `{"name": "Echo", "systemPrompt": "Echo back what you hear."}`; + +describe("deployablePackageFromBody", () => { + test("reads a package from two labelled fenced blocks and strips them", () => { + const body = + `Here's the agent.\n\npackage.json\n\`\`\`\n${PACKAGE_JSON}\n\`\`\`\n\n` + + `definition.json\n\`\`\`\n${DEFINITION_JSON}\n\`\`\`\n\nPress Deploy.`; + const result = deployablePackageFromBody(body); + expect(result?.pkg.name).toBe("Echo"); + expect(result?.strippedBody).toBe("Here's the agent.\n\nPress Deploy."); + }); + + test("matches an info-string label instead of a preceding line", () => { + const body = `\`\`\`json package.json\n${PACKAGE_JSON}\n\`\`\`\n\`\`\`definition.json\n${DEFINITION_JSON}\n\`\`\``; + const result = deployablePackageFromBody(body); + expect(result?.pkg.name).toBe("Echo"); + }); + + test("is null when only one of the two files is present", () => { + const body = `package.json\n\`\`\`\n${PACKAGE_JSON}\n\`\`\``; + expect(deployablePackageFromBody(body)).toBeNull(); + }); + + test("is null when the definition block fails validation", () => { + const body = + `package.json\n\`\`\`\n${PACKAGE_JSON}\n\`\`\`\n` + + `definition.json\n\`\`\`\n{"name": ""}\n\`\`\``; + expect(deployablePackageFromBody(body)).toBeNull(); + }); +}); + +describe("resolveMessagePackage", () => { + test("falls back to the body when there are no attachments", () => { + const body = `package.json\n\`\`\`\n${PACKAGE_JSON}\n\`\`\`\ndefinition.json\n\`\`\`\n${DEFINITION_JSON}\n\`\`\``; + const { pkg, renderedBody } = resolveMessagePackage([], body); + expect(pkg?.name).toBe("Echo"); + expect(renderedBody).toBe(""); + }); + + test("prefers attachments over a body that also carries fenced blocks", () => { + const body = `package.json\n\`\`\`\n${PACKAGE_JSON}\n\`\`\`\ndefinition.json\n\`\`\`\n${DEFINITION_JSON}\n\`\`\``; + const { pkg, renderedBody } = resolveMessagePackage( + [ + { name: "package.json", contentType: "application/json", text: PACKAGE_JSON }, + { + name: "definition.json", + contentType: "application/json", + text: `{"name": "Attached", "systemPrompt": "hi"}`, + }, + ], + body, + ); + expect(pkg?.name).toBe("Attached"); + expect(renderedBody).toBe(body); + }); +}); diff --git a/apps/web/src/chat/deployable-package.ts b/apps/web/src/chat/deployable-package.ts index 331229235..e56a20fee 100644 --- a/apps/web/src/chat/deployable-package.ts +++ b/apps/web/src/chat/deployable-package.ts @@ -1,8 +1,11 @@ // The one contract between an agent that writes a package and the client -// that deploys it: a mail reply carrying `package.json` plus a -// `definition.json` of {name, systemPrompt, description?}. The client -// renders the source tree itself (`agent-deploy.ts`), so the agent never -// has to know the deploy pipeline's shape. +// that deploys it: a reply carrying `package.json` plus a +// `definition.json` of {name, systemPrompt, description?, schedule?} — +// either as mail attachments, or (since `@intx/tools-mail`'s `mail_send` +// has no attachments parameter) as two labelled fenced code blocks in the +// message body. The client renders the source tree itself +// (`agent-deploy.ts`), so the agent never has to know the deploy +// pipeline's shape. import { type } from "arktype"; import { reportError } from "@corbits/error-sink"; @@ -48,3 +51,125 @@ export function deployablePackage( if (parsed.name.trim() === "" || parsed.systemPrompt.trim() === "") return null; return parsed; } + +/** Strips wrapping backticks/colon and returns the canonical file name the + * text names, or null when it names neither of the two contract files. */ +function namedFile(text: string): string | null { + const stripped = text.trim().replace(/^`+/, "").replace(/`+$/, "").replace(/:$/, "").trim(); + if (/^package\.json$/i.test(stripped)) return PACKAGE_MANIFEST_NAME; + if (/^definition\.json$/i.test(stripped)) return AGENT_DEFINITION_NAME; + return null; +} + +/** A fence's info string names a file directly (```package.json) or as the + * trailing token after a language (```json package.json). */ +function namedFileFromInfoString(info: string): string | null { + const direct = namedFile(info); + if (direct !== null) return direct; + const tokens = info.trim().split(/\s+/); + return namedFile(tokens[tokens.length - 1] ?? ""); +} + +interface FencedBlockMatch { + readonly content: string; + /** Line index the block starts at — the label line above the fence when + * that's what named it, otherwise the fence line itself. */ + readonly start: number; + /** Line index just past the closing fence, for slicing it out. */ + readonly end: number; +} + +/** The first fenced block in `body` naming each of the two contract files, + * by info string or by the non-empty line immediately above the fence. */ +function findNamedFencedBlocks(lines: readonly string[]): Map { + const found = new Map(); + let index = 0; + while (index < lines.length) { + const line = lines[index] ?? ""; + const fenceMatch = /^```(.*)$/.exec(line.trim()); + if (fenceMatch === null) { + index++; + continue; + } + const fenceLine = index; + let name = namedFileFromInfoString(fenceMatch[1] ?? ""); + let labelLine: number | null = null; + if (name === null && fenceLine > 0) { + const previous = lines[fenceLine - 1] ?? ""; + const fromLabel = namedFile(previous); + if (fromLabel !== null) { + name = fromLabel; + labelLine = fenceLine - 1; + } + } + const contentLines: string[] = []; + index++; + while (index < lines.length && (lines[index] ?? "").trim() !== "```") { + contentLines.push(lines[index] ?? ""); + index++; + } + const end = Math.min(index + 1, lines.length); + index = end; + if (name !== null && !found.has(name)) { + found.set(name, { + content: contentLines.join("\n"), + start: labelLine ?? fenceLine, + end, + }); + } + } + return found; +} + +/** The agent package a message body carries as fenced code blocks, plus + * the body with those blocks removed. Used when attachments are absent — + * `@intx/tools-mail`'s `mail_send` has no attachments parameter, so this is + * the mailed-package contract's actual delivery path today. */ +export function deployablePackageFromBody( + body: string, +): { readonly pkg: DeployablePackage; readonly strippedBody: string } | null { + const lines = body.split("\n"); + const blocks = findNamedFencedBlocks(lines); + const manifest = blocks.get(PACKAGE_MANIFEST_NAME); + const definition = blocks.get(AGENT_DEFINITION_NAME); + if (manifest === undefined || definition === undefined) return null; + let parsedBody: unknown; + try { + parsedBody = JSON.parse(definition.content); + } catch (cause) { + reportError(cause, { operation: "chat_deployable_package_body_parse" }); + return null; + } + const parsed = AgentDefinition(parsedBody); + if (parsed instanceof type.errors) return null; + if (parsed.name.trim() === "" || parsed.systemPrompt.trim() === "") return null; + + const ranges = [manifest, definition].sort((a, b) => a.start - b.start); + const kept: string[] = []; + let cursor = 0; + for (const range of ranges) { + kept.push(...lines.slice(cursor, range.start)); + cursor = range.end; + } + kept.push(...lines.slice(cursor)); + const strippedBody = kept + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return { pkg: parsed, strippedBody }; +} + +/** The package a message describes and the body to render for it — + * attachments win when present (the intended path once `mail_send` grows + * attachments support), otherwise the body's fenced blocks are read and + * stripped from the rendered text. */ +export function resolveMessagePackage( + attachments: readonly MailAttachment[], + body: string, +): { readonly pkg: DeployablePackage | null; readonly renderedBody: string } { + const fromAttachments = deployablePackage(attachments); + if (fromAttachments !== null) return { pkg: fromAttachments, renderedBody: body }; + const fromBody = deployablePackageFromBody(body); + if (fromBody !== null) return { pkg: fromBody.pkg, renderedBody: fromBody.strippedBody }; + return { pkg: null, renderedBody: body }; +} diff --git a/apps/web/src/chat/message-attachments.tsx b/apps/web/src/chat/message-attachments.tsx index d940420a2..29ec6c3e0 100644 --- a/apps/web/src/chat/message-attachments.tsx +++ b/apps/web/src/chat/message-attachments.tsx @@ -8,7 +8,7 @@ import { cronSentence } from "@corbits/workflows/client"; import { useDeployAgentMutation } from "../agents-api"; import { chatPath } from "../chat-path"; import { Link } from "../navigation"; -import { deployablePackage, isFiveFieldCron, type DeployablePackage } from "./deployable-package"; +import { isFiveFieldCron, type DeployablePackage } from "./deployable-package"; import type { MailAttachment } from "./threads-api"; function errorText(cause: unknown): string { @@ -18,12 +18,15 @@ function errorText(cause: unknown): string { export function MessageAttachments({ tenantId, attachments, + pkg, }: { readonly tenantId: string; readonly attachments: readonly MailAttachment[]; + /** Resolved by the caller via `resolveMessagePackage` — attachments and + * body-carried fenced blocks both land here. */ + readonly pkg: DeployablePackage | null; }) { - if (attachments.length === 0) return null; - const pkg = deployablePackage(attachments); + if (pkg === null && attachments.length === 0) return null; if (pkg === null) { return (
    diff --git a/apps/web/src/pages/chat-thread-page.tsx b/apps/web/src/pages/chat-thread-page.tsx index 6dc5cfe19..1e449358e 100644 --- a/apps/web/src/pages/chat-thread-page.tsx +++ b/apps/web/src/pages/chat-thread-page.tsx @@ -12,6 +12,7 @@ import { IdentityAvatar } from "@/chat/avatar"; import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; import { MessageAttachments } from "@/chat/message-attachments"; +import { resolveMessagePackage } from "@/chat/deployable-package"; import { agentFromMention, isAgentNotRunning, @@ -230,21 +231,28 @@ function ChatTranscript({

    {chat.title}

    - {chat.messages.map((message) => ( -
    - - - -
    - - + {chat.messages.map((message) => { + const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body); + return ( +
    + + + +
    + + +
    -
    - ))} + ); + })}
    {approvals.length === 0 ? null : (
      diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index e62a2815e..20cc5891c 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -23,6 +23,7 @@ import { IdentityAvatar } from "@/chat/avatar"; import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; import { MessageAttachments } from "@/chat/message-attachments"; +import { resolveMessagePackage } from "@/chat/deployable-package"; import { ancestorChain, listRoomParticipants, @@ -63,6 +64,7 @@ function RoomMessageRow({ const displayName = resolveParticipantName(message, participants); const matched = participants.find((participant) => participant.address === message.address); const kind = message.author !== "me" && matched?.kind === "agent" ? "agent" : "person"; + const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body); return (
      @@ -73,8 +75,8 @@ function RoomMessageRow({ />
      - - + + {onReply === undefined ? null : (