mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 08:01:56 +00:00
* fix(native-chat): a subagent's words are presented as that subagent's, never the parent's The journal already names the agent that produced every row, but the transcript projection dropped it, so a subagent's prose rendered as the parent's reply, its tool calls folded into the parent's runs, and a settled turn could fold down to a subagent's words as its only visible answer. The transcript message now keeps the row's producer. The fold keeps each agent's calls in that agent's own run, a turn's answer is the session's own agent's last prose, and a subagent's row names the subagent on desktop, mobile and a worker's transcript text. * test(native-chat): give the window fixture's slot the attribution field it now carries * fix(mobile): read the subagent label the row is given, and pin the caption * fix(native-chat): keep interleaved agents in order and each agent's own run live Review follow-ups: - the fold is main's adjacency fold plus one condition: a row never folds into another agent's run, so an agent's later call stays below its subagent's work instead of jumping back into its earlier row - each agent has its own live frontier, so a parent still inside its spawn call reads as running while its subagent works below it - mobile names no one on a row whose only content is hidden behind its settled turn - a pending question from a subagent keeps its producer - worker reads serve only the producing agent's id, bounded like the roster key that names it, and drop the provenance fields - the single-message worker formatter is private, so no caller can drop names
133 lines
5.5 KiB
TypeScript
133 lines
5.5 KiB
TypeScript
/**
|
|
* The one plain-text rendering of a worker transcript message.
|
|
*
|
|
* The CLI prints `worker-read --source transcript` with it, and `terminal read` serves a structured
|
|
* worker's recent output through it, so a peer sees the same text either way. Shared rather than
|
|
* copied: two renderings would let the two surfaces disagree about what a tool call looked like.
|
|
*/
|
|
|
|
import { claimBackgroundTaskTwins } from './native-chat-background-task-row'
|
|
import {
|
|
isSubagentGroupFallbackText,
|
|
subagentGroupFallbackText
|
|
} from './native-chat-subagent-summary'
|
|
import type { NativeChatMessage } from './native-chat-types'
|
|
import { agentJournalItemSubagentId } from './agent-session-journal-producer'
|
|
import {
|
|
nativeChatSubagentLabel,
|
|
nativeChatSubagentLabels
|
|
} from './native-chat-subagent-attribution'
|
|
|
|
/** Every message in one read, each tagged with who it speaks as. A subagent is
|
|
* named by the roster on the same page. */
|
|
export function formatWorkerTranscriptMessages(messages: readonly NativeChatMessage[]): string[] {
|
|
const subagentLabels = nativeChatSubagentLabels(messages)
|
|
return messages.map((message) => formatWorkerTranscriptMessage(message, subagentLabels))
|
|
}
|
|
|
|
/** Who a line speaks as. A subagent's lines are tagged as its own, so a peer reading
|
|
* a worker's transcript never takes a subagent's words for the worker's. */
|
|
function workerTranscriptSpeaker(
|
|
message: NativeChatMessage,
|
|
subagentLabels: ReadonlyMap<string, string> | undefined
|
|
): string {
|
|
if (agentJournalItemSubagentId(message) === null) {
|
|
return message.role
|
|
}
|
|
const label = nativeChatSubagentLabel(subagentLabels, message)
|
|
return label === undefined ? `${message.role}, subagent` : `${message.role}, subagent ${label}`
|
|
}
|
|
|
|
function formatWorkerTranscriptMessage(
|
|
message: NativeChatMessage,
|
|
subagentLabels: ReadonlyMap<string, string>
|
|
): string {
|
|
// Every roster block is written beside a plain-text twin carrying the same
|
|
// sentence, for clients that cannot draw the block. Text surfaces are those
|
|
// clients, so they print the twin and drop the block. The renderer reaches the
|
|
// same single print from the other side but not by the same rule: it drops
|
|
// every fallback-shaped text block as soon as any group is present and draws
|
|
// each group, so it never has to decide which twin belongs to which group.
|
|
const standIns = claimSubagentGroupTwins(message.blocks)
|
|
// A background task's row is the same shape: one block, one frozen twin. Its
|
|
// twin is matched on exact text rather than by shape, because the sentence is
|
|
// often the provider's own and has none.
|
|
const taskTwins = claimBackgroundTaskTwins(message.blocks)
|
|
const blocks = message.blocks.map((block, index) => {
|
|
if (block.type === 'text') {
|
|
return block.text
|
|
}
|
|
if (block.type === 'background-task') {
|
|
return taskTwins.unpairedRows.get(index) ?? null
|
|
}
|
|
if (block.type === 'tool-call') {
|
|
return `[tool ${block.name}] ${safeJson(block.input)}`
|
|
}
|
|
if (block.type === 'tool-result') {
|
|
return `[tool result${block.isError ? ' error' : ''}] ${block.output}`
|
|
}
|
|
if (block.type === 'image-ref') {
|
|
return block.url ? `[image] ${block.url}` : `[image omitted]`
|
|
}
|
|
if (block.type === 'subagent-group') {
|
|
return standIns.get(index) ?? null
|
|
}
|
|
// The journal deliberately admits block types this build does not know, and
|
|
// a newer remote host can send one over the wire. Degrade to a marker rather
|
|
// than reading fields off a shape that has none.
|
|
return '[unsupported block]'
|
|
})
|
|
return `[${workerTranscriptSpeaker(message, subagentLabels)}] ${blocks.filter((line) => line !== null).join('\n')}`.trimEnd()
|
|
}
|
|
|
|
/** For each roster block, the sentence it must print itself — absent when a twin
|
|
* beside it already prints one.
|
|
*
|
|
* Exact-text claims are settled for EVERY group before any leftover twin is
|
|
* claimed by position: claiming in block order let an earlier group consume a
|
|
* later group's twin, silencing the earlier roster while the later one printed
|
|
* twice. The positional fallback stays because a roster written by a newer build
|
|
* holds a state this build reads as `unverifiable`, so its frozen twin can never
|
|
* equal the sentence recomputed here and a text match alone would print it
|
|
* twice. A group left with no twin prints its own: the wire admits a roster that
|
|
* arrived without one, and dropping that would lose the sentence altogether. */
|
|
function claimSubagentGroupTwins(blocks: NativeChatMessage['blocks']): Map<number, string> {
|
|
const twins = new Map<string, number>()
|
|
let remainingTwins = 0
|
|
const groups: { index: number; sentence: string }[] = []
|
|
blocks.forEach((block, index) => {
|
|
if (block.type === 'text' && isSubagentGroupFallbackText(block.text)) {
|
|
twins.set(block.text, (twins.get(block.text) ?? 0) + 1)
|
|
remainingTwins += 1
|
|
} else if (block.type === 'subagent-group') {
|
|
groups.push({ index, sentence: subagentGroupFallbackText(block.agents) })
|
|
}
|
|
})
|
|
const standIns = new Map<number, string>()
|
|
const unclaimed = groups.filter((group) => {
|
|
const count = twins.get(group.sentence) ?? 0
|
|
if (count === 0) {
|
|
return true
|
|
}
|
|
twins.set(group.sentence, count - 1)
|
|
remainingTwins -= 1
|
|
return false
|
|
})
|
|
for (const group of unclaimed) {
|
|
if (remainingTwins > 0) {
|
|
remainingTwins -= 1
|
|
continue
|
|
}
|
|
standIns.set(group.index, `[subagents] ${group.sentence}`)
|
|
}
|
|
return standIns
|
|
}
|
|
|
|
function safeJson(value: unknown): string {
|
|
try {
|
|
return JSON.stringify(value)
|
|
} catch {
|
|
return '[unserializable input]'
|
|
}
|
|
}
|