Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
42 changes: 40 additions & 2 deletions docs/spec/runtime/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
McpTool,
} from "../lib/mcp";
import {
type AgentDetailDto,
ApiError,
type ApiErrorBody,
type AppSpec,
Expand All @@ -26,6 +27,7 @@ import {
type ConnectionState,
type CreateDeskInput,
type DeskDto,
type EditAgentInput,
type FeedbackInput,
type FeedbackResponse,
type FeedbackSummary,
Expand Down Expand Up @@ -465,6 +467,50 @@ export class OpenCompanyClient {
return this.request<TeamMemberDto>("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<AgentDetailDto> {
return this.request<AgentDetailDto>(
"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<AgentDetailDto> {
return this.request<AgentDetailDto>(
"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
Expand Down
91 changes: 91 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,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`.
*
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -974,7 +974,18 @@ export function AppShell({
}}
/>
)}
{view === "team" && <TeamView client={client} company={company} />}
{view === "team" && (
<TeamView
client={client}
company={company}
// `#/team/<agentId>` 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" && <MemoryView client={client} company={company} />}
{view === "workspace" && (
<Suspense
Expand Down
151 changes: 151 additions & 0 deletions frontend/src/lib/agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// One agent, read and edited (issue #264). The pure half of the detail view:
// the field definitions both the create dialog and the edit form render from,
// and the three derivations that are easy to get quietly wrong.

import type { AgentDetailDto, AgentToolsDto, EditAgentInput } from "@/api/types";

/** The fields that describe an agent, in the order both forms show them. */
export type AgentFieldKey = "name" | "role" | "description";

export interface AgentFieldSpec {
key: AgentFieldKey;
label: string;
placeholder: string;
/** `prose` renders a textarea; `line` renders a single-line input. */
kind: "line" | "prose";
}

/**
* The single definition of an agent's authored fields.
*
* Shared deliberately by "Define an agent" and the detail view's edit form: the
* two collect the same three things, and before this they would have collected
* them under two sets of labels and placeholders that drifted apart. The host
* accepts exactly these keys in a `PATCH`, so the list is also the client half
* of that contract.
*/
export const AGENT_FIELDS: AgentFieldSpec[] = [
{ key: "name", label: "Name", placeholder: "e.g. Nova", kind: "line" },
{ key: "role", label: "Role", placeholder: "e.g. Growth Marketer", kind: "line" },
{
key: "description",
label: "What they do",
placeholder: "e.g. Runs paid acquisition and reports on ROAS.",
kind: "prose",
},
];

/** The three authored values, as a form holds them. */
export type AgentDraft = Record<AgentFieldKey, string>;

/** 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";
}
Loading
Loading