mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-31 03:47:12 +08:00
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:
+24
-10
@@ -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}
|
||||
|
||||
+1
-31
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]>
|
||||
|
||||
Reference in New Issue
Block a user