picocode-core 0.9.174 → 0.9.176

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocode-core",
3
- "version": "0.9.174",
3
+ "version": "0.9.176",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/controller.js CHANGED
@@ -11,6 +11,7 @@ import { createAgentManager } from './agents.js'
11
11
  import { runDeliberation, validateDeliberation } from './deliberation.js'
12
12
  import { deliberationsFromEvents } from './deliberation-history.js'
13
13
  import { compactionPrompt, formatCompactSummary, summarySections, compactionKeepFrom } from './compaction.js'
14
+ import { completedToolCalls, TOOL_TRIM_VERSION } from './tool-trimming.js'
14
15
  import { createToolset } from './tools/index.js'
15
16
  import { defaultTitle } from './tools/recorder.js'
16
17
  import { scanUserTools } from './user-tools.js'
@@ -824,6 +825,33 @@ export function createController({ boot }) {
824
825
  flash(value ? `session color: ${names[values.indexOf(value)] || value}` : 'session color cleared')
825
826
  }
826
827
 
828
+ function changeToolTrims(type, callIds) {
829
+ if (state.busy || state.compacting) return flash('error: finish or interrupt the current turn first')
830
+ if (!Array.isArray(callIds) || !callIds.length || callIds.some((id) => typeof id !== 'string' || !id)) {
831
+ return flash('error: tool call IDs must be a non-empty array of strings')
832
+ }
833
+ const ids = [...new Set(callIds)]
834
+ const completed = completedToolCalls(state.derived.providerHistory)
835
+ const unavailable = ids.filter((id) => !completed.has(id))
836
+ if (unavailable.length) return flash(`error: tool calls are not completed in current context: ${unavailable.join(', ')}`)
837
+ const eligible = type === 'tool_trim'
838
+ ? ids.filter((id) => state.derived.toolItems.get(id)?.contextCanTrim)
839
+ : ids.filter((id) => state.derived.toolItems.get(id)?.contextTrimmed)
840
+ if (!eligible.length) return false
841
+ persist(makeEvent(type, { callIds: eligible, version: TOOL_TRIM_VERSION }))
842
+ ensureSession()
843
+ reDerive()
844
+ return true
845
+ }
846
+
847
+ function trimTools(callIds) {
848
+ return changeToolTrims('tool_trim', callIds)
849
+ }
850
+
851
+ function restoreTools(callIds) {
852
+ return changeToolTrims('tool_restore', callIds)
853
+ }
854
+
827
855
  function clear() {
828
856
  if (state.busy) return flash('finish or interrupt the current turn first')
829
857
  persist(makeEvent('clear', {}))
@@ -1343,6 +1371,8 @@ export function createController({ boot }) {
1343
1371
  send,
1344
1372
  interrupt,
1345
1373
  compact,
1374
+ trimTools,
1375
+ restoreTools,
1346
1376
  answerQuestion,
1347
1377
  cancelQuestion,
1348
1378
  recallPending,
package/src/derive.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { continuationMessage } from './compaction.js'
2
2
  import { applySteering } from './steer.js'
3
+ import { annotateToolContext, applyToolTrims } from './tool-trimming.js'
3
4
 
4
5
  const DROPPING_MODES = ['both', 'chat', 'summary']
5
6
 
@@ -178,6 +179,7 @@ export function deriveState(events) {
178
179
  loadedContext: new Set(),
179
180
  toolItems: new Map(),
180
181
  latestCompactIndex,
182
+ trimmedToolIds: new Set(),
181
183
  }
182
184
 
183
185
  for (const event of effectiveEvents) {
@@ -256,11 +258,20 @@ export function deriveState(events) {
256
258
  state.lastPromptTokens = 0
257
259
  break
258
260
  }
261
+ case 'tool_trim':
262
+ for (const callId of event.data.callIds || []) state.trimmedToolIds.add(callId)
263
+ state.lastPromptTokens = 0
264
+ break
265
+ case 'tool_restore':
266
+ for (const callId of event.data.callIds || []) state.trimmedToolIds.delete(callId)
267
+ state.lastPromptTokens = 0
268
+ break
259
269
  case 'clear':
260
270
  state.transcript = []
261
271
  state.providerHistory = []
262
272
  state.historyEventIds = []
263
273
  state.toolItems = new Map()
274
+ state.trimmedToolIds = new Set()
264
275
  state.lastPromptTokens = 0
265
276
  break
266
277
  case 'context_file':
@@ -287,7 +298,9 @@ export function deriveState(events) {
287
298
  }
288
299
  }
289
300
 
290
- state.providerHistory = elideStaleToolResults(state.providerHistory)
301
+ const effectiveHistory = elideStaleToolResults(state.providerHistory)
302
+ state.providerHistory = applyToolTrims(effectiveHistory, state.trimmedToolIds)
303
+ annotateToolContext(state, state.providerHistory, state.trimmedToolIds)
291
304
  return state
292
305
  }
293
306
 
@@ -0,0 +1,142 @@
1
+ const ALGORITHM = 'tool-trim-v1'
2
+ const ARGUMENT_CHARS = 8000
3
+ const RESULT_CHARS = 12000
4
+ const STRING_CHARS = 6000
5
+ const ARRAY_ITEMS = 40
6
+ const OBJECT_KEYS = 80
7
+ const HEAD_RATIO = 0.6
8
+
9
+ function truncateText(value, limit, label = 'content') {
10
+ const text = String(value ?? '')
11
+ if (text.length <= limit) return text
12
+ const marker = `\n[${label} omitted: ${text.length - limit} or more chars]\n`
13
+ const available = Math.max(0, limit - marker.length)
14
+ const head = Math.floor(available * HEAD_RATIO)
15
+ return text.slice(0, head) + marker + text.slice(text.length - (available - head))
16
+ }
17
+
18
+ function boundedValue(value, depth = 0) {
19
+ if (typeof value === 'string') return truncateText(value, STRING_CHARS)
20
+ if (value === null || typeof value !== 'object') return value
21
+ if (depth >= 8) return '[nested value omitted]'
22
+ if (Array.isArray(value)) {
23
+ if (value.length <= ARRAY_ITEMS) return value.map((item) => boundedValue(item, depth + 1))
24
+ const head = Math.ceil(ARRAY_ITEMS * HEAD_RATIO)
25
+ const tail = ARRAY_ITEMS - head
26
+ return [
27
+ ...value.slice(0, head).map((item) => boundedValue(item, depth + 1)),
28
+ `[${value.length - ARRAY_ITEMS} array items omitted]`,
29
+ ...value.slice(-tail).map((item) => boundedValue(item, depth + 1)),
30
+ ]
31
+ }
32
+ const entries = Object.entries(value)
33
+ const kept = entries.length <= OBJECT_KEYS
34
+ ? entries
35
+ : [...entries.slice(0, Math.ceil(OBJECT_KEYS * HEAD_RATIO)), ['_trimmed', `${entries.length - OBJECT_KEYS} object fields omitted`], ...entries.slice(-(OBJECT_KEYS - Math.ceil(OBJECT_KEYS * HEAD_RATIO)))]
36
+ return Object.fromEntries(kept.map(([key, item]) => [key, boundedValue(item, depth + 1)]))
37
+ }
38
+
39
+ function importantArguments(value) {
40
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
41
+ return Object.fromEntries(['description', 'name', 'outcome'].filter((key) => key in value).map((key) => [key, boundedValue(value[key])]))
42
+ }
43
+
44
+ function trimArguments(raw) {
45
+ let parsed
46
+ try {
47
+ parsed = JSON.parse(raw)
48
+ } catch {
49
+ parsed = { _raw: String(raw ?? '') }
50
+ }
51
+ let bounded = boundedValue(parsed)
52
+ let encoded = JSON.stringify(bounded)
53
+ if (encoded.length <= ARGUMENT_CHARS) return encoded
54
+ const important = importantArguments(parsed)
55
+ let previewLimit = ARGUMENT_CHARS
56
+ while (previewLimit > 0) {
57
+ encoded = JSON.stringify({ ...important, _trimmed: truncateText(JSON.stringify(parsed), previewLimit, 'arguments') })
58
+ if (encoded.length <= ARGUMENT_CHARS) return encoded
59
+ previewLimit = Math.floor(previewLimit / 2)
60
+ }
61
+ return JSON.stringify({ _trimmed: truncateText(JSON.stringify(parsed), 1000, 'arguments') })
62
+ }
63
+
64
+ function isImagePart(part) {
65
+ return part && typeof part === 'object' && (part.type === 'image' || part.type === 'image_url' || part.type === 'input_image' || part.source?.type === 'base64')
66
+ }
67
+
68
+ function trimResult(content) {
69
+ if (!Array.isArray(content)) return truncateText(content, RESULT_CHARS, 'tool result')
70
+ const safe = content.map((part) => isImagePart(part)
71
+ ? { type: 'text', text: '[tool image omitted to save context]' }
72
+ : boundedValue(part))
73
+ const encoded = JSON.stringify(safe)
74
+ return encoded.length <= RESULT_CHARS
75
+ ? safe
76
+ : [{ type: 'text', text: truncateText(encoded, RESULT_CHARS, 'tool result') }]
77
+ }
78
+
79
+ function callsIn(history) {
80
+ const calls = new Map()
81
+ history.forEach((message, index) => {
82
+ for (const call of message.tool_calls || []) calls.set(call.id, { call, callIndex: index, result: null, resultIndex: -1 })
83
+ if (message.role === 'tool') {
84
+ const entry = calls.get(message.tool_call_id)
85
+ if (entry) Object.assign(entry, { result: message, resultIndex: index })
86
+ }
87
+ })
88
+ return calls
89
+ }
90
+
91
+ export function completedToolCalls(history) {
92
+ return new Map([...callsIn(history)].filter(([, entry]) => entry.result))
93
+ }
94
+
95
+ export function applyToolTrims(history, trimmedIds) {
96
+ if (!trimmedIds.size) return history
97
+ const completed = completedToolCalls(history)
98
+ const callMessages = new Map()
99
+ const resultMessages = new Map()
100
+ for (const id of trimmedIds) {
101
+ const entry = completed.get(id)
102
+ if (!entry) continue
103
+ let callMessage = callMessages.get(entry.callIndex)
104
+ if (!callMessage) {
105
+ callMessage = { ...history[entry.callIndex], tool_calls: history[entry.callIndex].tool_calls.map((call) => ({ ...call, function: { ...call.function } })) }
106
+ callMessages.set(entry.callIndex, callMessage)
107
+ }
108
+ const call = callMessage.tool_calls.find((candidate) => candidate.id === id)
109
+ call.function.arguments = trimArguments(call.function.arguments)
110
+ resultMessages.set(entry.resultIndex, { ...entry.result, content: trimResult(entry.result.content) })
111
+ }
112
+ return history.map((message, index) => callMessages.get(index) || resultMessages.get(index) || message)
113
+ }
114
+
115
+ function resultText(content) {
116
+ if (typeof content === 'string') return content
117
+ if (Array.isArray(content)) return content.filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n')
118
+ return JSON.stringify(content ?? '')
119
+ }
120
+
121
+ function entryTokens(args, result) {
122
+ return Math.ceil((String(args ?? '').length + resultText(result).length) / 4)
123
+ }
124
+
125
+ export function annotateToolContext(state, effectiveHistory, trimmedIds) {
126
+ const completed = completedToolCalls(effectiveHistory)
127
+ for (const item of state.toolItems.values()) {
128
+ const entry = completed.get(item.callId)
129
+ item.contextAvailable = !!entry
130
+ item.contextTrimmed = !!entry && trimmedIds.has(item.callId)
131
+ item.contextTokens = entry ? entryTokens(entry.call.function.arguments, entry.result.content) : 0
132
+ item.contextCanTrim = !!entry && !item.contextTrimmed &&
133
+ entryTokens(trimArguments(entry.call.function.arguments), trimResult(entry.result.content)) < item.contextTokens
134
+ }
135
+ }
136
+
137
+ export const TOOL_TRIM_VERSION = {
138
+ algorithm: ALGORITHM,
139
+ argumentChars: ARGUMENT_CHARS,
140
+ resultChars: RESULT_CHARS,
141
+ stringChars: STRING_CHARS,
142
+ }