diff --git a/CHANGELOG.md b/CHANGELOG.md index f8bd925..3b8b3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [0.15.0] - 2026-08-03 + +### Added + +- Antigravity subagent activity is now shown as rich status cards with agent + roles, types, prompts, tool actions, and execution states. (#122) + +### Fixed + +- Subagent cards now support both current native `invokeSubagent` payloads and + older captures where invocation arguments are stored on the preceding planner + response. (#124) +- Multiple invoked subagents are all displayed, and `define_subagent`, + `send_message`, and `manage_subagents` are rendered with tool-specific + details. (#124) +- Pending, running, completed, canceled, interrupted, invalid, and failed + subagent states are now visually distinguishable. (#124) + +### Security + +- Updated `brace-expansion` to a version containing upstream regular-expression + denial-of-service fixes. (#118) + ## [0.14.0] - 2026-07-26 ### Added diff --git a/README.md b/README.md index 1f6b700..314679d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![CI](https://github.com/L1M80/porta/actions/workflows/ci.yml/badge.svg)](https://github.com/L1M80/porta/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -![Version](https://img.shields.io/badge/version-0.14.0-green) +![Version](https://img.shields.io/badge/version-0.15.0-green) Remote web interface for [Antigravity](https://antigravity.google/) Agent Manager. Access your local Antigravity sessions from your phone, tablet, or any remote browser through a lightweight LSP bridge. diff --git a/package.json b/package.json index 3088802..b9c3a6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "porta", - "version": "0.14.0", + "version": "0.15.0", "private": true, "scripts": { "dev": "node scripts/dev.mjs", diff --git a/packages/web/package-lock.json b/packages/web/package-lock.json index f72bf79..c8da340 100644 --- a/packages/web/package-lock.json +++ b/packages/web/package-lock.json @@ -3759,16 +3759,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { diff --git a/packages/web/src/__tests__/SubagentCard.test.tsx b/packages/web/src/__tests__/SubagentCard.test.tsx new file mode 100644 index 0000000..2abb033 --- /dev/null +++ b/packages/web/src/__tests__/SubagentCard.test.tsx @@ -0,0 +1,101 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { SubagentCard } from "../components/StepCards"; +import type { TrajectoryStep } from "../types"; + +function nativeStep( + status = "CORTEX_STEP_STATUS_DONE", +): TrajectoryStep { + return { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + status, + invokeSubagent: { + subagents: [ + { + role: "Integration Reviewer", + typeName: "general-purpose", + initialPrompt: "Review the integration", + }, + { + role: "Security Reviewer", + typeName: "research", + initialPrompt: "Review security boundaries", + }, + ], + }, + }; +} + +describe("SubagentCard", () => { + it("renders every native subagent and expands every prompt", async () => { + render(); + + expect(screen.getByText("Integration Reviewer")).toBeInTheDocument(); + expect(screen.getByText("Security Reviewer")).toBeInTheDocument(); + expect(screen.queryByText("Review the integration")).not.toBeInTheDocument(); + + await userEvent.click( + screen.getByRole("button", { name: /2 Subagents Invoked/i }), + ); + + expect(screen.getByText("Review the integration")).toBeInTheDocument(); + expect(screen.getByText("Review security boundaries")).toBeInTheDocument(); + }); + + it.each([ + ["CORTEX_STEP_STATUS_PENDING", "Pending", "cmd-wait"], + ["CORTEX_STEP_STATUS_ERROR", "Failed", "cmd-fail"], + ["CORTEX_STEP_STATUS_CANCELED", "Canceled", "cmd-fail"], + ["CORTEX_STEP_STATUS_INTERRUPTED", "Interrupted", "cmd-fail"], + ["CORTEX_STEP_STATUS_DONE", "Done", "cmd-ok"], + ])("renders %s with the correct state", (status, label, className) => { + const { container } = render(); + + expect(screen.getByText(label)).toBeInTheDocument(); + expect(container.querySelector(".subagent-card")).toHaveClass(className); + }); + + it("renders tool-specific send_message content", async () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_TOOL_CALL", + metadata: { + toolCall: { + name: "send_message", + argumentsJson: JSON.stringify({ + Recipient: "conversation-123", + Message: "Please inspect the auth flow", + }), + }, + }, + }; + render(); + + expect(screen.getByText("Message to conversation-123")).toBeInTheDocument(); + expect(screen.getByText("conversation-123")).toBeInTheDocument(); + await userEvent.click( + screen.getByRole("button", { name: /Message to conversation-123/i }), + ); + expect(screen.getByText("Please inspect the auth flow")).toBeInTheDocument(); + }); + + it("renders untrusted labels as text rather than HTML", () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + invokeSubagent: { + subagents: [ + { + role: '', + initialPrompt: "safe text", + }, + ], + }, + }; + const { container } = render(); + + expect( + screen.getByText(''), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + }); +}); diff --git a/packages/web/src/__tests__/stepsToMessages.test.ts b/packages/web/src/__tests__/stepsToMessages.test.ts index 92dd75f..8115d4c 100644 --- a/packages/web/src/__tests__/stepsToMessages.test.ts +++ b/packages/web/src/__tests__/stepsToMessages.test.ts @@ -428,4 +428,215 @@ describe("stepsToMessages", () => { expect(msgs[3].role).toBe("assistant"); expect(msgs).toHaveLength(4); }); + + // ── Subagent steps ── + + it("converts invoke_subagent tool call to subagent system message", () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_TOOL_CALL", + metadata: { + toolCall: { + name: "invoke_subagent", + argumentsJson: JSON.stringify({ + Subagents: [ + { + Role: "Config Auditor", + TypeName: "research", + Prompt: "Audit all config files", + }, + ], + toolAction: "Invoking research subagent", + }), + }, + }, + }; + + const msgs = stepsToMessages([step]); + expect(msgs).toHaveLength(1); + expect(msgs[0].type).toBe("CORTEX_STEP_TYPE_SUBAGENT"); + expect(msgs[0].step).toBe(step); + expect(msgs[0].subagent).toMatchObject({ + kind: "invoke", + title: "Subagent Invoked", + action: "Invoking research subagent", + items: [ + { + role: "Config Auditor", + typeName: "research", + details: [ + { label: "Instructions", text: "Audit all config files" }, + ], + }, + ], + }); + }); + + it("correlates a payloadless native marker with captured planner arguments", () => { + const marker: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + status: "CORTEX_STEP_STATUS_DONE", + }; + const steps: TrajectoryStep[] = [ + { + type: "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + plannerResponse: { + toolCalls: [ + { + name: "invoke_subagent", + argumentsJson: JSON.stringify({ + Subagents: [ + { + Role: "Integration Reviewer", + TypeName: "general-purpose", + Prompt: "Review API integration", + }, + { + Role: "Security Reviewer", + TypeName: "research", + Prompt: "Review trust boundaries", + }, + ], + }), + }, + ], + }, + }, + marker, + ]; + + const msgs = stepsToMessages(steps); + + expect(msgs).toHaveLength(1); + expect(msgs[0].step).toBe(marker); + expect(msgs[0].subagent?.title).toBe("2 Subagents Invoked"); + expect(msgs[0].subagent?.items).toHaveLength(2); + expect(msgs[0].subagent?.items.map((item) => item.role)).toEqual([ + "Integration Reviewer", + "Security Reviewer", + ]); + }); + + it("does not correlate a stale tool call across planner turns", () => { + const steps: TrajectoryStep[] = [ + { + type: "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + plannerResponse: { + toolCalls: [ + { + name: "invoke_subagent", + argumentsJson: JSON.stringify({ + Subagents: [{ Role: "Stale Reviewer" }], + }), + }, + ], + }, + }, + { + type: "CORTEX_STEP_TYPE_PLANNER_RESPONSE", + plannerResponse: {}, + }, + { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + }, + ]; + + const [msg] = stepsToMessages(steps); + + expect(msg.subagent?.items[0].role).toBe("Subagent"); + }); + + it("uses the current native invokeSubagent payload and metadata", () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_INVOKE_SUBAGENT", + status: "CORTEX_STEP_STATUS_RUNNING", + metadata: { + toolSummary: "Review team", + toolAction: "Running two reviews", + }, + invokeSubagent: { + subagents: [ + { + role: "Bug Hunter", + typeName: "general-purpose", + initialPrompt: "Find bugs", + modelTier: "MODEL_TIER_PRO", + }, + { + role: "Security Auditor", + typeName: "research", + initialPrompt: "Find vulnerabilities", + }, + ], + }, + }; + + const [msg] = stepsToMessages([step]); + + expect(msg.subagent).toMatchObject({ + kind: "invoke", + title: "Review team", + action: "Running two reviews", + }); + expect(msg.subagent?.items).toHaveLength(2); + expect(msg.subagent?.items[0]).toMatchObject({ + role: "Bug Hunter", + typeName: "general-purpose", + model: "MODEL_TIER_PRO", + }); + }); + + it.each([ + { + name: "define_subagent", + args: { + name: "security-reviewer", + description: "Reviews trust boundaries", + system_prompt: "Inspect untrusted input", + }, + kind: "define", + title: "Define security-reviewer", + role: "security-reviewer", + }, + { + name: "send_message", + args: { Recipient: "conversation-123", Message: "Check the parser" }, + kind: "message", + title: "Message to conversation-123", + role: "conversation-123", + }, + { + name: "manage_subagents", + args: { Action: "kill", ConversationIds: ["one", "two"] }, + kind: "manage", + title: "Stop Subagents", + role: "kill", + }, + ])("normalizes $name instead of labeling it as an invocation", (fixture) => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_TOOL_CALL", + metadata: { + toolCall: { + name: fixture.name, + argumentsJson: JSON.stringify(fixture.args), + }, + }, + }; + + const [msg] = stepsToMessages([step]); + + expect(msg.subagent).toMatchObject({ + kind: fixture.kind, + title: fixture.title, + items: [{ role: fixture.role }], + }); + }); + + it("does not treat inherited object properties as subagent tool names", () => { + const step: TrajectoryStep = { + type: "CORTEX_STEP_TYPE_TOOL_CALL", + metadata: { toolCall: { name: "toString" } }, + }; + + expect(stepsToMessages([step])).toEqual([]); + }); }); diff --git a/packages/web/src/components/ChatPanel.tsx b/packages/web/src/components/ChatPanel.tsx index eb0842b..8751eae 100644 --- a/packages/web/src/components/ChatPanel.tsx +++ b/packages/web/src/components/ChatPanel.tsx @@ -24,6 +24,7 @@ import { CommandCard, CodeActionCard, FilePermissionCard, + SubagentCard, } from "./StepCards"; import { getAskQuestionRequest, getFilePermissionRequest } from "../utils/stepCards"; import { @@ -313,6 +314,13 @@ function SystemMessage({ ); } + if (msg.type === "CORTEX_STEP_TYPE_SUBAGENT") { + return ( +
+ +
+ ); + } } return ( diff --git a/packages/web/src/components/Icons.tsx b/packages/web/src/components/Icons.tsx index ae0b5d7..8e55698 100644 --- a/packages/web/src/components/Icons.tsx +++ b/packages/web/src/components/Icons.tsx @@ -247,3 +247,11 @@ export const IconGear = ({ size = 16, className }: IconProps) => export const IconChevronLeft = ({ size = 16, className }: IconProps) => d(size, className, "m15 18-6-6 6-6"); + +export const IconUsers = ({ size = 16, className }: IconProps) => + m(size, className, [ + "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2", + "M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", + "M22 21v-2a4 4 0 0 0-3-3.87", + "M16 3.13a4 4 0 0 1 0 7.75", + ]); diff --git a/packages/web/src/components/StepCards.tsx b/packages/web/src/components/StepCards.tsx index ff1ccb0..33fdc61 100644 --- a/packages/web/src/components/StepCards.tsx +++ b/packages/web/src/components/StepCards.tsx @@ -8,14 +8,17 @@ import { IconFileText, IconLock, IconMessageCircle, + IconUsers, } from "./Icons"; import type { AskQuestionEntry, AskQuestionOption, AskQuestionRequest, - TrajectoryStep, FilePermissionRequest, + SubagentDisplayData, + TrajectoryStep, } from "../types"; +import { subagentDataFromStep } from "../utils/subagents"; /** Extract file basename from a URI or path */ function basename(uriOrPath: string): string { @@ -592,3 +595,128 @@ export function CodeActionCard({ step }: CodeActionCardProps) { ); } + +// ── Subagent Card ── + +const ACTIVE_SUBAGENT_STATUSES = new Set([ + "CORTEX_STEP_STATUS_GENERATING", + "CORTEX_STEP_STATUS_QUEUED", + "CORTEX_STEP_STATUS_PENDING", + "CORTEX_STEP_STATUS_RUNNING", + "CORTEX_STEP_STATUS_WAITING", +]); + +const FAILED_SUBAGENT_STATUSES = new Set([ + "CORTEX_STEP_STATUS_INVALID", + "CORTEX_STEP_STATUS_CANCELED", + "CORTEX_STEP_STATUS_ERROR", + "CORTEX_STEP_STATUS_INTERRUPTED", +]); + +function subagentStatus(status?: string): { + label?: string; + className: string; +} { + if (!status) return { className: "" }; + if (status === "CORTEX_STEP_STATUS_DONE") { + return { label: "Done", className: "cmd-ok" }; + } + if (FAILED_SUBAGENT_STATUSES.has(status)) { + const labels: Record = { + CORTEX_STEP_STATUS_INVALID: "Invalid", + CORTEX_STEP_STATUS_CANCELED: "Canceled", + CORTEX_STEP_STATUS_ERROR: "Failed", + CORTEX_STEP_STATUS_INTERRUPTED: "Interrupted", + }; + return { label: labels[status], className: "cmd-fail" }; + } + if (ACTIVE_SUBAGENT_STATUSES.has(status)) { + const labels: Record = { + CORTEX_STEP_STATUS_GENERATING: "Generating", + CORTEX_STEP_STATUS_QUEUED: "Queued", + CORTEX_STEP_STATUS_PENDING: "Pending", + CORTEX_STEP_STATUS_RUNNING: "Running", + CORTEX_STEP_STATUS_WAITING: "Waiting", + }; + return { label: labels[status], className: "cmd-wait" }; + } + return { className: "" }; +} + +interface SubagentCardProps { + step: TrajectoryStep; + data?: SubagentDisplayData; +} + +export function SubagentCard({ step, data }: SubagentCardProps) { + const [expanded, setExpanded] = useState(false); + const display = data ?? subagentDataFromStep(step); + if (!display) return null; + + const hasDetails = display.items.some((item) => item.details.length > 0); + const status = subagentStatus(step.status); + + return ( +
+ +
+ {display.items.map((item, itemIndex) => ( +
+
+ {item.role} + {item.typeName} + {item.model && ( + {item.model} + )} +
+ {expanded && item.details.length > 0 && ( +
+ {item.details.map((itemDetail, detailIndex) => ( +
+
+ {itemDetail.label}: +
+
+                      {itemDetail.text}
+                    
+
+ ))} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/packages/web/src/styles/step-cards.css b/packages/web/src/styles/step-cards.css index 3430973..b60e04a 100644 --- a/packages/web/src/styles/step-cards.css +++ b/packages/web/src/styles/step-cards.css @@ -688,3 +688,93 @@ border-color: var(--accent-hover); box-shadow: 0 2px 12px rgba(99, 102, 241, 0.3); } + +/* ── Subagent Card ── */ + +.subagent-card .subagent-role { + font-weight: 600; + color: var(--text-primary); + min-width: 0; +} + +.subagent-type-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 6px; + border-radius: 4px; + background: var(--bg-hover); + color: var(--accent); + border: 1px solid var(--border-subtle); + white-space: nowrap; +} + +.subagent-status { + margin-left: auto; + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; +} + +.subagent-list { + border-top: 1px solid var(--border-subtle); + background: var(--bg-surface); + border-bottom-left-radius: var(--radius-md); + border-bottom-right-radius: var(--radius-md); +} + +.subagent-entry { + padding: 8px 14px; +} + +.subagent-entry + .subagent-entry { + border-top: 1px solid var(--border-subtle); +} + +.subagent-entry-header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.subagent-entry-role { + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + overflow-wrap: anywhere; +} + +.subagent-model { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 10px; + overflow-wrap: anywhere; +} + +.subagent-details { + margin-top: 8px; +} + +.subagent-detail + .subagent-detail { + margin-top: 10px; +} + +.subagent-prompt-label { + font-weight: 600; + color: var(--text-muted); + margin-bottom: 4px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.subagent-prompt-text { + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + color: var(--text-secondary); + margin: 0; + line-height: 1.4; +} diff --git a/packages/web/src/transforms/stepsToMessages.ts b/packages/web/src/transforms/stepsToMessages.ts index b25482d..7ba5d70 100644 --- a/packages/web/src/transforms/stepsToMessages.ts +++ b/packages/web/src/transforms/stepsToMessages.ts @@ -1,5 +1,9 @@ -import type { ChatMessage, TrajectoryStep } from "../types"; +import type { ChatMessage, ToolCallData, TrajectoryStep } from "../types"; import { getAskQuestionRequest, getFilePermissionRequest } from "../utils/stepCards"; +import { + isSubagentToolName, + subagentDataFromStep, +} from "../utils/subagents"; function textFromItems(items?: { text?: string }[]): string { if (!items) return ""; @@ -12,11 +16,25 @@ function textFromItems(items?: { text?: string }[]): string { /** Extract displayable messages from raw trajectory steps */ export function stepsToMessages(steps: TrajectoryStep[]): ChatMessage[] { const messages: ChatMessage[] = []; + const pendingInvokeToolCalls: ToolCallData[] = []; for (let i = 0; i < steps.length; i++) { const step = steps[i]; const type = step.type; + // Older AG trajectories put invoke_subagent arguments on the planner + // response, followed by a payloadless native invocation marker. + if (type === "CORTEX_STEP_TYPE_PLANNER_RESPONSE") { + // Do not let an incomplete/truncated older invocation become the + // fallback for a later planner turn. + pendingInvokeToolCalls.length = 0; + for (const toolCall of step.plannerResponse?.toolCalls ?? []) { + if (toolCall.name === "invoke_subagent") { + pendingInvokeToolCalls.push(toolCall); + } + } + } + // File permission request: emit as a dedicated message type const fpr = getFilePermissionRequest(step); if (fpr) { @@ -46,6 +64,31 @@ export function stepsToMessages(steps: TrajectoryStep[]): ChatMessage[] { continue; } + const toolName = step.metadata?.toolCall?.name; + const isNativeSubagent = + type === "CORTEX_STEP_TYPE_INVOKE_SUBAGENT" || + type === "CORTEX_STEP_TYPE_SUBAGENT"; + const isSubagent = isSubagentToolName(toolName) || isNativeSubagent; + + if (isSubagent) { + const fallbackToolCall = isNativeSubagent + ? pendingInvokeToolCalls.shift() + : undefined; + if (!isNativeSubagent && toolName === "invoke_subagent") { + pendingInvokeToolCalls.shift(); + } + const subagent = subagentDataFromStep(step, fallbackToolCall); + messages.push({ + role: "system", + content: "", + stepIndex: i, + type: "CORTEX_STEP_TYPE_SUBAGENT", + step, + subagent, + }); + continue; + } + if (type === "CORTEX_STEP_TYPE_USER_INPUT" && step.userInput?.items) { const text = textFromItems(step.userInput.items); const media = step.userInput.media; diff --git a/packages/web/src/types/index.ts b/packages/web/src/types/index.ts index 7b0beff..815f5bb 100644 --- a/packages/web/src/types/index.ts +++ b/packages/web/src/types/index.ts @@ -124,6 +124,7 @@ export interface TrajectoryStep { metadata?: StepMetadata; userInput?: { items: StepItem[]; media?: unknown[] }; plannerResponse?: PlannerResponseData; + invokeSubagent?: InvokeSubagentData; runCommand?: RunCommandData; codeAction?: CodeActionData; commandStatus?: CommandStatusData; @@ -146,23 +147,54 @@ export interface PlannerResponseData { modifiedResponse?: string; thinking?: string; thinkingDuration?: string; + toolCalls?: ToolCallData[]; +} + +export interface ToolCallData { + id?: string; + name?: string; + argumentsJson?: string; } export interface StepMetadata { createdAt?: string; completedAt?: string; source?: string; - toolCall?: { - id?: string; - name?: string; - argumentsJson?: string; - }; + executionId?: string; + toolCall?: ToolCallData; + toolSummary?: string; + toolAction?: string; sourceTrajectoryStepInfo?: { trajectoryId?: string; stepIndex?: number; }; } +export interface NativeSubagentSpec { + typeName?: string; + role?: string; + initialPrompt?: string; + model?: string; + modelTier?: string; +} + +export interface SubagentResult { + conversationId?: string; + logAbsoluteUri?: string; + workspaceUris?: string[]; +} + +/** Native payload of CORTEX_STEP_TYPE_INVOKE_SUBAGENT. */ +export interface InvokeSubagentData { + subagents?: NativeSubagentSpec[]; + taskMode?: boolean; + results?: SubagentResult[]; + /** Legacy single-subagent fields retained by the AG protocol. */ + subagentName?: string; + prompt?: string; + conversationId?: string; +} + export interface RunCommandData { command?: string; commandLine?: string; @@ -257,6 +289,29 @@ export interface StepItem { text?: string; } +export type SubagentToolKind = "invoke" | "define" | "manage" | "message"; + +export interface SubagentDisplayDetail { + label: string; + text: string; +} + +export interface SubagentDisplayItem { + role: string; + typeName: string; + model?: string; + details: SubagentDisplayDetail[]; +} + +/** Sanitized, tool-independent data consumed by SubagentCard. */ +export interface SubagentDisplayData { + toolName: string; + kind: SubagentToolKind; + title: string; + action?: string; + items: SubagentDisplayItem[]; +} + /** Normalized message for display */ export interface ChatMessage { role: "user" | "assistant" | "system"; @@ -265,6 +320,8 @@ export interface ChatMessage { type: string; /** Original step data for rich rendering */ step?: TrajectoryStep; + /** Normalized subagent data for rich rendering */ + subagent?: SubagentDisplayData; /** Media attachments (images/video) */ media?: unknown[]; /** Extended thinking / chain-of-thought content */ diff --git a/packages/web/src/utils/subagents.ts b/packages/web/src/utils/subagents.ts new file mode 100644 index 0000000..f788028 --- /dev/null +++ b/packages/web/src/utils/subagents.ts @@ -0,0 +1,239 @@ +import type { + SubagentDisplayData, + SubagentDisplayDetail, + SubagentDisplayItem, + SubagentToolKind, + ToolCallData, + TrajectoryStep, +} from "../types"; + +const SUBAGENT_TOOL_KINDS: Record = { + invoke_subagent: "invoke", + define_subagent: "define", + manage_subagents: "manage", + send_message: "message", +}; + +type JsonRecord = Record; + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseArguments(argumentsJson?: string): JsonRecord | undefined { + if (!argumentsJson) return undefined; + try { + const parsed: unknown = JSON.parse(argumentsJson); + return isRecord(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + +function stringField( + value: JsonRecord | undefined, + ...names: string[] +): string | undefined { + if (!value) return undefined; + for (const name of names) { + const field = value[name]; + if (typeof field === "string" && field.trim()) return field.trim(); + } + return undefined; +} + +function stringArrayField( + value: JsonRecord | undefined, + ...names: string[] +): string[] { + if (!value) return []; + for (const name of names) { + const field = value[name]; + if (!Array.isArray(field)) continue; + return field.filter( + (entry): entry is string => typeof entry === "string" && !!entry.trim(), + ); + } + return []; +} + +function detail(label: string, text?: string): SubagentDisplayDetail[] { + return text ? [{ label, text }] : []; +} + +function parsedSubagents(args?: JsonRecord): JsonRecord[] { + const value = args?.Subagents ?? args?.subagents; + return Array.isArray(value) ? value.filter(isRecord) : []; +} + +function invokeItems( + step: TrajectoryStep, + args?: JsonRecord, +): SubagentDisplayItem[] { + const nativeItems = step.invokeSubagent?.subagents ?? []; + if (nativeItems.length > 0) { + return nativeItems.map((subagent) => ({ + role: subagent.role?.trim() || "Subagent", + typeName: subagent.typeName?.trim() || "subagent", + model: subagent.model?.trim() || subagent.modelTier?.trim() || undefined, + details: detail("Instructions", subagent.initialPrompt?.trim()), + })); + } + + const items = parsedSubagents(args).map((subagent) => { + const model = stringField( + subagent, + "Model", + "model", + "ModelTier", + "modelTier", + ); + return { + role: stringField(subagent, "Role", "role") ?? "Subagent", + typeName: + stringField(subagent, "TypeName", "typeName", "Name", "name") ?? + "subagent", + model, + details: detail( + "Instructions", + stringField( + subagent, + "Prompt", + "prompt", + "InitialPrompt", + "initialPrompt", + ), + ), + }; + }); + if (items.length > 0) return items; + + const role = + step.invokeSubagent?.subagentName?.trim() || + stringField(args, "SubagentName", "subagentName", "Role", "role"); + const prompt = + step.invokeSubagent?.prompt?.trim() || + stringField(args, "Prompt", "prompt"); + + return [ + { + role: role || "Subagent", + typeName: "subagent", + details: detail("Instructions", prompt), + }, + ]; +} + +function defineItems(args?: JsonRecord): SubagentDisplayItem[] { + const name = stringField(args, "name", "Name") ?? "Subagent definition"; + const description = stringField(args, "description", "Description"); + const systemPrompt = stringField(args, "system_prompt", "systemPrompt"); + const details: SubagentDisplayDetail[] = []; + if (description) details.push({ label: "Description", text: description }); + if (systemPrompt) details.push({ label: "System prompt", text: systemPrompt }); + return [{ role: name, typeName: "definition", details }]; +} + +function messageItems(args?: JsonRecord): SubagentDisplayItem[] { + const recipient = + stringField(args, "Recipient", "recipient") ?? "Subagent"; + const message = stringField(args, "Message", "message"); + return [ + { + role: recipient, + typeName: "message", + details: detail("Message", message), + }, + ]; +} + +function manageItems(args?: JsonRecord): SubagentDisplayItem[] { + const action = stringField(args, "Action", "action") ?? "Manage"; + const ids = stringArrayField( + args, + "ConversationIds", + "conversationIds", + "conversation_ids", + ); + return [ + { + role: action.replaceAll("_", " "), + typeName: "manage", + details: detail("Conversation IDs", ids.join("\n")), + }, + ]; +} + +function defaultTitle( + kind: SubagentToolKind, + items: SubagentDisplayItem[], + args?: JsonRecord, +): string { + switch (kind) { + case "invoke": + return items.length === 1 + ? "Subagent Invoked" + : `${items.length} Subagents Invoked`; + case "define": + return `Define ${items[0]?.role ?? "Subagent"}`; + case "message": + return `Message to ${items[0]?.role ?? "Subagent"}`; + case "manage": { + const action = stringField(args, "Action", "action")?.toLowerCase(); + if (action === "list") return "List Subagents"; + if (action === "kill_all") return "Stop All Subagents"; + if (action === "kill") return "Stop Subagents"; + return "Manage Subagents"; + } + } +} + +export function isSubagentToolName(name?: string): name is string { + return ( + !!name && Object.prototype.hasOwnProperty.call(SUBAGENT_TOOL_KINDS, name) + ); +} + +/** + * Converts both current native AG subagent steps and older tool-call-shaped + * steps into a small display model. The fallback call covers older captures + * where the native marker has no payload and arguments only exist on the + * preceding planner response. + */ +export function subagentDataFromStep( + step: TrajectoryStep, + fallbackToolCall?: ToolCallData, +): SubagentDisplayData | undefined { + const nativeInvoke = + step.type === "CORTEX_STEP_TYPE_INVOKE_SUBAGENT" || + step.type === "CORTEX_STEP_TYPE_SUBAGENT"; + const toolCall = step.metadata?.toolCall ?? fallbackToolCall; + const toolName = nativeInvoke + ? toolCall?.name || "invoke_subagent" + : toolCall?.name; + if (!isSubagentToolName(toolName)) return undefined; + + const kind = SUBAGENT_TOOL_KINDS[toolName]; + const args = parseArguments(toolCall?.argumentsJson); + const items = + kind === "invoke" + ? invokeItems(step, args) + : kind === "define" + ? defineItems(args) + : kind === "message" + ? messageItems(args) + : manageItems(args); + const legacySummary = stringField(args, "toolSummary", "tool_summary"); + const legacyAction = stringField(args, "toolAction", "tool_action"); + + return { + toolName, + kind, + title: + step.metadata?.toolSummary || + legacySummary || + defaultTitle(kind, items, args), + action: step.metadata?.toolAction || legacyAction, + items, + }; +}