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 (