Files
orca/src/main/runtime/orchestration/worker-transcript-payload.ts
T
Merge Sim c96dd59904 fix(native-chat): correct the Codex subagent roster's build, journal write, and failure reporting
* Restore the exhaustive block handling that adding `subagent-group` to
  `NativeChatBlock` broke. `formatWorkerTranscriptMessage` and `boundBlock`
  both fell through to `image-ref` field access, so `tsc -p` failed for the
  CLI and node projects and `build:cli` could not emit. Both now guard on
  `image-ref` explicitly and give the roster block its own branch.

* Stop the roster's publish from evicting its own append. The sink queue
  coalesces by `coalescingKey` alone with no op-kind check, so passing the
  append's key to `tryPublish` spliced the queued append out and the row
  never reached the journal — permanently, since `lastSerialized` was
  already set. `tryPublish()` now takes no argument, matching every other
  call site. The regression test's fake sink honours the key, which the
  previous fake did not.

* Keep `collabAgentToolCall` substantive. Only the MultiAgentV2 path emits
  `subAgentActivity`, so a V1 turn has no roster row; suppressing its collab
  tool calls too would have left a V1 fan-out showing nothing at all.

* Surface a settled failure while siblings still work. The summary now
  reports the worst adverse outcome independently of the group verdict, so
  the row shows `3 working +1 failed` with a failed-coloured dot instead of
  a neutral pulsing dot. The plain-text twin names it too.

* Treat `/morpheus` as a child. Only `/root` is the turn itself; the old
  segment-count test silently dropped a valid single-segment agent.

* Refresh token-usage recency on update so an active thread is not evicted
  as the oldest entry, and scope the `agentsStates` comment to the V2 path.
2026-09-05 01:38:41 -07:00

240 lines
7.8 KiB
TypeScript

import { createHash } from 'node:crypto'
import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types'
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
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]'
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 warnings = new Set<string>()
const bounded: NativeChatMessage[] = []
let bytes = 2
for (const message of messages) {
const next = boundMessage(message, transcriptPath, warnings)
const serializedBytes = Buffer.byteLength(JSON.stringify(next), 'utf8') + 1
if (bounded.length > 0 && bytes + serializedBytes > MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES) {
warnings.add('Transcript response was clipped to the wire-size limit.')
return { messages: bounded, limited: true, warnings: [...warnings] }
}
bounded.push(next)
bytes += serializedBytes
}
return { messages: bounded, limited: false, warnings: [...warnings] }
}
function boundMessage(
message: NativeChatMessage,
transcriptPath: string | undefined,
warnings: Set<string>
): NativeChatMessage {
const blocks = message.blocks.slice(0, MAX_WORKER_TRANSCRIPT_BLOCKS)
if (blocks.length < message.blocks.length) {
warnings.add('Some transcript blocks were omitted from oversized messages.')
}
return {
...message,
id: boundIdentifier(message.id, transcriptPath, warnings),
...(message.turnId
? { turnId: boundIdentifier(message.turnId, transcriptPath, warnings) }
: {}),
blocks: blocks.map((block) => boundBlock(block, warnings))
}
}
function boundBlock(block: NativeChatBlock, warnings: Set<string>): NativeChatBlock {
if (block.type === 'text') {
return { ...block, text: clipText(block.text, warnings) }
}
if (block.type === 'tool-result') {
return { ...block, output: clipText(block.output, warnings) }
}
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, warnings),
input: boundToolInput(block.input, budget, 0, warnings)
}
}
if (block.type === 'subagent-group') {
// Labels come from provider-supplied agent paths, so they get the same
// redaction and clipping every other piece of transcript metadata gets.
return {
...block,
groupId: clipMetadata(block.groupId, warnings),
agents: block.agents.map((agent) => ({
...agent,
id: clipMetadata(agent.id, warnings),
label: clipMetadata(agent.label, warnings)
}))
}
}
if (block.path || (block.url && isLocalFileLocator(block.url))) {
warnings.add('Local image paths were omitted from transcript output.')
return {
type: 'image-ref',
...(block.alt ? { alt: clipText(block.alt, warnings) } : {})
}
}
return {
...block,
...(block.url ? { url: clipMetadata(block.url, warnings) } : {}),
...(block.alt ? { alt: clipText(block.alt, warnings) } : {})
}
}
function boundIdentifier(
value: string,
transcriptPath: string | undefined,
warnings: Set<string>
): string {
if (transcriptPath && value.includes(transcriptPath)) {
warnings.add('Transcript-backed message identifiers were made opaque.')
return `worker-message-${createHash('sha256').update(value).digest('base64url').slice(0, 32)}`
}
return clipMetadata(value, warnings)
}
function isLocalFileLocator(value: string): boolean {
return (
/^file:/i.test(value) ||
/^[a-z]:[\\/]/i.test(value) ||
value.startsWith('/') ||
value.startsWith('\\\\')
)
}
function clipMetadata(value: string, warnings: Set<string>): string {
const redacted = redactSensitiveText(value, warnings)
if (redacted.length <= 512) {
return redacted
}
warnings.add('Oversized transcript metadata was clipped.')
return redacted.slice(0, 512)
}
function clipText(value: string, warnings: Set<string>): string {
const redacted = redactSensitiveText(value, warnings)
if (redacted.length <= MAX_WORKER_TRANSCRIPT_BLOCK_CHARS) {
return redacted
}
warnings.add('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,
warnings: Set<string>
): unknown {
budget.nodes--
if (budget.nodes < 0 || budget.remaining <= 0) {
warnings.add('Oversized tool input was clipped.')
return '… (truncated)'
}
if (typeof value === 'string') {
const redacted = redactSensitiveText(value, warnings)
const length = Math.min(redacted.length, budget.remaining)
budget.remaining -= length
if (length < redacted.length) {
warnings.add('Oversized tool input was clipped.')
return `${redacted.slice(0, length)}… (truncated)`
}
return redacted
}
if (!value || typeof value !== 'object') {
return value
}
if (depth >= 5) {
warnings.add('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, warnings))
if (value.length > MAX_WORKER_TRANSCRIPT_INPUT_ITEMS) {
warnings.add('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) {
warnings.add('Oversized tool input was clipped.')
result['…'] = 'truncated'
break
}
const redactedKey = redactSensitiveText(rawKey, warnings)
const key = redactedKey.slice(0, Math.min(redactedKey.length, budget.remaining, 128))
budget.remaining -= key.length
result[key] = boundToolInput(entry, budget, depth + 1, warnings)
count++
}
return result
}
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
}
}