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
11 changes: 8 additions & 3 deletions agents/myra/src/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
5 changes: 4 additions & 1 deletion apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) });
},
});
}
55 changes: 55 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/chat/deployable-package.ts
Original file line number Diff line number Diff line change
@@ -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;
}
77 changes: 77 additions & 0 deletions apps/web/src/chat/message-attachments.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ul className="chat-attachment-list" aria-label="Attachments">
{attachments.map((attachment) => (
<li key={attachment.name}>
<span className="chat-attachment-name">{attachment.name}</span>
<span className="chat-attachment-type">{attachment.contentType}</span>
</li>
))}
</ul>
);
}
return <DeployPackageCard tenantId={tenantId} pkg={pkg} />;
}

function DeployPackageCard({
tenantId,
pkg,
}: {
readonly tenantId: string;
readonly pkg: DeployablePackage;
}) {
const deploy = useDeployAgentMutation(tenantId);
const deployed = deploy.data;
return (
<div className="chat-deploy-card">
<div className="chat-deploy-card-text">
<span className="chat-deploy-card-name">{pkg.name}</span>
{pkg.description === undefined ? null : (
<span className="chat-deploy-card-note">{pkg.description}</span>
)}
</div>
{deployed === undefined ? (
<Button
variant="primary"
size="sm"
disabled={deploy.isPending}
onClick={() => deploy.mutate({ name: pkg.name, systemPrompt: pkg.systemPrompt })}
>
{deploy.isPending ? "Deploying…" : `Deploy ${pkg.name}`}
</Button>
) : (
<Link to={chatPath(deployed.definitionAssetId)} className="chat-deploy-card-link">
Open {pkg.name}
</Link>
)}
{deploy.error === null ? null : (
<p className="chat-deploy-card-error">{errorText(deploy.error)}</p>
)}
</div>
);
}
60 changes: 60 additions & 0 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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'
Expand All @@ -285,6 +339,7 @@ async function readFolder(tenantId: string, folder: "INBOX" | "Sent"): Promise<M
subject: message.envelope.subject,
body: frameBody(message.raw),
at: message.envelope.date,
attachments: frameAttachments(message.raw),
},
];
});
Expand Down Expand Up @@ -478,6 +533,7 @@ export async function readChat(tenantId: string, chatId: string): Promise<ChatTh
authorName: turn.author === "me" ? "You" : agent.name,
body: turn.body,
at: turn.at,
attachments: turn.attachments,
})),
};
}
Expand Down Expand Up @@ -570,6 +626,7 @@ export type RoomMessage = {
* message — metadata for the sub-thread panel, never used to hide a turn
* from the main timeline. */
readonly parentMessageId: string | undefined;
readonly attachments: readonly MailAttachment[];
};

function authorName(address: string): string {
Expand Down Expand Up @@ -599,6 +656,7 @@ type RoomTurn = {
readonly address: string;
readonly body: string;
readonly at: string;
readonly attachments: readonly MailAttachment[];
};

/** One folder of the room mailbox: the person's own turns live in `Sent`,
Expand All @@ -615,6 +673,7 @@ async function readRoomFolder(tenantId: string, folder: "INBOX" | "Sent"): Promi
address: extractAddress(message.envelope.from),
body: frameBody(message.raw),
at: message.envelope.date,
attachments: frameAttachments(message.raw),
}));
}

Expand All @@ -637,6 +696,7 @@ export async function readRoom(tenantId: string): Promise<readonly RoomMessage[]
address: turn.address,
body: turn.body,
at: turn.at,
attachments: turn.attachments,
parentMessageId:
turn.parentId !== undefined && known.has(turn.parentId) ? turn.parentId : undefined,
}));
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,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 {
agentFromMention,
listChatAgents,
Expand Down Expand Up @@ -203,6 +204,7 @@ function ChatTranscript({
</span>
<div className="chat-thread-body">
<Markdown text={message.body} />
<MessageAttachments tenantId={tenantId} attachments={message.attachments} />
</div>
</div>
))}
Expand Down
Loading
Loading