From 7ff8ab95d59eb7cb84ebaa941aea49ba79d01c17 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 10:10:07 +0000 Subject: [PATCH 1/2] Add agent nicknames to tell apart agents with the same name Optional, unique (ignoring case) among enabled agents, set when connecting or later on the agent page, and shown instead of the card name across the console, inbox, approvals, notifications and audit trail. The agents list also shows each card's host and port. Schema version 9. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Lu73uozkZGX8gP8LWNbBqt --- CHANGELOG.md | 2 + docs/guides/connect-an-agent.md | 4 +- docs/guides/use-the-console.md | 3 +- docs/reference/http-api.md | 4 +- src/app/agents/[agentId]/page.tsx | 9 ++- src/app/agents/layout.tsx | 8 ++- src/app/api/agents/route.ts | 10 +-- .../agents/agent-nickname-card.test.tsx | 27 ++++++++ src/components/agents/agent-nickname-card.tsx | 61 +++++++++++++++++++ .../agents/connect-agent-dialog.tsx | 11 +++- src/server/adapters/db/agent-registry.ts | 4 +- .../adapters/db/agent-settings.db.test.ts | 20 ++++++ src/server/adapters/db/database.db.test.ts | 2 +- src/server/adapters/db/entities.ts | 1 + .../migrations/Migration0009_AgentNickname.js | 15 +++++ src/server/adapters/db/repositories.ts | 5 ++ src/server/adapters/db/schema-version.ts | 2 +- .../adapters/db/task-link-repository.ts | 2 +- src/server/adapters/db/task-links.db.test.ts | 7 ++- .../application/ports/agent-registry.ts | 4 +- src/server/application/ports/persistence.ts | 2 + .../application/services/agent-catalog.ts | 22 +++++-- .../application/services/observe-task.ts | 4 +- src/server/application/services/task-query.ts | 4 +- src/server/domain/agent-label.test.ts | 33 ++++++++++ src/server/domain/agent-label.ts | 28 +++++++++ src/server/domain/persistence-model.ts | 2 + src/server/runtime/agent-settings.ts | 14 +++++ src/server/runtime/decisions.ts | 2 +- src/server/runtime/inbox.ts | 2 +- src/server/runtime/projection-rebuild.ts | 2 +- src/server/runtime/shared-ports.ts | 4 +- src/store/agent-store.ts | 22 +++++-- 33 files changed, 302 insertions(+), 40 deletions(-) create mode 100644 src/components/agents/agent-nickname-card.test.tsx create mode 100644 src/components/agents/agent-nickname-card.tsx create mode 100644 src/server/adapters/db/migrations/Migration0009_AgentNickname.js create mode 100644 src/server/domain/agent-label.test.ts create mode 100644 src/server/domain/agent-label.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be14f3d..58f5136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guides/connect-an-agent.md b/docs/guides/connect-an-agent.md index b226b37..fcc6893 100644 --- a/docs/guides/connect-an-agent.md +++ b/docs/guides/connect-an-agent.md @@ -24,9 +24,11 @@ For the sample agents, the paths are `http://127.0.0.1:4010//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 replaces the card name everywhere in the console, 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. diff --git a/docs/guides/use-the-console.md b/docs/guides/use-the-console.md index ba002b3..b43a048 100644 --- a/docs/guides/use-the-console.md +++ b/docs/guides/use-the-console.md @@ -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). diff --git a/docs/reference/http-api.md b/docs/reference/http-api.md index 9b164c7..5b15279 100644 --- a/docs/reference/http-api.md +++ b/docs/reference/http-api.md @@ -210,7 +210,7 @@ After a successful fetch, the catalog and individual-agent reads include `extend ### `POST /api/agents` -`administer`. Body `{ "cardUrl": "" }`. Registers the agent; it does not fetch the card. Response `201 { "agent": { "id", "cardUrl", "source": "managed" } }`. +`administer`. Body `{ "cardUrl": "", "nickname": " | 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"}}`. @@ -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.`. `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.`. `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` diff --git a/src/app/agents/[agentId]/page.tsx b/src/app/agents/[agentId]/page.tsx index 19722bc..e06e6c9 100644 --- a/src/app/agents/[agentId]/page.tsx +++ b/src/app/agents/[agentId]/page.tsx @@ -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"; @@ -65,7 +66,7 @@ export default function AgentDetailPage({ params }: { params: Promise<{ agentId: Reachable · Agent Card v{view.version} : Discovery failed} actions={ @@ -88,6 +89,7 @@ export default function AgentDetailPage({ params }: { params: Promise<{ agentId: )} {view ? (
+ {discovery.data && router.push(`/discover?agent=${agent.id}` as Route)} />} @@ -149,7 +151,10 @@ export default function AgentDetailPage({ params }: { params: Promise<{ agentId:
) : ( - {agent.error} +
+ {agent.error} + +
)}
diff --git a/src/app/agents/layout.tsx b/src/app/agents/layout.tsx index 3e637eb..59fd4c3 100644 --- a/src/app/agents/layout.tsx +++ b/src/app/agents/layout.tsx @@ -11,6 +11,9 @@ import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { 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() ?? ""; const agents = useAgentStore((state) => state.agents); @@ -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)); }); @@ -56,6 +59,9 @@ export default function AgentsLayout({ children }: { children: React.ReactNode }
{agent.view ? `v${agent.view.version} · ${agent.view.security[0] ?? "no auth"}` : (agent.error ?? "discovery failed")}
+
+ {hostOf(agent.cardUrl)} +
diff --git a/src/app/api/agents/route.ts b/src/app/api/agents/route.ts index a9fd033..d1611de 100644 --- a/src/app/api/agents/route.ts +++ b/src/app/api/agents/route.ts @@ -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[] }>; @@ -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" } }); @@ -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); diff --git a/src/components/agents/agent-nickname-card.test.tsx b/src/components/agents/agent-nickname-card.test.tsx new file mode 100644 index 0000000..f80cc47 --- /dev/null +++ b/src/components/agents/agent-nickname-card.test.tsx @@ -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[0]> = {}) => + renderToStaticMarkup( 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: "x" }); + expect(html).not.toContain("x"); + }); + + it("says when none is set", () => { + expect(render()).toContain("None set."); + }); +}); diff --git a/src/components/agents/agent-nickname-card.tsx b/src/components/agents/agent-nickname-card.tsx new file mode 100644 index 0000000..80e0b78 --- /dev/null +++ b/src/components/agents/agent-nickname-card.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { InfoCard } from "@/components/a2a/primitives"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +/** + * Lets an administrator give this registration its own name, to tell apart agents whose cards advertise the same name + * (for example the same agent built with two frameworks). The nickname replaces the card name across the console; nicknames are unique. + */ +export function AgentNicknameCard({ agentId, nickname, cardName, admin, onChanged }: { + agentId: string; + nickname?: string; + /** The name the agent's card advertises; undefined when the card could not be fetched. */ + cardName?: string; + admin: boolean; + onChanged: () => void | Promise; +}) { + const [value, setValue] = useState(nickname ?? ""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function save(next: string) { + setSaving(true); + setError(null); + try { + const response = await fetch(`/api/agents/${agentId}/settings`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nickname: next.trim() || null }) }); + if (!response.ok) throw new Error(((await response.json().catch(() => ({}))) as { error?: { message?: string } }).error?.message ?? "Could not save the nickname."); + setValue(next.trim()); + await onChanged(); + toast.success(next.trim() ? "Nickname saved" : "Nickname cleared"); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not save the nickname."); + } finally { + setSaving(false); + } + } + + const dirty = value.trim() !== (nickname ?? ""); + return ( + +

+ {cardName ? <>The agent calls itself {cardName}. : null} + A nickname replaces that name everywhere in the console, so you can tell agents with the same name apart. It must be unique. +

+ {admin ? ( +
{ event.preventDefault(); if (dirty && !saving) void save(value); }}> + setValue(event.target.value)} maxLength={100} placeholder="For example: Support (LangGraph)" aria-label="Nickname" disabled={saving} /> + + {nickname && } +
+ ) : ( +

{nickname ? {nickname} : None set. An administrator can set one.}

+ )} + {error &&

{error}

} +
+ ); +} diff --git a/src/components/agents/connect-agent-dialog.tsx b/src/components/agents/connect-agent-dialog.tsx index 5865089..65d4512 100644 --- a/src/components/agents/connect-agent-dialog.tsx +++ b/src/components/agents/connect-agent-dialog.tsx @@ -11,6 +11,7 @@ import { ResponsiveDialog } from "@/components/patterns/responsive-dialog"; import { Pill } from "@/components/patterns/status"; import { Button } from "@/components/ui/button"; import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group"; import { useAgentStore } from "@/store/agent-store"; import { useServerResource } from "@/lib/use-server-resource"; @@ -48,6 +49,7 @@ function ConnectFlow({ initialUrl, onDone }: { initialUrl: string; onDone: () => const session = useServerResource<{ taskDiscoveryCap?: string }>("/api/auth/session"); const [step, setStep] = useState(0); const [url, setUrl] = useState(initialUrl); + const [nickname, setNickname] = useState(""); const [view, setView] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -79,11 +81,11 @@ function ConnectFlow({ initialUrl, onDone }: { initialUrl: string; onDone: () => const response = await fetch("/api/agents", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ cardUrl: url }), + body: JSON.stringify({ cardUrl: url, nickname: nickname.trim() || null }), }); const body = await response.json(); if (!response.ok) throw new Error(body?.error?.message ?? "Failed to connect the agent."); - toast.success("Agent connected", { description: view?.name }); + toast.success("Agent connected", { description: nickname.trim() || view?.name }); await refresh(); onDone(); } catch (cause) { @@ -129,6 +131,11 @@ function ConnectFlow({ initialUrl, onDone }: { initialUrl: string; onDone: () => Fetch the card to review what the agent can do before adding it. + + Nickname (optional) + setNickname(event.target.value)} placeholder="For example: Support (LangGraph)" /> + Your own name for this agent. Use it to tell apart agents whose cards share a name. It must be unique and can be changed later. + Register its URL directly, then configure the credentials on the server. diff --git a/src/server/adapters/db/agent-registry.ts b/src/server/adapters/db/agent-registry.ts index 98878ff..9c2234a 100644 --- a/src/server/adapters/db/agent-registry.ts +++ b/src/server/adapters/db/agent-registry.ts @@ -97,11 +97,11 @@ export class DatabaseAgentRegistry implements AgentRegistry { const policy = await requestAccessPolicy(tx); return agent && (!policy || policy.discovers(agent.id)) ? agent : undefined; }); } - async add(cardUrl: string) { + async add(cardUrl: string, nickname?: string | null) { const principal = currentPrincipal(); if (principal) authorize(principal, "administer"); return this.execute(async (catalog, transaction) => { - const agent = await catalog.add(cardUrl); + const agent = await catalog.add(cardUrl, nickname); if (principal) await new DatabaseIdentityRepository(transaction).appendAudit(principal, "agent.registered", agent.id); return agent; }); diff --git a/src/server/adapters/db/agent-settings.db.test.ts b/src/server/adapters/db/agent-settings.db.test.ts index 834bf35..69fbc23 100644 --- a/src/server/adapters/db/agent-settings.db.test.ts +++ b/src/server/adapters/db/agent-settings.db.test.ts @@ -69,6 +69,25 @@ async function contract(config: DatabaseConfig, directory: string) { expect((await orm.em.fork().find(SyncCursorEntity, { agentId: agent.id, resourceKey: "" })).map((cursor) => cursor.tenant)).toEqual([""]); delete process.env.A2A_TASK_DISCOVERY; await changeAgentSettings(admin, agent.id, { taskDiscovery: "browse" }, orm); + // A nickname tells apart registrations whose cards share a name: trimmed, unique ignoring case, clearable, audited, and shown by the catalog. + const second = await registry.add("https://agent.test/other-card", " Support (ADK) "); + expect(second.nickname).toBe("Support (ADK)"); + await changeAgentSettings(admin, agent.id, { nickname: "Support (LangGraph)" }, orm); + expect((await stored()).nickname).toBe("Support (LangGraph)"); + expect((await registry.list()).map((item) => item.nickname).sort()).toEqual(["Support (ADK)", "Support (LangGraph)"]); + await expect(changeAgentSettings(admin, agent.id, { nickname: "support (adk)" }, orm)).rejects.toMatchObject({ status: 409, kind: "nickname_taken" }); + await expect(registry.add("https://agent.test/third-card", "SUPPORT (ADK)")).rejects.toThrow(/already called/); + await expect(changeAgentSettings(admin, agent.id, { nickname: "x".repeat(101) }, orm)).rejects.toThrow(); + await expect(changeAgentSettings(admin, agent.id, { nickname: "bad\u0007name" }, orm)).rejects.toMatchObject({ status: 400 }); + await changeAgentSettings(admin, agent.id, { nickname: " " }, orm); + expect((await stored()).nickname).toBeNull(); + // A removed agent frees its nickname. + await registry.remove(second.id); + await changeAgentSettings(admin, agent.id, { nickname: "Support (ADK)" }, orm); + expect((await stored()).nickname).toBe("Support (ADK)"); + expect((await orm.em.fork().find(SecurityAuditEntity, { targetId: agent.id })).map((row) => row.action).filter((action) => action.startsWith("agent.nickname")).sort()) + .toEqual(["agent.nickname.cleared", "agent.nickname.set", "agent.nickname.set"]); + await changeAgentSettings(admin, agent.id, { nickname: null }, orm); // Another organization's agent is unknown. await expect(changeAgentSettings({ ...admin, organizationId: randomUUID() }, agent.id, { taskDiscovery: "off" }, orm)).rejects.toMatchObject({ status: 404 }); @@ -93,6 +112,7 @@ if (url && !decodeURIComponent(new URL(url).pathname).includes("test")) throw ne describe("agentSettingsSchema", () => { it("accepts the supported settings and refuses anything else", () => { expect(agentSettingsSchema.safeParse({ taskDiscovery: "browse", taskDiscoveryHistoryDays: 90 }).success).toBe(true); + expect(agentSettingsSchema.safeParse({ nickname: null }).success).toBe(true); for (const body of [{}, { taskDiscovery: "on" }, { taskDiscoveryHistoryDays: -1 }, { taskDiscoveryHistoryDays: 91 }, { taskDiscoveryHistoryDays: 1.5 }, { surprise: true }, { taskDiscovery: "off", extra: 1 }]) expect(agentSettingsSchema.safeParse(body).success, JSON.stringify(body)).toBe(false); }); diff --git a/src/server/adapters/db/database.db.test.ts b/src/server/adapters/db/database.db.test.ts index 601980f..d7639bd 100644 --- a/src/server/adapters/db/database.db.test.ts +++ b/src/server/adapters/db/database.db.test.ts @@ -12,7 +12,7 @@ import { withRequestEntityManager, } from "./orm"; -const migrationNames = ["Migration0001_Baseline", "Migration0002_TaskDiscovery", "Migration0003_CommandErrorKind", "Migration0004_AgentExtensionOverride", "Migration0005_DiscoverySweepProgress", "Migration0006_TaskContextIndex", "Migration0007_ExtendedCardKind", "Migration0008_TaskLinks"]; +const migrationNames = ["Migration0001_Baseline", "Migration0002_TaskDiscovery", "Migration0003_CommandErrorKind", "Migration0004_AgentExtensionOverride", "Migration0005_DiscoverySweepProgress", "Migration0006_TaskContextIndex", "Migration0007_ExtendedCardKind", "Migration0008_TaskLinks", "Migration0009_AgentNickname"]; async function verifyMigrationContract(config: DatabaseConfig) { const orm = await createDatabaseOrm(config); diff --git a/src/server/adapters/db/entities.ts b/src/server/adapters/db/entities.ts index 5310ea9..5971a8d 100644 --- a/src/server/adapters/db/entities.ts +++ b/src/server/adapters/db/entities.ts @@ -44,6 +44,7 @@ const AgentSchema = defineEntity({ source: p.string().length(32).$type<"env" | "managed">(), enabled: p.boolean(), displayName: p.string().length(300).nullable(), + nickname: p.string().length(100).nullable(), description: p.text().nullable(), protocolSnapshotVersion: p.string().length(100).nullable(), lastDiscoveryAt: p.datetime().nullable(), diff --git a/src/server/adapters/db/migrations/Migration0009_AgentNickname.js b/src/server/adapters/db/migrations/Migration0009_AgentNickname.js new file mode 100644 index 0000000..6a8c1e1 --- /dev/null +++ b/src/server/adapters/db/migrations/Migration0009_AgentNickname.js @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations"; + +// An administrator can give a registration its own name, to tell apart agents whose cards advertise the same name. +// Nullable, so an instance on the previous release keeps working against this schema. +export class Migration0009_AgentNickname extends Migration { + name = "Migration0009_AgentNickname"; + + async up() { + this.addSql(`alter table "agents" add "nickname" varchar(100) null;`); + } + + async down() { + this.addSql(`alter table "agents" drop column "nickname";`); + } +} diff --git a/src/server/adapters/db/repositories.ts b/src/server/adapters/db/repositories.ts index da9f4b1..ec27b70 100644 --- a/src/server/adapters/db/repositories.ts +++ b/src/server/adapters/db/repositories.ts @@ -58,6 +58,7 @@ function agentRecord(entity: AgentEntity): AgentRecord { source: entity.source, enabled: entity.enabled, displayName: entity.displayName ?? null, + nickname: entity.nickname ?? null, description: entity.description ?? null, protocolSnapshotVersion: entity.protocolSnapshotVersion ?? null, lastDiscoveryAt: entity.lastDiscoveryAt ?? null, @@ -224,6 +225,10 @@ export class MikroOrmAgentRepository implements AgentRepository { return (await this.entityManager.nativeUpdate(AgentEntity, { organizationId, id }, { extensionOverride, updatedAt: now })) > 0; } + async setNickname(organizationId: string, id: string, nickname: string | null, now: Date) { + return (await this.entityManager.nativeUpdate(AgentEntity, { organizationId, id }, { nickname, updatedAt: now })) > 0; + } + async setTaskDiscovery( organizationId: string, id: string, diff --git a/src/server/adapters/db/schema-version.ts b/src/server/adapters/db/schema-version.ts index 8cf59f1..dda932c 100644 --- a/src/server/adapters/db/schema-version.ts +++ b/src/server/adapters/db/schema-version.ts @@ -5,7 +5,7 @@ import { readdirSync } from "node:fs"; * `Migration_` and applied in order, so a database at version N reaches this version by * running every migration after N. A test keeps this equal to the files on disk. Raise it only by adding a migration. */ -export const DB_SCHEMA_VERSION = 8; +export const DB_SCHEMA_VERSION = 9; const NUMBERED = /^Migration(\d{4})_/; diff --git a/src/server/adapters/db/task-link-repository.ts b/src/server/adapters/db/task-link-repository.ts index 8414728..30543ca 100644 --- a/src/server/adapters/db/task-link-repository.ts +++ b/src/server/adapters/db/task-link-repository.ts @@ -38,7 +38,7 @@ export class MikroOrmTaskLinkRepository implements TaskLinkRepository { { fields: ["id", "remoteTaskId", "agentId", "state", "title", "skillId", "tenant", "kind", "terminalAt"], orderBy: { createdAt: "asc", id: "asc" }, refresh: true }); if (!tasks.length) return empty; const agents = await this.em.find(AgentEntity, { organizationId, id: { $in: [...new Set(tasks.map((task) => task.agentId))] } }); - const names = new Map(agents.map((agent) => [agent.id, agent.displayName ?? "Agent"])); + const names = new Map(agents.map((agent) => [agent.id, agent.nickname || agent.displayName || "Agent"])); const summary = (task: typeof tasks[number]): RelatedTask => ({ localId: task.id, taskId: task.remoteTaskId ?? task.id, agentId: task.agentId, tenant: task.tenant, canOperate: policy?.allows(task.agentId, "operate", task.skillId) ?? false, cancelable: task.kind === "task" && !task.terminalAt && !terminalTask(task.state), diff --git a/src/server/adapters/db/task-links.db.test.ts b/src/server/adapters/db/task-links.db.test.ts index d0b454a..e890c7f 100644 --- a/src/server/adapters/db/task-links.db.test.ts +++ b/src/server/adapters/db/task-links.db.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import type { DatabaseConfig } from "./config"; import { createDatabaseOrm } from "./orm"; import { createPersistenceRepositories } from "./repositories"; +import { bootstrapDefaultLocalOrganization } from "../../application/services/bootstrap-default-organization"; import { DatabaseAgentRegistry } from "./agent-registry"; import { provisionIdentity } from "./identity-repository"; import { createGrant } from "./security-repository"; @@ -26,8 +27,10 @@ async function contract(config: DatabaseConfig, directory: string) { await orm.migrator.up({ to: "Migration0007_ExtendedCardKind" }); const repos = () => createPersistenceRepositories(orm.em.fork()); const registry = () => new DatabaseAgentRegistry({ orm, legacyFilePath: join(directory, "absent"), environmentUrls: () => [] }); - const agent = await registry().add("https://links.test/card.json"); - const org = (await repos().organizations.findBySlug("local"))!; + // Written as SQL: this schema predates the entity's newest columns. + const org = await bootstrapDefaultLocalOrganization(repos().organizations); + const agent = { id: randomUUID(), cardUrl: "https://links.test/card.json" }; + await orm.em.getConnection().execute(`insert into "agents" ("id", "organization_id", "card_url", "source", "enabled", "created_at", "updated_at") values (?, ?, ?, 'managed', true, now(), now())`, [agent.id, org.id, agent.cardUrl]); const now = new Date(); const old: TaskRecord = { id: randomUUID(), organizationId: org.id, agentId: agent.id, tenant: "", remoteTaskId: "before-upgrade", remoteContextId: null, kind: "task", state: "TASK_STATE_COMPLETED", title: "Kept on upgrade", ownerUserId: null, ownerTeamId: null, diff --git a/src/server/application/ports/agent-registry.ts b/src/server/application/ports/agent-registry.ts index dc8f26c..199796f 100644 --- a/src/server/application/ports/agent-registry.ts +++ b/src/server/application/ports/agent-registry.ts @@ -4,6 +4,8 @@ export interface RegisteredAgent { id: string; cardUrl: string; source: "env" | "managed"; + /** The administrator's own name for this agent; shown instead of the card name. */ + nickname?: string; /** True when an administrator allows sending although the card requires an unsupported extension. */ extensionOverride?: boolean; } @@ -23,7 +25,7 @@ export interface AgentDiscoverySnapshot { export interface AgentRegistry { list(): Promise; get(id: string): Promise; - add(cardUrl: string): Promise; + add(cardUrl: string, nickname?: string | null): Promise; remove(id: string): Promise; recordDiscovery(id: string, snapshot: AgentDiscoverySnapshot): Promise; } diff --git a/src/server/application/ports/persistence.ts b/src/server/application/ports/persistence.ts index 3d1c1c0..02a88f7 100644 --- a/src/server/application/ports/persistence.ts +++ b/src/server/application/ports/persistence.ts @@ -27,6 +27,8 @@ export interface AgentRepository { registration: Pick, ): Promise; setExtensionOverride(organizationId: string, id: string, extensionOverride: boolean, now: Date): Promise; + /** Sets or clears (null) an agent's nickname. The caller has normalized it and checked it is not taken. */ + setNickname(organizationId: string, id: string, nickname: string | null, now: Date): Promise; /** Changes an agent's task discovery settings. Rejects an unknown mode or a history outside 0 to 90 days. */ setTaskDiscovery( organizationId: string, diff --git a/src/server/application/services/agent-catalog.ts b/src/server/application/services/agent-catalog.ts index 905f2fc..6bda92f 100644 --- a/src/server/application/services/agent-catalog.ts +++ b/src/server/application/services/agent-catalog.ts @@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; import type { AgentDiscoverySnapshot, RegisteredAgent } from "../ports/agent-registry"; import type { Clock } from "../ports/clock"; import type { AgentRepository } from "../ports/persistence"; +import { assertNicknameFree, normalizeNickname } from "../../domain/agent-label"; import type { AgentRecord } from "../../domain/persistence-model"; /** Compatibility alias only. Durable primary keys are locally generated UUIDs. */ @@ -40,7 +41,7 @@ export class AgentCatalogService { const now = this.clock.now(); return { id: this.createId(), organizationId: this.organizationId, cardUrl, source, - enabled: true, displayName: null, description: null, protocolSnapshotVersion: null, + enabled: true, displayName: null, nickname: null, description: null, protocolSnapshotVersion: null, lastDiscoveryAt: null, lastHealthyAt: null, createdAt: now, updatedAt: now, }; } @@ -74,7 +75,7 @@ export class AgentCatalogService { return agents.filter((agent) => agent.enabled && (agent.source === "managed" || this.environmentUrls.has(agent.cardUrl))) .map((agent) => ({ - id: agent.id, cardUrl: agent.cardUrl, + id: agent.id, cardUrl: agent.cardUrl, ...(agent.nickname ? { nickname: agent.nickname } : {}), source: this.environmentUrls.has(agent.cardUrl) ? "env" : agent.source, ...(agent.extensionOverride ? { extensionOverride: true } : {}), })); @@ -85,8 +86,20 @@ export class AgentCatalogService { agent.id === id || legacyAgentIdFromCardUrl(agent.cardUrl) === id); } - async add(cardUrl: string): Promise { + /** Sets or clears (empty or null) an agent's nickname; throws if another enabled agent has it. Returns the stored nickname. */ + async setNickname(id: string, nickname: string | null): Promise { + const agent = await this.get(id); + if (!agent) throw new Error("Unknown agent."); + const normalized = normalizeNickname(nickname); + if (normalized) assertNicknameFree(await this.agents.listByOrganization(this.organizationId), agent.id, normalized); + await this.agents.setNickname(this.organizationId, agent.id, normalized, this.clock.now()); + return normalized; + } + + /** Registering a card URL again keeps its record; a nickname given with it replaces the old one. */ + async add(cardUrl: string, nickname?: string | null): Promise { const validated = validateCardUrl(cardUrl); + const wanted = normalizeNickname(nickname); const isEnvironment = this.environmentUrls.has(validated); const agent = await this.agents.getOrCreate(this.createAgent(validated, isEnvironment ? "env" : "managed")); if (!agent.enabled || (!isEnvironment && agent.source !== "managed")) { @@ -94,7 +107,8 @@ export class AgentCatalogService { source: isEnvironment ? agent.source : "managed", enabled: true, updatedAt: this.clock.now(), }); } - return { id: agent.id, cardUrl: validated, source: isEnvironment ? "env" : "managed" }; + const stored = wanted ? await this.setNickname(agent.id, wanted) : agent.nickname ?? null; + return { id: agent.id, cardUrl: validated, source: isEnvironment ? "env" : "managed", ...(stored ? { nickname: stored } : {}) }; } async remove(id: string): Promise { diff --git a/src/server/application/services/observe-task.ts b/src/server/application/services/observe-task.ts index 1e1f46f..eea9dfa 100644 --- a/src/server/application/services/observe-task.ts +++ b/src/server/application/services/observe-task.ts @@ -84,7 +84,7 @@ export class ObserveTaskService { if (input.source === "command_response" && (input.skillId ?? null) !== (task.skillId ?? null)) throw new Error("Agent response crossed a task skill boundary."); let view: DurableTaskView = { - taskId: task.remoteTaskId ?? task.id, agentId: agent.id, agentName: agent.displayName ?? "Agent", + taskId: task.remoteTaskId ?? task.id, agentId: agent.id, agentName: agent.nickname || agent.displayName || "Agent", kind: task.kind === "message" ? "message" : "task", state: task.state, createdAt: task.createdAt.toISOString(), updatedAt: task.updatedAt.toISOString(), messages: [], artifacts: [], ...object(task.contentJson), localId: task.id, tenant: task.tenant, referenceLinks: {}, @@ -127,7 +127,7 @@ export class ObserveTaskService { await append(input.event, input.source, input.sourceKey, input.payloadDigest, input.originalEventObjectKey); if (changed) { const ledger = await this.events.findByTaskId(input.organizationId, task.id); - view = reduceTaskLedger(task, agent.displayName ?? "Agent", ledger); + view = reduceTaskLedger(task, agent.nickname || agent.displayName || "Agent", ledger); const terminal = ["COMPLETED", "FAILED", "CANCELED", "REJECTED"].includes(view.state.replace("TASK_STATE_", "")); const projection: TaskRecord = { ...task, state: view.state, remoteContextId: view.contextId ?? task.remoteContextId, diff --git a/src/server/application/services/task-query.ts b/src/server/application/services/task-query.ts index 05683c9..d5c515b 100644 --- a/src/server/application/services/task-query.ts +++ b/src/server/application/services/task-query.ts @@ -36,7 +36,7 @@ export class TaskQueryService { const agents = new Map((await this.agents.listByOrganization(organizationId)).map((agent) => [agent.id, agent])); const tasks = rows.map((task) => ({ localId: task.id, taskId: task.remoteTaskId!, tenant: task.tenant, - agentId: task.agentId, agentName: agents.get(task.agentId)?.displayName ?? "Agent", + agentId: task.agentId, agentName: agents.get(task.agentId)?.nickname || agents.get(task.agentId)?.displayName || "Agent", state: task.state, kind: "task" as const, title: task.title ?? undefined, contextId: task.remoteContextId ?? undefined, origin: task.origin ?? "created", createdAt: task.createdAt.toISOString(), updatedAt: task.updatedAt.toISOString(), @@ -71,7 +71,7 @@ export class TaskQueryService { private async view(organizationId: string, task: TaskRecord): Promise { const agent = await this.agents.findById(organizationId, task.agentId); const view = { - taskId: task.remoteTaskId ?? task.id, agentId: task.agentId, agentName: agent?.displayName ?? "Agent", + taskId: task.remoteTaskId ?? task.id, agentId: task.agentId, agentName: agent?.nickname || agent?.displayName || "Agent", kind: task.kind === "message" ? "message" : "task", state: task.state, createdAt: task.createdAt.toISOString(), updatedAt: task.updatedAt.toISOString(), messages: [], artifacts: [], ...object(task.contentJson), localId: task.id, tenant: task.tenant, skillId: task.skillId ?? undefined, version: task.version, referenceLinks: {}, diff --git a/src/server/domain/agent-label.test.ts b/src/server/domain/agent-label.test.ts new file mode 100644 index 0000000..56c8d90 --- /dev/null +++ b/src/server/domain/agent-label.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { assertNicknameFree, NicknameTakenError, normalizeNickname } from "./agent-label"; + +describe("normalizeNickname", () => { + it("trims and collapses whitespace, and treats empty as no nickname", () => { + expect(normalizeNickname(" Support \t (ADK) ")).toBe("Support (ADK)"); + for (const empty of ["", " ", null, undefined]) expect(normalizeNickname(empty)).toBeNull(); + }); + + it("refuses a nickname that is too long or holds control characters", () => { + expect(normalizeNickname("x".repeat(100))).toHaveLength(100); + expect(() => normalizeNickname("x".repeat(101))).toThrow(/at most 100/); + expect(() => normalizeNickname("a\u0007b")).toThrow(/control/); + }); +}); + +describe("assertNicknameFree", () => { + const agents = [ + { id: "1", enabled: true, nickname: "Support" }, + { id: "2", enabled: false, nickname: "Retired" }, + { id: "3", enabled: true, nickname: null }, + ]; + + it("refuses a name another enabled agent has, ignoring case", () => { + expect(() => assertNicknameFree(agents, "3", "SUPPORT")).toThrow(NicknameTakenError); + }); + + it("allows the agent's own name, a removed agent's name and a new name", () => { + expect(() => assertNicknameFree(agents, "1", "support")).not.toThrow(); + expect(() => assertNicknameFree(agents, "3", "Retired")).not.toThrow(); + expect(() => assertNicknameFree(agents, "3", "Billing")).not.toThrow(); + }); +}); diff --git a/src/server/domain/agent-label.ts b/src/server/domain/agent-label.ts new file mode 100644 index 0000000..b8daa75 --- /dev/null +++ b/src/server/domain/agent-label.ts @@ -0,0 +1,28 @@ +/** The longest nickname an administrator can give an agent. */ +export const MAX_AGENT_NICKNAME_LENGTH = 100; + +/** + * Trims a nickname and refuses one that is too long or holds control characters. An empty value clears the nickname (null). + * The nickname is the administrator's own name for a registration; the Agent Card name is whatever the agent advertises. + */ +export function normalizeNickname(value: string | null | undefined): string | null { + const nickname = (value ?? "").replace(/\s+/g, " ").trim(); + if (!nickname) return null; + if (nickname.length > MAX_AGENT_NICKNAME_LENGTH) throw new Error(`Nickname must be at most ${MAX_AGENT_NICKNAME_LENGTH} characters.`); + // eslint-disable-next-line no-control-regex + if (/[\u0000-\u001f\u007f]/.test(nickname)) throw new Error("Nickname must not contain control characters."); + return nickname; +} + +/** Thrown when another agent already has the nickname. */ +export class NicknameTakenError extends Error { + constructor(nickname: string) { super(`Another agent is already called “${nickname}”. Nicknames are unique so they can tell agents apart.`); } +} + +/** A nickname is unique, ignoring case, among the organization's enabled agents. A removed agent frees its nickname. */ +export function assertNicknameFree( + agents: ReadonlyArray<{ id: string; enabled: boolean; nickname?: string | null }>, agentId: string, nickname: string, +): void { + const wanted = nickname.toLowerCase(); + if (agents.some((agent) => agent.enabled && agent.id !== agentId && agent.nickname?.toLowerCase() === wanted)) throw new NicknameTakenError(nickname); +} diff --git a/src/server/domain/persistence-model.ts b/src/server/domain/persistence-model.ts index 571128e..712449e 100644 --- a/src/server/domain/persistence-model.ts +++ b/src/server/domain/persistence-model.ts @@ -28,6 +28,8 @@ export interface AgentRecord { source: "env" | "managed"; enabled: boolean; displayName: string | null; + /** The administrator's own name for this registration; shown instead of the card name when set. Absent or null means none. */ + nickname?: string | null; description: string | null; protocolSnapshotVersion: string | null; lastDiscoveryAt: Date | null; diff --git a/src/server/runtime/agent-settings.ts b/src/server/runtime/agent-settings.ts index 82be98c..187cb64 100644 --- a/src/server/runtime/agent-settings.ts +++ b/src/server/runtime/agent-settings.ts @@ -7,12 +7,15 @@ import { requestAccessPolicy } from "../adapters/db/security-repository"; import type { Principal } from "../application/ports/identity"; import { DEFAULT_TASK_DISCOVERY_HISTORY_DAYS, DEFAULT_TASK_DISCOVERY_MODE, MAX_TASK_DISCOVERY_HISTORY_DAYS, TASK_DISCOVERY_MODES, effectiveTaskDiscovery, type TaskDiscoveryMode } from "../domain/discovery-mode"; +import { assertNicknameFree, NicknameTakenError, normalizeNickname } from "../domain/agent-label"; import { taskDiscoveryCap } from "./runtime-config"; import { startAutomaticDiscovery } from "./discovery-cursors"; /** Per-agent settings an administrator can change. Fields are added as features need them; unknown fields are refused. */ export const agentSettingsSchema = z.object({ extensionOverride: z.boolean().optional(), + /** Empty or null clears it. */ + nickname: z.string().max(100).nullable().optional(), taskDiscovery: z.enum(TASK_DISCOVERY_MODES as [TaskDiscoveryMode, ...TaskDiscoveryMode[]]).optional(), taskDiscoveryHistoryDays: z.number().int().min(0).max(MAX_TASK_DISCOVERY_HISTORY_DAYS).optional(), }).strict().refine((value) => Object.keys(value).length > 0, "At least one setting is required."); @@ -32,6 +35,17 @@ export async function changeAgentSettings(principal: Principal, agentId: string, const agent = await ports.agents.findById(principal.organizationId, agentId); if (!agent) throw new AgentSettingsError("Unknown agent.", 404); const now = new Date(); + if (change.nickname !== undefined) { + let nickname: string | null; + try { + nickname = normalizeNickname(change.nickname); + if (nickname) assertNicknameFree(await ports.agents.listByOrganization(principal.organizationId), agentId, nickname); + } catch (error) { + throw new AgentSettingsError(error instanceof Error ? error.message : "Invalid nickname.", error instanceof NicknameTakenError ? 409 : 400, error instanceof NicknameTakenError ? "nickname_taken" : undefined); + } + await ports.agents.setNickname(principal.organizationId, agentId, nickname, now); + await audit.appendAudit(principal, nickname ? "agent.nickname.set" : "agent.nickname.cleared", agentId, `agent-nickname:${agentId}:${crypto.randomUUID()}`); + } if (change.extensionOverride !== undefined) { await ports.agents.setExtensionOverride(principal.organizationId, agentId, change.extensionOverride, now); await audit.appendAudit(principal, change.extensionOverride ? "agent.extension.override" : "agent.extension.override.removed", agentId, diff --git a/src/server/runtime/decisions.ts b/src/server/runtime/decisions.ts index 5a71590..e21aaa1 100644 --- a/src/server/runtime/decisions.ts +++ b/src/server/runtime/decisions.ts @@ -81,7 +81,7 @@ async function contextFor(em: Parameters return new Map(requests.map((request): [string, RequestContext] => { const agent = agents.find((candidate) => candidate.id === request.agentId); const task = tasks.find((candidate) => candidate.id === request.taskId); - return [request.id, { agentName: agent?.displayName ?? agent?.cardUrl ?? "Agent", taskTitle: task?.title ?? null, + return [request.id, { agentName: agent?.nickname || agent?.displayName || agent?.cardUrl || "Agent", taskTitle: task?.title ?? null, taskState: task?.state ?? null, taskRemoteId: task?.remoteTaskId ?? null }]; })); } diff --git a/src/server/runtime/inbox.ts b/src/server/runtime/inbox.ts index fac3404..06f8ca4 100644 --- a/src/server/runtime/inbox.ts +++ b/src/server/runtime/inbox.ts @@ -17,7 +17,7 @@ export async function readInbox(request: InboxRequest, options: { orm?: MikroORM const scope = !policy || policy.principal.role === "admin" ? undefined : policy.grants.map((grant) => ({ agentId: grant.agentId, skillId: grant.skillId })); const service = new InboxQueryService(new MikroOrmInboxRepository(em), { agents: async (organizationId, ids) => Object.fromEntries((ids.length ? await em.find(AgentEntity, { organizationId, id: { $in: ids } }) : []) - .map((agent) => [agent.id, agent.displayName ?? agent.cardUrl])), + .map((agent) => [agent.id, agent.nickname || agent.displayName || agent.cardUrl])), people: async (organizationId, ids) => { const memberships = ids.length ? await em.find(MembershipEntity, { organizationId, id: { $in: ids } }) : []; const users = memberships.length ? await em.find(UserEntity, { id: { $in: memberships.map((membership) => membership.userId) } }) : []; diff --git a/src/server/runtime/projection-rebuild.ts b/src/server/runtime/projection-rebuild.ts index 69624f2..696bfeb 100644 --- a/src/server/runtime/projection-rebuild.ts +++ b/src/server/runtime/projection-rebuild.ts @@ -19,7 +19,7 @@ export function createProjectionRebuilder(options: { orm?: MikroORM; store?: Art const task = await ports.tasks.findById(organizationId, taskId, false); if (!task) return undefined; const agent = await ports.agents.findById(organizationId, task.agentId); - return { task, agentName: agent?.displayName ?? "Agent", events: await ports.taskEvents.findByTaskId(organizationId, taskId) }; + return { task, agentName: agent?.nickname || agent?.displayName || "Agent", events: await ports.taskEvents.findByTaskId(organizationId, taskId) }; }, { isolationLevel: IsolationLevel.REPEATABLE_READ }), options.orm), activate: (snapshot, view, binaryDigests = []) => withJobEntityManager((em) => em.transactional(async (tx) => { // Lock without attempting to repair or insert an unknown identity. diff --git a/src/server/runtime/shared-ports.ts b/src/server/runtime/shared-ports.ts index 7ab51fb..d02d47b 100644 --- a/src/server/runtime/shared-ports.ts +++ b/src/server/runtime/shared-ports.ts @@ -39,7 +39,7 @@ export function sharedPorts(em: EntityManager) { const membership = await em.findOne(MembershipEntity, { organizationId, id: membershipId }); return (membership && (await em.findOne(UserEntity, { id: membership.userId }))?.displayName) || "A member"; }, - agentName: async (organizationId: string, agentId: string) => { const agent = await em.findOne(AgentEntity, { organizationId, id: agentId }); return agent?.displayName ?? "An agent"; }, + agentName: async (organizationId: string, agentId: string) => { const agent = await em.findOne(AgentEntity, { organizationId, id: agentId }); return agent?.nickname || agent?.displayName || "An agent"; }, notify: async (organizationId: string, event: NotificationEvent) => { await enqueueNotificationEvent(base.outbox, organizationId, event, new Date()); }, isReviewer: async (organizationId: string, membershipId: string) => { const membership = await em.findOne(MembershipEntity, { id: membershipId, organizationId, enabled: true }, { refresh: true }); @@ -64,7 +64,7 @@ export function sharedPorts(em: EntityManager) { const agentIds = [...new Set(tasks.map((task) => task.agentId))]; const agents = agentIds.length ? await em.find(AgentEntity, { organizationId, id: { $in: agentIds } }) : []; return Object.fromEntries(tasks.map((task) => [task.id, { title: task.title ?? null, state: task.state, - agentName: agents.find((agent) => agent.id === task.agentId)?.displayName ?? agents.find((agent) => agent.id === task.agentId)?.cardUrl ?? "Agent" }])); + agentName: (() => { const agent = agents.find((candidate) => candidate.id === task.agentId); return agent?.nickname || agent?.displayName || agent?.cardUrl || "Agent"; })() }])); }, freshen: async (organizationId: string, taskId: string) => { await enqueueTaskFreshness(base.outbox, organizationId, taskId, new Date()); }, audit: async (principal: Principal | null, organizationId: string, action: string, targetId: string, eventKey: string, actorType?: "agent") => { diff --git a/src/store/agent-store.ts b/src/store/agent-store.ts index 48441bc..7fe0532 100644 --- a/src/store/agent-store.ts +++ b/src/store/agent-store.ts @@ -9,6 +9,9 @@ export interface CatalogAgent { id: string; cardUrl: string; source: "env" | "managed"; + /** The administrator's own name for this agent. When set it is also `view.name`; the card's own name is `cardName`. */ + nickname?: string; + cardName?: string; /** Undefined when discovery failed; see `error`. */ view?: AgentView; trust?: string; @@ -36,15 +39,20 @@ export const useAgentStore = create()((set, get) => ({ const response = await fetch("/api/agents", { cache: "no-store" }); const body = await response.json(); if (!response.ok) throw new Error(body?.error?.message ?? "Could not load agents."); - const agents: CatalogAgent[] = (body.agents as Array<{ id: string; cardUrl: string; source: "env" | "managed"; card?: unknown; trust?: string; error?: string; extensions?: CatalogAgent["extensions"]; connection?: ConnectionView; extendedCard?: CatalogAgent["extendedCard"] }>).map( - (entry) => ({ + const agents: CatalogAgent[] = (body.agents as Array<{ id: string; cardUrl: string; source: "env" | "managed"; nickname?: string; card?: unknown; trust?: string; error?: string; extensions?: CatalogAgent["extensions"]; connection?: ConnectionView; extendedCard?: CatalogAgent["extendedCard"] }>).map( + (entry) => { + const card = entry.card ? viewAgentCard(entry.card) : undefined; + return { id: entry.id, cardUrl: entry.cardUrl, source: entry.source, - view: entry.card ? viewAgentCard(entry.card) : undefined, + nickname: entry.nickname || undefined, cardName: card?.name, + // Every screen names an agent by `view.name`, so the nickname replaces the card name there. + view: card && entry.nickname ? { ...card, name: entry.nickname } : card, error: entry.error, trust: entry.trust, extensions: entry.extensions, connection: entry.connection, extendedCard: entry.extendedCard, - }), + }; + }, ); set({ agents, status: "ready" }); } catch { @@ -53,5 +61,7 @@ export const useAgentStore = create()((set, get) => ({ }, })); -export const agentName = (agents: CatalogAgent[], id: string, fallback = id) => - agents.find((agent) => agent.id === id)?.view?.name ?? fallback; +export const agentName = (agents: CatalogAgent[], id: string, fallback = id) => { + const agent = agents.find((item) => item.id === id); + return agent?.nickname ?? agent?.view?.name ?? fallback; +}; From 3411020d47901585530283d039f91da84cbbcb45 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 10:12:18 +0000 Subject: [PATCH 2/2] Show the advertised card name beside an agent's nickname The nickname leads; the name on the agent's card stays visible on the agents list and page, the chat header and picker, and in brackets in dropdowns. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01Lu73uozkZGX8gP8LWNbBqt --- docs/guides/connect-an-agent.md | 2 +- src/app/admin/access/page.tsx | 6 +++--- src/app/agents/[agentId]/page.tsx | 4 ++-- src/app/agents/layout.tsx | 5 +++-- src/components/chat/chat-landing.tsx | 3 ++- src/components/chat/chat-view.tsx | 4 ++-- src/components/escalation-settings.tsx | 4 ++-- src/server/adapters/db/task-links.db.test.ts | 2 +- src/server/domain/agent-label.ts | 1 - src/store/agent-store.test.ts | 22 ++++++++++++++++++++ src/store/agent-store.ts | 10 +++++++++ 11 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 src/store/agent-store.test.ts diff --git a/docs/guides/connect-an-agent.md b/docs/guides/connect-an-agent.md index fcc6893..b266cfd 100644 --- a/docs/guides/connect-an-agent.md +++ b/docs/guides/connect-an-agent.md @@ -24,7 +24,7 @@ For the sample agents, the paths are `http://127.0.0.1:4010//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 replaces the card name everywhere in the console, 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. +**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). diff --git a/src/app/admin/access/page.tsx b/src/app/admin/access/page.tsx index 894918b..ada126d 100644 --- a/src/app/admin/access/page.tsx +++ b/src/app/admin/access/page.tsx @@ -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(); @@ -40,7 +40,7 @@ export default function AgentAccessPage() { setAgentId(event.target.value)} required> - {agents.map((agent) => )} + {agents.map((agent) => )} @@ -59,7 +59,7 @@ export default function AgentAccessPage() { const credential = view.credentials.find((item) => item.agentId === agent.id); return (
  • - {agent.view?.name ?? agent.cardUrl} + {agentOptionLabel(agent, agent.cardUrl)} {credential ? {credential.kind} · {credential.enabled ? "configured" : "revoked"} : none}
  • ); diff --git a/src/app/agents/[agentId]/page.tsx b/src/app/agents/[agentId]/page.tsx index e06e6c9..b85c87e 100644 --- a/src/app/agents/[agentId]/page.tsx +++ b/src/app/agents/[agentId]/page.tsx @@ -21,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); @@ -67,7 +67,7 @@ export default function AgentDetailPage({ params }: { params: Promise<{ agentId: detail size="wide" title={view?.name ?? agent.nickname ?? "Unreachable agent"} - description={view?.description ?? agent.error} + description={[advertisedName(agent) && `Card name: ${advertisedName(agent)}`, view?.description ?? agent.error].filter(Boolean).join(" · ")} eyebrow={view ? Reachable · Agent Card v{view.version} : Discovery failed} actions={ <> diff --git a/src/app/agents/layout.tsx b/src/app/agents/layout.tsx index 59fd4c3..11b5a0e 100644 --- a/src/app/agents/layout.tsx +++ b/src/app/agents/layout.tsx @@ -9,7 +9,7 @@ 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; } }; @@ -55,7 +55,8 @@ export default function AgentsLayout({ children }: { children: React.ReactNode }
    -
    {agent.view?.name ?? "Unreachable agent"}
    +
    {agent.view?.name ?? agent.nickname ?? "Unreachable agent"}
    + {advertisedName(agent) &&
    {advertisedName(agent)}
    }
    {agent.view ? `v${agent.view.version} · ${agent.view.security[0] ?? "no auth"}` : (agent.error ?? "discovery failed")}
    diff --git a/src/components/chat/chat-landing.tsx b/src/components/chat/chat-landing.tsx index 78cc672..b3148d2 100644 --- a/src/components/chat/chat-landing.tsx +++ b/src/components/chat/chat-landing.tsx @@ -14,7 +14,7 @@ import { useMemo } from "react"; import { useShallow } from "zustand/react/shallow"; import { groupConversations } from "@/lib/conversations"; import { relativeTime } from "@/lib/task-view"; -import { useAgentStore } from "@/store/agent-store"; +import { advertisedName, useAgentStore } from "@/store/agent-store"; import { useTaskStore } from "@/store/task-store"; export function ChatMissing() { @@ -65,6 +65,7 @@ export function ChatLanding({ agentId }: { agentId?: string }) { {agent.view?.name} + {advertisedName(agent) && {advertisedName(agent)}} {agent.view?.skills.length ? `${agent.view.skills.length} skill${agent.view.skills.length === 1 ? "" : "s"}` : `v${agent.view?.version ?? ""}`} diff --git a/src/components/chat/chat-view.tsx b/src/components/chat/chat-view.tsx index 442b0b8..d1cd5e6 100644 --- a/src/components/chat/chat-view.tsx +++ b/src/components/chat/chat-view.tsx @@ -34,7 +34,7 @@ import { runSend, userThreadMessage } from "@/lib/run-message"; import { normalizeParts } from "@/lib/content"; import type { SteeringEntry } from "@/shared/task-types"; import { isActiveState, stateName, taskTitle } from "@/lib/task-view"; -import { useAgentStore } from "@/store/agent-store"; +import { advertisedName, useAgentStore } from "@/store/agent-store"; import { useSettingsStore } from "@/store/settings-store"; import { useTaskStore, type SendConfig, type ThreadMessage, type TrackedTask } from "@/store/task-store"; import type { DurableTaskView } from "@/shared/task-types"; @@ -304,7 +304,7 @@ export function ChatView({ conversationKey: initialKey, agentId: initialAgentId
    -
    {name}{tasks.some((task) => task.origin === "discovered") && Discovered}
    +
    {name}{advertisedName(agent) && {advertisedName(agent)}}{tasks.some((task) => task.origin === "discovered") && Discovered}
    {/* Beside the name from `md` up; on a phone it sits on its own row below, where there is room for the ID. */}
    {conversationMeta}
    diff --git a/src/components/escalation-settings.tsx b/src/components/escalation-settings.tsx index a0121d7..3e3d53e 100644 --- a/src/components/escalation-settings.tsx +++ b/src/components/escalation-settings.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { InfoCard } from "./a2a/primitives"; import { Button } from "./ui/button"; import { NativeSelect } from "./ui/native-select"; -import { useAgentStore } from "@/store/agent-store"; +import { agentOptionLabel, useAgentStore } from "@/store/agent-store"; import type { EscalationPolicyView } from "@/shared/workflow-types"; interface PolicyData { policies: EscalationPolicyView[]; people: Record } @@ -65,7 +65,7 @@ export function EscalationSettings() { }}> - {agents.map((agent) => )} + {agents.map((agent) => )} diff --git a/src/server/adapters/db/task-links.db.test.ts b/src/server/adapters/db/task-links.db.test.ts index e890c7f..e9b7d39 100644 --- a/src/server/adapters/db/task-links.db.test.ts +++ b/src/server/adapters/db/task-links.db.test.ts @@ -29,7 +29,7 @@ async function contract(config: DatabaseConfig, directory: string) { const registry = () => new DatabaseAgentRegistry({ orm, legacyFilePath: join(directory, "absent"), environmentUrls: () => [] }); // Written as SQL: this schema predates the entity's newest columns. const org = await bootstrapDefaultLocalOrganization(repos().organizations); - const agent = { id: randomUUID(), cardUrl: "https://links.test/card.json" }; + const agent: { id: string; cardUrl: string } = { id: randomUUID(), cardUrl: "https://links.test/card.json" }; await orm.em.getConnection().execute(`insert into "agents" ("id", "organization_id", "card_url", "source", "enabled", "created_at", "updated_at") values (?, ?, ?, 'managed', true, now(), now())`, [agent.id, org.id, agent.cardUrl]); const now = new Date(); const old: TaskRecord = { id: randomUUID(), organizationId: org.id, agentId: agent.id, tenant: "", remoteTaskId: "before-upgrade", diff --git a/src/server/domain/agent-label.ts b/src/server/domain/agent-label.ts index b8daa75..abce12b 100644 --- a/src/server/domain/agent-label.ts +++ b/src/server/domain/agent-label.ts @@ -9,7 +9,6 @@ export function normalizeNickname(value: string | null | undefined): string | nu const nickname = (value ?? "").replace(/\s+/g, " ").trim(); if (!nickname) return null; if (nickname.length > MAX_AGENT_NICKNAME_LENGTH) throw new Error(`Nickname must be at most ${MAX_AGENT_NICKNAME_LENGTH} characters.`); - // eslint-disable-next-line no-control-regex if (/[\u0000-\u001f\u007f]/.test(nickname)) throw new Error("Nickname must not contain control characters."); return nickname; } diff --git a/src/store/agent-store.test.ts b/src/store/agent-store.test.ts new file mode 100644 index 0000000..5a93636 --- /dev/null +++ b/src/store/agent-store.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { advertisedName, agentOptionLabel, type CatalogAgent } from "./agent-store"; + +const agent = (extra: Partial): CatalogAgent => ({ id: "a", cardUrl: "https://x.test/card", source: "managed", ...extra }); + +describe("advertisedName", () => { + it("returns the card name only when a nickname hides it", () => { + expect(advertisedName(agent({ nickname: "Support (ADK)", cardName: "Support" }))).toBe("Support"); + expect(advertisedName(agent({ cardName: "Support" }))).toBeUndefined(); + expect(advertisedName(agent({ nickname: "Support", cardName: "Support" }))).toBeUndefined(); + expect(advertisedName(agent({ nickname: "Support (ADK)" }))).toBeUndefined(); + expect(advertisedName(undefined)).toBeUndefined(); + }); +}); + +describe("agentOptionLabel", () => { + it("puts the card name in brackets after a nickname", () => { + expect(agentOptionLabel(agent({ nickname: "Support (ADK)", cardName: "Support", view: { name: "Support (ADK)" } as CatalogAgent["view"] }), "x")).toBe("Support (ADK) (Support)"); + expect(agentOptionLabel(agent({ cardName: "Support", view: { name: "Support" } as CatalogAgent["view"] }), "x")).toBe("Support"); + expect(agentOptionLabel(agent({}), "fallback")).toBe("fallback"); + }); +}); diff --git a/src/store/agent-store.ts b/src/store/agent-store.ts index 7fe0532..42d4531 100644 --- a/src/store/agent-store.ts +++ b/src/store/agent-store.ts @@ -65,3 +65,13 @@ export const agentName = (agents: CatalogAgent[], id: string, fallback = id) => const agent = agents.find((item) => item.id === id); return agent?.nickname ?? agent?.view?.name ?? fallback; }; + +/** The card's own name when a nickname hides it, else undefined. Show it beside the nickname so the advertised name stays visible. */ +export const advertisedName = (agent: CatalogAgent | undefined) => + agent?.nickname && agent.cardName && agent.cardName !== agent.nickname ? agent.cardName : undefined; + +/** A one-line label for pickers: the nickname with the card name in brackets, else the name. */ +export const agentOptionLabel = (agent: CatalogAgent, fallback: string) => { + const advertised = advertisedName(agent); + return `${agent.view?.name ?? fallback}${advertised ? ` (${advertised})` : ""}`; +};