Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions agents/myra/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
61 changes: 61 additions & 0 deletions apps/web/src/chat/deployable-package.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
133 changes: 129 additions & 4 deletions apps/web/src/chat/deployable-package.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, FencedBlockMatch> {
const found = new Map<string, FencedBlockMatch>();
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 };
}
9 changes: 6 additions & 3 deletions apps/web/src/chat/message-attachments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 (
<ul className="chat-attachment-list" aria-label="Attachments">
Expand Down
36 changes: 22 additions & 14 deletions apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -230,21 +231,28 @@ function ChatTranscript({
<PageShell width="prose" className="page-fill">
<h1 className="chat-thread-title">{chat.title}</h1>
<div className="chat-thread-messages">
{chat.messages.map((message) => (
<div key={message.id} className="chat-thread-message" data-author={message.author}>
<span className="shell-ch-avatar">
<IdentityAvatar
kind={message.author === "me" ? "person" : "agent"}
name={message.authorName}
principalId={message.author === "me" ? (selectedPrincipalId ?? "me") : chat.id}
/>
</span>
<div className="chat-thread-body">
<Markdown text={message.body} />
<MessageAttachments tenantId={tenantId} attachments={message.attachments} />
{chat.messages.map((message) => {
const { pkg, renderedBody } = resolveMessagePackage(message.attachments, message.body);
return (
<div key={message.id} className="chat-thread-message" data-author={message.author}>
<span className="shell-ch-avatar">
<IdentityAvatar
kind={message.author === "me" ? "person" : "agent"}
name={message.authorName}
principalId={message.author === "me" ? (selectedPrincipalId ?? "me") : chat.id}
/>
</span>
<div className="chat-thread-body">
<Markdown text={renderedBody} />
<MessageAttachments
tenantId={tenantId}
attachments={message.attachments}
pkg={pkg}
/>
</div>
</div>
</div>
))}
);
})}
</div>
{approvals.length === 0 ? null : (
<ul className="room-info-approval-list" aria-label={`${chat.agentName} is asking`}>
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/pages/workbench-room-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="chat-thread-message" data-author={message.author}>
<span className="shell-ch-avatar">
Expand All @@ -73,8 +75,8 @@ function RoomMessageRow({
/>
</span>
<div className="chat-thread-body">
<Markdown text={message.body} />
<MessageAttachments tenantId={roomTenantId} attachments={message.attachments} />
<Markdown text={renderedBody} />
<MessageAttachments tenantId={roomTenantId} attachments={message.attachments} pkg={pkg} />
{onReply === undefined ? null : (
<button type="button" className="room-replies-link" onClick={() => onReply(message)}>
Reply
Expand Down
Loading