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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ the project uses [semantic versioning](https://semver.org/) once it reaches 1.0;

### Added

- **Agent nicknames.** Give an agent your own name to tell apart registrations whose cards advertise the same name, for example the same agent built with LangGraph and with ADK. Set it in the connect dialog or later on the agent page (administrators). The nickname replaces the card name in the console, inbox, approvals, notifications and audit trail, and the agents list now shows each agent's host and port. Nicknames are unique among enabled agents, ignoring case; clearing one restores the card name. Schema version 9.

- **IDs are labeled and copyable.** Every ID in chat, task pages, approvals and the task browser now says what it is (Task ID, Context ID, Message ID, Artifact ID, Call ID, Taskbay ID, Approval ID, Command ID, Digest), with a copy icon that copies the whole value. Selecting a label explains the ID: its protocol field, whether A2A defines it or only Taskbay keeps it, its full value and who creates it. The task page's **Identifiers** panel is rebuilt around this, shows each message's ID in **History**, lists artifact and referenced-task IDs, wraps very long values and folds those over 100 characters behind **Show all**. Copying works over plain HTTP, and the chat header's context ID moves to its own row on a phone.

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion docs/guides/connect-an-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ For the sample agents, the paths are `http://127.0.0.1:4010/<variant>/card.json`
2. Paste the URL and choose **Fetch Agent Card**. The console fetches the card without registering it and shows its name, version, skill count, whether it streams, whether it supports push notifications, and its advertised security schemes ("None advertised" if empty).
3. Choose **Continue**, then **Connect**.

**Nickname.** The first step also has an optional **Nickname**. Card names are whatever each agent advertises, so two agents (or one agent rebuilt in another framework and registered at another address) can show the same name. A nickname is your own name for the registration, such as "Support (LangGraph)". It leads wherever the console names the agent, and the name on the agent's card stays visible beside it (on the agents list and agent page, in the chat header and picker, and in brackets in dropdowns), and the agents list shows each agent's host and port under its name. It must be unique among your enabled agents, ignoring case; a removed agent frees its nickname. Administrators can change or clear it later in the **Nickname** card on the agent page. The same card URL is one agent: registering a URL again returns the existing agent, so to run two agents side by side give them different addresses.

If the card is behind authentication, previewing will fail because the preview call sends no credentials. In that case use **Register URL** on the first step, which saves the URL without fetching the card, then configure the credential (next step).

You can also register through the API: `POST /api/agents` with `{"cardUrl": "..."}` returns 201 and the agent record. The call is administrator-only and does not fetch the card, so a wrong URL is registered and then shows up as unreachable. Registering the same URL again returns the existing agent, and re-enables it if it was removed.
You can also register through the API: `POST /api/agents` with `{"cardUrl": "...", "nickname": "..."}` (nickname optional) returns 201 and the agent record. The call is administrator-only and does not fetch the card, so a wrong URL is registered and then shows up as unreachable. Registering the same URL again returns the existing agent, and re-enables it if it was removed.

An agent can also be seeded from the `A2A_REGISTERED_AGENTS` environment variable (comma-separated card URLs). Those agents cannot be removed in the UI.

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/use-the-console.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,13 @@ Opening the console root (`/`) takes you to **Chat**.

## Agents

**Agents** lists the agents you can see. A success-coloured dot means the Agent Card was fetched ("Reachable" on hover). A different dot means it failed ("Discovery failed" on hover), and the row shows the error. Use **Search agents or skills…** to filter by name, description, skill name or card URL.
**Agents** lists the agents you can see. A success-coloured dot means the Agent Card was fetched ("Reachable" on hover). A different dot means it failed ("Discovery failed" on hover), and the row shows the error. Each row shows the agent's nickname (or card name) and the host and port of its card, so agents with the same name can be told apart. Use **Search agents or skills…** to filter by nickname, name, description, skill name or card URL.

![The sample agents, with the showcase card open](../images/agents.png)

Select an agent to see its card:

- **Nickname**: your own name for the agent, which replaces the card name across the console. Administrators can set or clear it; it must be unique. See [Nickname](connect-an-agent.md#2-register-it).
- **Capabilities**: streaming, push notifications, extended card, and the advertised extension URIs.
- **Connection**: the binding, interface URL, protocol version, tenant or tenants, and how each advertised extension is used (negotiated, ignored, or required but not supported). See [Reading the Connection card](connect-an-agent.md#reading-the-connection-card).
- **Task discovery**: how Taskbay finds tasks other systems started on this agent (**Browse only**, **Off**), with **Browse tasks** for people who may use it. Only administrators can change the mode; the choices a deployment forbids are disabled and say so. See [Task discovery](connect-an-agent.md#task-discovery).
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ After a successful fetch, the catalog and individual-agent reads include `extend

### `POST /api/agents`

`administer`. Body `{ "cardUrl": "<string>" }`. Registers the agent; it does not fetch the card. Response `201 { "agent": { "id", "cardUrl", "source": "managed" } }`.
`administer`. Body `{ "cardUrl": "<string>", "nickname": "<string> | null" }` (`nickname` optional; see the settings route for its rules). Registers the agent; it does not fetch the card. Response `201 { "agent": { "id", "cardUrl", "source": "managed", "nickname"? } }`. A nickname another enabled agent has is `400`. `GET /api/agents` entries carry `nickname` beside the card's `name`.
Registering the same URL again returns the same agent (and re-enables it if it was removed). `400` for a missing `cardUrl`, a URL that is not `http(s)`, or one with credentials, a query string or a fragment. The network policy (origin allowlist, private addresses) is applied when the card is first fetched, not at registration. The audit trail records `agent.registered`.

Observed: `{"agent":{"id":"e6e890ae-...","cardUrl":"http://127.0.0.1:4011/showcase/card.json","source":"managed"}}`.
Expand All @@ -231,7 +231,7 @@ status of the underlying error). Records a card snapshot.
`administer`. Body `{ "extensionOverride": true | false }`; unknown fields and an empty body are `400`. `true` allows sending to an agent whose card requires an extension
Taskbay does not support; `false` blocks again. Response `200 { "agentId", "extensionOverride" }`.

The same route also takes `taskDiscovery` (`off`, `browse` or `auto`) and `taskDiscoveryHistoryDays` (a whole number from 0 to 90, used by automatic discovery). A mode above the deployment's `A2A_TASK_DISCOVERY` is `409` with `error.kind` `discovery_capped`. Switching a mode to `auto` starts the worker's sweep for the agent. A change of mode is audited as `task.discovery.mode_changed.<mode>`. `404` for an unknown agent. Audited as `agent.extension.override` or
The same route also takes `nickname`: a string of at most 100 characters (whitespace is trimmed and collapsed; control characters are `400`), or an empty string or `null` to clear it. It replaces the card name in every view, and must be unique among the organization's enabled agents, ignoring case (`409` with `error.kind` `nickname_taken`). Audited as `agent.nickname.set` or `agent.nickname.cleared`. It also takes `taskDiscovery` (`off`, `browse` or `auto`) and `taskDiscoveryHistoryDays` (a whole number from 0 to 90, used by automatic discovery). A mode above the deployment's `A2A_TASK_DISCOVERY` is `409` with `error.kind` `discovery_capped`. Switching a mode to `auto` starts the worker's sweep for the agent. A change of mode is audited as `task.discovery.mode_changed.<mode>`. `404` for an unknown agent. Audited as `agent.extension.override` or
`agent.extension.override.removed`. See [Required extensions](../guides/connect-an-agent.md#required-extensions).

### `GET /api/agents/{agentId}/discovery`
Expand Down
6 changes: 3 additions & 3 deletions src/app/admin/access/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Pill } from "@/components/patterns/status";
import { Button } from "@/components/ui/button";
import { NativeSelect } from "@/components/ui/native-select";
import { recipientName } from "@/lib/admin-access";
import { useAgentStore } from "@/store/agent-store";
import { agentOptionLabel, useAgentStore } from "@/store/agent-store";

export default function AgentAccessPage() {
const { change, busy } = useAdminData();
Expand Down Expand Up @@ -40,7 +40,7 @@ export default function AgentAccessPage() {
</NativeSelect>
<NativeSelect name="agentId" aria-label="Grant agent" value={agentId} onChange={(event) => setAgentId(event.target.value)} required>
<option value="">Choose agent</option>
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.view?.name ?? agent.cardUrl}</option>)}
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agentOptionLabel(agent, agent.cardUrl)}</option>)}
</NativeSelect>
<NativeSelect key={agentId} name="skillId" aria-label="Grant scope">
<option value="">Whole agent</option>
Expand All @@ -59,7 +59,7 @@ export default function AgentAccessPage() {
const credential = view.credentials.find((item) => item.agentId === agent.id);
return (
<li key={agent.id} className="flex items-center justify-between gap-2 py-2 text-sm first:pt-0 last:pb-0">
<span className="min-w-0 truncate">{agent.view?.name ?? agent.cardUrl}</span>
<span className="min-w-0 truncate">{agentOptionLabel(agent, agent.cardUrl)}</span>
{credential ? <Pill tone={credential.enabled ? "success" : "danger"}>{credential.kind} · {credential.enabled ? "configured" : "revoked"}</Pill> : <Pill tone="neutral">none</Pill>}
</li>
);
Expand Down
13 changes: 9 additions & 4 deletions src/app/agents/[agentId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Callout } from "@/components/patterns/data-display";
import { DefinitionList } from "@/components/patterns/layout";
import { Eyebrow, Pill } from "@/components/patterns/status";
import { ConnectAgentDialog } from "@/components/agents/connect-agent-dialog";
import { AgentNicknameCard } from "@/components/agents/agent-nickname-card";
import { ConnectionCard } from "@/components/agents/connection-card";
import { RequiredExtensionsNotice } from "@/components/agents/required-extensions-notice";
import { TaskDiscoveryCard } from "@/components/agents/task-discovery-card";
Expand All @@ -20,7 +21,7 @@ import { mergeExtendedSkills } from "@/lib/extended-card";
import type { DiscoveryStatusView } from "@/lib/browse-tasks";
import { useServerResource } from "@/lib/use-server-resource";
import { Button, buttonVariants } from "@/components/ui/button";
import { useAgentStore } from "@/store/agent-store";
import { advertisedName, useAgentStore } from "@/store/agent-store";

export default function AgentDetailPage({ params }: { params: Promise<{ agentId: string }> }) {
const { agentId } = use(params);
Expand Down Expand Up @@ -65,8 +66,8 @@ export default function AgentDetailPage({ params }: { params: Promise<{ agentId:
<Page
detail
size="wide"
title={view?.name ?? "Unreachable agent"}
description={view?.description ?? agent.error}
title={view?.name ?? agent.nickname ?? "Unreachable agent"}
description={[advertisedName(agent) && `Card name: ${advertisedName(agent)}`, view?.description ?? agent.error].filter(Boolean).join(" · ")}
eyebrow={view ? <Pill tone="success" dot live>Reachable · Agent Card v{view.version}</Pill> : <Pill tone="danger" dot>Discovery failed</Pill>}
actions={
<>
Expand All @@ -88,6 +89,7 @@ export default function AgentDetailPage({ params }: { params: Promise<{ agentId:
)}
{view ? (
<div className="grid gap-4 xl:grid-cols-2">
<AgentNicknameCard agentId={agent.id} nickname={agent.nickname} cardName={agent.cardName} admin={session.data?.user.role === "admin"} onChanged={refresh} />
{discovery.data && <TaskDiscoveryCard agentId={agent.id} status={discovery.data} admin={session.data?.user.role === "admin"} onChanged={discovery.refresh} onBrowse={() => router.push(`/discover?agent=${agent.id}` as Route)} />}

<InfoCard label="Capabilities">
Expand Down Expand Up @@ -149,7 +151,10 @@ export default function AgentDetailPage({ params }: { params: Promise<{ agentId:
</InfoCard>
</div>
) : (
<Callout tone="danger" title="This agent could not be reached"><span className="font-mono">{agent.error}</span></Callout>
<div className="grid gap-4">
<Callout tone="danger" title="This agent could not be reached"><span className="font-mono">{agent.error}</span></Callout>
<AgentNicknameCard agentId={agent.id} nickname={agent.nickname} admin={session.data?.user.role === "admin"} onChanged={refresh} />
</div>
)}
<ConnectAgentDialog open={connectOpen} onOpenChange={setConnectOpen} initialUrl={agent.cardUrl} />
</Page>
Expand Down
13 changes: 10 additions & 3 deletions src/app/agents/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import { ListPane, ListRow, ListStatus, MasterDetail } from "@/components/consol
import { StatusDot } from "@/components/patterns/status";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { useAgentStore } from "@/store/agent-store";
import { advertisedName, useAgentStore } from "@/store/agent-store";

/** `host:port` of a card URL, so agents with the same name can be told apart in a list. */
const hostOf = (cardUrl: string) => { try { return new URL(cardUrl).host; } catch { return cardUrl; } };

export default function AgentsLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname() ?? "";
Expand All @@ -22,7 +25,7 @@ export default function AgentsLayout({ children }: { children: React.ReactNode }
const visible = agents.filter((agent) => {
if (!needle) return true;
const view = agent.view;
return [view?.name, view?.description, ...(view?.skills.map((skill) => skill.name) ?? []), agent.cardUrl]
return [agent.nickname, view?.name, agent.cardName, view?.description, ...(view?.skills.map((skill) => skill.name) ?? []), agent.cardUrl]
.filter(Boolean)
.some((text) => String(text).toLowerCase().includes(needle));
});
Expand Down Expand Up @@ -52,10 +55,14 @@ export default function AgentsLayout({ children }: { children: React.ReactNode }
<ListRow key={agent.id} href={`/agents/${agent.id}`} active={activeId === agent.id} className="flex-row items-center gap-3">
<AgentAvatar name={agent.view?.name ?? "Agent"} id={agent.id} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{agent.view?.name ?? "Unreachable agent"}</div>
<div className="truncate text-sm font-medium">{agent.view?.name ?? agent.nickname ?? "Unreachable agent"}</div>
{advertisedName(agent) && <div className="truncate text-[11px] text-muted-foreground" title="Name on the agent's card">{advertisedName(agent)}</div>}
<div className="truncate font-mono text-[11px] text-muted-foreground">
{agent.view ? `v${agent.view.version} · ${agent.view.security[0] ?? "no auth"}` : (agent.error ?? "discovery failed")}
</div>
<div className="truncate font-mono text-[11px] text-muted-foreground" title={agent.cardUrl}>
{hostOf(agent.cardUrl)}
</div>
</div>
<span title={agent.view ? "Reachable" : "Discovery failed"}><StatusDot tone={agent.view ? "success" : "danger"} live={Boolean(agent.view)} /></span>
</ListRow>
Expand Down
10 changes: 6 additions & 4 deletions src/app/api/agents/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface CatalogEntry {
id: string;
cardUrl: string;
source: "env" | "managed";
/** The administrator's own name for this agent; `name` is what its card advertises. */
nickname?: string;
name?: string;
description?: string;
skills?: Array<{ id: string; name: string; description: string; tags: string[] }>;
Expand All @@ -38,11 +40,11 @@ async function handleGET() {
try {
const discovery = await discoverRegisteredAgent(agent);
const card = discovery.card as { name?: string; description?: string; skills?: CatalogEntry["skills"] };
return { id: agent.id, cardUrl: agent.cardUrl, source: agent.source, name: card.name, description: card.description, skills: card.skills, card: discovery.card, trust: discovery.trust, connection: discovery.connection,
return { id: agent.id, cardUrl: agent.cardUrl, source: agent.source, nickname: agent.nickname, name: card.name, description: card.description, skills: card.skills, card: discovery.card, trust: discovery.trust, connection: discovery.connection,
extendedCard: await readExtendedCard(agent.id),
extensions: { required: discovery.sideband?.requiredUris ?? [], unsupportedRequired: discovery.sideband?.unsupportedRequiredUris ?? [], override: agent.extensionOverride ?? false } };
} catch (error) {
return { id: agent.id, cardUrl: agent.cardUrl, source: agent.source, error: error instanceof Error ? error.message : "Agent discovery failed." };
return { id: agent.id, cardUrl: agent.cardUrl, source: agent.source, nickname: agent.nickname, error: error instanceof Error ? error.message : "Agent discovery failed." };
}
}));
return NextResponse.json({ agents: entries }, { headers: { "Cache-Control": "no-store" } });
Expand All @@ -51,9 +53,9 @@ async function handleGET() {
/** Catalog management (design doc's "next features"): register a new agent by its Agent Card URL. */
async function handlePOST(request: Request) {
try {
const body = await readJsonRequest<{ cardUrl?: string }>(request);
const body = await readJsonRequest<{ cardUrl?: string; nickname?: string | null }>(request);
if (!body.cardUrl?.trim()) throw new Error("cardUrl is required.");
const agent = await agentRegistry().add(body.cardUrl);
const agent = await agentRegistry().add(body.cardUrl, body.nickname);
return NextResponse.json({ agent }, { status: 201, headers: { "Cache-Control": "no-store" } });
} catch (error) {
return apiError(error, 400);
Expand Down
27 changes: 27 additions & 0 deletions src/components/agents/agent-nickname-card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { AgentNicknameCard } from "./agent-nickname-card";

const render = (props: Partial<Parameters<typeof AgentNicknameCard>[0]> = {}) =>
renderToStaticMarkup(<AgentNicknameCard agentId="a" admin={false} onChanged={() => undefined} {...props} />);

// No browser test environment exists, so these check the rendered states; the settings route is covered by the database and HTTP suites.
describe("AgentNicknameCard", () => {
it("gives an administrator a named field, prefilled, and a way to clear it", () => {
const html = render({ admin: true, nickname: "Support (ADK)", cardName: "Support" });
expect(html).toContain('aria-label="Nickname"');
expect(html).toContain('value="Support (ADK)"');
expect(html).toContain("Clear");
expect(html).toContain("The agent calls itself");
});

it("offers no field to a non-administrator and shows the nickname as text", () => {
const html = render({ nickname: "<b>x</b>" });
expect(html).not.toContain("<input");
expect(html).not.toContain("<b>x</b>");
});

it("says when none is set", () => {
expect(render()).toContain("None set.");
});
});
Loading
Loading