picocode-core 0.9.179 → 0.9.181

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.179",
3
+ "version": "0.9.181",
4
4
  "description": "The agent runtime behind pico: sessions, tools, subagents, MCP, memory, and model access",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -54,6 +54,7 @@ function deliberationTranscript(agent) {
54
54
  if (entry.kind === 'turn') {
55
55
  const turn = turnFor(entry.value.role, entry.value.round)
56
56
  turn.text = entry.value.text
57
+ if (entry.value.parallelGroup) turn.parallelGroup = entry.value.parallelGroup
57
58
  turn.active = false
58
59
  continue
59
60
  }
@@ -61,23 +62,35 @@ function deliberationTranscript(agent) {
61
62
  if (event.type === 'tool_executing') {
62
63
  const item = toolItem(event.call || {}, event.at)
63
64
  tools.set(item.callId, item)
64
- turnFor(event.role, event.round).tools.push(item)
65
+ const turn = turnFor(event.role, event.round)
66
+ if (event.parallelGroup) turn.parallelGroup = event.parallelGroup
67
+ turn.tools.push(item)
65
68
  }
66
69
  if (event.type === 'tool_complete' || event.type === 'tool_error') settleTool(tools, event)
67
70
  }
68
- // whoever is speaking right now shows their words as they arrive
69
- const live = agent.live
70
- if (live?.text && live.role !== 'synthesis') {
71
+ const liveTurns = agent.live?.turns || (agent.live?.role ? [agent.live] : [])
72
+ for (const live of liveTurns.filter((turn) => turn.role !== 'synthesis')) {
71
73
  const turn = turnFor(live.role, live.round)
72
- if (turn.text == null) turn.text = live.text
74
+ if (live.parallelGroup) turn.parallelGroup = live.parallelGroup
75
+ if (turn.text == null && live.text) turn.text = live.text
73
76
  }
77
+ const liveSynthesis = liveTurns.find((turn) => turn.role === 'synthesis')
74
78
  if (agent.result) {
75
79
  items.push({ kind: 'deliberation-turn', role: 'synthesis', text: agent.result, tools: [], interrupted: agent.status === 'cancelled' })
76
- } else if (live?.text && live.role === 'synthesis') {
77
- items.push({ kind: 'deliberation-turn', role: 'synthesis', text: live.text, tools: [], active: true })
80
+ } else if (liveSynthesis?.text) {
81
+ items.push({ kind: 'deliberation-turn', role: 'synthesis', text: liveSynthesis.text, tools: [], active: true })
78
82
  } else if (agent.error) {
79
83
  items.push({ kind: 'assistant', text: agent.error, interrupted: true })
80
84
  }
85
+ const order = (turn) => turn.role === 'participant-a' ? 0 : 1
86
+ const grouped = items.filter((turn) => turn.parallelGroup)
87
+ for (const group of new Set(grouped.map((turn) => turn.parallelGroup))) {
88
+ const sorted = grouped.filter((turn) => turn.parallelGroup === group).sort((a, b) => order(a) - order(b))
89
+ let index = 0
90
+ for (let i = 0; i < items.length; i++) {
91
+ if (items[i].parallelGroup === group) items[i] = sorted[index++]
92
+ }
93
+ }
81
94
  return items
82
95
  }
83
96
 
package/src/config.js CHANGED
@@ -8,7 +8,12 @@ function configFile() {
8
8
 
9
9
  export async function readConfig() {
10
10
  try {
11
- return JSON.parse(await readFile(configFile(), 'utf-8'))
11
+ const config = JSON.parse(await readFile(configFile(), 'utf-8'))
12
+ if (config.models) {
13
+ if (!Object.hasOwn(config.models, 'participantA')) config.models.participantA = config.models.proposer ?? null
14
+ if (!Object.hasOwn(config.models, 'participantB')) config.models.participantB = config.models.reviewer ?? null
15
+ }
16
+ return config
12
17
  } catch {
13
18
  return {}
14
19
  }
package/src/controller.js CHANGED
@@ -98,6 +98,8 @@ function errorText(err, limit) {
98
98
  }
99
99
 
100
100
  export function createController({ boot }) {
101
+ if (!Object.hasOwn(boot, 'participantAModel')) boot.participantAModel = boot.proposerModel ?? null
102
+ if (!Object.hasOwn(boot, 'participantBModel')) boot.participantBModel = boot.reviewerModel ?? null
101
103
  const { on, emit } = createEmitter()
102
104
 
103
105
  const state = {
@@ -238,10 +240,10 @@ export function createController({ boot }) {
238
240
  rounds = options.rounds
239
241
  const existingIds = deliberationsFromEvents(state.events).map((item) => Number(item.deliberationId)).filter(Number.isFinite)
240
242
  const id = String(Math.max(0, ...existingIds) + 1)
241
- const roleModel = (role) => (role === 'proposer' ? boot.proposerModel : role === 'reviewer' ? boot.reviewerModel : null) || boot.deliberationModel || boot.proposerModel || boot.reviewerModel
243
+ const roleModel = (role) => (role === 'participant-a' ? boot.participantAModel : role === 'participant-b' ? boot.participantBModel : null) || boot.deliberationModel || boot.participantAModel || boot.participantBModel
242
244
  const roleWorkers = {}
243
245
  const roleAuths = {}
244
- for (const part of ['proposer', 'reviewer', 'synthesizer']) {
246
+ for (const part of ['participant-a', 'participant-b', 'synthesizer']) {
245
247
  const name = roleModel(part)
246
248
  const found = boot.models.find((m) => m.name === name)
247
249
  if (!found || found.available === false) throw new Error(`deliberation model unavailable: ${name}`)
@@ -250,17 +252,18 @@ export function createController({ boot }) {
250
252
  }
251
253
  const sessionId = state.session?.id
252
254
  if (!sessionId) throw new Error('deliberation requires an active session')
253
- persist(makeEvent('deliberation_start', { deliberationId: id, brief, rounds, model: roleModel('synthesizer'), models: { proposer: roleModel('proposer'), reviewer: roleModel('reviewer') } }))
255
+ persist(makeEvent('deliberation_start', { deliberationId: id, brief, rounds, model: roleModel('synthesizer'), models: { participantA: roleModel('participant-a'), participantB: roleModel('participant-b') } }))
254
256
  bumpActivity()
255
- const live = { role: null, round: null, text: '' }
257
+ const live = { turns: {} }
256
258
  liveDeliberations.set(id, live)
257
- const speak = (role, round) => (event) => {
259
+ const speak = (role, round, parallelGroup) => (event) => {
260
+ const key = `${role}:${round}`
258
261
  if (event.type === 'content') {
259
- if (live.role !== role || live.round !== round) Object.assign(live, { role, round, text: '' })
260
- live.text += event.content
262
+ const current = live.turns[key] || { role, round, text: '', ...(parallelGroup ? { parallelGroup } : {}) }
263
+ live.turns[key] = { ...current, text: current.text + event.content }
261
264
  bumpActivity()
262
- } else if (event.type === 'tool_calls_ready' && live.text) {
263
- live.text = ''
265
+ } else if (event.type === 'tool_calls_ready' && live.turns[key]?.text) {
266
+ live.turns[key] = { ...live.turns[key], text: '' }
264
267
  bumpActivity()
265
268
  }
266
269
  }
@@ -270,7 +273,7 @@ export function createController({ boot }) {
270
273
  bumpActivity()
271
274
  }
272
275
 
273
- const runWorker = async ({ history, role, tools: enabled = true, onStream }) => {
276
+ const runWorker = async ({ history, role, tools: enabled = true, onStream, workerSignal = signal }) => {
274
277
  const scratchpad = ensureDir(agentScratchDir(boot.root, sessionId, `deliberation-${id}-${role}`))
275
278
  const scan = await refreshProjectIndexes()
276
279
  const context = await createAgentContext(boot, { userTools: scan.tools, isolated: true, instructions: participantSystemPrompt(scratchpad) })
@@ -279,7 +282,7 @@ export function createController({ boot }) {
279
282
  env: { ...boot.env, PICO_SCRATCHPAD: scratchpad },
280
283
  sessionId,
281
284
  sessionFile: state.session?.file,
282
- signal,
285
+ signal: workerSignal,
283
286
  maxToolCalls: 30,
284
287
  allowNames: enabled ? undefined : [],
285
288
  })
@@ -291,7 +294,7 @@ export function createController({ boot }) {
291
294
  effort: roleWorkers[role].effort ? 'low' : null,
292
295
  auth: roleAuths[role],
293
296
  system: context.system,
294
- signal,
297
+ signal: workerSignal,
295
298
  onStream,
296
299
  })
297
300
  }
@@ -300,19 +303,24 @@ export function createController({ boot }) {
300
303
  brief,
301
304
  rounds,
302
305
  signal,
303
- runParticipant: ({ history, role, round }) => runWorker({
304
- history,
305
- role,
306
- onStream: (event) => {
307
- speak(role, round)(event)
308
- if (['tool_executing', 'tool_complete', 'tool_error'].includes(event.type)) {
309
- persistDeliberation(makeEvent('deliberation_event', { deliberationId: id, role, round, event }))
310
- }
311
- },
312
- }),
306
+ runParticipant: ({ history, role, round, parallelGroup, signal: workerSignal }) => {
307
+ live.turns[`${role}:${round}`] = { role, round, text: '', ...(parallelGroup ? { parallelGroup } : {}) }
308
+ bumpActivity()
309
+ return runWorker({
310
+ workerSignal,
311
+ history,
312
+ role,
313
+ onStream: (event) => {
314
+ speak(role, round, parallelGroup)(event)
315
+ if (['tool_executing', 'tool_complete', 'tool_error'].includes(event.type)) {
316
+ persistDeliberation(makeEvent('deliberation_event', { deliberationId: id, role, round, parallelGroup, event }))
317
+ }
318
+ },
319
+ })
320
+ },
313
321
  runSynthesis: ({ history }) => runWorker({ history, role: 'synthesizer', tools: false, onStream: speak('synthesis', null) }),
314
322
  onEvent: (event) => {
315
- Object.assign(live, { role: null, round: null, text: '' })
323
+ delete live.turns[`${event.role}:${event.round}`]
316
324
  persistDeliberation(makeEvent('deliberation_turn', { deliberationId: id, ...event }))
317
325
  },
318
326
  }).finally(() => liveDeliberations.delete(id))
@@ -608,7 +616,7 @@ export function createController({ boot }) {
608
616
  sessionFile: state.session?.file,
609
617
  wakeups: boot.wakeups,
610
618
  agents: boot.researchModel ? agents : null,
611
- deliberations: boot.deliberationModel || (boot.proposerModel && boot.reviewerModel) ? deliberations : null,
619
+ deliberations: boot.deliberationModel || (boot.participantAModel && boot.participantBModel) ? deliberations : null,
612
620
  onAgentsCollected: discardCollectedAgentNotes,
613
621
  askUser,
614
622
  signal: controller.signal,
@@ -1073,8 +1081,8 @@ export function createController({ boot }) {
1073
1081
  flash('usage: /deliberate <decision>')
1074
1082
  return true
1075
1083
  }
1076
- if (!modelAvailable(boot.proposerModel || boot.deliberationModel)) return false
1077
- if (!modelAvailable(boot.reviewerModel || boot.deliberationModel)) return false
1084
+ if (!modelAvailable(boot.participantAModel || boot.deliberationModel)) return false
1085
+ if (!modelAvailable(boot.participantBModel || boot.deliberationModel)) return false
1078
1086
  const n = Number(rounds)
1079
1087
  send(deliberatePrompt(decision, Number.isInteger(n) && n >= 1 && n <= MAX_DELIBERATION_ROUNDS ? n : null))
1080
1088
  return true
@@ -1110,17 +1118,17 @@ export function createController({ boot }) {
1110
1118
  changed()
1111
1119
  }
1112
1120
 
1113
- async function setProposerModel(name) {
1121
+ async function setParticipantAModel(name) {
1114
1122
  if (name && !modelAvailable(name)) return flash(`${name} is not available`)
1115
- boot.proposerModel = name || null
1116
- await writeConfig({ models: { proposer: name || null } })
1123
+ boot.participantAModel = name || null
1124
+ await writeConfig({ models: { participantA: name || null } })
1117
1125
  changed()
1118
1126
  }
1119
1127
 
1120
- async function setReviewerModel(name) {
1128
+ async function setParticipantBModel(name) {
1121
1129
  if (name && !modelAvailable(name)) return flash(`${name} is not available`)
1122
- boot.reviewerModel = name || null
1123
- await writeConfig({ models: { reviewer: name || null } })
1130
+ boot.participantBModel = name || null
1131
+ await writeConfig({ models: { participantB: name || null } })
1124
1132
  changed()
1125
1133
  }
1126
1134
 
@@ -1323,7 +1331,7 @@ export function createController({ boot }) {
1323
1331
  function activity() {
1324
1332
  const withLive = (item) => {
1325
1333
  const live = liveDeliberations.get(item.deliberationId)
1326
- return live?.role ? { ...item, live: { ...live } } : item
1334
+ return live && Object.keys(live.turns).length ? { ...item, live: { turns: Object.values(live.turns) } } : item
1327
1335
  }
1328
1336
  const rows = [...agents.list(), ...deliberationsFromEvents(state.events).map(withLive)]
1329
1337
  return rows.sort((a, b) => (b.startedAt || 0) - (a.startedAt || 0))
@@ -1404,8 +1412,10 @@ export function createController({ boot }) {
1404
1412
  sendInit,
1405
1413
  setResearchModel,
1406
1414
  setDeliberationModel,
1407
- setProposerModel,
1408
- setReviewerModel,
1415
+ setParticipantAModel,
1416
+ setParticipantBModel,
1417
+ setProposerModel: setParticipantAModel,
1418
+ setReviewerModel: setParticipantBModel,
1409
1419
  setDefaultModel,
1410
1420
  previewSteer,
1411
1421
  applySteer,
@@ -35,11 +35,11 @@ export function deliberationsFromEvents(events) {
35
35
  item.updatedAt = event.at
36
36
 
37
37
  if (event.type === 'deliberation_event') {
38
- const recorded = { ...data.event, role: data.role, round: data.round, at: event.at }
38
+ const recorded = { ...data.event, role: data.role, round: data.round, ...(data.parallelGroup ? { parallelGroup: data.parallelGroup } : {}), at: event.at }
39
39
  item.events.push(recorded)
40
40
  item.timeline.push({ kind: 'event', value: recorded })
41
41
  } else if (event.type === 'deliberation_turn') {
42
- const turn = { role: data.role, round: data.round, text: data.text }
42
+ const turn = { role: data.role, round: data.round, text: data.text, ...(data.parallelGroup ? { parallelGroup: data.parallelGroup } : {}) }
43
43
  item.turns.push(turn)
44
44
  item.timeline.push({ kind: 'turn', value: turn })
45
45
  item.usage = mergeUsage(item.usage, data.usage)
@@ -2,6 +2,8 @@ import { resultText } from './agents.js'
2
2
 
3
3
  export const DEFAULT_DELIBERATION_ROUNDS = 3
4
4
  export const MAX_DELIBERATION_ROUNDS = 5
5
+ export const INITIAL_PARALLEL_GROUP = 'initial'
6
+ export const PARTICIPANT_ROLES = ['participant-a', 'participant-b']
5
7
 
6
8
  export function validateDeliberation({ brief, rounds = DEFAULT_DELIBERATION_ROUNDS } = {}) {
7
9
  if (!brief?.trim()) throw new Error('deliberation brief is required')
@@ -11,17 +13,14 @@ export function validateDeliberation({ brief, rounds = DEFAULT_DELIBERATION_ROUN
11
13
  return { brief: brief.trim(), rounds }
12
14
  }
13
15
 
14
- const ROLE_BRIEFS = {
15
- proposer: 'You are the proposer: take a position on the decision and argue for it with evidence, laying out the reasoning and the tradeoffs you accept.',
16
- reviewer: 'You are the reviewer: scrutinize the proposal, test its evidence and premises, and put forward the strongest alternative where one exists.',
17
- }
18
-
19
16
  function participantPrompt(brief, role, rounds) {
20
- return `${ROLE_BRIEFS[role]} This is a ${rounds}-round deliberation about the following decision:\n\n${brief}\n\nResearch before making claims. Use the available project and web tools whenever they can replace assumption with evidence. Cite URLs and project paths in your response. Challenge weak premises and address the peer's strongest points. Do not seek agreement for its own sake or defend a fixed position. Change your position only when evidence warrants it, and preserve material disagreement and uncertainty. Do not modify project files. Write your own argument and reasoning; never instruct the other participant what to say or conclude. Be concise.`
17
+ const name = role === 'participant-a' ? 'Participant A' : 'Participant B'
18
+ return `You are ${name}, one of two equal participants. Independently investigate and reason about this decision. This is a ${rounds}-round deliberation about the following decision:\n\n${brief}\n\nResearch before making claims. Use the available project and web tools whenever they can replace assumption with evidence. Cite URLs and project paths in your response. Challenge weak premises and address the peer's strongest points once their work is available. Do not seek agreement for its own sake or defend a fixed position. Change your position only when evidence warrants it, and preserve material disagreement and uncertainty. Do not modify project files. Write your own argument and reasoning; never instruct the other participant what to say or conclude. Be concise.`
21
19
  }
22
20
 
23
21
  function peerMessage(role, text, round, rounds) {
24
- return `Round ${round} of ${rounds}. The ${role} replied:\n\n${text}\n\nResearch and respond to the substance of this message.`
22
+ const name = role === 'participant-a' ? 'Participant A' : 'Participant B'
23
+ return `Round ${round} of ${rounds}. ${name} replied:\n\n${text}\n\nResearch and respond to the substance of this message.`
25
24
  }
26
25
 
27
26
  function synthesisPrompt(brief, turns) {
@@ -29,38 +28,66 @@ function synthesisPrompt(brief, turns) {
29
28
  return `Synthesize this deliberation into a decision for the main agent. State the recommendation, decisive evidence, unresolved uncertainty, material disagreement, and implementation constraints. Do not manufacture consensus: preserve disagreements that the evidence did not resolve. Preserve useful URLs and project paths. Do not mention the deliberation process unless disagreement remains.\n\nDecision brief:\n${brief}\n\nTranscript:\n${transcript}`
30
29
  }
31
30
 
31
+ function settledTurn(result, role, round, parallelGroup) {
32
+ if (result.error) return { error: result.error }
33
+ const text = resultText(result.messages)
34
+ if (result.interrupted || !text) return { error: text ? null : 'deliberation participant returned no response' }
35
+ return { turn: { role, round, text, messages: result.messages || [], usage: result.usage || null, ...(parallelGroup ? { parallelGroup } : {}) } }
36
+ }
37
+
32
38
  export async function runDeliberation({ brief, rounds, runParticipant, runSynthesis, onEvent = () => {}, signal }) {
33
39
  const valid = validateDeliberation({ brief, rounds })
34
- const participants = {
35
- proposer: [{ role: 'user', content: participantPrompt(valid.brief, 'proposer', valid.rounds) }],
36
- reviewer: [{ role: 'user', content: participantPrompt(valid.brief, 'reviewer', valid.rounds) }],
37
- }
40
+ const participants = Object.fromEntries(PARTICIPANT_ROLES.map((role) => [role, [{ role: 'user', content: participantPrompt(valid.brief, role, valid.rounds) }]]))
38
41
  const turns = []
39
- let peer = null
40
42
 
41
- for (let round = 1; round <= valid.rounds; round++) {
42
- for (const role of ['proposer', 'reviewer']) {
43
+ if (signal?.aborted) return { turns, interrupted: true }
44
+ const initialController = new AbortController()
45
+ const abortInitial = () => initialController.abort(signal?.reason)
46
+ signal?.addEventListener('abort', abortInitial, { once: true })
47
+ let failure
48
+ await Promise.allSettled(PARTICIPANT_ROLES.map(async (role) => {
49
+ try {
50
+ const result = await runParticipant({
51
+ role, round: 1, rounds: valid.rounds, history: [...participants[role]],
52
+ signal: initialController.signal, parallelGroup: INITIAL_PARALLEL_GROUP,
53
+ })
54
+ const parsed = settledTurn(result, role, 1, INITIAL_PARALLEL_GROUP)
55
+ if (!parsed.turn) {
56
+ failure ??= { interrupted: true, error: parsed.error }
57
+ initialController.abort()
58
+ return
59
+ }
60
+ participants[role].push(...(result.messages || []))
61
+ turns.push(parsed.turn)
62
+ onEvent({ type: 'deliberation_turn', ...parsed.turn })
63
+ } catch (error) {
64
+ failure ??= { interrupted: true, error: error?.message || String(error) }
65
+ initialController.abort(error)
66
+ }
67
+ }))
68
+ signal?.removeEventListener('abort', abortInitial)
69
+ turns.sort((a, b) => PARTICIPANT_ROLES.indexOf(a.role) - PARTICIPANT_ROLES.indexOf(b.role))
70
+ if (failure) return { turns, ...failure }
71
+ if (signal?.aborted) return { turns, interrupted: true }
72
+
73
+ participants['participant-b'].push({ role: 'user', content: peerMessage('participant-a', turns[0].text, 1, valid.rounds) })
74
+ let peer = turns.at(-1)
75
+ for (let round = 2; round <= valid.rounds; round++) {
76
+ for (const role of PARTICIPANT_ROLES) {
43
77
  if (signal?.aborted) return { turns, interrupted: true }
44
78
  const history = participants[role]
45
- if (peer) history.push({ role: 'user', content: peerMessage(peer.role, peer.text, round, valid.rounds) })
79
+ history.push({ role: 'user', content: peerMessage(peer.role, peer.text, round, valid.rounds) })
46
80
  const result = await runParticipant({ role, round, rounds: valid.rounds, history: [...history], signal })
47
81
  history.push(...(result.messages || []))
48
- const text = resultText(result.messages)
49
- if (result.error) return { turns, interrupted: true, error: result.error }
50
- if (result.interrupted || !text) return { turns, interrupted: true, error: text ? null : 'deliberation participant returned no response' }
51
- const turn = { role, round, text, messages: result.messages || [], usage: result.usage || null }
52
- turns.push(turn)
53
- peer = turn
54
- onEvent({ type: 'deliberation_turn', ...turn })
82
+ const parsed = settledTurn(result, role, round)
83
+ if (!parsed.turn) return { turns, interrupted: true, error: parsed.error }
84
+ turns.push(parsed.turn)
85
+ peer = parsed.turn
86
+ onEvent({ type: 'deliberation_turn', ...parsed.turn })
55
87
  }
56
88
  }
57
89
 
90
+ if (signal?.aborted) return { turns, interrupted: true }
58
91
  const synthesis = await runSynthesis({ history: [{ role: 'user', content: synthesisPrompt(valid.brief, turns) }], signal })
59
- return {
60
- turns,
61
- result: resultText(synthesis.messages),
62
- usage: synthesis.usage || null,
63
- interrupted: !!synthesis.interrupted,
64
- error: synthesis.error || null,
65
- }
92
+ return { turns, result: resultText(synthesis.messages), usage: synthesis.usage || null, interrupted: !!synthesis.interrupted, error: synthesis.error || null }
66
93
  }
@@ -229,7 +229,7 @@ export function createToolset({ toolResult, cwd, env, tracker, skills, shells, s
229
229
  schema: {
230
230
  description: describeParam,
231
231
  brief: { type: 'string', description: 'self-contained decision, relevant context, constraints, and desired outcome' },
232
- rounds: { type: 'integer', description: 'number of proposer-reviewer exchanges (1-5, default 3)', optional: true },
232
+ rounds: { type: 'integer', description: 'number of Participant A/B exchange rounds (1-5, default 3)', optional: true },
233
233
  },
234
234
  execute: ({ brief, rounds }) => deliberations.run({ brief, rounds, sessionId, sessionFile, signal }),
235
235
  })