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 ( +
{errorText(deploy.error)}
+ )} +