@tarquinen/opencode-dcp 3.2.5-beta0 → 3.2.6-beta0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/index.ts +141 -0
  2. package/lib/analysis/tokens.ts +225 -0
  3. package/lib/auth.ts +37 -0
  4. package/lib/commands/compression-targets.ts +137 -0
  5. package/lib/commands/context.ts +132 -0
  6. package/lib/commands/decompress.ts +275 -0
  7. package/lib/commands/help.ts +76 -0
  8. package/lib/commands/index.ts +11 -0
  9. package/lib/commands/manual.ts +125 -0
  10. package/lib/commands/recompress.ts +224 -0
  11. package/lib/commands/stats.ts +148 -0
  12. package/lib/commands/sweep.ts +268 -0
  13. package/lib/compress/index.ts +3 -0
  14. package/lib/compress/message-utils.ts +250 -0
  15. package/lib/compress/message.ts +137 -0
  16. package/lib/compress/pipeline.ts +106 -0
  17. package/lib/compress/protected-content.ts +154 -0
  18. package/lib/compress/range-utils.ts +308 -0
  19. package/lib/compress/range.ts +180 -0
  20. package/lib/compress/search.ts +267 -0
  21. package/lib/compress/state.ts +268 -0
  22. package/lib/compress/timing.ts +77 -0
  23. package/lib/compress/types.ts +108 -0
  24. package/lib/compress-permission.ts +25 -0
  25. package/lib/config.ts +1071 -0
  26. package/lib/hooks.ts +378 -0
  27. package/lib/host-permissions.ts +101 -0
  28. package/lib/logger.ts +235 -0
  29. package/lib/message-ids.ts +172 -0
  30. package/lib/messages/index.ts +8 -0
  31. package/lib/messages/inject/inject.ts +215 -0
  32. package/lib/messages/inject/subagent-results.ts +82 -0
  33. package/lib/messages/inject/utils.ts +374 -0
  34. package/lib/messages/priority.ts +102 -0
  35. package/lib/messages/prune.ts +238 -0
  36. package/lib/messages/query.ts +56 -0
  37. package/lib/messages/reasoning-strip.ts +40 -0
  38. package/lib/messages/sync.ts +124 -0
  39. package/lib/messages/utils.ts +187 -0
  40. package/lib/prompts/compress-message.ts +42 -0
  41. package/lib/prompts/compress-range.ts +60 -0
  42. package/lib/prompts/context-limit-nudge.ts +18 -0
  43. package/lib/prompts/extensions/nudge.ts +43 -0
  44. package/lib/prompts/extensions/system.ts +32 -0
  45. package/lib/prompts/extensions/tool.ts +35 -0
  46. package/lib/prompts/index.ts +29 -0
  47. package/lib/prompts/iteration-nudge.ts +6 -0
  48. package/lib/prompts/store.ts +467 -0
  49. package/lib/prompts/system.ts +33 -0
  50. package/lib/prompts/turn-nudge.ts +10 -0
  51. package/lib/protected-patterns.ts +128 -0
  52. package/lib/state/index.ts +4 -0
  53. package/lib/state/persistence.ts +256 -0
  54. package/lib/state/state.ts +190 -0
  55. package/lib/state/tool-cache.ts +98 -0
  56. package/lib/state/types.ts +112 -0
  57. package/lib/state/utils.ts +334 -0
  58. package/lib/strategies/deduplication.ts +127 -0
  59. package/lib/strategies/index.ts +2 -0
  60. package/lib/strategies/purge-errors.ts +88 -0
  61. package/lib/subagents/subagent-results.ts +74 -0
  62. package/lib/token-utils.ts +162 -0
  63. package/lib/ui/notification.ts +346 -0
  64. package/lib/ui/utils.ts +287 -0
  65. package/package.json +9 -2
  66. package/tui/data/context.ts +177 -0
  67. package/tui/index.tsx +34 -0
  68. package/tui/routes/summary.tsx +175 -0
  69. package/tui/shared/names.ts +9 -0
  70. package/tui/shared/theme.ts +58 -0
  71. package/tui/shared/types.ts +38 -0
  72. package/tui/slots/sidebar-content.tsx +502 -0
@@ -0,0 +1,334 @@
1
+ import type {
2
+ CompressionBlock,
3
+ PruneMessagesState,
4
+ PrunedMessageEntry,
5
+ SessionState,
6
+ WithParts,
7
+ } from "./types"
8
+ import { isIgnoredUserMessage, messageHasCompress } from "../messages/query"
9
+ import { countTokens } from "../token-utils"
10
+
11
+ export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => {
12
+ if (msg.info.time.created < state.lastCompaction) {
13
+ return true
14
+ }
15
+ const pruneEntry = state.prune.messages.byMessageId.get(msg.info.id)
16
+ if (pruneEntry && pruneEntry.activeBlockIds.length > 0) {
17
+ return true
18
+ }
19
+ return false
20
+ }
21
+
22
+ interface PersistedPruneMessagesState {
23
+ byMessageId: Record<string, PrunedMessageEntry>
24
+ blocksById: Record<string, CompressionBlock>
25
+ activeBlockIds: number[]
26
+ activeByAnchorMessageId: Record<string, number>
27
+ nextBlockId: number
28
+ nextRunId: number
29
+ }
30
+
31
+ export function serializePruneMessagesState(
32
+ messagesState: PruneMessagesState,
33
+ ): PersistedPruneMessagesState {
34
+ return {
35
+ byMessageId: Object.fromEntries(messagesState.byMessageId),
36
+ blocksById: Object.fromEntries(
37
+ Array.from(messagesState.blocksById.entries()).map(([blockId, block]) => [
38
+ String(blockId),
39
+ block,
40
+ ]),
41
+ ),
42
+ activeBlockIds: Array.from(messagesState.activeBlockIds),
43
+ activeByAnchorMessageId: Object.fromEntries(messagesState.activeByAnchorMessageId),
44
+ nextBlockId: messagesState.nextBlockId,
45
+ nextRunId: messagesState.nextRunId,
46
+ }
47
+ }
48
+
49
+ export async function isSubAgentSession(client: any, sessionID: string): Promise<boolean> {
50
+ try {
51
+ const result = await client.session.get({ path: { id: sessionID } })
52
+ return !!result.data?.parentID
53
+ } catch (error: any) {
54
+ return false
55
+ }
56
+ }
57
+
58
+ export function findLastCompactionTimestamp(messages: WithParts[]): number {
59
+ for (let i = messages.length - 1; i >= 0; i--) {
60
+ const msg = messages[i]
61
+ if (msg.info.role === "assistant" && msg.info.summary === true) {
62
+ return msg.info.time.created
63
+ }
64
+ }
65
+ return 0
66
+ }
67
+
68
+ export function countTurns(state: SessionState, messages: WithParts[]): number {
69
+ let turnCount = 0
70
+ for (const msg of messages) {
71
+ if (isMessageCompacted(state, msg)) {
72
+ continue
73
+ }
74
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
75
+ for (const part of parts) {
76
+ if (part.type === "step-start") {
77
+ turnCount++
78
+ }
79
+ }
80
+ }
81
+ return turnCount
82
+ }
83
+
84
+ export function loadPruneMap(obj?: Record<string, number>): Map<string, number> {
85
+ if (!obj || typeof obj !== "object") {
86
+ return new Map()
87
+ }
88
+
89
+ const entries = Object.entries(obj).filter(
90
+ (entry): entry is [string, number] =>
91
+ typeof entry[0] === "string" && typeof entry[1] === "number",
92
+ )
93
+ return new Map(entries)
94
+ }
95
+
96
+ export function createPruneMessagesState(): PruneMessagesState {
97
+ return {
98
+ byMessageId: new Map<string, PrunedMessageEntry>(),
99
+ blocksById: new Map<number, CompressionBlock>(),
100
+ activeBlockIds: new Set<number>(),
101
+ activeByAnchorMessageId: new Map<string, number>(),
102
+ nextBlockId: 1,
103
+ nextRunId: 1,
104
+ }
105
+ }
106
+
107
+ export function loadPruneMessagesState(
108
+ persisted?: PersistedPruneMessagesState,
109
+ ): PruneMessagesState {
110
+ const state = createPruneMessagesState()
111
+ if (!persisted || typeof persisted !== "object") {
112
+ return state
113
+ }
114
+
115
+ if (typeof persisted.nextBlockId === "number" && Number.isInteger(persisted.nextBlockId)) {
116
+ state.nextBlockId = Math.max(1, persisted.nextBlockId)
117
+ }
118
+ if (typeof persisted.nextRunId === "number" && Number.isInteger(persisted.nextRunId)) {
119
+ state.nextRunId = Math.max(1, persisted.nextRunId)
120
+ }
121
+
122
+ if (persisted.byMessageId && typeof persisted.byMessageId === "object") {
123
+ for (const [messageId, entry] of Object.entries(persisted.byMessageId)) {
124
+ if (!entry || typeof entry !== "object") {
125
+ continue
126
+ }
127
+
128
+ const tokenCount = typeof entry.tokenCount === "number" ? entry.tokenCount : 0
129
+ const allBlockIds = Array.isArray(entry.allBlockIds)
130
+ ? [
131
+ ...new Set(
132
+ entry.allBlockIds.filter(
133
+ (id): id is number => Number.isInteger(id) && id > 0,
134
+ ),
135
+ ),
136
+ ]
137
+ : []
138
+ const activeBlockIds = Array.isArray(entry.activeBlockIds)
139
+ ? [
140
+ ...new Set(
141
+ entry.activeBlockIds.filter(
142
+ (id): id is number => Number.isInteger(id) && id > 0,
143
+ ),
144
+ ),
145
+ ]
146
+ : []
147
+
148
+ state.byMessageId.set(messageId, {
149
+ tokenCount,
150
+ allBlockIds,
151
+ activeBlockIds,
152
+ })
153
+ }
154
+ }
155
+
156
+ if (persisted.blocksById && typeof persisted.blocksById === "object") {
157
+ for (const [blockIdStr, block] of Object.entries(persisted.blocksById)) {
158
+ const blockId = Number.parseInt(blockIdStr, 10)
159
+ if (!Number.isInteger(blockId) || blockId < 1 || !block || typeof block !== "object") {
160
+ continue
161
+ }
162
+
163
+ const toNumberArray = (value: unknown): number[] =>
164
+ Array.isArray(value)
165
+ ? [
166
+ ...new Set(
167
+ value.filter(
168
+ (item): item is number => Number.isInteger(item) && item > 0,
169
+ ),
170
+ ),
171
+ ]
172
+ : []
173
+ const toStringArray = (value: unknown): string[] =>
174
+ Array.isArray(value)
175
+ ? [...new Set(value.filter((item): item is string => typeof item === "string"))]
176
+ : []
177
+
178
+ state.blocksById.set(blockId, {
179
+ blockId,
180
+ runId:
181
+ typeof block.runId === "number" &&
182
+ Number.isInteger(block.runId) &&
183
+ block.runId > 0
184
+ ? block.runId
185
+ : blockId,
186
+ active: block.active === true,
187
+ deactivatedByUser: block.deactivatedByUser === true,
188
+ compressedTokens:
189
+ typeof block.compressedTokens === "number" &&
190
+ Number.isFinite(block.compressedTokens)
191
+ ? Math.max(0, block.compressedTokens)
192
+ : 0,
193
+ summaryTokens:
194
+ typeof block.summaryTokens === "number" && Number.isFinite(block.summaryTokens)
195
+ ? Math.max(0, block.summaryTokens)
196
+ : typeof block.summary === "string"
197
+ ? countTokens(block.summary)
198
+ : 0,
199
+ durationMs:
200
+ typeof block.durationMs === "number" && Number.isFinite(block.durationMs)
201
+ ? Math.max(0, block.durationMs)
202
+ : 0,
203
+ mode: block.mode === "range" || block.mode === "message" ? block.mode : undefined,
204
+ topic: typeof block.topic === "string" ? block.topic : "",
205
+ batchTopic:
206
+ typeof block.batchTopic === "string"
207
+ ? block.batchTopic
208
+ : typeof block.topic === "string"
209
+ ? block.topic
210
+ : "",
211
+ startId: typeof block.startId === "string" ? block.startId : "",
212
+ endId: typeof block.endId === "string" ? block.endId : "",
213
+ anchorMessageId:
214
+ typeof block.anchorMessageId === "string" ? block.anchorMessageId : "",
215
+ compressMessageId:
216
+ typeof block.compressMessageId === "string" ? block.compressMessageId : "",
217
+ compressCallId:
218
+ typeof block.compressCallId === "string" ? block.compressCallId : undefined,
219
+ includedBlockIds: toNumberArray(block.includedBlockIds),
220
+ consumedBlockIds: toNumberArray(block.consumedBlockIds),
221
+ parentBlockIds: toNumberArray(block.parentBlockIds),
222
+ directMessageIds: toStringArray(block.directMessageIds),
223
+ directToolIds: toStringArray(block.directToolIds),
224
+ effectiveMessageIds: toStringArray(block.effectiveMessageIds),
225
+ effectiveToolIds: toStringArray(block.effectiveToolIds),
226
+ createdAt: typeof block.createdAt === "number" ? block.createdAt : 0,
227
+ deactivatedAt:
228
+ typeof block.deactivatedAt === "number" ? block.deactivatedAt : undefined,
229
+ deactivatedByBlockId:
230
+ typeof block.deactivatedByBlockId === "number" &&
231
+ Number.isInteger(block.deactivatedByBlockId)
232
+ ? block.deactivatedByBlockId
233
+ : undefined,
234
+ summary: typeof block.summary === "string" ? block.summary : "",
235
+ })
236
+ }
237
+ }
238
+
239
+ if (Array.isArray(persisted.activeBlockIds)) {
240
+ for (const blockId of persisted.activeBlockIds) {
241
+ if (!Number.isInteger(blockId) || blockId < 1) {
242
+ continue
243
+ }
244
+ state.activeBlockIds.add(blockId)
245
+ }
246
+ }
247
+
248
+ if (
249
+ persisted.activeByAnchorMessageId &&
250
+ typeof persisted.activeByAnchorMessageId === "object"
251
+ ) {
252
+ for (const [anchorMessageId, blockId] of Object.entries(
253
+ persisted.activeByAnchorMessageId,
254
+ )) {
255
+ if (typeof blockId !== "number" || !Number.isInteger(blockId) || blockId < 1) {
256
+ continue
257
+ }
258
+ state.activeByAnchorMessageId.set(anchorMessageId, blockId)
259
+ }
260
+ }
261
+
262
+ for (const [blockId, block] of state.blocksById) {
263
+ if (block.active) {
264
+ state.activeBlockIds.add(blockId)
265
+ if (block.anchorMessageId) {
266
+ state.activeByAnchorMessageId.set(block.anchorMessageId, blockId)
267
+ }
268
+ }
269
+ if (blockId >= state.nextBlockId) {
270
+ state.nextBlockId = blockId + 1
271
+ }
272
+ if (block.runId >= state.nextRunId) {
273
+ state.nextRunId = block.runId + 1
274
+ }
275
+ }
276
+
277
+ return state
278
+ }
279
+
280
+ export function collectTurnNudgeAnchors(messages: WithParts[]): Set<string> {
281
+ const anchors = new Set<string>()
282
+ let pendingUserMessageId: string | null = null
283
+
284
+ for (let i = messages.length - 1; i >= 0; i--) {
285
+ const message = messages[i]
286
+
287
+ if (messageHasCompress(message)) {
288
+ break
289
+ }
290
+
291
+ if (message.info.role === "user") {
292
+ if (!isIgnoredUserMessage(message)) {
293
+ pendingUserMessageId = message.info.id
294
+ }
295
+ continue
296
+ }
297
+
298
+ if (message.info.role === "assistant" && pendingUserMessageId) {
299
+ anchors.add(message.info.id)
300
+ anchors.add(pendingUserMessageId)
301
+ pendingUserMessageId = null
302
+ }
303
+ }
304
+
305
+ return anchors
306
+ }
307
+
308
+ export function getActiveSummaryTokenUsage(state: SessionState): number {
309
+ let total = 0
310
+ for (const blockId of state.prune.messages.activeBlockIds) {
311
+ const block = state.prune.messages.blocksById.get(blockId)
312
+ if (!block || !block.active) {
313
+ continue
314
+ }
315
+ total += block.summaryTokens
316
+ }
317
+ return total
318
+ }
319
+
320
+ export function resetOnCompaction(state: SessionState): void {
321
+ state.toolParameters.clear()
322
+ state.prune.tools = new Map<string, number>()
323
+ state.prune.messages = createPruneMessagesState()
324
+ state.messageIds = {
325
+ byRawId: new Map<string, string>(),
326
+ byRef: new Map<string, string>(),
327
+ nextRef: 1,
328
+ }
329
+ state.nudges = {
330
+ contextLimitAnchors: new Set<string>(),
331
+ turnNudgeAnchors: new Set<string>(),
332
+ iterationNudgeAnchors: new Set<string>(),
333
+ }
334
+ }
@@ -0,0 +1,127 @@
1
+ import { PluginConfig } from "../config"
2
+ import { Logger } from "../logger"
3
+ import type { SessionState, WithParts } from "../state"
4
+ import {
5
+ getFilePathsFromParameters,
6
+ isFilePathProtected,
7
+ isToolNameProtected,
8
+ } from "../protected-patterns"
9
+ import { getTotalToolTokens } from "../token-utils"
10
+
11
+ /**
12
+ * Deduplication strategy - prunes older tool calls that have identical
13
+ * tool name and parameters, keeping only the most recent occurrence.
14
+ * Modifies the session state in place to add pruned tool call IDs.
15
+ */
16
+ export const deduplicate = (
17
+ state: SessionState,
18
+ logger: Logger,
19
+ config: PluginConfig,
20
+ messages: WithParts[],
21
+ ): void => {
22
+ if (state.manualMode && !config.manualMode.automaticStrategies) {
23
+ return
24
+ }
25
+
26
+ if (!config.strategies.deduplication.enabled) {
27
+ return
28
+ }
29
+
30
+ const allToolIds = state.toolIdList
31
+ if (allToolIds.length === 0) {
32
+ return
33
+ }
34
+
35
+ // Filter out IDs already pruned
36
+ const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
37
+
38
+ if (unprunedIds.length === 0) {
39
+ return
40
+ }
41
+
42
+ const protectedTools = config.strategies.deduplication.protectedTools
43
+
44
+ // Group by signature (tool name + normalized parameters)
45
+ const signatureMap = new Map<string, string[]>()
46
+
47
+ for (const id of unprunedIds) {
48
+ const metadata = state.toolParameters.get(id)
49
+ if (!metadata) {
50
+ // logger.warn(`Missing metadata for tool call ID: ${id}`)
51
+ continue
52
+ }
53
+
54
+ // Skip protected tools
55
+ if (isToolNameProtected(metadata.tool, protectedTools)) {
56
+ continue
57
+ }
58
+
59
+ const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
60
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
61
+ continue
62
+ }
63
+
64
+ const signature = createToolSignature(metadata.tool, metadata.parameters)
65
+ if (!signatureMap.has(signature)) {
66
+ signatureMap.set(signature, [])
67
+ }
68
+ const ids = signatureMap.get(signature)
69
+ if (ids) {
70
+ ids.push(id)
71
+ }
72
+ }
73
+
74
+ // Find duplicates - keep only the most recent (last) in each group
75
+ const newPruneIds: string[] = []
76
+
77
+ for (const [, ids] of signatureMap.entries()) {
78
+ if (ids.length > 1) {
79
+ // All except last (most recent) should be pruned
80
+ const idsToRemove = ids.slice(0, -1)
81
+ newPruneIds.push(...idsToRemove)
82
+ }
83
+ }
84
+
85
+ state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds)
86
+
87
+ if (newPruneIds.length > 0) {
88
+ for (const id of newPruneIds) {
89
+ const entry = state.toolParameters.get(id)
90
+ state.prune.tools.set(id, entry?.tokenCount ?? 0)
91
+ }
92
+ logger.debug(`Marked ${newPruneIds.length} duplicate tool calls for pruning`)
93
+ }
94
+ }
95
+
96
+ function createToolSignature(tool: string, parameters?: any): string {
97
+ if (!parameters) {
98
+ return tool
99
+ }
100
+ const normalized = normalizeParameters(parameters)
101
+ const sorted = sortObjectKeys(normalized)
102
+ return `${tool}::${JSON.stringify(sorted)}`
103
+ }
104
+
105
+ function normalizeParameters(params: any): any {
106
+ if (typeof params !== "object" || params === null) return params
107
+ if (Array.isArray(params)) return params
108
+
109
+ const normalized: any = {}
110
+ for (const [key, value] of Object.entries(params)) {
111
+ if (value !== undefined && value !== null) {
112
+ normalized[key] = value
113
+ }
114
+ }
115
+ return normalized
116
+ }
117
+
118
+ function sortObjectKeys(obj: any): any {
119
+ if (typeof obj !== "object" || obj === null) return obj
120
+ if (Array.isArray(obj)) return obj.map(sortObjectKeys)
121
+
122
+ const sorted: any = {}
123
+ for (const key of Object.keys(obj).sort()) {
124
+ sorted[key] = sortObjectKeys(obj[key])
125
+ }
126
+ return sorted
127
+ }
@@ -0,0 +1,2 @@
1
+ export { deduplicate } from "./deduplication"
2
+ export { purgeErrors } from "./purge-errors"
@@ -0,0 +1,88 @@
1
+ import { PluginConfig } from "../config"
2
+ import { Logger } from "../logger"
3
+ import type { SessionState, WithParts } from "../state"
4
+ import {
5
+ getFilePathsFromParameters,
6
+ isFilePathProtected,
7
+ isToolNameProtected,
8
+ } from "../protected-patterns"
9
+ import { getTotalToolTokens } from "../token-utils"
10
+
11
+ /**
12
+ * Purge Errors strategy - prunes tool inputs for tools that errored
13
+ * after they are older than a configurable number of turns.
14
+ * The error message is preserved, but the (potentially large) inputs
15
+ * are removed to save context.
16
+ *
17
+ * Modifies the session state in place to add pruned tool call IDs.
18
+ */
19
+ export const purgeErrors = (
20
+ state: SessionState,
21
+ logger: Logger,
22
+ config: PluginConfig,
23
+ messages: WithParts[],
24
+ ): void => {
25
+ if (state.manualMode && !config.manualMode.automaticStrategies) {
26
+ return
27
+ }
28
+
29
+ if (!config.strategies.purgeErrors.enabled) {
30
+ return
31
+ }
32
+
33
+ const allToolIds = state.toolIdList
34
+ if (allToolIds.length === 0) {
35
+ return
36
+ }
37
+
38
+ // Filter out IDs already pruned
39
+ const unprunedIds = allToolIds.filter((id) => !state.prune.tools.has(id))
40
+
41
+ if (unprunedIds.length === 0) {
42
+ return
43
+ }
44
+
45
+ const protectedTools = config.strategies.purgeErrors.protectedTools
46
+ const turnThreshold = Math.max(1, config.strategies.purgeErrors.turns)
47
+
48
+ const newPruneIds: string[] = []
49
+
50
+ for (const id of unprunedIds) {
51
+ const metadata = state.toolParameters.get(id)
52
+ if (!metadata) {
53
+ continue
54
+ }
55
+
56
+ // Skip protected tools
57
+ if (isToolNameProtected(metadata.tool, protectedTools)) {
58
+ continue
59
+ }
60
+
61
+ const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
62
+ if (isFilePathProtected(filePaths, config.protectedFilePatterns)) {
63
+ continue
64
+ }
65
+
66
+ // Only process error tools
67
+ if (metadata.status !== "error") {
68
+ continue
69
+ }
70
+
71
+ // Check if the tool is old enough to prune
72
+ const turnAge = state.currentTurn - metadata.turn
73
+ if (turnAge >= turnThreshold) {
74
+ newPruneIds.push(id)
75
+ }
76
+ }
77
+
78
+ if (newPruneIds.length > 0) {
79
+ state.stats.totalPruneTokens += getTotalToolTokens(state, newPruneIds)
80
+ for (const id of newPruneIds) {
81
+ const entry = state.toolParameters.get(id)
82
+ state.prune.tools.set(id, entry?.tokenCount ?? 0)
83
+ }
84
+ logger.debug(
85
+ `Marked ${newPruneIds.length} error tool calls for pruning (older than ${turnThreshold} turns)`,
86
+ )
87
+ }
88
+ }
@@ -0,0 +1,74 @@
1
+ import type { WithParts } from "../state"
2
+
3
+ const SUB_AGENT_RESULT_BLOCK_REGEX = /(<task_result>\s*)([\s\S]*?)(\s*<\/task_result>)/i
4
+
5
+ export function getSubAgentId(part: any): string | null {
6
+ const sessionId = part?.state?.metadata?.sessionId
7
+ if (typeof sessionId !== "string") {
8
+ return null
9
+ }
10
+
11
+ const value = sessionId.trim()
12
+ return value.length > 0 ? value : null
13
+ }
14
+
15
+ export function buildSubagentResultText(messages: WithParts[]): string {
16
+ const assistantMessages = messages.filter((message) => message.info.role === "assistant")
17
+ if (assistantMessages.length === 0) {
18
+ return ""
19
+ }
20
+
21
+ const lastAssistant = assistantMessages[assistantMessages.length - 1]
22
+ const lastText = getLastTextPart(lastAssistant)
23
+
24
+ if (assistantMessages.length < 2) {
25
+ return lastText
26
+ }
27
+
28
+ const secondToLastAssistant = assistantMessages[assistantMessages.length - 2]
29
+ if (!assistantMessageHasCompressTool(secondToLastAssistant)) {
30
+ return lastText
31
+ }
32
+
33
+ const secondToLastText = getLastTextPart(secondToLastAssistant)
34
+ return [secondToLastText, lastText].filter((text) => text.length > 0).join("\n\n")
35
+ }
36
+
37
+ export function mergeSubagentResult(output: string, subAgentResultText: string): string {
38
+ if (!subAgentResultText || typeof output !== "string") {
39
+ return output
40
+ }
41
+
42
+ return output.replace(
43
+ SUB_AGENT_RESULT_BLOCK_REGEX,
44
+ (_match, openTag: string, _body: string, closeTag: string) =>
45
+ `${openTag}${subAgentResultText}${closeTag}`,
46
+ )
47
+ }
48
+
49
+ function getLastTextPart(message: WithParts): string {
50
+ const parts = Array.isArray(message.parts) ? message.parts : []
51
+ for (let index = parts.length - 1; index >= 0; index--) {
52
+ const part = parts[index]
53
+ if (part.type !== "text" || typeof part.text !== "string") {
54
+ continue
55
+ }
56
+
57
+ const text = part.text.trim()
58
+ if (!text) {
59
+ continue
60
+ }
61
+
62
+ return text
63
+ }
64
+
65
+ return ""
66
+ }
67
+
68
+ function assistantMessageHasCompressTool(message: WithParts): boolean {
69
+ const parts = Array.isArray(message.parts) ? message.parts : []
70
+ return parts.some(
71
+ (part) =>
72
+ part.type === "tool" && part.tool === "compress" && part.state?.status === "completed",
73
+ )
74
+ }