diff --git a/docs/spec/runtime/api.md b/docs/spec/runtime/api.md index 0c49cb3e..0d67edb5 100644 --- a/docs/spec/runtime/api.md +++ b/docs/spec/runtime/api.md @@ -133,6 +133,8 @@ POST …/skills/{slug}/install install a registry/company skill POST …/skills/{slug}/uninstall uninstall a skill PUT …/skills/{slug} enable / disable a skill POST …/team add an operator-overlay teammate +GET …/team/{agentId} one agent in full (tier, tools, desks) +PATCH …/team/{agentId} edit an overlay teammate DELETE …/team/{agentId} remove an overlay teammate PUT …/team/{agentId}/inbox toggle a teammate's inbox PUT …/team/{agentId}/budget set / change / remove a daily cap @@ -168,8 +170,44 @@ note. Team writes are an **operator overlay** persisted through the store, merged into the manifest roster at read time — the version-controlled `company.toml` -is never rewritten. In v1 overlay teammates are **roster-only**: they appear in -the roster and get an inbox, but no harness `Agent` is built for them yet. +is never rewritten. Overlay teammates are addressable: since issue #71 the +harness builds a real agent for each one, with the company-wide tool grant, no +cognition tier, and never the orchestrator. + +`GET …/team/{agentId}` is the **agent detail** read (issue #264). `GET …/team` +answers "who is on the roster"; this answers "what is this agent", and before it +existed neither the console nor any other client could reach an agent's tier, +its tool grants or its desk membership — the roster row carried none of them, so +checking what a company actually grants an agent was not possible from outside +the process. + +The `tools` object is the reason the route earns its keep. It carries three +lists, because only the third is the answer: + +| field | meaning | +|---|---| +| `requested` | the agent's own `[[agent]].tools` globs. **Empty means the company's standard grant**, not "no tools" | +| `companyAllow` | the `[tools].allow` ceiling the request is intersected with | +| `effective` | what the agent actually holds | + +`effective` is computed by the same `agent_effective_grants` the harness calls +when it builds the agent, so the readout cannot drift from what is enforced. +`isOrchestrator` is likewise resolved by the roster rule (a `tier = +"orchestrator"` agent, else the first declared) rather than read off `tier`, so +a company that tags nobody still names its orchestrator. + +`PATCH …/team/{agentId}` edits an **overlay** teammate's `name`, `role` and +`description`. It is a patch: an omitted key is left alone, and `"description": +null` clears it — the two must stay apart or every partial save would erase an +agent's instructions. A blank `name`/`role` is `400`, an unknown teammate `404`, +and a **manifest** teammate is `409`: its fields live in the version-controlled +`company.toml`, and the console does not rewrite the blueprint. The one thing +that *is* changeable on such a teammate is its daily budget, and that works +because #343 modelled it as an override rather than as a rewrite. Every detail +response carries an `editable` list naming the fields this route will accept, so +a client renders read-only from the host's answer instead of re-deriving the +rule. `tier` and `tools` are read-only for both kinds: there is no override +layer for either, and adding one is a policy decision rather than a form field. The two **budget** routes (issue #343) are how a teammate's `budget_usd_daily` becomes changeable without a redeploy. Both are **admin-only** — a member gets diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 643b6c4a..14346517 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -8,6 +8,7 @@ import type { ConsoleConfig } from "../config"; import { + type AgentDetailDto, ApiError, type ApiErrorBody, type AppSpec, @@ -20,6 +21,7 @@ import { type ConnectionState, type CreateDeskInput, type DeskDto, + type EditAgentInput, type FeedbackInput, type FeedbackResponse, type FeedbackSummary, @@ -459,6 +461,50 @@ export class OpenCompanyClient { return this.request("POST", `${this.scope(company)}/team`, input); } + /** + * One agent in full (issue #264): identity, tier, **resolved** tool grants and + * desk membership. + * + * Not derivable from `listTeam`, and that is the point — the roster row + * carries none of it, and the tool grants had no read surface anywhere before + * this route, so what a company actually grants an agent could not be checked + * from outside the process. + * + * Hosts predating the route 404; callers should treat that as "this host + * can't open an agent yet" rather than as a missing teammate. + */ + getAgent(agentId: string, company?: string | null): Promise { + return this.request( + "GET", + `${this.scope(company)}/team/${encodeURIComponent(agentId)}`, + ); + } + + /** + * Edit an agent, and get the whole agent back (issue #264). + * + * A patch: keys absent from `input` are left alone, so a caller that renders + * some of an agent's fields cannot blank the rest by omission. `description: + * null` clears the instructions and `description: undefined` leaves them, + * which is why the two must not be collapsed on the way in. + * + * The host refuses a manifest teammate with a 409 — its fields live in the + * version-controlled `company.toml`, and the console does not rewrite that. + * Ask `getAgent` first: its `editable` list is the host's own statement of + * which fields this call will accept. + */ + updateAgent( + agentId: string, + input: EditAgentInput, + company?: string | null, + ): Promise { + return this.request( + "PATCH", + `${this.scope(company)}/team/${encodeURIComponent(agentId)}`, + input, + ); + } + /** * Set, change, or remove a teammate's daily spend cap (issue #343). Admin-only * on the host — a member gets 403 — and the change is enforced on the diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 8df104e3..1a884d5f 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -512,6 +512,97 @@ export interface TeamMemberDto { budgetSetAtMillis?: number; } +/** + * One agent in full, from `GET .../team/{agentId}` (issue #264). + * + * `GET .../team` answers "who is on the roster"; this answers "what is this + * agent". Everything below the identity block was unreachable from the console + * before this route existed, and the tool grants were unreachable from + * *anywhere* — which is why a change to what a company grants its agents could + * not be checked from outside the process. + */ +export interface AgentDetailDto { + id: string; + /** Absent for a manifest teammate, which is named by its role. */ + name?: string; + role: string; + /** + * What the agent was defined with. This text frames the agent's persona on + * every turn, so it is the closest thing the company has to an `AGENT.md`. + */ + description?: string; + /** + * Which half of the roster this teammate comes from, and therefore what may + * be done to it. `manifest` teammates are declared in the version-controlled + * `company.toml`; `overlay` teammates were added at runtime and live on the + * company record this host writes. + */ + source: "manifest" | "overlay"; + /** + * The field names the host will accept in a `PATCH`. **The console renders a + * field read-only exactly when this list omits it** rather than deciding for + * itself — a client-side copy of the rule would eventually disagree with the + * host, and the operator would meet the disagreement as a failed save. + */ + editable: string[]; + /** The declared cognition-tier hint, when the manifest sets one. */ + tier?: string; + /** + * Whether this teammate is the company's orchestrator. Resolved by the roster + * rule (a tagged tier first, else the first declared agent), so it is NOT the + * same question as `tier === "orchestrator"`: a company that tags nobody still + * has one. + */ + isOrchestrator: boolean; + tools: AgentToolsDto; + desks: AgentDeskDto[]; + inboxEnabled: boolean; + /** The cap in force and its attribution; same absent-means-uncapped contract as `TeamMemberDto`. */ + budgetUsdDaily?: number; + spentTodayUsd?: number; + budgetSetBy?: string; + budgetSetAtMillis?: number; +} + +/** + * An agent's tool grants at all three levels. + * + * The distinction is the point: `requested` is what the agent's own `tools` + * line asks for, `companyAllow` is the ceiling it is intersected with, and + * `effective` is what the agent actually holds. An **empty `requested` means + * the company's standard grant**, not "no tools", so a surface that renders the + * request alone reports the opposite of the truth for exactly those agents. + */ +export interface AgentToolsDto { + requested: string[]; + companyAllow: string[]; + effective: string[]; +} + +/** A desk this agent sits on, and whether it leads it. */ +export interface AgentDeskDto { + id: string; + name: string; + /** The desk's first effective member, who receives a `delegate_to_desk` hand-off. */ + lead: boolean; +} + +/** + * The body of `PATCH .../team/{agentId}` (issue #264). + * + * A patch, not a replacement: an omitted key leaves that field alone. That is + * why `description` is `string | null | undefined` and the three are all + * different — `undefined` leaves the instructions be, `null` clears them, and a + * string sets them. Building this object with a spread that drops `undefined` + * is correct; one that turns `undefined` into `null` would erase an agent's + * instructions on every partial save. + */ +export interface EditAgentInput { + name?: string; + role?: string; + description?: string | null; +} + /** * The body of `PUT .../team/{id}/budget`. * diff --git a/frontend/src/components/app-shell.tsx b/frontend/src/components/app-shell.tsx index b1a4cacf..c0a98c18 100644 --- a/frontend/src/components/app-shell.tsx +++ b/frontend/src/components/app-shell.tsx @@ -1016,7 +1016,18 @@ export function AppShell({ }} /> )} - {view === "team" && } + {view === "team" && ( + ` opens that agent (issue #264). The sub-page + // comes back unvalidated, as `useHashView` documents: only this + // view knows which ids exist, and the detail screen resolves an + // unknown one against the host rather than guessing here. + sub={sub} + onOpenAgent={(agentId) => navigate("team", agentId ?? undefined)} + /> + )} {view === "memory" && } {view === "workspace" && ( ; + +/** A blank draft, for the create form and for a detail view that has not loaded. */ +export function emptyDraft(): AgentDraft { + return { name: "", role: "", description: "" }; +} + +/** The draft a detail response starts an edit from. */ +export function draftFrom(detail: AgentDetailDto): AgentDraft { + return { + // A manifest teammate carries no name of its own and is shown by its role, + // so the form starts from the same thing the card does. + name: detail.name ?? "", + role: detail.role, + description: detail.description ?? "", + }; +} + +/** + * Whether the host will accept an edit to this field. + * + * Read from the host's own `editable` list rather than inferred from `source`. + * The two agree today, and the moment they stop agreeing the host is right: + * duplicating the rule here is how a console starts offering a field that saves + * with a 409. + */ +export function isEditable(detail: AgentDetailDto, key: AgentFieldKey): boolean { + return detail.editable.includes(key); +} + +/** + * The `PATCH` body for a draft, or `null` when nothing changed. + * + * Two rules, and both matter: + * + * - **Only changed, editable fields are sent.** An unchanged field is left out + * entirely, so a form that renders a read-only field cannot echo it back and + * have the host refuse the whole save. + * - **A cleared description is `null`, not `""`.** `undefined` means "leave the + * instructions alone" on the wire; sending `undefined` for a field the + * operator deliberately emptied would silently keep the old text, and the + * operator would watch their deletion come back. + */ +export function agentEdits(detail: AgentDetailDto, draft: AgentDraft): EditAgentInput | null { + const current = draftFrom(detail); + const edits: EditAgentInput = {}; + let changed = false; + + for (const field of AGENT_FIELDS) { + if (!isEditable(detail, field.key)) continue; + const next = draft[field.key].trim(); + if (next === current[field.key].trim()) continue; + changed = true; + if (field.key === "description") { + edits.description = next === "" ? null : next; + } else if (field.key === "name") { + edits.name = next; + } else { + edits.role = next; + } + } + + return changed ? edits : null; +} + +/** Whether a draft could be saved at all: name and role are required. */ +export function draftIsValid(detail: AgentDetailDto, draft: AgentDraft): boolean { + return AGENT_FIELDS.every( + (field) => + field.kind !== "line" || !isEditable(detail, field.key) || draft[field.key].trim() !== "", + ); +} + +/** What an agent's tool grants amount to, once the intersection is applied. */ +export interface ToolGrantSummary { + /** What the agent actually holds. */ + effective: string[]; + /** + * Globs the agent asked for that the company's allow-list does not cover, so + * they are not grants however plainly the manifest lists them. This is the + * line an operator checking a tool change is looking for. + */ + dropped: string[]; + /** + * Whether the agent lists no tools of its own and therefore inherits the + * company's whole allow-list. + * + * The case worth naming: an empty `requested` reads like "no tools" and means + * the opposite. A screen that showed an empty list here would tell an + * operator their agent is powerless when it holds everything the company + * allows. + */ + standardGrant: boolean; +} + +export function summarizeGrants(tools: AgentToolsDto): ToolGrantSummary { + return { + effective: tools.effective, + dropped: tools.requested.filter((glob) => !tools.effective.includes(glob)), + standardGrant: tools.requested.length === 0, + }; +} + +/** + * How an agent's tier reads on screen. + * + * `isOrchestrator` rather than `tier === "orchestrator"`: a company that tags + * nobody still has an orchestrator (the first agent declared), and the host has + * already resolved which one it is. + */ +export function tierLabel(detail: AgentDetailDto): string { + return detail.isOrchestrator ? "Orchestrator" : "Worker"; +} diff --git a/frontend/src/views/TeamView.tsx b/frontend/src/views/TeamView.tsx index af3b0181..8aa9d5ef 100644 --- a/frontend/src/views/TeamView.tsx +++ b/frontend/src/views/TeamView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { Mail, MoreHorizontal, Plus, Sparkles, UserPlus, Wallet } from "lucide-react"; import { toast } from "sonner"; @@ -28,7 +28,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Switch } from "@/components/ui/switch"; -import { Textarea } from "@/components/ui/textarea"; +import { emptyDraft, type AgentDraft, type AgentFieldKey } from "@/lib/agent"; import { fromDto, initials, @@ -38,16 +38,26 @@ import { type TeamMember, } from "@/lib/team"; import { cn } from "@/lib/utils"; +import { AgentDetailView } from "@/views/team/AgentDetailView"; +import { AgentFields } from "@/views/team/AgentFields"; interface Props { client: OpenCompanyClient; company: string | null; + /** + * The agent id in the hash (`#/team/`), when one is addressed. The + * detail view is a sub-page rather than a modal so an operator can link to an + * agent, refresh onto it, and use Back (issue #264). + */ + sub: string | null; + /** Open an agent, or return to the roster with `null`. */ + onOpenAgent: (agentId: string | null) => void; } type Load = "loading" | "ready"; /** The company's agents — showcased and operator-definable. */ -export function TeamView({ client, company }: Props) { +export function TeamView({ client, company, sub, onOpenAgent }: Props) { const [load, setLoad] = useState("loading"); const [fromHost, setFromHost] = useState(false); const [members, setMembers] = useState([]); @@ -143,6 +153,34 @@ export function TeamView({ client, company }: Props) { void loadViewer(); }, [boot, loadViewer]); + /** + * Re-read the roster on the way back from the agent sub-page (issue #264). + * + * This view renders the detail as an early return, so opening an agent never + * unmounts the roster and never re-runs `boot`. An edit saved in the panel + * therefore landed on the host while these cards went on showing what they + * held before it: press Back after renaming an agent and the old role is + * still on the card, until a hard reload. The panel and the roster disagreed + * about the same company, and the roster was the wrong one. + * + * Keyed on `sub` rather than on the Back button's callback, so the browser's + * own Back — and a hand-edited hash — refresh too. The ref is what keeps the + * first mount from fetching twice: the effect above already did. + */ + const leftAgentPage = useRef(false); + useEffect(() => { + if (sub) { + leftAgentPage.current = true; + return; + } + if (!leftAgentPage.current) return; + leftAgentPage.current = false; + // Deliberately without `setLoad("loading")`: the cards on screen are the + // right cards, only possibly stale, so they stay put until the new ones + // land rather than blanking to a skeleton on every Back. + void boot(); + }, [sub, boot]); + /** A human label for whoever set a cap — never a raw user id. */ function whoSet(userId: string): string { const person = people.find((p) => p.id === userId); @@ -238,6 +276,20 @@ export function TeamView({ client, company }: Props) { } } + // `#/team/` is the agent detail sub-page. Every hook above has + // already run, so this early return keeps hook order stable across both + // shapes of the view. + if (sub) { + return ( + onOpenAgent(null)} + /> + ); + } + return (
@@ -268,6 +320,11 @@ export function TeamView({ client, company }: Props) { inboxOn={m.inboxEnabled} onToggleInbox={() => void toggleMemberInbox(m)} onRemove={() => void removeMember(m)} + // Only a host-backed teammate can be opened: a starter-team + // card is a local placeholder with no record behind it, so its + // id would 404 and the detail view would report a teammate that + // was never removed. + onOpen={fromHost ? () => onOpenAgent(m.id) : undefined} // Budget edits need a teammate the host actually knows about; // starter-team cards are local placeholders with no record. canEditBudget={isAdmin && fromHost} @@ -339,6 +396,7 @@ function MemberCard({ inboxOn, onToggleInbox, onRemove, + onOpen, canEditBudget, onEditBudget, onRemoveCap, @@ -349,6 +407,8 @@ function MemberCard({ inboxOn: boolean; onToggleInbox: () => void; onRemove: () => void; + /** Open this agent's detail page. Undefined when the card has no host record. */ + onOpen?: () => void; /** Whether to offer the budget controls at all (admins, host-backed cards). */ canEditBudget: boolean; onEditBudget: () => void; @@ -374,10 +434,25 @@ function MemberCard({ > {initials(member.name)}
-
-

{member.name}

-

{member.role}

-
+ {onOpen ? ( + // The card's own name is the way in (issue #264). A whole-card + // click would swallow the inbox switch and the actions menu, both + // of which live inside it, so the target is deliberately this + // block rather than the card. + + ) : ( +
+

{member.name}

+

{member.role}

+
+ )} } @@ -385,6 +460,14 @@ function MemberCard({ + {onOpen && ( + <> + + View agent + + + + )} {canEditBudget && ( <> @@ -563,16 +646,15 @@ function AddMemberDialog({ /** Whether to offer the cap field — setting one is admin-only on the host. */ canSetBudget: boolean; }) { - const [name, setName] = useState(""); - const [role, setRole] = useState(""); - const [description, setDescription] = useState(""); + // The same three authored fields the detail view edits, held in the same + // shape (issue #264) so "Define an agent" and "Edit agent" cannot drift into + // two different sets of labels for one set of values. + const [draft, setDraft] = useState(emptyDraft); const [inbox, setInbox] = useState(false); const [budget, setBudget] = useState(""); function reset() { - setName(""); - setRole(""); - setDescription(""); + setDraft(emptyDraft()); setInbox(false); setBudget(""); } @@ -587,8 +669,14 @@ function AddMemberDialog({ const budgetInvalid = budget.trim() !== "" && budgetUsdDaily === undefined; function submit() { - if (!name.trim() || !role.trim() || budgetInvalid) return; - onAdd({ name, role, description, inbox, budgetUsdDaily }); + if (!draft.name.trim() || !draft.role.trim() || budgetInvalid) return; + onAdd({ + name: draft.name, + role: draft.role, + description: draft.description, + inbox, + budgetUsdDaily, + }); reset(); } @@ -605,34 +693,11 @@ function AddMemberDialog({ Define an agent Add a teammate to your company's roster. -
- - setName(e.target.value)} - placeholder="e.g. Nova" - /> -
-
- - setRole(e.target.value)} - placeholder="e.g. Growth Marketer" - /> -
-
- -