diff --git a/agents/myra/src/system-prompt.ts b/agents/myra/src/system-prompt.ts index d259713cf..2b7111070 100644 --- a/agents/myra/src/system-prompt.ts +++ b/agents/myra/src/system-prompt.ts @@ -20,8 +20,13 @@ export const ASSISTANT_SYSTEM_PROMPT = "behalf.\n" + "\n" + "You have mail and a working tree, and nothing else reaches the " + - "workbench itself. To stand up a new agent or workflow, write it as " + - "a package in your working tree and ask the person to deploy it " + - "from Workbench. New capabilities, connected services, and access " + + "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", ' + + '"description", "systemPrompt"} — a one-line summary of what it ' + + "does, and a note to press Deploy. Workbench renders and deploys the " + + "package itself from those two files, so send exactly them and " + + "never try to deploy anything yourself. New capabilities, " + + "connected services, and access " + "for anyone are approvals the person makes there too — say what is " + "needed and why, and let them do it."; diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index b1b094420..13c080835 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -12,7 +12,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { ApiQueryError } from "@/lib/api-query"; import { deployAgentSource, type DeployedAgent, type NewAgentInput } from "./agent-deploy"; -import { chatKeys } from "./chat-path"; +import { chatKeys, roomKeys } from "./chat-path"; import { tenantKeys } from "./query-client"; export type AgentDefinition = typeof WorkflowDefinitionResponse.infer; @@ -89,7 +89,10 @@ export function useDeployAgentMutation(tenantId: string) { deployAgentSource({ tenantId, input }), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: tenantKeys.agentDirectory(tenantId) }); + void queryClient.invalidateQueries({ queryKey: tenantKeys.visibleAgents(tenantId) }); void queryClient.invalidateQueries({ queryKey: chatKeys.agents(tenantId) }); + // A deploy from a room transcript adds a participant to that room. + void queryClient.invalidateQueries({ queryKey: roomKeys.participants(tenantId) }); }, }); } diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 6bc5ac588..301c227c8 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -3668,6 +3668,61 @@ tr.insights-row-clickable:hover { gap: 0.4rem; } +/* A message's attachments: a package gets a Deploy card, anything else a + quiet file list. */ +.chat-attachment-list { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0.5rem 0 0; + padding: 0; + list-style: none; + font-size: 0.8rem; +} +.chat-attachment-name { + font-family: var(--font-mono, monospace); +} +.chat-attachment-type { + margin-left: 0.5rem; + color: var(--muted-foreground); +} +.chat-deploy-card { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + margin-top: 0.6rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius, 0.5rem); + background: var(--card); +} +.chat-deploy-card-text { + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} +.chat-deploy-card-name { + font-weight: 600; +} +.chat-deploy-card-note, +.chat-deploy-card-error { + font-size: 0.8rem; + color: var(--muted-foreground); +} +.chat-deploy-card-error { + flex-basis: 100%; + margin: 0; + color: var(--destructive); +} +.chat-deploy-card-link { + font-size: 0.85rem; + font-weight: 600; + text-decoration: underline; +} + /* Sidebar sections: Workbenches above Chats, each with its own label. */ .shell-panel-section-label { padding: 0.25rem 0.5rem; diff --git a/apps/web/src/chat/deployable-package.ts b/apps/web/src/chat/deployable-package.ts new file mode 100644 index 000000000..12d1ff900 --- /dev/null +++ b/apps/web/src/chat/deployable-package.ts @@ -0,0 +1,42 @@ +// 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. + +import { type } from "arktype"; +import { reportError } from "@corbits/error-sink"; + +import type { MailAttachment } from "./threads-api"; + +const AgentDefinition = type({ + name: "string", + systemPrompt: "string", + "description?": "string", +}); + +export type DeployablePackage = typeof AgentDefinition.infer; + +export const PACKAGE_MANIFEST_NAME = "package.json"; +export const AGENT_DEFINITION_NAME = "definition.json"; + +/** The agent package a message's attachments describe, or null when they + * are just files. Untrusted input: parsed, never cast. */ +export function deployablePackage( + attachments: readonly MailAttachment[], +): DeployablePackage | null { + if (!attachments.some((attachment) => attachment.name === PACKAGE_MANIFEST_NAME)) return null; + const definition = attachments.find((attachment) => attachment.name === AGENT_DEFINITION_NAME); + if (definition === undefined) return null; + let body: unknown; + try { + body = JSON.parse(definition.text); + } catch (cause) { + reportError(cause, { operation: "chat_deployable_package_parse" }); + return null; + } + const parsed = AgentDefinition(body); + if (parsed instanceof type.errors) return null; + if (parsed.name.trim() === "" || parsed.systemPrompt.trim() === "") return null; + return parsed; +} diff --git a/apps/web/src/chat/message-attachments.tsx b/apps/web/src/chat/message-attachments.tsx new file mode 100644 index 000000000..266911462 --- /dev/null +++ b/apps/web/src/chat/message-attachments.tsx @@ -0,0 +1,77 @@ +// A message's attachments, rendered under its body. A package an agent +// wrote gets a Deploy card — the person is the one who deploys, since an +// agent never calls the hub; anything else is a plain file list. + +import { Button } from "@corbits/react-ui"; + +import { useDeployAgentMutation } from "../agents-api"; +import { chatPath } from "../chat-path"; +import { Link } from "../navigation"; +import { deployablePackage, type DeployablePackage } from "./deployable-package"; +import type { MailAttachment } from "./threads-api"; + +function errorText(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +export function MessageAttachments({ + tenantId, + attachments, +}: { + readonly tenantId: string; + readonly attachments: readonly MailAttachment[]; +}) { + if (attachments.length === 0) return null; + const pkg = deployablePackage(attachments); + if (pkg === null) { + return ( + + ); + } + return ; +} + +function DeployPackageCard({ + tenantId, + pkg, +}: { + readonly tenantId: string; + readonly pkg: DeployablePackage; +}) { + const deploy = useDeployAgentMutation(tenantId); + const deployed = deploy.data; + return ( +
+
+ {pkg.name} + {pkg.description === undefined ? null : ( + {pkg.description} + )} +
+ {deployed === undefined ? ( + + ) : ( + + Open {pkg.name} + + )} + {deploy.error === null ? null : ( +

{errorText(deploy.error)}

+ )} +
+ ); +} diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index 4cbe0defc..f4e8371f8 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -49,12 +49,21 @@ const LIVE_DEPLOYMENT_STATUSES = new Set(["deployed", "pending", "recovering"]); const DeploymentsSchema = WorkflowDeploymentResponse.array(); const WorkflowAssetSchema = type({ id: "string", name: "string" }).array(); +/** One non-text MIME part of a mail frame, decoded to text — what the + * mail tools' `attachments: [{name, contentType, data}]` arrives as. */ +export type MailAttachment = { + readonly name: string; + readonly contentType: string; + readonly text: string; +}; + export type ChatMessage = { readonly id: string; readonly author: "me" | "agent"; readonly authorName: string; readonly body: string; readonly at: string; + readonly attachments: readonly MailAttachment[]; }; export type ChatSummary = { @@ -225,6 +234,50 @@ function textPart(entity: string): string | undefined { return undefined; } +/** Every named, non-inline MIME leaf of a frame, decoded. A part counts as + * an attachment when it carries a filename; the text body has none. */ +export function frameAttachments(raw: string): readonly MailAttachment[] { + let decoded: string; + try { + decoded = atob(raw); + } catch (cause) { + reportError(cause, { operation: "chat_frame_attachments" }); + return []; + } + return attachmentParts(decoded.replace(/\r\n/g, "\n")); +} + +function attachmentParts(entity: string): MailAttachment[] { + const split = entity.indexOf("\n\n"); + if (split < 0) return []; + const headers = entity.slice(0, split).replace(/\n[ \t]+/g, " "); + const body = entity.slice(split + 2); + const boundary = /boundary="?([^";\n]+)"?/i.exec(headers)?.[1]; + if (boundary !== undefined) { + const found: MailAttachment[] = []; + for (const part of body.split(`--${boundary}`).slice(1)) { + if (part.startsWith("--")) break; + found.push(...attachmentParts(part.replace(/^\n/, ""))); + } + return found; + } + const name = /(?:filename|name)\*?="?([^";\n]+)"?/i.exec(headers)?.[1]; + if (name === undefined) return []; + const contentType = + /content-type:\s*([^;\n]+)/i.exec(headers)?.[1]?.trim() ?? "application/octet-stream"; + const base64 = /content-transfer-encoding:\s*base64/i.test(headers); + return [{ name, contentType, text: base64 ? decodeBase64(body) : body.trim() }]; +} + +function decodeBase64(body: string): string { + try { + return atob(body.replace(/\s+/g, "")); + } catch (cause) { + reportError(cause, { operation: "chat_attachment_decode" }); + return ""; + } +} + function extractAddress(raw: string): string { return (/<([^>]+)>/.exec(raw)?.[1] ?? raw).trim(); } @@ -266,6 +319,7 @@ type MailTurn = { readonly subject: string; readonly body: string; readonly at: string; + readonly attachments: readonly MailAttachment[]; }; /** Every chat turn in one folder: the person's own in `Sent`, the agents' @@ -285,6 +339,7 @@ async function readFolder(tenantId: string, folder: "INBOX" | "Sent"): Promise
+
))} diff --git a/apps/web/src/pages/workbench-room-page.tsx b/apps/web/src/pages/workbench-room-page.tsx index dc7b0fcf2..e62a2815e 100644 --- a/apps/web/src/pages/workbench-room-page.tsx +++ b/apps/web/src/pages/workbench-room-page.tsx @@ -22,6 +22,7 @@ import { ApprovalRow } from "@/chat/approval-row"; import { IdentityAvatar } from "@/chat/avatar"; import { Composer } from "@/chat/composer"; import { Markdown } from "@/chat/markdown"; +import { MessageAttachments } from "@/chat/message-attachments"; import { ancestorChain, listRoomParticipants, @@ -48,10 +49,12 @@ function errorText(cause: unknown): string { function RoomMessageRow({ message, participants, + roomTenantId, onReply, }: { readonly message: RoomMessage; readonly participants: readonly RoomParticipant[]; + readonly roomTenantId: string; /** Undefined in the sub-thread panel, where a row is read-only context. */ readonly onReply?: (message: RoomMessage) => void; }) { @@ -71,6 +74,7 @@ function RoomMessageRow({
+ {onReply === undefined ? null : (