diff --git a/CHANGELOG.md b/CHANGELOG.md index b4876146d..efa50060a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,32 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Coworkers are made in a wizard and managed in a dialog + +Creating a coworker is now a three-step wizard — who it is, who may see it, then where it runs, +with **Built in** offered only when the deployment actually has a managed Bot to run it on. +Managing one is a dialog opened from wherever the coworker appears, with sections for its profile +(each field edited in place), what it may reach, its connection, handoff grants, routines, and the +hide/duplicate/delete verbs — usable on a phone, where the old side panel hid most of this. The +panel beside a conversation slims down to who-you-are-talking-to plus two buttons: start a new +channel, or open that dialog. + +### Routines live on each coworker + +The sidebar's global Routines entry is gone; a coworker's routines are a section of its own dialog, +since a routine is something *it* carries out. The `/routines` page still answers direct links. +Each routine row now wears its state as chips — the channel it posts to, how the last run went, and +either the next run, **Paused**, or **Due** when a firing is waiting on the sweep (which used to +render as "Next 5 hours ago"). + +### A built-in coworker is no longer asked for credentials it will never need + +A coworker running on the deployment's own Bot was nagged for a callback token and shown an +endpoint form. Its connection tab now says what is true — it runs here, nothing to connect, nothing +to authenticate. In the same spirit, the handoff panel explains once when a coworker cannot hand +work on (it runs as its own agent, outside this deployment's loop) instead of offering switches the +server can only refuse; its existing grants stay visible so they can still be revoked. + ### A hop the boundary refused now names the Bot that was refused The audit page renders its Bot column from `payload.bot` and nothing else. `agent.handoff_offered` @@ -16,6 +42,7 @@ refused, a hop retried, and a hop that failed for good — were not, so they sho Bot belongs. Those are the rows somebody actually opens the trail for: a hop that happened is visible in the transcript anyway, and a refused or lost one is visible nowhere else. All four now name the asking Bot, exactly as the accepted pair and `agent.escalated` already did. + ### A failed tool refresh no longer leaves a connector offering nothing Refreshing a connector's tools replaced the list with a delete and then an insert, as two separate @@ -26,6 +53,7 @@ once, every grant an administrator had made was silently un-offered, and the Bot none of that vendor's tools. Nothing brought them back until somebody read the error on the Plugins page and pressed Refresh. The two statements are now one, so a bad refresh is recorded and the tools already held are left alone, which is what the code always claimed to do. + ### A rule tried in dry-run now says what it would have refused a Bot's tools `dry-run` exists so a boundary can be measured against live traffic before it starts refusing @@ -36,6 +64,7 @@ about `mcp.server`, `mcp.tool` or `mcp.effect` therefore looked inert, and enfor refusing Bots with nothing in the trail to have warned anybody. A refused tool call is now recorded whatever the mode does with it, carrying `carriedOut` so a reader can tell a call this deployment stopped from one dry-run recorded and let past. Enforcing deployments behave exactly as before. + ### A policy dry-run no longer counts a failed action twice, or invents a change it did not make Testing a boundary against recent history replayed three kinds of audit row, and one of them is a @@ -45,6 +74,7 @@ carries a refused action out, so a refused action can fail too — and its two r decision row saying "refused" and the failure row reading as "allowed", so a candidate policy that refused it identically was reported as a new refusal it never introduced. The replay now scores each action once, from the row that recorded its decision. + ### A message no longer routes to a specialist because a longer word contained a connector's name When the intent router falls back — it is unreachable, or it declines — and exactly one coworker can @@ -53,6 +83,7 @@ substring, so "how do I deal with a slacker" matched the **slack** connector and giraffe) matched **jira**: a message naming neither system was pinned, for the life of the thread, to a specialist that could not answer it. A connector's name now has to appear on a word boundary, so a system named on its own still routes and one buried inside another word does not. + ### The audit page no longer says "Allowed" about six kinds of refusal A hop one Bot was not allowed to make, an endpoint this deployment would not dial, a rotation the @@ -62,6 +93,7 @@ attempts and a question that reached nobody, which are "Did not happen" rather t page recognised six refusal types and the six added since were never added to it. Refusals now read as refusals, the two saved views are built from the same lists the rows are labelled from, and a refusal added later is added in one place or in none. + ### A conversation deleted while a server was reconnecting no longer lingers on the screen Announcements between servers travel as Postgres notifications, which reach whoever is subscribed at @@ -75,6 +107,7 @@ showing a conversation that had been deleted until the page was reloaded. A server now tells the browsers it is holding to refetch when its subscription is re-established. Nothing to configure, and no change for a deployment whose database connection never drops. + ### A Bot's answer comes back to the conversation that asked **This reverses what 0.0.5 shipped.** The 0.0.5 notes below say the asking Bot does not relay text @@ -162,6 +195,7 @@ cluster and is not empty is now refused with a sentence naming the mount to use Reported by [@jerelvelarde](https://github.com/CopilotKit/OpenBot/issues/269) with the container logs for both mount paths, which is what made the two failure modes separable. + ### A sign-in a site opens in a new window is shown, and can be clicked A Bot's browser was bound to the page it launched with, and to nothing the site opened afterwards. @@ -177,6 +211,7 @@ afterwards with the same "take a new snapshot" it already gives after a navigati cannot act on the wrong document. Nothing to configure. + ### Stopping a Bot's computer stops it, and the person watching is told A computer somebody stopped came back up on its own about a second later, and reset did the same. The @@ -336,6 +371,7 @@ uses. It still cannot address another pod, a node, or a cloud metadata endpoint. destination and so permitted everything. That rule now covers the API server alone, and `networkPolicy.kubernetesApiCidr` narrows it to your cluster's service range; left empty it stays as it was, because a chart cannot know that range. + ### Taking the wheel stops the Bot's shell, not just its clicks While a person held the wheel the Bot was refused on the page, and not in the shell. `/exec` and a @@ -350,6 +386,7 @@ to be able to say what it was doing. Nothing to configure. A Bot that acts during a takeover gets the refusal it already got for a click, and the trail records the attempt and the failure the same way. + ### A computer that was suspended once suspends again Scale-to-zero worked once per Bot. A computer suspended, resumed, used and then left alone again was @@ -366,6 +403,7 @@ the same clock the offer runs on, so a Bot cannot come back round as idle until Nothing to configure, and the sweep already runs on a schedule. A deployment where each Bot has its own computer stops paying for browsers that were used once. + ### A Bot's egress proxy is reachable on Kubernetes, or the install is refused The chart named no egress variable anywhere, so a Helm deployment read the per-Bot proxy settings @@ -508,6 +546,7 @@ unknown session means "no opinion" and skips the generation check, so on exactly shape it was written for, the check that stops a ref from a replaced computer resolving against a live one was silently absent. It now asks the supervisor when it does not know, by listing rather than by ensuring, so asking never starts a computer that had stopped. + ### A routing trail says why a message was not routed, not only that it was not Every untagged message writes a `channel.routed` row, and that row carried `fallback: true` for two @@ -599,6 +638,7 @@ credential is refused rather than quietly attached to fail on its next call. Curated servers keep working as they did. Their URL comes from the catalogue rather than the request, and a per-instance hostname is matched against the vendor's own anchored pattern before anything is stored, so re-adding one cannot point it at an address of the caller's choosing. + ### A configured egress proxy reaches the browser that uses it `EGRESS_PROXY_DEFAULT` and `EGRESS_PROXY_` were documented as the way to give a Bot a stable @@ -620,6 +660,7 @@ given them. It is optional, so a deployment with no proxy is unchanged, and giti proxy URL can carry a password. **Move these two out of `.env` and into `egress.env`.** In `.env` they reach no process. + ### Screens without a conversation stop polling for a Bot that does not exist Every surface asks which components its Bot holds, and asks again every few seconds so a revoked @@ -634,6 +675,7 @@ that matters is invisible. The grant queries now wait for a surface to declare a real Bot, and simply do not run while the placeholder holds. Conversation surfaces — the Bot page, channels — declare one and are unchanged. + ### A rule can be tested against history before it is saved A boundary was written blind: an administrator typed a CEL rule, saved it, and learned what it @@ -713,6 +755,7 @@ this port has to reach it another way**, which is what publishing it on every in This does not reach back in time. A deployment that has been running with the two on one network should assume a Bot could have read or written the database, and look at the trail with that in mind. + ### A credential in an MCP server address is refused in the query and the fragment too Refusing `https://user:token@vendor.example/mcp` closed the userinfo spelling of a credential in the @@ -739,6 +782,7 @@ beside `metadata.google.internal`, and it carries a dot and none of the suffixes it read as an ordinary vendor name. The long spelling was only ever refused incidentally, by the `.internal` rule. Both are now named, so the address this check was written for is refused on purpose rather than by luck. + ### A curated MCP server is pointed at its own kind of credential too Adding a server by URL was made to check which credential it is being pointed at. Adding one from the @@ -1012,6 +1056,7 @@ one they are, so those match too. No configuration changes and nothing is stored differently; a deployment that was already on the light theme sees no difference at all. + ### `start.sh` refuses a port that answers but is not OpenBot The startup checks asked whether a port answered, and treated that as proof the port belonged to this @@ -1076,6 +1121,7 @@ the decision row is written, so an action somebody tried to take still appears o **A deployment may see refusals it did not see before.** That is the point: those are the actions that were being carried out without the boundary seeing what they touched. A Bot that meets one takes a fresh snapshot and continues. + ### A package ships its skills, so tool selection works on a clone Tool selection narrows a Bot's tools to the ones its matching skills declare, and a deployment starts diff --git a/app/src/components/agents/agent-dialog.tsx b/app/src/components/agents/agent-dialog.tsx new file mode 100644 index 000000000..bfc60023e --- /dev/null +++ b/app/src/components/agents/agent-dialog.tsx @@ -0,0 +1,824 @@ +import { + IconAdjustments, + IconArrowsExchange, + IconClock, + IconPencil, + IconPlugConnected, + IconPuzzle, + IconUser, +} from "@tabler/icons-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; +import type { ZodType } from "zod"; +import { AbstractAvatar } from "@/components/agents/abstract-avatar"; +import { CallbackTokenPanel } from "@/components/agents/callback-token-panel"; +import { HandoffPanel } from "@/components/agents/handoff-panel"; +import { RoutinesList } from "@/components/routines/routines-list"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, +} from "@/components/ui/empty"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemTitle, +} from "@/components/ui/item"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, +} from "@/components/ui/sidebar"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { + type AgentFormValues, + agentFormSchema, + agentInputFrom, +} from "@/lib/agents/form"; +import { + deleteAgentMutationOptions, + duplicateAgentMutationOptions, + setAgentHiddenMutationOptions, + updateAgentMutationOptions, +} from "@/lib/agents/mutations"; +import { type AgentProfile, agentQueryOptions } from "@/lib/agents/queries"; +import { agentPluginsQueryOptions } from "@/lib/plugins/queries"; +import { readToolName } from "@/lib/plugins/tool-name"; + +/** + * A coworker, in a dialog with its own sidebar. + * + * The agents screen used to slide this in as a side panel; a profile carries enough distinct + * concerns — who it is, where it runs, what it may hand work to, and what can be done to it — that + * a single scrolling column buried the later ones. Each concern is a section here, and the sidebar + * is the map. + */ +export function AgentDialog({ + agentId, + open, + onClose, +}: { + /** Which coworker to show. Null renders nothing but keeps the dialog mounted for its exit. */ + agentId: string | null; + open: boolean; + onClose: () => void; +}) { + return ( + !next && onClose()} open={open}> + {/* p-0/overflow-hidden hands the popup's rounding to the sidebar; wider than the default + dialog because it holds a two-pane layout, which is the stated reason to deviate. */} + {/* Tall enough that General's rows and its Delete sit on screen together; the popup's own + max-h-[85svh] still caps it on a short display, where the main pane scrolls. */} + + {/* Keyed by coworker so the section and edit state never carry over from another one. */} + {agentId ? : null} + + + ); +} + +const SECTIONS = [ + { id: "general", name: "General", icon: IconUser }, + { id: "access", name: "Access", icon: IconPuzzle }, + { id: "connection", name: "Connection", icon: IconPlugConnected }, + { id: "handoff", name: "Handoff", icon: IconArrowsExchange }, + { id: "routines", name: "Routines", icon: IconClock }, + { id: "manage", name: "Manage", icon: IconAdjustments }, +] as const; + +type SectionId = (typeof SECTIONS)[number]["id"]; + +function AgentDialogBody({ agentId }: { agentId: string }) { + const [section, setSection] = useState("general"); + const agent = useQuery(agentQueryOptions(agentId)); + + if (agent.isPending) { + return ( +
+ + + +
+ ); + } + if (agent.error || !agent.data) { + return ( +

+ Could not load this coworker. +

+ ); + } + const profile = agent.data; + const active = SECTIONS.find((candidate) => candidate.id === section); + + return ( + <> + {profile.name} + {/* min-h-full overrides the provider's own min-h-svh, which is sized for a page. */} + + + {/* Who this dialog is about, said once here rather than repeated per section. */} + + +
+ + {profile.name} + + + {profile.title} + +
+
+ + + + + {SECTIONS.map((item) => ( + + setSection(item.id)} + > + + {item.name} + + + ))} + + + + +
+
+ {/* + * The sidebar hides below md, and without this strip that left the sections unreachable + * on a phone: the dialog opened on General and nothing could leave it. A scrollable row + * of the same sections, shown only where the sidebar is not. The identity the sidebar + * header carries rides along, with room kept for the popup's close button. + */} +
+
+ + + {profile.name} + +
+
+ {SECTIONS.map((item) => ( + + ))} +
+
+
+

{active?.name}

+
+
+ {section === "general" ? ( + + ) : section === "access" ? ( + + ) : section === "connection" ? ( + + ) : section === "handoff" ? ( + + ) : section === "routines" ? ( + + ) : ( + + )} +
+
+
+ + ); +} + +function GeneralSection({ + agentId, + profile, +}: { + agentId: string; + profile: AgentProfile; +}) { + const queryClient = useQueryClient(); + const navigate = useNavigate(); + const updateAgent = useMutation(updateAgentMutationOptions(queryClient)); + + /* + * One field at a time, over the whole update endpoint: the API takes the full profile, so the + * unchanged fields ride along as they are on screen. The empty key means "keep the current one". + */ + const save = (patch: Partial) => + updateAgent.mutateAsync({ + agentId, + input: agentInputFrom({ + name: profile.name, + title: profile.title, + roleDescription: profile.roleDescription, + visibility: profile.visibility, + endpoint: profile.endpoint ?? "", + authValue: "", + ...patch, + }), + }); + + return ( + <> + {/* Each stands on its own — muted, not bg-card, which is invisible against a popup — and + each edits in place: the field somebody wants to change is the only one that opens. */} +
+ save({ name })} + schema={agentFormSchema.shape.name} + value={profile.name} + /> + save({ title })} + schema={agentFormSchema.shape.title} + value={profile.title} + /> + save({ roleDescription })} + schema={agentFormSchema.shape.roleDescription} + value={profile.roleDescription} + /> + save({ visibility })} + value={profile.visibility} + /> + {profile.systemOwned ? ( + + + System owned + + Ships with this deployment rather than belonging to a person. + + + + ) : null} +
+ + + + Start channel + + Open a new channel with this coworker. + + + + + + + + ); +} + +/** + * One fact about the coworker, edited in place. + * + * Only the field somebody wants to change opens: Edit swaps this item — and this item alone — for + * its input, validated against the same limits the server enforces, and Save writes just it back. + */ +function EditableTextItem({ + label, + value, + canManage, + multiline = false, + schema, + onSave, +}: { + label: string; + value: string; + canManage: boolean; + /** A textarea rather than an input, for the field that is a paragraph. */ + multiline?: boolean; + /** The field's slice of the shared form contract, so errors match the server's limits. */ + schema: ZodType; + onSave: (draft: string) => Promise; +}) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(value); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const close = () => { + setEditing(false); + setError(null); + }; + const submit = async () => { + const parsed = schema.safeParse(draft); + if (!parsed.success) { + setError(parsed.error.issues[0]?.message ?? "That value does not fit."); + return; + } + setSaving(true); + try { + await onSave(parsed.data); + close(); + } catch (failure) { + setError(failure instanceof Error ? failure.message : "Could not save."); + } finally { + setSaving(false); + } + }; + + if (!editing) { + return ( + + + {label} + + + + {value} + + {canManage ? ( + + ) : null} + + + ); + } + + return ( + + + {label} + {multiline ? ( +