Files
orca/src/main/runtime/orchestration/worker-transcript-payload.ts
T
Brennan Benson fe1f0223af Native chat: read one saved row per subagent (part 1 of 2) (#27914)
* feat(chat): read one saved row per subagent

Adds the singular `subagent` journal block (one row per child, revised in
place, with a plain-text twin for older clients) and teaches every reader
to draw it: desktop and phone fold a turn's sibling rows into the existing
"Ran N subagents" row, the client and off-page rosters name children from
their own rows, the reopen pass settles a row a dead host left working,
and the phone and worker-transcript wires bound it. No producer writes the
block yet; that switch lands in the follow-up.

* fix(chat): open subagent background flag, separate nested group ids, page window skips subagent rows

- background is an open boolean so a later build's value never fails a chat load
- a helper's own helpers fold under a groupId of their own, so the phone opens them apart
- each subagent's own row rides along with the page window instead of counting as conversation

* refactor(chat): a subagent's group entry as one object
2026-10-12 14:21:25 -07:00

302 lines
11 KiB
TypeScript

import { createHash } from 'node:crypto'
import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types'
import { boundSubagentEntryId } from '../../native-chat/subagent-entry-id-bounds'
import { boundWorkerTranscriptActivityBlock } from './worker-transcript-activity-block-bounds'
export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40
export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50
const MAX_WORKER_TRANSCRIPT_BLOCKS = 6
const MAX_WORKER_TRANSCRIPT_BLOCK_CHARS = 1_200
const MAX_WORKER_TRANSCRIPT_INPUT_ITEMS = 20
const MAX_WORKER_TRANSCRIPT_INPUT_NODES = 100
// Message ids, turn ids, tool-call names and image urls, not only roster fields.
// Equal to `MAX_SUBAGENT_FIELD_CHARS` today, kept a separate literal so a
// roster-motivated change to that cap cannot silently move this one.
const MAX_WORKER_TRANSCRIPT_METADATA_CHARS = 512
const MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES = 512 * 1024
const TRUNCATION_MARKER = '\n… (truncated)'
const DISPATCH_CAPABILITY_PATTERN = /\bdcap_[A-Za-z0-9_-]{20,}\b/g
const DISPATCH_CAPABILITY_REDACTION = '[dispatch capability redacted]'
type TranscriptBoundState = {
warnings: Set<string>
clipped: boolean
}
export function clampWorkerTranscriptLimit(limit: number | undefined): number {
if (!Number.isFinite(limit) || (limit ?? 0) <= 0) {
return DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT
}
return Math.min(Math.floor(limit!), MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT)
}
export function redactWorkerTerminalLines(lines: readonly string[]): {
lines: string[]
warnings: string[]
} {
let redacted = false
const bounded = lines.map((line) => {
const result = replaceDispatchCapabilities(line)
redacted ||= result.redacted
return result.value
})
return {
lines: bounded,
warnings: redacted ? ['Dispatch capability tokens were redacted from terminal output.'] : []
}
}
export function boundWorkerTranscriptMessages(
messages: readonly NativeChatMessage[],
transcriptPath?: string
): {
messages: NativeChatMessage[]
limited: boolean
warnings: string[]
} {
const state: TranscriptBoundState = { warnings: new Set<string>(), clipped: false }
const bounded: NativeChatMessage[] = []
let bytes = 2
for (const message of messages) {
const next = boundMessage(message, transcriptPath, state)
const serializedBytes = Buffer.byteLength(JSON.stringify(next), 'utf8') + 1
if (bounded.length > 0 && bytes + serializedBytes > MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES) {
markClipped(state, 'Transcript response was clipped to the wire-size limit.')
return { messages: bounded, limited: true, warnings: [...state.warnings] }
}
bounded.push(next)
bytes += serializedBytes
}
return { messages: bounded, limited: state.clipped, warnings: [...state.warnings] }
}
/**
* The same per-message bounding, accumulated NEWEST-first.
*
* `boundWorkerTranscriptMessages` keeps the head, which is right for a forward page and wrong for
* an archive: the evidence anyone reads a released worker back for is its final answer, so the
* tail is what must survive the budget.
*/
export function boundWorkerTranscriptTail(
messages: readonly NativeChatMessage[],
maxBytes: number
): { messages: NativeChatMessage[]; limited: boolean; warnings: string[] } {
const state: TranscriptBoundState = { warnings: new Set<string>(), clipped: false }
const keptReversed: NativeChatMessage[] = []
let bytes = 2
let limited = false
for (let index = messages.length - 1; index >= 0; index -= 1) {
const next = boundMessage(messages[index]!, undefined, state)
const serializedBytes = Buffer.byteLength(JSON.stringify(next), 'utf8') + 1
if (keptReversed.length > 0 && bytes + serializedBytes > maxBytes) {
limited = true
break
}
keptReversed.push(next)
bytes += serializedBytes
}
keptReversed.reverse()
return { messages: keptReversed, limited, warnings: [...state.warnings] }
}
function boundMessage(
message: NativeChatMessage,
transcriptPath: string | undefined,
state: TranscriptBoundState
): NativeChatMessage {
const blocks = message.blocks.slice(0, MAX_WORKER_TRANSCRIPT_BLOCKS)
if (blocks.length < message.blocks.length) {
markClipped(state, 'Some transcript blocks were omitted from oversized messages.')
}
// The journal position only orders a live list; a worker read is already in order.
const { journalPosition: _journalPosition, ...served } = message
return {
...served,
id: boundIdentifier(message.id, transcriptPath, state),
...(message.turnId ? { turnId: boundIdentifier(message.turnId, transcriptPath, state) } : {}),
...(message.parentId
? { parentId: boundIdentifier(message.parentId, transcriptPath, state) }
: {}),
blocks: blocks.map((block) => boundBlock(block, state))
}
}
function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): NativeChatBlock {
if (block.type === 'text') {
return { ...block, text: clipText(block.text, state) }
}
if (block.type === 'tool-result') {
return { ...block, output: clipText(block.output, state) }
}
if (block.type === 'tool-call') {
const budget = {
remaining: MAX_WORKER_TRANSCRIPT_BLOCK_CHARS,
nodes: MAX_WORKER_TRANSCRIPT_INPUT_NODES
}
return {
...block,
name: clipMetadata(block.name, state),
input: boundToolInput(block.input, budget, 0, state)
}
}
if (
block.type === 'subagent-group' ||
block.type === 'subagent' ||
block.type === 'background-task'
) {
return boundWorkerTranscriptActivityBlock(block, {
clipMetadata: (value) => clipMetadata(value, state),
clipText: (value) => clipText(value, state),
boundEntryId: (value) => boundEntryId(value, state),
markClipped: (warning) => markClipped(state, warning)
})
}
if (block.path || (block.url && isLocalFileLocator(block.url))) {
markClipped(state, 'Local image paths were omitted from transcript output.')
return {
type: 'image-ref',
...(block.alt ? { alt: clipText(block.alt, state) } : {})
}
}
return {
...block,
...(block.url ? { url: clipMetadata(block.url, state) } : {}),
...(block.alt ? { alt: clipText(block.alt, state) } : {})
}
}
function boundIdentifier(
value: string,
transcriptPath: string | undefined,
state: TranscriptBoundState
): string {
if (transcriptPath && value.includes(transcriptPath)) {
state.warnings.add('Transcript-backed message identifiers were made opaque.')
return `worker-message-${createHash('sha256').update(value).digest('base64url').slice(0, 32)}`
}
return clipMetadata(value, state)
}
function isLocalFileLocator(value: string): boolean {
return (
/^file:/i.test(value) ||
/^[a-z]:[\\/]/i.test(value) ||
value.startsWith('/') ||
value.startsWith('\\\\')
)
}
/** A roster entry's id is the roster KEY, so it is redacted like other metadata
* but bounded with a digest rather than clipped: two ids sharing a 512-char
* head must not collapse onto one entry. */
function boundEntryId(value: string, state: TranscriptBoundState): string {
const redacted = redactSensitiveText(value, state.warnings)
const bounded = boundSubagentEntryId(redacted)
if (bounded !== redacted) {
markClipped(state, 'Oversized transcript metadata was clipped.')
}
return bounded
}
function clipMetadata(value: string, state: TranscriptBoundState): string {
const redacted = redactSensitiveText(value, state.warnings)
if (redacted.length <= MAX_WORKER_TRANSCRIPT_METADATA_CHARS) {
return redacted
}
markClipped(state, 'Oversized transcript metadata was clipped.')
return redacted.slice(0, MAX_WORKER_TRANSCRIPT_METADATA_CHARS)
}
function clipText(value: string, state: TranscriptBoundState): string {
const redacted = redactSensitiveText(value, state.warnings)
if (redacted.length <= MAX_WORKER_TRANSCRIPT_BLOCK_CHARS) {
return redacted
}
markClipped(state, 'Oversized transcript text was clipped.')
return `${redacted.slice(0, MAX_WORKER_TRANSCRIPT_BLOCK_CHARS)}${TRUNCATION_MARKER}`
}
function boundToolInput(
value: unknown,
budget: { remaining: number; nodes: number },
depth: number,
state: TranscriptBoundState
): unknown {
budget.nodes--
if (budget.nodes < 0 || budget.remaining <= 0) {
markClipped(state, 'Oversized tool input was clipped.')
return '… (truncated)'
}
if (typeof value === 'string') {
const redacted = redactSensitiveText(value, state.warnings)
const length = Math.min(redacted.length, budget.remaining)
budget.remaining -= length
if (length < redacted.length) {
markClipped(state, 'Oversized tool input was clipped.')
return `${redacted.slice(0, length)}… (truncated)`
}
return redacted
}
if (!value || typeof value !== 'object') {
return value
}
if (depth >= 5) {
markClipped(state, 'Deep tool input was clipped.')
return '… (truncated)'
}
if (Array.isArray(value)) {
const result = value
.slice(0, MAX_WORKER_TRANSCRIPT_INPUT_ITEMS)
.map((item) => boundToolInput(item, budget, depth + 1, state))
if (value.length > MAX_WORKER_TRANSCRIPT_INPUT_ITEMS) {
markClipped(state, 'Oversized tool input was clipped.')
result.push('… (truncated)')
}
return result
}
const result: Record<string, unknown> = Object.create(null)
let count = 0
for (const [rawKey, entry] of Object.entries(value)) {
if (count >= MAX_WORKER_TRANSCRIPT_INPUT_ITEMS || budget.remaining <= 0) {
markClipped(state, 'Oversized tool input was clipped.')
result['…'] = 'truncated'
break
}
const redactedKey = redactSensitiveText(rawKey, state.warnings)
const key = redactedKey.slice(0, Math.min(redactedKey.length, budget.remaining, 128))
if (key.length < redactedKey.length) {
markClipped(state, 'Oversized tool input was clipped.')
}
budget.remaining -= key.length
result[key] = boundToolInput(entry, budget, depth + 1, state)
count++
}
return result
}
function markClipped(state: TranscriptBoundState, warning: string): void {
state.clipped = true
state.warnings.add(warning)
}
function redactSensitiveText(value: string, warnings: Set<string>): string {
const result = replaceDispatchCapabilities(value)
if (!result.redacted) {
return result.value
}
warnings.add('Dispatch capability tokens were redacted from transcript output.')
return result.value
}
function replaceDispatchCapabilities(value: string): { value: string; redacted: boolean } {
DISPATCH_CAPABILITY_PATTERN.lastIndex = 0
const redacted = DISPATCH_CAPABILITY_PATTERN.test(value)
DISPATCH_CAPABILITY_PATTERN.lastIndex = 0
return {
value: redacted
? value.replace(DISPATCH_CAPABILITY_PATTERN, DISPATCH_CAPABILITY_REDACTION)
: value,
redacted
}
}