fix(native-chat): bound the subagent roster on every boundary that carries it

The spawn-group arm was the one collection in the worker-transcript payload with
no cap, and the one block type mobile's `sanitizeBlock` forwarded verbatim. The
producer's `MAX_CODEX_SUBAGENTS_PER_GROUP` does not reach either boundary: the
journal schema declares no maximum on `agents`, and a remote host may run a build
with a different cap. Both transports now cap the roster and bound `id`, `label`
and the open `state` string; `label` and `id` also take the standard inline bound
on the journal write path, where every other provider string already does.

A token count is now persisted onto its entry at write time. `write` rebuilt
`tokens` from the LRU-capped thread map on every write, so an eviction silently
retracted a count the durable row had already shown.

Adds the first coverage of the three roster caps, including the group eviction
that rewrites a row from N children down to one.
This commit is contained in:
Merge Sim
2026-09-07 00:26:57 -07:00
parent 319cedbcb0
commit ea406ac389
7 changed files with 411 additions and 114 deletions
@@ -12,6 +12,11 @@ import {
codexSubagentGroupId
} from './codex-subagent-roster'
import type { CodexThreadItem } from './codex-structured-item-translation'
import {
MAX_CODEX_SUBAGENT_GROUPS,
MAX_CODEX_SUBAGENTS_PER_GROUP,
MAX_CODEX_TOKEN_USAGE_THREADS
} from './codex-structured-journal-limits'
const THREAD = 'thread-parent'
const TURN = 'turn-1'
@@ -53,6 +58,10 @@ function createHarness(options: { threadId?: string | null } = {}): {
return { roster, appended, agents, latest: () => appended.at(-1) }
}
function latestIdentity(appended: Appended[]): AgentJournalItemIdentity | undefined {
return appended.at(-1)?.identity
}
function activity(input: {
id?: string
kind: string
@@ -461,6 +470,112 @@ describe('CodexSubagentRoster', () => {
expect(agents()[0]).not.toHaveProperty('tokens')
})
it('bounds the provider strings the roster row carries into the journal', () => {
const { roster, agents, latest } = createHarness()
const oversized = 'a'.repeat(20 * 1024)
deliver(
roster,
activity({ kind: 'started', agentThreadId: oversized, agentPath: `/root/${oversized}` })
)
const entry = agents()[0]
expect(entry?.label).toContain('output truncated')
expect(entry?.label.length).toBeLessThan(oversized.length)
expect(entry?.id).toContain('output truncated')
expect(entry?.id.length).toBeLessThan(oversized.length)
expect(isAdmissibleAgentJournalItemBody(latest()?.body)).toBe(true)
})
it('caps the children one spawn group admits', () => {
const { roster, agents, appended } = createHarness()
for (let index = 0; index < MAX_CODEX_SUBAGENTS_PER_GROUP; index++) {
deliver(
roster,
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' })
)
}
const atCap = appended.length
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-over-cap', agentPath: '/root/read' })
)
expect(agents()).toHaveLength(MAX_CODEX_SUBAGENTS_PER_GROUP)
expect(agents().map((agent) => agent.id)).not.toContain('child-over-cap')
// Refusing the child must not burn a revision either.
expect(appended).toHaveLength(atCap)
})
// The eviction is the KNOWN LIMITATION the module documents: `groups` is never
// seeded from the journal, so the evicted group's next child rebuilds its
// durable row from that one child. Pinned so the boundary cannot move silently.
it('caps live spawn groups, and an evicted group rebuilds its row from one child', () => {
const { roster, appended, agents } = createHarness()
for (let index = 0; index <= MAX_CODEX_SUBAGENT_GROUPS; index++) {
deliver(
roster,
activity({ kind: 'started', agentThreadId: `child-${index}`, agentPath: '/root/read' }),
`turn-${index}`
)
}
const evicted = codexSubagentGroupIdentity(codexSubagentGroupId(THREAD, 'turn-0'))
const rowsFor = (identity: AgentJournalItemIdentity): Appended[] =>
appended.filter((entry) => JSON.stringify(entry.identity) === JSON.stringify(identity))
expect(rowsFor(evicted)).toHaveLength(1)
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-late', agentPath: '/root/search' }),
'turn-0'
)
expect(latestIdentity(appended)).toEqual(evicted)
expect(agents().map((agent) => agent.id)).toEqual(['child-late'])
})
it('keeps a token count a later thread-map eviction would otherwise retract', () => {
const { roster, agents } = createHarness()
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 4242 } } })
expect(agents()).toMatchObject([{ tokens: 4242 }])
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
roster.handleTokenUsage({
threadId: `other-${index}`,
tokenUsage: { total: { totalTokens: index } }
})
}
deliver(
roster,
activity({ kind: 'completed', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()).toMatchObject([{ state: 'completed', tokens: 4242 }])
})
it('caps retained usage threads, so a frame evicted before its child is dropped', () => {
const { roster, agents } = createHarness()
roster.handleTokenUsage({ threadId: 'child-1', tokenUsage: { total: { totalTokens: 900 } } })
for (let index = 0; index < MAX_CODEX_TOKEN_USAGE_THREADS; index++) {
roster.handleTokenUsage({
threadId: `other-${index}`,
tokenUsage: { total: { totalTokens: index } }
})
}
deliver(
roster,
activity({ kind: 'started', agentThreadId: 'child-1', agentPath: '/root/read' })
)
expect(agents()[0]).not.toHaveProperty('tokens')
})
it('declines a payload that is not a subagent item or a usage frame', () => {
const { roster } = createHarness()
+27 -5
View File
@@ -28,6 +28,10 @@ import {
subagentGroupFallbackText
} from '../../shared/native-chat-subagent-summary'
import type { NativeChatSubagentEntry } from '../../shared/native-chat-types'
import {
boundInlineText,
DEFAULT_JOURNAL_PAYLOAD_LIMITS
} from '../native-chat/agent-session-journal/journal-payload-bounds'
import type {
StructuredAgentSessionEventSink,
StructuredAgentSessionSinkAdmission
@@ -263,9 +267,16 @@ export class CodexSubagentRoster {
}
private write(group: RosterGroup): StructuredAgentSessionSinkAdmission {
const agents = [...group.entries.values()].map((entry) => {
const tokens = this.tokensByThread.get(entry.id)
return typeof tokens === 'number' ? { ...entry, tokens } : entry
const agents = [...group.entries].map(([id, entry]) => {
const tokens = this.tokensByThread.get(id)
if (typeof tokens !== 'number' || tokens === entry.tokens) {
return entry
}
// Persisted, not merely read: the thread map is LRU-capped, and reading it
// afresh each write would retract a count this row has already shown.
const merged = { ...entry, tokens }
group.entries.set(id, merged)
return merged
})
const body = codexSubagentGroupBody(group.groupId, agents)
const serialized = JSON.stringify(body)
@@ -307,12 +318,23 @@ export function codexSubagentGroupBody(
groupId: string,
agents: readonly NativeChatSubagentEntry[]
): AgentJournalItemBody {
const bounded = agents.map((agent) => ({
...agent,
id: boundSubagentField(agent.id),
label: boundSubagentField(agent.label)
}))
return {
kind: 'message',
role: 'system',
blocks: [
{ type: 'text', text: subagentGroupFallbackText(agents) },
{ type: 'subagent-group', groupId, agents: [...agents] }
{ type: 'text', text: subagentGroupFallbackText(bounded) },
{ type: 'subagent-group', groupId, agents: bounded }
]
}
}
/** `id` and `label` are provider strings, so they take the same inline bound
* every other piece of journal-bound text takes before it reaches a row. */
function boundSubagentField(value: string): string {
return boundInlineText(value, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text
}
@@ -57,6 +57,77 @@ describe('worker transcript wire bounds', () => {
)
})
// The producer's per-group cap never reaches this boundary: the journal schema
// declares no maximum and a remote host may run a build with a different one,
// so the transport keeps its own — the invariant that no single block is huge.
it('caps and redacts a spawn group the way every other collection is capped', () => {
const result = boundWorkerTranscriptMessages([
{
id: 'message-roster',
role: 'system',
timestamp: null,
source: 'transcript',
blocks: [
{
type: 'subagent-group',
groupId: 'thread-1:turn-1',
agents: Array.from({ length: 40 }, (_unused, index) => ({
id: `child-${index}`,
label: index === 0 ? `dcap_${'A'.repeat(24)}` : 'read',
state: 'working' as const
}))
}
]
}
])
const block = result.messages[0]?.blocks[0]
expect(block?.type).toBe('subagent-group')
expect(block?.type === 'subagent-group' ? block.agents : []).toHaveLength(20)
expect(JSON.stringify(result.messages)).not.toContain('dcap_')
expect(result.limited).toBe(true)
expect(result.warnings).toEqual(
expect.arrayContaining([
'Some subagents were omitted from oversized spawn groups.',
'Dispatch capability tokens were redacted from transcript output.'
])
)
})
it('bounds a spawn-group state a newer build wrote as an oversized open string', () => {
const result = boundWorkerTranscriptMessages([
{
id: 'message-roster-state',
role: 'system',
timestamp: null,
source: 'transcript',
blocks: [
{
type: 'subagent-group',
groupId: 'g'.repeat(900),
agents: [
{
id: 'i'.repeat(900),
label: 'l'.repeat(900),
state: 's'.repeat(900) as 'working'
}
]
}
]
}
])
const block = result.messages[0]?.blocks[0]
const agent = block?.type === 'subagent-group' ? block.agents[0] : undefined
expect(block?.type === 'subagent-group' ? block.groupId.length : 0).toBe(512)
expect(agent?.id.length).toBe(512)
expect(agent?.label.length).toBe(512)
// A clipped state names no state any build knows, which is what
// `unverifiable` records — a 512-character fragment is not a state at all.
expect(agent?.state).toBe('unverifiable')
expect(result.limited).toBe(true)
})
it('keeps complete bounded messages unlimited', () => {
const result = boundWorkerTranscriptMessages([
{
@@ -1,5 +1,10 @@
import { createHash } from 'node:crypto'
import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types'
import { normalizeSubagentState } from '../../../shared/native-chat-subagent-summary'
import type {
NativeChatBlock,
NativeChatMessage,
NativeChatSubagentState
} from '../../../shared/native-chat-types'
export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40
export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50
@@ -7,6 +12,9 @@ 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
// The producer's per-group cap does not reach this boundary: the journal schema
// declares no maximum, and a remote host may run a build with a different one.
const MAX_WORKER_TRANSCRIPT_SUBAGENTS = 20
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
@@ -129,15 +137,20 @@ function boundBlock(block: NativeChatBlock, state: TranscriptBoundState): Native
}
}
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.
const agents = block.agents.slice(0, MAX_WORKER_TRANSCRIPT_SUBAGENTS)
if (agents.length < block.agents.length) {
markClipped(state, 'Some subagents were omitted from oversized spawn groups.')
}
// Labels, ids and states come from provider-supplied strings, so they get the
// same redaction and clipping every other piece of transcript metadata gets.
return {
...block,
groupId: clipMetadata(block.groupId, state),
agents: block.agents.map((agent) => ({
agents: agents.map((agent) => ({
...agent,
id: clipMetadata(agent.id, state),
label: clipMetadata(agent.label, state)
label: clipMetadata(agent.label, state),
state: clipSubagentState(agent.state, state)
}))
}
}
@@ -185,6 +198,17 @@ function clipMetadata(value: string, state: TranscriptBoundState): string {
return redacted.slice(0, 512)
}
/** `state` is an open string on the wire, so it takes the same bound. A value
* that had to be redacted or clipped names no state any build knows, which is
* exactly what `unverifiable` records. */
function clipSubagentState(
value: NativeChatSubagentState,
state: TranscriptBoundState
): NativeChatSubagentState {
const clipped = clipMetadata(value, state)
return clipped === value ? value : normalizeSubagentState(clipped)
}
function clipText(value: string, state: TranscriptBoundState): string {
const redacted = redactSensitiveText(value, state.warnings)
if (redacted.length <= MAX_WORKER_TRANSCRIPT_BLOCK_CHARS) {
@@ -0,0 +1,130 @@
import { normalizeSubagentState } from '../../../../shared/native-chat-subagent-summary'
import type { NativeChatBlock, NativeChatSubagentState } from '../../../../shared/native-chat-types'
import type { RpcContext } from '../core'
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
// Why: the mobile-only payload diet. Inline image bytes are kept off every RPC
// transport; everything below that only applies to `mobile` clients, whose
// renderer previews block bodies rather than showing them whole.
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
// The mobile view only previews tool block bodies, so truncate them on the wire
// to keep the payload small; the marker tells the user content was clipped.
const MOBILE_BLOCK_CHAR_CAP = 4000
// Why: text blocks are the message body itself, rendered in full by the chat
// view — a preview-sized cap cut long assistant replies mid-sentence with no way
// to read on (STA-3230). Keep only a generous safety ceiling: a transcript
// record can legally reach 2MB, and shipping that much markdown in one block
// would freeze the phone.
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
const MOBILE_TOOL_INPUT_NODE_CAP = 100
// Why: a spawn group's roster is metadata, not a body — provider-supplied agent
// paths and an open-string lifecycle whose schema declares no maximum, so a
// journal from a newer build can carry more children and longer strings than
// this build ever writes.
const MOBILE_SUBAGENT_CAP = 64
const MOBILE_SUBAGENT_FIELD_CHAR_CAP = 512
const TRUNCATION_MARKER = '\n… (truncated)'
function clip(text: string, cap: number): string {
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
}
export function sanitizeNativeChatRpcBlock(
block: NativeChatBlock,
clientKind: RpcContext['clientKind']
): NativeChatBlock {
if (block.type === 'image-ref') {
return sanitizeNativeChatRpcImageBlock(block)
}
if (clientKind !== 'mobile') {
return block
}
if (block.type === 'text') {
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-result') {
return block.output.length > MOBILE_BLOCK_CHAR_CAP
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-call') {
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
}
if (block.type === 'subagent-group') {
return {
...block,
groupId: clip(block.groupId, MOBILE_SUBAGENT_FIELD_CHAR_CAP),
agents: block.agents.slice(0, MOBILE_SUBAGENT_CAP).map((agent) => ({
...agent,
id: clip(agent.id, MOBILE_SUBAGENT_FIELD_CHAR_CAP),
label: clip(agent.label, MOBILE_SUBAGENT_FIELD_CHAR_CAP),
state: clipSubagentState(agent.state)
}))
}
}
return block
}
/** A state too long to be one this build knows names no state at all, which is
* what `unverifiable` records — clipping it would ship a truncated word. */
function clipSubagentState(value: NativeChatSubagentState): NativeChatSubagentState {
return value.length > MOBILE_SUBAGENT_FIELD_CHAR_CAP ? normalizeSubagentState(value) : value
}
function sanitizeToolInput(
value: unknown,
budget: { remaining: number; nodes: number },
depth: number
): unknown {
budget.nodes--
if (budget.nodes < 0 || budget.remaining <= 0) {
return '… (truncated)'
}
if (typeof value === 'string') {
const length = Math.min(value.length, budget.remaining)
budget.remaining -= length
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
}
if (!value || typeof value !== 'object' || depth >= 5) {
return value && typeof value === 'object' ? '… (truncated)' : value
}
if (Array.isArray(value)) {
const result = value
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
.map((item) => sanitizeToolInput(item, budget, depth + 1))
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
result.push('… (truncated)')
}
return result
}
const result: Record<string, unknown> = {}
let count = 0
for (const key in value) {
if (!Object.hasOwn(value, key)) {
continue
}
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
result['…'] = 'truncated'
break
}
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
// Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse
// to the same bounded key; suffix collisions so neither field is silently lost.
if (Object.hasOwn(result, boundedKey)) {
boundedKey = `${boundedKey}~${count}`
}
budget.remaining -= boundedKey.length
result[boundedKey] = sanitizeToolInput(
(value as Record<string, unknown>)[key],
budget,
depth + 1
)
count++
}
return result
}
@@ -266,6 +266,39 @@ describe('nativeChat.readSession clientKind truncation gating', () => {
expect(JSON.stringify(input)).toContain('truncated')
})
// The roster block reached mobile through a bare fall-through, uncapped, on the
// one path that exists to keep the payload off the phone.
it('bounds a spawn-group roster before sending it to mobile', async () => {
cachedResult.value = {
messages: [
{
...makeMessage('ignored'),
blocks: [
{
type: 'subagent-group',
groupId: 'thread-1:turn-1',
agents: Array.from({ length: 80 }, (_unused, index) => ({
id: `child-${index}`,
label: index === 0 ? OVERSIZED : 'read',
state: index === 0 ? (OVERSIZED as 'working') : ('working' as const)
}))
}
]
}
]
}
const result = await readSessionHandler()({ agent: 'codex', sessionId: 's' }, ctxWith('mobile'))
const block = (result as { messages: NativeChatMessage[] }).messages[0].blocks[0]
if (block.type !== 'subagent-group') {
throw new Error('expected a subagent-group block')
}
expect(block.agents).toHaveLength(64)
expect(block.agents[0].label.length).toBeLessThan(OVERSIZED.length)
expect(block.agents[0].state).toBe('unverifiable')
})
it('preserves AskUserQuestion option objects at the supported nesting depth', async () => {
cachedResult.value = {
messages: [
+6 -104
View File
@@ -1,9 +1,5 @@
import { z } from 'zod'
import type {
NativeChatBlock,
NativeChatMessage,
AgentType
} from '../../../../shared/native-chat-types'
import type { NativeChatMessage, AgentType } from '../../../../shared/native-chat-types'
import {
readNativeChatTranscriptTail,
subscribeNativeChatTranscript,
@@ -11,7 +7,7 @@ import {
type SubscribeNativeChatTranscriptArgs
} from '../../../native-chat/transcript-watch'
import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext } from '../core'
import { sanitizeNativeChatRpcImageBlock } from './native-chat-rpc-image-block'
import { sanitizeNativeChatRpcBlock } from './native-chat-rpc-block-sanitize'
// Why: native chat renders an agent's own transcript (Claude/Codex JSONL). The
// desktop reaches the readers via Electron IPC; mobile/web clients reach the
@@ -68,109 +64,15 @@ const NativeChatUnsubscribe = z.object({
// older history as the user scrolls back.
const MOBILE_NATIVE_CHAT_DEFAULT_WINDOW = 40
const MOBILE_NATIVE_CHAT_MAX_WINDOW = 2000
// Why: a single tool result (a big file read, a long diff) can be hundreds of KB.
// The mobile view only previews tool block bodies, so truncate them on the wire
// to keep the payload small; the marker tells the user content was clipped.
const MOBILE_BLOCK_CHAR_CAP = 4000
// Why: text blocks are the message body itself, rendered in full by the chat
// view — a preview-sized cap cut long assistant replies mid-sentence with no way
// to read on (STA-3230). Keep only a generous safety ceiling: a transcript
// record can legally reach 2MB, and shipping that much markdown in one block
// would freeze the phone.
const MOBILE_TEXT_BLOCK_CHAR_CAP = 64_000
const MOBILE_TOOL_INPUT_ITEMS_CAP = 20
const MOBILE_TOOL_INPUT_NODE_CAP = 100
const TRUNCATION_MARKER = '\n… (truncated)'
function clip(text: string, cap: number): string {
return text.length > cap ? text.slice(0, cap) + TRUNCATION_MARKER : text
}
function sanitizeBlock(
block: NativeChatBlock,
clientKind: RpcContext['clientKind']
): NativeChatBlock {
if (block.type === 'image-ref') {
return sanitizeNativeChatRpcImageBlock(block)
}
if (clientKind !== 'mobile') {
return block
}
if (block.type === 'text') {
return block.text.length > MOBILE_TEXT_BLOCK_CHAR_CAP
? { ...block, text: clip(block.text, MOBILE_TEXT_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-result') {
return block.output.length > MOBILE_BLOCK_CHAR_CAP
? { ...block, output: clip(block.output, MOBILE_BLOCK_CHAR_CAP) }
: block
}
if (block.type === 'tool-call') {
const budget = { remaining: MOBILE_BLOCK_CHAR_CAP, nodes: MOBILE_TOOL_INPUT_NODE_CAP }
return { ...block, input: sanitizeToolInput(block.input, budget, 0) }
}
return block
}
function sanitizeToolInput(
value: unknown,
budget: { remaining: number; nodes: number },
depth: number
): unknown {
budget.nodes--
if (budget.nodes < 0 || budget.remaining <= 0) {
return '… (truncated)'
}
if (typeof value === 'string') {
const length = Math.min(value.length, budget.remaining)
budget.remaining -= length
return length < value.length ? `${value.slice(0, length)}… (truncated)` : value
}
if (!value || typeof value !== 'object' || depth >= 5) {
return value && typeof value === 'object' ? '… (truncated)' : value
}
if (Array.isArray(value)) {
const result = value
.slice(0, MOBILE_TOOL_INPUT_ITEMS_CAP)
.map((item) => sanitizeToolInput(item, budget, depth + 1))
if (value.length > MOBILE_TOOL_INPUT_ITEMS_CAP) {
result.push('… (truncated)')
}
return result
}
const result: Record<string, unknown> = {}
let count = 0
for (const key in value) {
if (!Object.hasOwn(value, key)) {
continue
}
if (count >= MOBILE_TOOL_INPUT_ITEMS_CAP || budget.remaining <= 0) {
result['…'] = 'truncated'
break
}
let boundedKey = key.slice(0, Math.min(key.length, budget.remaining, 128))
// Why: sibling keys sharing a >=128-char (or budget-truncated) prefix collapse
// to the same bounded key; suffix collisions so neither field is silently lost.
if (Object.hasOwn(result, boundedKey)) {
boundedKey = `${boundedKey}~${count}`
}
budget.remaining -= boundedKey.length
result[boundedKey] = sanitizeToolInput(
(value as Record<string, unknown>)[key],
budget,
depth + 1
)
count++
}
return result
}
function sanitizeMessage(
message: NativeChatMessage,
clientKind: RpcContext['clientKind']
): NativeChatMessage {
return { ...message, blocks: message.blocks.map((block) => sanitizeBlock(block, clientKind)) }
return {
...message,
blocks: message.blocks.map((block) => sanitizeNativeChatRpcBlock(block, clientKind))
}
}
function sanitizeAppendForClient(