picocode-core 0.9.173 → 0.9.175

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.173",
3
+ "version": "0.9.175",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,39 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { createContextTracker } from './context.js'
3
+ import { buildSystemPrompt } from './system-prompt.js'
4
+ import { memoryIndex } from './memory.js'
5
+
6
+ export async function createAgentContext(boot, { userTools = [], isolated = false, instructions = '' } = {}) {
7
+ const files = [...boot.startupContext.files]
8
+ if (isolated) {
9
+ const known = new Set(files.map((file) => file.path))
10
+ for (const path of boot.tracker.loaded) {
11
+ if (known.has(path)) continue
12
+ const content = await readFile(path, 'utf8').catch(() => null)
13
+ if (content !== null) files.push({ path, content })
14
+ }
15
+ }
16
+ const tracker = isolated
17
+ ? createContextTracker({ stopDir: boot.startupContext.stopDir, loaded: new Set(files.map((file) => file.path)) })
18
+ : boot.tracker
19
+ const system = buildSystemPrompt({
20
+ cwd: boot.cwd,
21
+ contextFiles: files,
22
+ skills: boot.skills.list(),
23
+ memoryIndexText: memoryIndex(await boot.memory.list().catch(() => []), boot.root),
24
+ })
25
+ return {
26
+ system: instructions ? `${system}\n\n${instructions}` : system,
27
+ tools: {
28
+ cwd: boot.cwd,
29
+ env: boot.env,
30
+ tracker,
31
+ skills: boot.skills,
32
+ memory: boot.memory,
33
+ shells: boot.shells,
34
+ hostTools: (boot.hostTools ?? []).filter((tool) => !isolated || !tool.name.startsWith('browser_')),
35
+ userTools,
36
+ mcpTools: boot.mcp.tools(),
37
+ },
38
+ }
39
+ }
package/src/controller.js CHANGED
@@ -3,7 +3,7 @@ import { writeFile } from 'node:fs/promises'
3
3
  import { join } from 'node:path'
4
4
  import { makeEvent } from './events.js'
5
5
  import { createSession, createEphemeralSession, forkSession, openSession, loadSession, listSessions, deleteSession, appendSessionEvent, onSessionWriteError } from './session.js'
6
- import { createContextTracker } from './context.js'
6
+ import { createAgentContext } from './agent-context.js'
7
7
  import { deriveState, userEntries, rewindStats } from './derive.js'
8
8
  import { appendPrompt } from './history.js'
9
9
  import { runTurn, summarizeText, compactHistory, compactProgress } from './agent.js'
@@ -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'
@@ -53,7 +54,6 @@ export const SESSION_COLORS = {
53
54
  gray: '#9ca3af',
54
55
  }
55
56
 
56
- const WORKER_TOOLS = ['read', 'write', 'edit', 'bash', 'glob', 'grep', 'shell_output', 'shell_kill']
57
57
  const AGENT_TOOLS = ['agent_plan', 'agent_start', 'agent_list', 'agent_collect', 'agent_cancel']
58
58
  const CONTEXT_COLORS = ['#67b7ff', '#c792ea', '#f7c66a', '#f78c6c', '#6be795']
59
59
 
@@ -202,23 +202,16 @@ export function createController({ boot }) {
202
202
  const sessionId = state.session?.id
203
203
  if (!sessionId) throw new Error('worker requires an active session')
204
204
  const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, agent.id))
205
- const mcpTools = boot.mcp.tools()
206
- const availableNames = [...WORKER_TOOLS, ...mcpTools.map((tool) => tool.name)]
207
- const requestedTools = agent.tools?.length ? agent.tools.filter((name) => availableNames.includes(name)) : availableNames
205
+ const scan = await refreshProjectIndexes()
206
+ const context = await createAgentContext(boot, { userTools: scan.tools, isolated: true, instructions: workerSystemPrompt(scratchpad) })
208
207
  const { tools, recorder } = createToolset({
209
- cwd: boot.cwd,
208
+ ...context.tools,
210
209
  env: { ...boot.env, PICO_SCRATCHPAD: scratchpad },
211
- // a private tracker seeded from the main one: a worker may read an
212
- // AGENTS.md the main agent has not seen, and consuming it from the
213
- // shared set would mean the main agent never receives it
214
- tracker: createContextTracker({ stopDir: boot.startupContext.stopDir, loaded: new Set(boot.tracker.loaded) }),
215
- shells: boot.shells,
216
210
  sessionId,
217
211
  sessionFile: state.session?.file,
218
212
  signal,
219
213
  maxToolCalls: 30,
220
- allowNames: requestedTools,
221
- mcpTools,
214
+ allowNames: agent.tools?.length ? agent.tools : undefined,
222
215
  })
223
216
  return runTurn({
224
217
  history: [{ role: 'user', content: agent.prompt }],
@@ -227,7 +220,7 @@ export function createController({ boot }) {
227
220
  modelName: worker.name,
228
221
  effort: worker.effort ? 'low' : null,
229
222
  auth,
230
- system: workerSystemPrompt(scratchpad),
223
+ system: context.system,
231
224
  signal,
232
225
  onStream,
233
226
  })
@@ -279,18 +272,16 @@ export function createController({ boot }) {
279
272
 
280
273
  const runWorker = async ({ history, role, tools: enabled = true, onStream }) => {
281
274
  const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, `deliberation-${id}-${role}`))
282
- const mcpTools = enabled ? boot.mcp.tools() : []
275
+ const scan = await refreshProjectIndexes()
276
+ const context = await createAgentContext(boot, { userTools: scan.tools, isolated: true, instructions: participantSystemPrompt(scratchpad) })
283
277
  const toolset = createToolset({
284
- cwd: boot.cwd,
278
+ ...context.tools,
285
279
  env: { ...boot.env, PICO_SCRATCHPAD: scratchpad },
286
- tracker: createContextTracker({ stopDir: boot.startupContext.stopDir, loaded: new Set(boot.tracker.loaded) }),
287
- shells: boot.shells,
288
280
  sessionId,
289
281
  sessionFile: state.session?.file,
290
282
  signal,
291
283
  maxToolCalls: 30,
292
- allowNames: enabled ? [...WORKER_TOOLS, ...mcpTools.map((tool) => tool.name)] : [],
293
- mcpTools,
284
+ allowNames: enabled ? undefined : [],
294
285
  })
295
286
  return runTurn({
296
287
  history,
@@ -299,7 +290,7 @@ export function createController({ boot }) {
299
290
  modelName: roleWorkers[role].name,
300
291
  effort: roleWorkers[role].effort ? 'low' : null,
301
292
  auth: roleAuths[role],
302
- system: participantSystemPrompt(scratchpad),
293
+ system: context.system,
303
294
  signal,
304
295
  onStream,
305
296
  })
@@ -609,24 +600,16 @@ export function createController({ boot }) {
609
600
  const { tracker } = boot
610
601
  const loadedBefore = new Set(tracker.loaded)
611
602
  const userToolScan = await refreshProjectIndexes()
612
- const freshSkills = boot.skills
603
+ const context = await createAgentContext(boot, { userTools: userToolScan.tools })
613
604
  const { tools, recorder } = createToolset({
614
- cwd: boot.cwd,
615
- env: boot.env,
616
- hostTools: boot.hostTools ?? [],
617
- tracker,
618
- skills: freshSkills,
619
- shells: boot.shells,
605
+ ...context.tools,
620
606
  sessionId: state.session?.id,
621
607
  sessionFile: state.session?.file,
622
608
  wakeups: boot.wakeups,
623
- memory: boot.memory,
624
609
  agents: boot.researchModel ? agents : null,
625
610
  deliberations: boot.deliberationModel || (boot.proposerModel && boot.reviewerModel) ? deliberations : null,
626
611
  onAgentsCollected: discardCollectedAgentNotes,
627
612
  askUser,
628
- mcpTools: boot.mcp.tools(),
629
- userTools: userToolScan.tools,
630
613
  signal: controller.signal,
631
614
  maxAgentStarts: researchAgentLimit ? 100 : undefined,
632
615
  requireAgentPlan: !!researchAgentLimit,
@@ -645,12 +628,7 @@ export function createController({ boot }) {
645
628
  modelName: state.model.name,
646
629
  effort: effortApplies() ? state.effort ?? 'auto' : null,
647
630
  auth,
648
- system: buildSystemPrompt({
649
- cwd: boot.cwd,
650
- contextFiles: boot.startupContext.files,
651
- skills: freshSkills.list(),
652
- memoryIndexText: memoryIndex(await boot.memory.list().catch(() => []), boot.root),
653
- }),
631
+ system: context.system,
654
632
  signal: controller.signal,
655
633
  onStream: streamHandler(recorder, controller),
656
634
  })
@@ -847,6 +825,29 @@ export function createController({ boot }) {
847
825
  flash(value ? `session color: ${names[values.indexOf(value)] || value}` : 'session color cleared')
848
826
  }
849
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
+ persist(makeEvent(type, { callIds: ids, version: TOOL_TRIM_VERSION }))
838
+ ensureSession()
839
+ reDerive()
840
+ return true
841
+ }
842
+
843
+ function trimTools(callIds) {
844
+ return changeToolTrims('tool_trim', callIds)
845
+ }
846
+
847
+ function restoreTools(callIds) {
848
+ return changeToolTrims('tool_restore', callIds)
849
+ }
850
+
850
851
  function clear() {
851
852
  if (state.busy) return flash('finish or interrupt the current turn first')
852
853
  persist(makeEvent('clear', {}))
@@ -1366,6 +1367,8 @@ export function createController({ boot }) {
1366
1367
  send,
1367
1368
  interrupt,
1368
1369
  compact,
1370
+ trimTools,
1371
+ restoreTools,
1369
1372
  answerQuestion,
1370
1373
  cancelQuestion,
1371
1374
  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,138 @@
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
+ export function annotateToolContext(state, effectiveHistory, trimmedIds) {
122
+ const completed = completedToolCalls(effectiveHistory)
123
+ for (const item of state.toolItems.values()) {
124
+ const entry = completed.get(item.callId)
125
+ item.contextAvailable = !!entry
126
+ item.contextTrimmed = !!entry && trimmedIds.has(item.callId)
127
+ item.contextTokens = entry
128
+ ? Math.ceil((String(entry.call.function.arguments ?? '').length + resultText(entry.result.content).length) / 4)
129
+ : 0
130
+ }
131
+ }
132
+
133
+ export const TOOL_TRIM_VERSION = {
134
+ algorithm: ALGORITHM,
135
+ argumentChars: ARGUMENT_CHARS,
136
+ resultChars: RESULT_CHARS,
137
+ stringChars: STRING_CHARS,
138
+ }