Replace intents with live tool-title status lines on agent cards

Intent parsing is fully removed (turn model, relay handler, persistence
fields, group extraction). The collapsed card's status is the latest tool
call in its RUNNING phrasing — never the completed rewrite, which stays in
the expanded log. Parallel tools show the most recently started still-running
title with a +N for concurrent siblings; between rounds the last title stays
frozen; a closed lane shows the bare name. Nested agent cards compute their
own status recursively from their own items.
This commit is contained in:
Siddharth Ganesan
2026-08-14 13:01:28 -07:00
parent 99397debde
commit af516f3f58
11 changed files with 28 additions and 296 deletions
@@ -21,8 +21,6 @@ export interface NestedAgentGroup {
id: string
agentName: string
agentLabel: string
/** The agent's latest <intent> tag — the collapsed row's live status. */
intent?: string
items: AgentGroupItem[]
isDelegating: boolean
isOpen: boolean
@@ -36,8 +34,6 @@ export type AgentGroupItem =
interface AgentGroupProps {
agentName: string
agentLabel: string
/** The agent's latest <intent> tag — shown inline after the label. */
intent?: string
items: AgentGroupItem[]
isDelegating?: boolean
isStreaming?: boolean
@@ -111,7 +107,6 @@ export function isAgentGroupResolved(items: AgentGroupItem[]): boolean {
export function AgentGroup({
agentName,
agentLabel,
intent,
items,
isDelegating = false,
isStreaming = false,
@@ -119,10 +114,30 @@ export function AgentGroup({
isLaneOpen = false,
}: AgentGroupProps) {
const AgentIcon = getAgentIcon(agentName)
// The status line is the agent's own <intent> narration — no tool-title
// fallback: the task-message protocol reminder makes every agent open with
// an intent tag, so a bare label means the run has not produced one yet.
const headerText = intent ? `${agentLabel} — ${intent}` : agentLabel
// Collapsed status line: the latest tool call, always in its RUNNING
// phrasing — it never flips to the completed rewrite (that lives in the
// expanded log). With parallel tools, the most recently started
// still-running one wins, with a +N for its running siblings; between
// rounds the last tool's title stays frozen; a closed lane shows the bare
// name.
const status = (() => {
if (!isLaneOpen) return undefined
let running: string | undefined
let runningCount = 0
let lastAny: string | undefined
for (const it of items) {
if (it.type !== 'tool') continue
const title = it.data.displayTitle || String(it.data.toolName ?? '')
lastAny = title
if (it.data.status === ToolCallStatus.executing) {
running = title
runningCount += 1
}
}
if (running) return runningCount > 1 ? `${running} +${runningCount - 1}` : running
return lastAny
})()
const headerText = status ? `${agentLabel} — ${status}` : agentLabel
const hasItems = items.length > 0
const resolved = isAgentGroupResolved(items)
const browserAgentAvailable = isBrowserAgentAvailable()
@@ -220,7 +235,6 @@ export function AgentGroup({
<AgentGroup
agentName={item.group.agentName}
agentLabel={item.group.agentLabel}
intent={item.group.intent}
items={item.group.items}
isDelegating={item.group.isDelegating}
isStreaming={isStreaming}
@@ -52,8 +52,6 @@ interface AgentGroupSegment {
id: string
agentName: string
agentLabel: string
/** The agent's latest <intent> tag (parsed upstream from its text). */
intent?: string
items: AgentGroupItem[]
isDelegating: boolean
isOpen: boolean
@@ -224,37 +222,12 @@ function createAgentGroupSegment(name: string, id: string): AgentGroupSegment {
* Streamed chunks and resume legs are concatenated verbatim, so a token split
* like `v2.` + `1` is never mutated.
*/
const INTENT_TAG_RE = /<intent>([\s\S]*?)<\/intent>\n?/g
/**
* Extracts complete <intent> tags from a group's accumulated text: the last
* tag becomes the group's live status line and every complete tag is removed
* from the rendered prose. Runs on the accumulated buffer each append, so a
* tag split across streamed chunks is picked up once its close arrives —
* covering the span path, the legacy block path, and persisted reloads alike.
*/
function extractGroupIntents(group: AgentGroupSegment, item: { content: string }): void {
let lastIntent: string | undefined
const stripped = item.content.replace(INTENT_TAG_RE, (_match, inner: string) => {
const intent = inner.trim()
if (intent) lastIntent = intent
return ''
})
if (lastIntent !== undefined) {
item.content = stripped
group.intent = lastIntent
}
}
function appendTextItem(group: AgentGroupSegment, content: string): void {
const lastItem = group.items[group.items.length - 1]
if (lastItem?.type === 'text') {
lastItem.content += content
extractGroupIntents(group, lastItem)
} else {
const item = { type: 'text' as const, content }
group.items.push(item)
extractGroupIntents(group, item)
group.items.push({ type: 'text', content })
}
}
@@ -408,7 +381,6 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
if (dispatchToolName) absorbDispatchTool(dispatchToolName, block.parentSpanId)
const g = ensureSpanGroup(block.content, block.spanId, block.parentSpanId)
if (block.subagentName) g.agentLabel = block.subagentName
if (block.subagentIntent) g.intent = block.subagentIntent
if (block.endedAt !== undefined) {
// Persisted backend path: the lane was stamped closed (endedAt) without
// a separate subagent_end block (the Sim backend stamps endedAt only;
@@ -653,7 +625,6 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
groupsByKey.delete(groupKey('mothership', undefined))
const { group: g } = ensureGroup(key, block.parentToolCallId)
if (block.subagentName) g.agentLabel = block.subagentName
if (block.subagentIntent) g.intent = block.subagentIntent
if (inheritedDelegation) g.isDelegating = true
g.isOpen = true
activeGroupKey = resolveGroupKey(key, block.parentToolCallId)
@@ -985,7 +956,6 @@ function MessageContentInner({
key={segment.id}
agentName={segment.agentName}
agentLabel={segment.agentLabel}
intent={segment.intent}
items={segment.items}
isDelegating={segment.isDelegating}
isStreaming={isStreaming}
@@ -168,7 +168,6 @@ export function modelToContentBlocks(model: TurnModel): ContentBlock[] {
type: 'subagent',
content: node.agentId,
...(node.displayName ? { subagentName: node.displayName } : {}),
...(node.currentIntent ? { subagentIntent: node.currentIntent } : {}),
spanId: node.spanId,
parentSpanId: node.parentSpanId,
...(node.triggerToolCallId ? { parentToolCallId: node.triggerToolCallId } : {}),
@@ -272,7 +271,6 @@ export function contentBlocksToModel(blocks: ContentBlock[]): TurnModel {
data: {
...(block.parentToolCallId ? { tool_call_id: block.parentToolCallId } : {}),
...(block.subagentName ? { name: block.subagentName } : {}),
...(block.subagentIntent ? { intent: block.subagentIntent } : {}),
},
},
scopeFor(block),
@@ -260,46 +260,6 @@ describe('reduceEvent — subagent lifecycle', () => {
expect(agent(m, 'S1').displayName).toBe('Pricing research')
})
it('parses intent tags out of subagent text into the agent status', () => {
const textEv = (seq: number, text: string) =>
envelope(
seq,
'text',
{ channel: 'assistant', text },
{ lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' }
)
const m = apply([
spanStart(1, 'S1', 'file', 'tc-f'),
textEv(2, '<intent>Drafting chapter outline</intent>\nStarting on the outline now.'),
])
expect(agent(m, 'S1').currentIntent).toBe('Drafting chapter outline')
const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1')
expect(text && text.kind === 'text' ? text.text : '').not.toContain('<intent>')
expect(text && text.kind === 'text' ? text.text : '').toContain('Starting on the outline')
})
it('handles an intent tag split across deltas and takes the latest tag', () => {
const textEv = (seq: number, text: string) =>
envelope(
seq,
'text',
{ channel: 'assistant', text },
{ lane: 'subagent', spanId: 'S1', parentSpanId: MAIN_SPAN, agentId: 'file' }
)
const m = apply([
spanStart(1, 'S1', 'file', 'tc-f'),
textEv(2, 'ok. <int'),
textEv(3, 'ent>Writing first chap'),
textEv(4, 'ter</intent>text after. <intent>Reviewing draft</intent>'),
])
expect(agent(m, 'S1').currentIntent).toBe('Reviewing draft')
const text = [...m.nodes.values()].find((n) => n.kind === 'text' && n.spanId === 'S1')
const rendered = text && text.kind === 'text' ? text.text : ''
expect(rendered).toContain('ok. ')
expect(rendered).toContain('text after. ')
expect(rendered).not.toContain('intent>')
})
it('settles an agent error when span end carries an error', () => {
const m = apply([
spanStart(1, 'S1', 'file', 'tc-file'),
@@ -87,10 +87,6 @@ export interface AgentNode extends NodeBase {
triggerToolCallId?: string
/** Orchestrator-chosen display name for this delegation (falls back to the agent label). */
displayName?: string
/** The agent's latest <intent> tag — the collapsed card's live status line. */
currentIntent?: string
/** Streaming carry for an intent tag split across text deltas (never serialized). */
intentCarry?: string
status: NodeStatus
/** Wire seq at which the run terminated (span end), for ordering the close marker. */
endSeq?: number
@@ -301,57 +297,6 @@ function breakLane(model: TurnModel, spanId: string, atMs?: number): void {
closeOpenText(model, spanId, 'thinking', atMs)
}
const INTENT_OPEN = '<intent>'
const INTENT_CLOSE = '</intent>'
/** A tag that never closes within this many chars flushes back as plain text. */
const INTENT_CARRY_MAX = 240
/** Length of the longest buf suffix that could still grow into `token`. */
function partialSuffixLen(buf: string, token: string): number {
const max = Math.min(buf.length, token.length - 1)
for (let len = max; len > 0; len--) {
if (token.startsWith(buf.slice(buf.length - len))) return len
}
return 0
}
/**
* Streams a subagent's assistant text through the <intent> protocol: complete
* tags update the owning agent's currentIntent and are removed from the prose;
* a tag split across deltas is carried until its close arrives. The returned
* string is what the transcript should render.
*/
function filterIntentText(owner: AgentNode, incoming: string): string {
let buf = (owner.intentCarry ?? '') + incoming
owner.intentCarry = ''
let out = ''
while (buf) {
const openIdx = buf.indexOf(INTENT_OPEN)
if (openIdx === -1) {
const keep = partialSuffixLen(buf, INTENT_OPEN)
out += keep ? buf.slice(0, buf.length - keep) : buf
if (keep) owner.intentCarry = buf.slice(buf.length - keep)
break
}
out += buf.slice(0, openIdx)
const rest = buf.slice(openIdx)
const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length)
if (closeIdx === -1) {
if (rest.length > INTENT_CARRY_MAX) {
out += rest
} else {
owner.intentCarry = rest
}
break
}
const intent = rest.slice(INTENT_OPEN.length, closeIdx).trim()
if (intent) owner.currentIntent = intent
buf = rest.slice(closeIdx + INTENT_CLOSE.length)
if (buf.startsWith('\n')) buf = buf.slice(1)
}
return out
}
function appendText(
model: TurnModel,
spanId: string,
@@ -517,15 +462,7 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
case MothershipStreamV1EventType.text: {
const payload = envelope.payload
ensureSubagentLane(model, spanId, scope, seq, tsMs)
let text = payload.text
if (spanId !== MAIN_SPAN && (payload.channel as TextChannel) === 'assistant') {
const ownerId = model.agentBySpanId.get(spanId)
const owner = ownerId ? model.nodes.get(ownerId) : undefined
if (owner && owner.kind === 'agent') {
text = filterIntentText(owner, text)
}
}
appendText(model, spanId, payload.channel as TextChannel, text, seq, tsMs)
appendText(model, spanId, payload.channel as TextChannel, payload.text, seq, tsMs)
break
}
case MothershipStreamV1EventType.tool: {
@@ -629,7 +566,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
scope?.parentToolCallId ?? asString(data?.tool_call_id) ?? asString(data?.toolCallId)
const agentId = asString(payload.agent) ?? scope?.agentId ?? ''
const displayName = asString(data?.name)
const restoredIntent = asString(data?.intent)
const resolvedSpanId =
scope?.spanId ?? (triggerToolCallId ? `span:${triggerToolCallId}` : `span:${seq}`)
const parentSpanId = scope?.parentSpanId ?? MAIN_SPAN
@@ -649,7 +585,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
// while this start's payload.agent is the authoritative lane owner.
if (agentId && existing.agentId !== agentId) existing.agentId = agentId
if (displayName) existing.displayName = displayName
if (restoredIntent && !existing.currentIntent) existing.currentIntent = restoredIntent
if (!existing.triggerToolCallId && triggerToolCallId) {
existing.triggerToolCallId = triggerToolCallId
}
@@ -672,7 +607,6 @@ export function reduceEvent(model: TurnModel, envelope: PersistedStreamEventEnve
...(tsMs !== undefined ? { startedAtMs: tsMs } : {}),
...(triggerToolCallId ? { triggerToolCallId } : {}),
...(displayName ? { displayName } : {}),
...(restoredIntent ? { currentIntent: restoredIntent } : {}),
}
model.nodes.set(node.id, node)
model.order.push(node.id)
@@ -116,8 +116,6 @@ export interface ContentBlock {
subagent?: string
/** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */
subagentName?: string
/** The agent's latest <intent> tag at serialization time — the collapsed card's status line. */
subagentIntent?: string
toolCall?: ToolCallInfo
options?: OptionItem[]
timestamp?: number
@@ -96,7 +96,6 @@ function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefi
type: ContentBlockType.subagent,
content: block.content,
...(block.name ? { subagentName: block.name } : {}),
...(block.intent ? { subagentIntent: block.intent } : {}),
}
case MothershipStreamV1EventType.complete:
if (block.status === MothershipStreamV1CompletionStatus.cancelled) {
@@ -55,8 +55,6 @@ export interface PersistedContentBlock {
content?: string
/** Orchestrator-chosen display name on a subagent start block. */
name?: string
/** The agent's latest <intent> tag at persistence time. */
intent?: string
toolCall?: PersistedToolCall
timestamp?: number
endedAt?: number
@@ -250,7 +248,6 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock {
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
content: block.content,
...(block.subagentName ? { name: block.subagentName } : {}),
...(block.subagentIntent ? { intent: block.subagentIntent } : {}),
}
case 'subagent_text':
return {
@@ -445,8 +442,6 @@ interface RawBlock {
/** Orchestrator-chosen subagent display name (legacy blocks store it as `subagentName`). */
name?: string
subagentName?: string
intent?: string
subagentIntent?: string
content?: string
/** Go persists text blocks with key "text" instead of "content" */
text?: string
@@ -515,7 +510,6 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock {
}
if (block.agent) result.agent = block.agent
if (block.name) result.name = block.name
if (block.intent) result.intent = block.intent
const blockContent = block.content ?? block.text
if (blockContent !== undefined) result.content = blockContent
if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel
@@ -598,7 +592,6 @@ function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock {
lifecycle: MothershipStreamV1SpanLifecycleEvent.start,
content: block.content,
...(block.subagentName ? { name: block.subagentName } : {}),
...(block.subagentIntent ? { intent: block.subagentIntent } : {}),
}
}
@@ -1,57 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { handleTextEvent } from '@/lib/copilot/request/handlers/text'
import type { StreamingContext } from '@/lib/copilot/request/types'
function laneTextEvent(text: string) {
return {
type: 'text',
payload: { channel: 'assistant', text },
scope: { lane: 'subagent', parentToolCallId: 'tc-1', agentId: 'file', spanId: 'S1' },
} as never
}
function makeContext(): StreamingContext {
return {
contentBlocks: [{ type: 'subagent', content: 'file', parentToolCallId: 'tc-1', timestamp: 1 }],
subAgentContent: {},
subagentThinkingBlocks: new Map(),
isInThinkingBlock: false,
} as unknown as StreamingContext
}
describe('subagent intent extraction (server relay)', () => {
it('strips a split tag and stamps the lane block intent', async () => {
const ctx = makeContext()
const handler = handleTextEvent('subagent')
await handler(laneTextEvent('<int'), ctx, {} as never, {} as never)
await handler(
laneTextEvent('ent>Drafting outline</intent>\nStarting now.'),
ctx,
{} as never,
{} as never
)
const start = ctx.contentBlocks.find((b) => b.type === 'subagent')
expect(start?.subagentIntent).toBe('Drafting outline')
const text = ctx.contentBlocks.find((b) => b.type === 'subagent_text')
expect(text?.content).toBe('Starting now.')
expect(ctx.subAgentContent['tc-1']).toBe('Starting now.')
})
it('takes the latest tag and keeps surrounding prose', async () => {
const ctx = makeContext()
const handler = handleTextEvent('subagent')
await handler(
laneTextEvent('a<intent>One</intent>b<intent>Two</intent>c'),
ctx,
{} as never,
{} as never
)
const start = ctx.contentBlocks.find((b) => b.type === 'subagent')
expect(start?.subagentIntent).toBe('Two')
expect(ctx.subAgentContent['tc-1']).toBe('abc')
})
})
+2 -75
View File
@@ -1,5 +1,4 @@
import { MothershipStreamV1TextChannel } from '@/lib/copilot/generated/mothership-stream-v1'
import type { StreamingContext } from '@/lib/copilot/request/types'
import type { StreamHandler, ToolScope } from './types'
import {
addContentBlock,
@@ -9,72 +8,6 @@ import {
getScopedSpanIdentity,
} from './types'
const INTENT_OPEN = '<intent>'
const INTENT_CLOSE = '</intent>'
/** A tag that never closes within this many chars flushes back as plain text. */
const INTENT_CARRY_MAX = 240
function partialSuffixLen(buf: string, token: string): number {
const max = Math.min(buf.length, token.length - 1)
for (let len = max; len > 0; len--) {
if (token.startsWith(buf.slice(buf.length - len))) return len
}
return 0
}
/**
* Streams one subagent lane's text through the <intent> protocol: complete
* tags are removed from the persisted prose and the latest one is returned;
* a tag split across chunks is carried (per lane) until its close arrives.
*/
function filterLaneIntent(
context: StreamingContext,
laneId: string,
incoming: string
): { text: string; intent?: string } {
const carries = (context.subagentIntentCarry ??= {})
let buf = (carries[laneId] ?? '') + incoming
carries[laneId] = ''
let out = ''
let intent: string | undefined
while (buf) {
const openIdx = buf.indexOf(INTENT_OPEN)
if (openIdx === -1) {
const keep = partialSuffixLen(buf, INTENT_OPEN)
out += keep ? buf.slice(0, buf.length - keep) : buf
if (keep) carries[laneId] = buf.slice(buf.length - keep)
break
}
out += buf.slice(0, openIdx)
const rest = buf.slice(openIdx)
const closeIdx = rest.indexOf(INTENT_CLOSE, INTENT_OPEN.length)
if (closeIdx === -1) {
if (rest.length > INTENT_CARRY_MAX) {
out += rest
} else {
carries[laneId] = rest
}
break
}
const inner = rest.slice(INTENT_OPEN.length, closeIdx).trim()
if (inner) intent = inner
buf = rest.slice(closeIdx + INTENT_CLOSE.length)
if (buf.startsWith('\n')) buf = buf.slice(1)
}
return intent !== undefined ? { text: out, intent } : { text: out }
}
/** Stamps the latest intent onto the lane's open `subagent` start block. */
function stampLaneIntent(context: StreamingContext, laneId: string, intent: string): void {
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
const b = context.contentBlocks[i]
if (b.type === 'subagent' && b.parentToolCallId === laneId) {
b.subagentIntent = intent
return
}
}
}
export function handleTextEvent(scope: ToolScope): StreamHandler {
return (event, context) => {
if (event.type !== 'text') {
@@ -115,17 +48,11 @@ export function handleTextEvent(scope: ToolScope): StreamHandler {
if (context.isInThinkingBlock) {
flushThinkingBlock(context)
}
// Catch the lane's <intent> protocol server-side: the latest tag becomes
// the persisted subagent block's status and the stored prose is stripped,
// so every surface (live, persisted, replay) agrees on both.
const { text: cleanChunk, intent } = filterLaneIntent(context, parentToolCallId, chunk)
if (intent) stampLaneIntent(context, parentToolCallId, intent)
if (!cleanChunk) return
context.subAgentContent[parentToolCallId] =
(context.subAgentContent[parentToolCallId] || '') + cleanChunk
(context.subAgentContent[parentToolCallId] || '') + chunk
addContentBlock(context, {
type: 'subagent_text',
content: cleanChunk,
content: chunk,
parentToolCallId,
...(event.scope?.agentId ? { subagent: event.scope.agentId } : {}),
...spanIdentity,
-4
View File
@@ -82,8 +82,6 @@ export interface ContentBlock {
subagent?: string
/** Orchestrator-chosen display name for a `subagent` start block. */
subagentName?: string
/** The agent's latest <intent> tag. */
subagentIntent?: string
/**
* Deterministic agent-run identity. `spanId` is the stable per-invocation id
* of the subagent that produced the block; `parentSpanId` links it to the run
@@ -150,8 +148,6 @@ export interface StreamingContext {
* block. Per-lane keying keeps each subagent's reasoning intact.
*/
subagentThinkingBlocks: Map<string, ContentBlock>
/** Per-lane carry for an <intent> tag split across streamed chunks. */
subagentIntentCarry?: Record<string, string>
isInThinkingBlock: boolean
subAgentContent: Record<string, string>
subAgentToolCalls: Record<string, ToolCallState[]>