diff --git a/apps/web/src/pages/worker-page.tsx b/apps/web/src/pages/worker-page.tsx
index 48e00c835..03b014ad4 100644
--- a/apps/web/src/pages/worker-page.tsx
+++ b/apps/web/src/pages/worker-page.tsx
@@ -12,16 +12,11 @@ import { isMyraAgent, listChatAgents, type ChatAgent } from "@/chat/threads-api"
import { ChatCircle } from "@/lib/icons";
import { QueryView, describeApiError } from "@/lib/api-query";
import { listTopLevelRuns } from "../agents-api";
-import {
- createGrant,
- listGrants,
- listPrincipals,
- revokeGrant,
- type Grant,
-} from "../settings/tenancy-api";
+import { createGrant, revokeGrant, type Grant } from "../settings/tenancy-api";
import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy";
import { readAgentMcpHandles, readAgentSource } from "../agent-source-read";
-import { applyGrantSnapshot, readGrantSnapshot, saveGrantSnapshot } from "../worker-grants-carry";
+import { readGrantSnapshot, saveGrantSnapshot } from "../worker-grants-carry";
+import { TOOL_PREFIX, readWorkerToolGrants, workerToolGrantsKey } from "../worker-tool-grants";
import { useBench } from "../bench-context";
import { Link } from "../navigation";
import { tenantKeys } from "../query-client";
@@ -30,7 +25,7 @@ import { StageTopBar } from "../shell/stage-top-bar";
import { WORKERS_PATH_PREFIX } from "../path-ids";
import { workbenchPath } from "../workbench-path";
import { useWorkerBenches } from "../worker-benches";
-import { StatusPill, workerStatus } from "./workers-page";
+import { StatusPill, WorkerRole, workerStatus } from "./workers-page";
function sourceKey(tenantId: string, agentId: string) {
return [...tenantKeys.agents(tenantId), "source", agentId] as const;
@@ -139,34 +134,6 @@ const MODES: readonly { readonly effect: GrantEffect; readonly label: string }[]
{ effect: "deny", label: "Deny" },
];
-const TOOL_PREFIX = "tool:";
-
-/** The worker's live run principal: runtime grants belong to it, and it only
- * exists once the worker has been triggered. */
-async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) {
- if (agent.liveAddress === null) return null;
- const [runs, principals] = await Promise.all([
- listTopLevelRuns(tenantId),
- listPrincipals(tenantId),
- ]);
- const run = runs.find((candidate) => candidate.address === agent.liveAddress);
- const principal = principals.find(
- (candidate) => candidate.kind === "workflow" && candidate.refId === run?.id,
- );
- if (principal === undefined) return null;
- const read = async () => {
- const grants = await listGrants(tenantId, { principalId: principal.id });
- return grants
- .filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke")
- .sort((a, b) => a.resource.localeCompare(b.resource));
- };
- let tools = await read();
- if (await applyGrantSnapshot(tenantId, agent.assetName, principal.id, tools)) {
- tools = await read();
- }
- return { principalId: principal.id, tools };
-}
-
function PermissionsCard({
tenantId,
agent,
@@ -175,7 +142,7 @@ function PermissionsCard({
readonly agent: ChatAgent;
}) {
const queryClient = useQueryClient();
- const key = [...tenantKeys.grants(tenantId), "worker", agent.id] as const;
+ const key = workerToolGrantsKey(tenantId, agent.id);
// While a redeploy's permissions are waiting for the new run to start,
// keep looking for its principal.
const carrying = readGrantSnapshot(tenantId, agent.assetName) !== null;
@@ -364,9 +331,11 @@ export function WorkerRoute({ agentId }: { readonly agentId: string }) {
/>
{agent.name}
- {isMyraAgent(agent) ? (
-
Your assistant
- ) : null}
+
{status.text}
diff --git a/apps/web/src/pages/workers-page.tsx b/apps/web/src/pages/workers-page.tsx
index 381cb0829..d9c83c4b9 100644
--- a/apps/web/src/pages/workers-page.tsx
+++ b/apps/web/src/pages/workers-page.tsx
@@ -20,6 +20,7 @@ import { PageLayout } from "../shell/page-layout";
import { StageTopBar } from "../shell/stage-top-bar";
import { CreateAgentPanel } from "./create-agent-panel";
import { WORKERS_PATH_PREFIX } from "../path-ids";
+import { useWorkerRole } from "../worker-role-query";
export type WorkerTone = "working" | "ready" | "idle";
@@ -46,6 +47,18 @@ const PILL_LABEL: Record = {
idle: "Idle",
};
+export function WorkerRole({
+ tenantId,
+ agent,
+ className,
+}: {
+ readonly tenantId: string;
+ readonly agent: ChatAgent;
+ readonly className?: string;
+}) {
+ return {useWorkerRole(tenantId, agent)}
;
+}
+
export function StatusPill({ tone }: { readonly tone: WorkerTone }) {
return (
- {agent.name}
+
+ {agent.name}
+
+
diff --git a/apps/web/src/worker-role-query.ts b/apps/web/src/worker-role-query.ts
new file mode 100644
index 000000000..2a7a776d4
--- /dev/null
+++ b/apps/web/src/worker-role-query.ts
@@ -0,0 +1,36 @@
+import { useQuery } from "@tanstack/react-query";
+import { reportError } from "@corbits/error-sink";
+
+import { isMyraAgent, type ChatAgent } from "./chat/threads-api";
+import { readAgentMcpHandles } from "./agent-source-read";
+import { tenantKeys } from "./query-client";
+import { workerRole } from "./worker-role";
+import { TOOL_PREFIX, readWorkerToolGrants, workerToolGrantsKey } from "./worker-tool-grants";
+
+/** The role line for a worker, from its bound MCP servers and its run's
+ * `tool:*` grants. Until both land it reads as no tools yet. */
+export function useWorkerRole(tenantId: string, agent: ChatAgent): string {
+ const isMyra = isMyraAgent(agent);
+ const grants = useQuery({
+ queryKey: workerToolGrantsKey(tenantId, agent.id),
+ queryFn: () => readWorkerToolGrants(tenantId, agent),
+ enabled: !isMyra,
+ });
+ const handles = useQuery({
+ queryKey: [...tenantKeys.agents(tenantId), "mcp-handles", agent.id],
+ queryFn: async () => {
+ try {
+ return await readAgentMcpHandles(tenantId, agent.id, agent.assetName);
+ } catch (error) {
+ reportError(error, { operation: "worker_role_mcp_handles_read" });
+ return [];
+ }
+ },
+ enabled: !isMyra,
+ });
+ return workerRole({
+ isMyra,
+ tools: (grants.data?.tools ?? []).map((grant) => grant.resource.slice(TOOL_PREFIX.length)),
+ mcpServers: handles.data ?? [],
+ });
+}
diff --git a/apps/web/src/worker-role.ts b/apps/web/src/worker-role.ts
new file mode 100644
index 000000000..886763125
--- /dev/null
+++ b/apps/web/src/worker-role.ts
@@ -0,0 +1,34 @@
+// A worker's role line: the stock definition has no role field, so it is
+// derived from what the worker can reach.
+
+const MAX_ITEMS = 3;
+
+const TOOL_FAMILIES: readonly (readonly [RegExp, string])[] = [
+ [/^mail_/, "Mail"],
+ [/^(posix|fs|shell)(_|$)/, "Files & shell"],
+];
+
+function titleCase(handle: string): string {
+ return handle
+ .split(/[-_\s]+/)
+ .filter((word) => word !== "")
+ .map((word) => word[0]?.toUpperCase() + word.slice(1))
+ .join(" ");
+}
+
+export function workerRole(input: {
+ readonly tools: readonly string[];
+ readonly mcpServers: readonly string[];
+ readonly isMyra: boolean;
+}): string {
+ if (input.isMyra) return "Assistant";
+ const items = new Set();
+ for (const [pattern, label] of TOOL_FAMILIES) {
+ if (input.tools.some((tool) => pattern.test(tool))) items.add(label);
+ }
+ for (const server of input.mcpServers) items.add(titleCase(server));
+ const all = [...items];
+ if (all.length === 0) return "No tools yet";
+ const shown = all.slice(0, MAX_ITEMS).join(" ยท ");
+ return all.length > MAX_ITEMS ? `${shown} +${all.length - MAX_ITEMS}` : shown;
+}
diff --git a/apps/web/src/worker-tool-grants.ts b/apps/web/src/worker-tool-grants.ts
new file mode 100644
index 000000000..0a7a6d67b
--- /dev/null
+++ b/apps/web/src/worker-tool-grants.ts
@@ -0,0 +1,40 @@
+// The worker's live run principal grants, shared by the worker page and the
+// role line.
+
+import { listTopLevelRuns } from "./agents-api";
+import type { ChatAgent } from "./chat/threads-api";
+import { tenantKeys } from "./query-client";
+import { listGrants, listPrincipals } from "./settings/tenancy-api";
+import { applyGrantSnapshot } from "./worker-grants-carry";
+
+export function workerToolGrantsKey(tenantId: string, agentId: string) {
+ return [...tenantKeys.grants(tenantId), "worker", agentId] as const;
+}
+
+export const TOOL_PREFIX = "tool:";
+
+/** The worker's live run principal: runtime grants belong to it, and it only
+ * exists once the worker has been triggered. */
+export async function readWorkerToolGrants(tenantId: string, agent: ChatAgent) {
+ if (agent.liveAddress === null) return null;
+ const [runs, principals] = await Promise.all([
+ listTopLevelRuns(tenantId),
+ listPrincipals(tenantId),
+ ]);
+ const run = runs.find((candidate) => candidate.address === agent.liveAddress);
+ const principal = principals.find(
+ (candidate) => candidate.kind === "workflow" && candidate.refId === run?.id,
+ );
+ if (principal === undefined) return null;
+ const read = async () => {
+ const grants = await listGrants(tenantId, { principalId: principal.id });
+ return grants
+ .filter((grant) => grant.resource.startsWith(TOOL_PREFIX) && grant.action === "invoke")
+ .sort((a, b) => a.resource.localeCompare(b.resource));
+ };
+ let tools = await read();
+ if (await applyGrantSnapshot(tenantId, agent.assetName, principal.id, tools)) {
+ tools = await read();
+ }
+ return { principalId: principal.id, tools };
+}