perf(frontend): optimize ACP tool call output handling to reduce CPU and memory pressure
Replace raw_output single-string accumulation with a chunks array to eliminate O(n^2) string concatenation on every 200ms terminal poll event. Batch tool_call_update dispatches via requestAnimationFrame so multiple agents no longer trigger 25+ React re-renders per second. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -62,7 +62,8 @@ export interface ToolCallInfo {
|
|||||||
status: string
|
status: string
|
||||||
content: string | null
|
content: string | null
|
||||||
raw_input: string | null
|
raw_input: string | null
|
||||||
raw_output: string | null
|
raw_output_chunks: string[]
|
||||||
|
raw_output_total_bytes: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PendingPermission {
|
export interface PendingPermission {
|
||||||
@@ -152,6 +153,21 @@ type Action =
|
|||||||
raw_output: string | null
|
raw_output: string | null
|
||||||
raw_output_append?: boolean
|
raw_output_append?: boolean
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: "BATCH_TOOL_CALL_UPDATES"
|
||||||
|
actions: Array<{
|
||||||
|
contextKey: string
|
||||||
|
tool_call_id: string
|
||||||
|
title: string | null
|
||||||
|
fallback_title: string
|
||||||
|
fallback_kind: string
|
||||||
|
status: string | null
|
||||||
|
content: string | null
|
||||||
|
raw_input: string | null
|
||||||
|
raw_output: string | null
|
||||||
|
raw_output_append?: boolean
|
||||||
|
}>
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: "PERMISSION_REQUEST"
|
type: "PERMISSION_REQUEST"
|
||||||
contextKey: string
|
contextKey: string
|
||||||
@@ -241,12 +257,6 @@ export function getCachedSelectors(agentType: string) {
|
|||||||
return selectorsCache.get(agentType) ?? null
|
return selectorsCache.get(agentType) ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampLiveRawOutput(output: string | null): string | null {
|
|
||||||
if (typeof output !== "string") return output
|
|
||||||
if (output.length <= MAX_LIVE_TOOL_RAW_OUTPUT_CHARS) return output
|
|
||||||
return output.slice(-MAX_LIVE_TOOL_RAW_OUTPUT_CHARS)
|
|
||||||
}
|
|
||||||
|
|
||||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||||
return null
|
return null
|
||||||
@@ -649,9 +659,14 @@ function connectionsReducer(
|
|||||||
status: action.status ?? block.info.status,
|
status: action.status ?? block.info.status,
|
||||||
content: action.content ?? block.info.content,
|
content: action.content ?? block.info.content,
|
||||||
raw_input: action.raw_input ?? block.info.raw_input,
|
raw_input: action.raw_input ?? block.info.raw_input,
|
||||||
raw_output: clampLiveRawOutput(
|
raw_output_chunks:
|
||||||
action.raw_output ?? block.info.raw_output
|
action.raw_output !== null
|
||||||
),
|
? [action.raw_output]
|
||||||
|
: block.info.raw_output_chunks,
|
||||||
|
raw_output_total_bytes:
|
||||||
|
action.raw_output !== null
|
||||||
|
? action.raw_output.length
|
||||||
|
: block.info.raw_output_total_bytes,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...prev.content.slice(existingIndex + 1),
|
...prev.content.slice(existingIndex + 1),
|
||||||
@@ -671,7 +686,9 @@ function connectionsReducer(
|
|||||||
status: action.status,
|
status: action.status,
|
||||||
content: action.content,
|
content: action.content,
|
||||||
raw_input: action.raw_input,
|
raw_input: action.raw_input,
|
||||||
raw_output: clampLiveRawOutput(action.raw_output),
|
raw_output_chunks:
|
||||||
|
action.raw_output !== null ? [action.raw_output] : [],
|
||||||
|
raw_output_total_bytes: action.raw_output?.length ?? 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -695,7 +712,9 @@ function connectionsReducer(
|
|||||||
let newContent: LiveContentBlock[]
|
let newContent: LiveContentBlock[]
|
||||||
|
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
const normalizedRawOutput = clampLiveRawOutput(action.raw_output)
|
const initialChunks =
|
||||||
|
action.raw_output !== null ? [action.raw_output] : []
|
||||||
|
const initialBytes = action.raw_output?.length ?? 0
|
||||||
newContent = [
|
newContent = [
|
||||||
...prev.content,
|
...prev.content,
|
||||||
{
|
{
|
||||||
@@ -706,23 +725,54 @@ function connectionsReducer(
|
|||||||
kind: action.fallback_kind,
|
kind: action.fallback_kind,
|
||||||
status:
|
status:
|
||||||
action.status ??
|
action.status ??
|
||||||
(normalizedRawOutput ? "in_progress" : "pending"),
|
(initialChunks.length > 0 ? "in_progress" : "pending"),
|
||||||
content: action.content,
|
content: action.content,
|
||||||
raw_input: action.raw_input,
|
raw_input: action.raw_input,
|
||||||
raw_output: normalizedRawOutput,
|
raw_output_chunks: initialChunks,
|
||||||
|
raw_output_total_bytes: initialBytes,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
const block = prev.content[existingIndex]
|
const block = prev.content[existingIndex]
|
||||||
if (block.type !== "tool_call") return state
|
if (block.type !== "tool_call") return state
|
||||||
const mergedRawOutput =
|
|
||||||
action.raw_output === null
|
let newChunks: string[]
|
||||||
? block.info.raw_output
|
let newTotalBytes: number
|
||||||
: action.raw_output_append
|
|
||||||
? (block.info.raw_output ?? "") + action.raw_output
|
if (action.raw_output === null) {
|
||||||
: action.raw_output
|
newChunks = block.info.raw_output_chunks
|
||||||
const normalizedRawOutput = clampLiveRawOutput(mergedRawOutput)
|
newTotalBytes = block.info.raw_output_total_bytes
|
||||||
|
} else if (action.raw_output_append) {
|
||||||
|
newChunks = [...block.info.raw_output_chunks, action.raw_output]
|
||||||
|
newTotalBytes =
|
||||||
|
block.info.raw_output_total_bytes + action.raw_output.length
|
||||||
|
|
||||||
|
// 超限时从头部批量移除 chunks(单次 slice 替代循环 shift)
|
||||||
|
if (
|
||||||
|
newTotalBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS &&
|
||||||
|
newChunks.length > 1
|
||||||
|
) {
|
||||||
|
let evictCount = 0
|
||||||
|
let evictedBytes = 0
|
||||||
|
while (
|
||||||
|
evictCount < newChunks.length - 1 &&
|
||||||
|
newTotalBytes - evictedBytes > MAX_LIVE_TOOL_RAW_OUTPUT_CHARS
|
||||||
|
) {
|
||||||
|
evictedBytes += newChunks[evictCount].length
|
||||||
|
evictCount++
|
||||||
|
}
|
||||||
|
if (evictCount > 0) {
|
||||||
|
newChunks = newChunks.slice(evictCount)
|
||||||
|
newTotalBytes -= evictedBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非 append 模式(替换)
|
||||||
|
newChunks = [action.raw_output]
|
||||||
|
newTotalBytes = action.raw_output.length
|
||||||
|
}
|
||||||
|
|
||||||
newContent = [
|
newContent = [
|
||||||
...prev.content.slice(0, existingIndex),
|
...prev.content.slice(0, existingIndex),
|
||||||
{
|
{
|
||||||
@@ -733,7 +783,8 @@ function connectionsReducer(
|
|||||||
status: action.status ?? block.info.status,
|
status: action.status ?? block.info.status,
|
||||||
content: action.content ?? block.info.content,
|
content: action.content ?? block.info.content,
|
||||||
raw_input: action.raw_input ?? block.info.raw_input,
|
raw_input: action.raw_input ?? block.info.raw_input,
|
||||||
raw_output: normalizedRawOutput,
|
raw_output_chunks: newChunks,
|
||||||
|
raw_output_total_bytes: newTotalBytes,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...prev.content.slice(existingIndex + 1),
|
...prev.content.slice(existingIndex + 1),
|
||||||
@@ -748,6 +799,17 @@ function connectionsReducer(
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case "BATCH_TOOL_CALL_UPDATES": {
|
||||||
|
let current = state
|
||||||
|
for (const sub of action.actions) {
|
||||||
|
current = connectionsReducer(current, {
|
||||||
|
type: "TOOL_CALL_UPDATE",
|
||||||
|
...sub,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
case "PERMISSION_REQUEST": {
|
case "PERMISSION_REQUEST": {
|
||||||
const conn = state.get(action.contextKey)
|
const conn = state.get(action.contextKey)
|
||||||
if (!conn) return state
|
if (!conn) return state
|
||||||
@@ -804,7 +866,8 @@ function connectionsReducer(
|
|||||||
status: "pending",
|
status: "pending",
|
||||||
content: null,
|
content: null,
|
||||||
raw_input: permissionToolInput,
|
raw_input: permissionToolInput,
|
||||||
raw_output: null,
|
raw_output_chunks: [],
|
||||||
|
raw_output_total_bytes: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -1306,6 +1369,11 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
notifyKeyListeners(key)
|
notifyKeyListeners(key)
|
||||||
}
|
}
|
||||||
|
} else if (action.type === "BATCH_TOOL_CALL_UPDATES") {
|
||||||
|
const keys = new Set(action.actions.map((item) => item.contextKey))
|
||||||
|
for (const key of keys) {
|
||||||
|
notifyKeyListeners(key)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const key = getAffectedKey(action)
|
const key = getAffectedKey(action)
|
||||||
if (key) notifyKeyListeners(key)
|
if (key) notifyKeyListeners(key)
|
||||||
@@ -1453,6 +1521,50 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── RAF batching for tool_call_update events ──
|
||||||
|
const pendingToolCallUpdates = useRef<
|
||||||
|
Array<{
|
||||||
|
contextKey: string
|
||||||
|
tool_call_id: string
|
||||||
|
title: string | null
|
||||||
|
fallback_title: string
|
||||||
|
fallback_kind: string
|
||||||
|
status: string | null
|
||||||
|
content: string | null
|
||||||
|
raw_input: string | null
|
||||||
|
raw_output: string | null
|
||||||
|
raw_output_append?: boolean
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
const toolCallUpdateRafId = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const flushPendingToolCallUpdates = useCallback(() => {
|
||||||
|
if (pendingToolCallUpdates.current.length === 0) return
|
||||||
|
if (toolCallUpdateRafId.current !== null) {
|
||||||
|
cancelAnimationFrame(toolCallUpdateRafId.current)
|
||||||
|
toolCallUpdateRafId.current = null
|
||||||
|
}
|
||||||
|
const batch = pendingToolCallUpdates.current
|
||||||
|
pendingToolCallUpdates.current = []
|
||||||
|
dispatch({ type: "BATCH_TOOL_CALL_UPDATES", actions: batch })
|
||||||
|
}, [dispatch])
|
||||||
|
|
||||||
|
const scheduleToolCallUpdateFlush = useCallback(() => {
|
||||||
|
if (toolCallUpdateRafId.current !== null) return
|
||||||
|
toolCallUpdateRafId.current = requestAnimationFrame(() => {
|
||||||
|
toolCallUpdateRafId.current = null
|
||||||
|
flushPendingToolCallUpdates()
|
||||||
|
})
|
||||||
|
}, [flushPendingToolCallUpdates])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (toolCallUpdateRafId.current !== null) {
|
||||||
|
cancelAnimationFrame(toolCallUpdateRafId.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleMappedEvent = useCallback(
|
const handleMappedEvent = useCallback(
|
||||||
(contextKey: string, e: AcpEvent) => {
|
(contextKey: string, e: AcpEvent) => {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
@@ -1486,8 +1598,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
break
|
break
|
||||||
case "tool_call_update":
|
case "tool_call_update":
|
||||||
flushStreamingQueue()
|
flushStreamingQueue()
|
||||||
dispatch({
|
pendingToolCallUpdates.current.push({
|
||||||
type: "TOOL_CALL_UPDATE",
|
|
||||||
contextKey,
|
contextKey,
|
||||||
tool_call_id: e.tool_call_id,
|
tool_call_id: e.tool_call_id,
|
||||||
title: e.title,
|
title: e.title,
|
||||||
@@ -1499,6 +1610,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
raw_output: e.raw_output,
|
raw_output: e.raw_output,
|
||||||
raw_output_append: e.raw_output_append,
|
raw_output_append: e.raw_output_append,
|
||||||
})
|
})
|
||||||
|
scheduleToolCallUpdateFlush()
|
||||||
break
|
break
|
||||||
case "permission_request":
|
case "permission_request":
|
||||||
flushStreamingQueue()
|
flushStreamingQueue()
|
||||||
@@ -1657,6 +1769,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
break
|
break
|
||||||
case "turn_complete": {
|
case "turn_complete": {
|
||||||
flushStreamingQueue()
|
flushStreamingQueue()
|
||||||
|
flushPendingToolCallUpdates()
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "STATUS_CHANGED",
|
type: "STATUS_CHANGED",
|
||||||
contextKey,
|
contextKey,
|
||||||
@@ -1779,7 +1892,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dispatch, enqueueStreamingAction, flushStreamingQueue, t]
|
[
|
||||||
|
dispatch,
|
||||||
|
enqueueStreamingAction,
|
||||||
|
flushPendingToolCallUpdates,
|
||||||
|
flushStreamingQueue,
|
||||||
|
scheduleToolCallUpdateFlush,
|
||||||
|
t,
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
// Single global event listener
|
// Single global event listener
|
||||||
|
|||||||
@@ -221,7 +221,10 @@ function buildStreamingTurnsFromLiveMessage(
|
|||||||
currentBlocks.push({
|
currentBlocks.push({
|
||||||
type: "tool_result",
|
type: "tool_result",
|
||||||
tool_use_id: block.info.tool_call_id,
|
tool_use_id: block.info.tool_call_id,
|
||||||
output_preview: block.info.raw_output ?? block.info.content,
|
output_preview:
|
||||||
|
block.info.raw_output_chunks.length > 0
|
||||||
|
? block.info.raw_output_chunks.join("")
|
||||||
|
: block.info.content,
|
||||||
is_error: block.info.status === "failed",
|
is_error: block.info.status === "failed",
|
||||||
})
|
})
|
||||||
currentGroupHasCompletedTool = true
|
currentGroupHasCompletedTool = true
|
||||||
|
|||||||
Reference in New Issue
Block a user