@sebastianandreasson/pi-autonomous-agents 0.5.2 → 0.7.0

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.
@@ -0,0 +1,654 @@
1
+ import path from 'node:path'
2
+ import process from 'node:process'
3
+ import { pathToFileURL } from 'node:url'
4
+ import {
5
+ formatHeartbeatReason,
6
+ formatHeartbeatTimeoutMessage,
7
+ getHeartbeatDecision,
8
+ resolveHeartbeatConfig,
9
+ } from './pi-heartbeat.mjs'
10
+
11
+ const THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh'])
12
+
13
+ function formatValue(value) {
14
+ const text = JSON.stringify(value)
15
+ if (text === undefined) {
16
+ return ''
17
+ }
18
+ if (text.length <= 160) {
19
+ return text
20
+ }
21
+ return `${text.slice(0, 157)}...`
22
+ }
23
+
24
+ function extractToolTarget(toolName, args) {
25
+ if (!args || typeof args !== 'object') {
26
+ return ''
27
+ }
28
+
29
+ if ((toolName === 'read' || toolName === 'write' || toolName === 'edit') && typeof args.path === 'string') {
30
+ return args.path
31
+ }
32
+
33
+ return ''
34
+ }
35
+
36
+ function extractShellCommand(args) {
37
+ if (!args || typeof args !== 'object') {
38
+ return ''
39
+ }
40
+
41
+ if (typeof args.command === 'string') {
42
+ return args.command
43
+ }
44
+
45
+ if (typeof args.cmd === 'string') {
46
+ return args.cmd
47
+ }
48
+
49
+ return ''
50
+ }
51
+
52
+ function isLargeShellRead(command) {
53
+ const text = String(command ?? '').trim()
54
+ if (text === '') {
55
+ return false
56
+ }
57
+
58
+ if (/^\s*cat\s+\S+/.test(text)) {
59
+ return true
60
+ }
61
+
62
+ const sedMatch = text.match(/sed\s+-n\s+['"]?(\d+)\s*,\s*(\d+)p['"]?/)
63
+ if (sedMatch) {
64
+ const start = Number.parseInt(sedMatch[1], 10)
65
+ const end = Number.parseInt(sedMatch[2], 10)
66
+ if (Number.isFinite(start) && Number.isFinite(end) && end >= start) {
67
+ return (end - start) >= 120
68
+ }
69
+ }
70
+
71
+ return false
72
+ }
73
+
74
+ function extractAssistantText(message) {
75
+ if (!message || message.role !== 'assistant') {
76
+ return ''
77
+ }
78
+
79
+ if (typeof message.content === 'string') {
80
+ return message.content
81
+ }
82
+
83
+ if (!Array.isArray(message.content)) {
84
+ return ''
85
+ }
86
+
87
+ return message.content
88
+ .filter((item) => item?.type === 'text')
89
+ .map((item) => item.text)
90
+ .join('')
91
+ }
92
+
93
+ function getLastAssistantMessage(messages) {
94
+ if (!Array.isArray(messages)) {
95
+ return null
96
+ }
97
+
98
+ const reversed = [...messages].reverse()
99
+ return reversed.find((message) => message?.role === 'assistant') ?? null
100
+ }
101
+
102
+ export function splitModelSpec(modelSpec) {
103
+ const raw = String(modelSpec ?? '').trim()
104
+ if (raw === '') {
105
+ return {
106
+ modelName: '',
107
+ thinkingLevel: '',
108
+ }
109
+ }
110
+
111
+ const lastColonIndex = raw.lastIndexOf(':')
112
+ if (lastColonIndex === -1) {
113
+ return {
114
+ modelName: raw,
115
+ thinkingLevel: '',
116
+ }
117
+ }
118
+
119
+ const maybeThinking = raw.slice(lastColonIndex + 1).trim().toLowerCase()
120
+ if (!THINKING_LEVELS.has(maybeThinking)) {
121
+ return {
122
+ modelName: raw,
123
+ thinkingLevel: '',
124
+ }
125
+ }
126
+
127
+ return {
128
+ modelName: raw.slice(0, lastColonIndex).trim(),
129
+ thinkingLevel: maybeThinking,
130
+ }
131
+ }
132
+
133
+ export function normalizeToolNames(tools) {
134
+ if (typeof tools !== 'string') {
135
+ return []
136
+ }
137
+
138
+ return [...new Set(
139
+ tools
140
+ .split(',')
141
+ .map((value) => value.trim())
142
+ .filter(Boolean)
143
+ )]
144
+ }
145
+
146
+ async function loadPiSdk() {
147
+ const overrideModule = String(process.env.PI_SDK_MODULE ?? '').trim()
148
+ if (overrideModule !== '') {
149
+ try {
150
+ const specifier = path.isAbsolute(overrideModule)
151
+ ? pathToFileURL(overrideModule).href
152
+ : overrideModule
153
+ return await import(specifier)
154
+ } catch (error) {
155
+ throw new Error(
156
+ `Failed to load PI SDK override module "${overrideModule}". `
157
+ + `Original error: ${error instanceof Error ? error.message : String(error)}`
158
+ )
159
+ }
160
+ }
161
+
162
+ try {
163
+ return await import('@mariozechner/pi-coding-agent')
164
+ } catch (error) {
165
+ throw new Error(
166
+ 'SDK transport requires @mariozechner/pi-coding-agent to be installed in this package. '
167
+ + `Original error: ${error instanceof Error ? error.message : String(error)}`
168
+ )
169
+ }
170
+ }
171
+
172
+ function resolveAgentDir(pi) {
173
+ if (process.env.PI_CODING_AGENT_DIR) {
174
+ return path.resolve(process.env.PI_CODING_AGENT_DIR)
175
+ }
176
+ return pi.getAgentDir()
177
+ }
178
+
179
+ function createSessionManager(pi, request, sessionDir) {
180
+ if (request.sessionFile) {
181
+ return pi.SessionManager.open(request.sessionFile, sessionDir)
182
+ }
183
+
184
+ if (request.sessionId) {
185
+ return pi.SessionManager.continueRecent(request.cwd, sessionDir)
186
+ }
187
+
188
+ return pi.SessionManager.create(request.cwd, sessionDir)
189
+ }
190
+
191
+ export function createTools(pi, cwd, tools) {
192
+ const names = normalizeToolNames(tools)
193
+ if (names.length === 0) {
194
+ return undefined
195
+ }
196
+
197
+ const factories = {
198
+ read: pi.createReadTool,
199
+ bash: pi.createBashTool,
200
+ edit: pi.createEditTool,
201
+ write: pi.createWriteTool,
202
+ grep: pi.createGrepTool,
203
+ find: pi.createFindTool,
204
+ ls: pi.createLsTool,
205
+ }
206
+
207
+ return names.map((name) => {
208
+ const factory = factories[name]
209
+ if (!factory) {
210
+ throw new Error(`Unsupported PI tool "${name}" in SDK transport.`)
211
+ }
212
+ return factory(cwd)
213
+ })
214
+ }
215
+
216
+ export async function resolveModel(modelRegistry, requestedModel) {
217
+ const raw = String(requestedModel ?? '').trim()
218
+ if (raw === '') {
219
+ return undefined
220
+ }
221
+
222
+ const { modelName } = splitModelSpec(raw)
223
+ if (modelName === '') {
224
+ return undefined
225
+ }
226
+
227
+ const slashIndex = modelName.indexOf('/')
228
+ if (slashIndex !== -1) {
229
+ const provider = modelName.slice(0, slashIndex).trim()
230
+ const id = modelName.slice(slashIndex + 1).trim()
231
+ const resolved = modelRegistry.find(provider, id)
232
+ if (!resolved) {
233
+ throw new Error(`Configured PI model "${raw}" could not be resolved via SDK model registry.`)
234
+ }
235
+ return resolved
236
+ }
237
+
238
+ const matches = modelRegistry.getAll().filter((model) => {
239
+ if (!model || typeof model !== 'object') {
240
+ return false
241
+ }
242
+
243
+ return model.id === modelName
244
+ || model.name === modelName
245
+ || `${model.provider}/${model.id}` === modelName
246
+ })
247
+
248
+ if (matches.length === 1) {
249
+ return matches[0]
250
+ }
251
+
252
+ if (matches.length > 1) {
253
+ const candidates = matches.map((model) => `${model.provider}/${model.id}`).join(', ')
254
+ throw new Error(`Configured PI model "${raw}" is ambiguous in SDK model registry. Candidates: ${candidates}`)
255
+ }
256
+
257
+ throw new Error(`Configured PI model "${raw}" could not be resolved via SDK model registry.`)
258
+ }
259
+
260
+ export async function createSdkSession(pi, request) {
261
+ const sessionDir = path.join(path.resolve(request.runtimeDir ?? path.join(request.cwd, '.pi-runtime')), 'sessions')
262
+ const agentDir = resolveAgentDir(pi)
263
+ const authStorage = pi.AuthStorage.create(path.join(agentDir, 'auth.json'))
264
+ const modelRegistry = pi.ModelRegistry.create(authStorage, path.join(agentDir, 'models.json'))
265
+ const settingsManager = pi.SettingsManager.create(request.cwd, agentDir)
266
+ settingsManager.applyOverrides({
267
+ retry: {
268
+ enabled: false,
269
+ },
270
+ })
271
+
272
+ const { thinkingLevel: modelSpecThinking } = splitModelSpec(request.model)
273
+ const thinkingLevel = String(request.thinking || modelSpecThinking || '').trim()
274
+ const resourceLoader = new pi.DefaultResourceLoader({
275
+ cwd: request.cwd,
276
+ agentDir,
277
+ settingsManager,
278
+ noExtensions: request.noExtensions === true,
279
+ noSkills: request.noSkills === true,
280
+ noPromptTemplates: request.noPromptTemplates === true,
281
+ noThemes: request.noThemes !== false,
282
+ })
283
+ await resourceLoader.reload()
284
+
285
+ const model = await resolveModel(modelRegistry, request.model)
286
+ const tools = createTools(pi, request.cwd, request.tools)
287
+ const sessionManager = createSessionManager(pi, request, sessionDir)
288
+
289
+ return pi.createAgentSession({
290
+ cwd: request.cwd,
291
+ agentDir,
292
+ authStorage,
293
+ modelRegistry,
294
+ resourceLoader,
295
+ sessionManager,
296
+ settingsManager,
297
+ ...(model ? { model } : {}),
298
+ ...(thinkingLevel !== '' ? { thinkingLevel } : {}),
299
+ ...(tools ? { tools } : {}),
300
+ })
301
+ }
302
+
303
+ async function safeAbort(session) {
304
+ try {
305
+ await session.abort()
306
+ } catch {}
307
+ }
308
+
309
+ export async function runSdkTurnWithPi(pi, request) {
310
+ const streamTerminal = request.streamTerminal === true
311
+ const requestedModel = typeof request.model === 'string' ? request.model : ''
312
+ const loopRepeatThreshold = Number.isFinite(Number(request.loopRepeatThreshold))
313
+ ? Number(request.loopRepeatThreshold)
314
+ : 12
315
+ const samePathRepeatThreshold = Number.isFinite(Number(request.samePathRepeatThreshold))
316
+ ? Number(request.samePathRepeatThreshold)
317
+ : 8
318
+ const {
319
+ continueAfterSeconds,
320
+ noEventTimeoutSeconds,
321
+ toolContinueAfterSeconds,
322
+ toolNoEventTimeoutSeconds,
323
+ } = resolveHeartbeatConfig(request)
324
+ const continueMessage = typeof request.continueMessage === 'string' && request.continueMessage.trim() !== ''
325
+ ? request.continueMessage.trim()
326
+ : 'continue'
327
+
328
+ let unsubscribe = () => {}
329
+ let assistantLineOpen = false
330
+ let streamedAssistantText = false
331
+ let lastToolSignature = ''
332
+ let repeatedToolCount = 0
333
+ let lastToolTarget = ''
334
+ let repeatedTargetCount = 0
335
+ let loopDetected = false
336
+ let loopSignature = ''
337
+ let abortRequested = false
338
+ let heartbeatTimedOut = false
339
+ let heartbeatReason = ''
340
+ let heartbeatInterval = null
341
+ let continueAttempted = false
342
+ let continueAccepted = false
343
+ let continueRejected = false
344
+ let agentStarted = false
345
+ let agentEnded = false
346
+ let activeToolName = ''
347
+ let activeToolStartedAt = 0
348
+ let lastEventAt = Date.now()
349
+ const events = []
350
+
351
+ const writeLive = (text) => {
352
+ if (!streamTerminal) {
353
+ return
354
+ }
355
+ process.stderr.write(text)
356
+ }
357
+
358
+ const ensureAssistantLine = () => {
359
+ if (!assistantLineOpen) {
360
+ writeLive('[PI assistant] ')
361
+ assistantLineOpen = true
362
+ }
363
+ }
364
+
365
+ const closeAssistantLine = () => {
366
+ if (assistantLineOpen) {
367
+ writeLive('\n')
368
+ assistantLineOpen = false
369
+ }
370
+ }
371
+
372
+ let session
373
+ try {
374
+ const created = await createSdkSession(pi, request)
375
+ session = created.session
376
+
377
+ if (!request.model && !session.model) {
378
+ throw new Error('No PI model configured. Set PI_MODEL or configure a default model in PI.')
379
+ }
380
+
381
+ const requestAbortForLoop = () => {
382
+ if (abortRequested) {
383
+ return
384
+ }
385
+
386
+ abortRequested = true
387
+ closeAssistantLine()
388
+ writeLive(`[PI guard] repeated tool loop detected: ${loopSignature} x${repeatedToolCount}. Aborting current turn.\n`)
389
+ void safeAbort(session)
390
+ }
391
+
392
+ const requestAbortForHeartbeat = (decision) => {
393
+ if (abortRequested) {
394
+ return
395
+ }
396
+
397
+ abortRequested = true
398
+ heartbeatTimedOut = true
399
+ heartbeatReason = formatHeartbeatReason(decision)
400
+ closeAssistantLine()
401
+ writeLive(`[PI guard] ${formatHeartbeatTimeoutMessage(decision)} Aborting current turn.\n`)
402
+ void safeAbort(session)
403
+ }
404
+
405
+ const requestSoftContinue = (decision) => {
406
+ if (abortRequested || continueAttempted || agentEnded) {
407
+ return
408
+ }
409
+
410
+ continueAttempted = true
411
+ closeAssistantLine()
412
+ const context = decision.activeToolName
413
+ ? ` while tool "${decision.activeToolName}" is running`
414
+ : ''
415
+ writeLive(`[PI guard] no PI events for ${decision.continueAfterSeconds}s${context}. Sending soft continue prompt.\n`)
416
+ void session.steer(continueMessage)
417
+ .then(() => {
418
+ continueAccepted = true
419
+ writeLive('[PI guard] soft continue accepted by PI.\n')
420
+ })
421
+ .catch((error) => {
422
+ continueRejected = true
423
+ writeLive(`[PI guard] soft continue failed: ${error instanceof Error ? error.message : String(error)}\n`)
424
+ })
425
+ }
426
+
427
+ unsubscribe = session.subscribe((event) => {
428
+ events.push(event)
429
+ lastEventAt = Date.now()
430
+
431
+ if (event.type === 'agent_start') {
432
+ agentStarted = true
433
+ writeLive('[PI] agent started\n')
434
+ }
435
+
436
+ if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta') {
437
+ ensureAssistantLine()
438
+ writeLive(event.assistantMessageEvent.delta)
439
+ streamedAssistantText = true
440
+ }
441
+
442
+ if (event.type === 'message_end' && event.message?.role === 'assistant') {
443
+ const text = extractAssistantText(event.message)
444
+ if (assistantLineOpen) {
445
+ closeAssistantLine()
446
+ } else if (!streamedAssistantText && text.trim() !== '') {
447
+ writeLive(`[PI assistant] ${text}\n`)
448
+ }
449
+ streamedAssistantText = false
450
+ }
451
+
452
+ if (event.type === 'tool_execution_start') {
453
+ closeAssistantLine()
454
+ const argsText = formatValue(event.args)
455
+ const suffix = argsText === '' ? '' : ` ${argsText}`
456
+ const signature = `${event.toolName}${suffix}`
457
+ activeToolName = String(event.toolName ?? '')
458
+ activeToolStartedAt = Date.now()
459
+ const target = extractToolTarget(event.toolName, event.args)
460
+ const shellCommand = event.toolName === 'bash' ? extractShellCommand(event.args) : ''
461
+
462
+ if (signature === lastToolSignature) {
463
+ repeatedToolCount += 1
464
+ } else {
465
+ lastToolSignature = signature
466
+ repeatedToolCount = 1
467
+ }
468
+
469
+ if (target !== '' && target === lastToolTarget) {
470
+ repeatedTargetCount += 1
471
+ } else if (target !== '') {
472
+ lastToolTarget = target
473
+ repeatedTargetCount = 1
474
+ } else {
475
+ lastToolTarget = ''
476
+ repeatedTargetCount = 0
477
+ }
478
+
479
+ if (!loopDetected && repeatedToolCount >= loopRepeatThreshold) {
480
+ loopDetected = true
481
+ loopSignature = signature
482
+ requestAbortForLoop()
483
+ }
484
+
485
+ if (!loopDetected && target !== '' && repeatedTargetCount >= samePathRepeatThreshold) {
486
+ loopDetected = true
487
+ loopSignature = `same_path:${target}`
488
+ requestAbortForLoop()
489
+ }
490
+
491
+ writeLive(`[PI tool:start] ${event.toolName}${suffix}\n`)
492
+ if (event.toolName === 'bash' && isLargeShellRead(shellCommand)) {
493
+ writeLive('[PI warning] large bash file read detected; prefer read or a smaller exact window to avoid truncated context.\n')
494
+ }
495
+ }
496
+
497
+ if (event.type === 'tool_execution_end') {
498
+ closeAssistantLine()
499
+ activeToolName = ''
500
+ activeToolStartedAt = 0
501
+ writeLive(`[PI tool:end] ${event.toolName} ${event.isError ? 'error' : 'ok'}\n`)
502
+ }
503
+
504
+ if (event.type === 'agent_end') {
505
+ agentEnded = true
506
+ closeAssistantLine()
507
+ writeLive('[PI] agent finished\n')
508
+ }
509
+ })
510
+
511
+ heartbeatInterval = setInterval(() => {
512
+ const decision = getHeartbeatDecision({
513
+ now: Date.now(),
514
+ agentStarted,
515
+ agentEnded,
516
+ heartbeatTimedOut,
517
+ childExited: false,
518
+ lastEventAt,
519
+ continueAttempted,
520
+ activeToolName,
521
+ activeToolStartedAt,
522
+ continueAfterSeconds,
523
+ noEventTimeoutSeconds,
524
+ toolContinueAfterSeconds,
525
+ toolNoEventTimeoutSeconds,
526
+ })
527
+
528
+ if (decision.action === 'soft_continue') {
529
+ requestSoftContinue(decision)
530
+ return
531
+ }
532
+
533
+ if (decision.action === 'abort') {
534
+ requestAbortForHeartbeat(decision)
535
+ }
536
+ }, 1000)
537
+
538
+ try {
539
+ await session.prompt(request.prompt)
540
+ } catch (error) {
541
+ if (!heartbeatTimedOut && !loopDetected) {
542
+ throw error
543
+ }
544
+ }
545
+
546
+ if (heartbeatTimedOut) {
547
+ const toolCalls = events.filter((event) => event.type === 'tool_execution_start').length
548
+ const toolErrors = events.filter((event) => event.type === 'tool_execution_end' && event.isError).length
549
+ const messageUpdates = events.filter((event) => event.type === 'message_update').length
550
+ return {
551
+ sessionId: session.sessionId ?? request.sessionId ?? '',
552
+ sessionFile: session.sessionFile ?? request.sessionFile ?? '',
553
+ status: 'timed_out',
554
+ output: [
555
+ '[heartbeat_timeout]',
556
+ formatHeartbeatTimeoutMessage({
557
+ noEventTimeoutSeconds,
558
+ activeToolName,
559
+ toolRuntimeSeconds: activeToolStartedAt > 0
560
+ ? Math.max(0, Math.floor((Date.now() - activeToolStartedAt) / 1000))
561
+ : 0,
562
+ }),
563
+ ].join('\n').trim(),
564
+ notes: [
565
+ heartbeatReason,
566
+ continueAttempted ? `continue_attempted=${continueMessage}` : '',
567
+ continueAccepted ? 'continue_accepted=true' : '',
568
+ continueRejected ? 'continue_rejected=true' : '',
569
+ ].join(' '),
570
+ role: '',
571
+ model: requestedModel,
572
+ toolCalls,
573
+ toolErrors,
574
+ messageUpdates,
575
+ stopReason: '',
576
+ loopDetected: false,
577
+ loopSignature: '',
578
+ terminalReason: 'heartbeat_timeout',
579
+ }
580
+ }
581
+
582
+ const toolCalls = events.filter((event) => event.type === 'tool_execution_start').length
583
+ const toolErrors = events.filter((event) => event.type === 'tool_execution_end' && event.isError).length
584
+ const messageUpdates = events.filter((event) => event.type === 'message_update').length
585
+ const lastAssistantMessage = getLastAssistantMessage(session.messages)
586
+ const assistantText = extractAssistantText(lastAssistantMessage).trim()
587
+ const assistantError = String(lastAssistantMessage?.errorMessage ?? '').trim()
588
+ const assistantStopReason = String(lastAssistantMessage?.stopReason ?? '').trim()
589
+ const status = loopDetected
590
+ ? 'stalled'
591
+ : assistantError !== '' || (assistantText === '' && toolCalls === 0 && messageUpdates === 0)
592
+ ? 'failed'
593
+ : 'success'
594
+ const terminalReason = loopDetected
595
+ ? 'loop_detected'
596
+ : assistantError !== ''
597
+ ? 'assistant_error'
598
+ : assistantStopReason === 'length'
599
+ ? 'assistant_stop_length'
600
+ : status === 'failed'
601
+ ? 'empty_agent_turn'
602
+ : 'agent_completed'
603
+ const notes = [
604
+ `PI session ${session.sessionId}`,
605
+ `tool_calls=${toolCalls}`,
606
+ `tool_errors=${toolErrors}`,
607
+ `message_updates=${messageUpdates}`,
608
+ activeToolName !== '' ? `active_tool=${activeToolName}` : '',
609
+ continueAttempted ? `continue_attempted=${continueMessage}` : '',
610
+ continueAccepted ? 'continue_accepted=true' : '',
611
+ continueRejected ? 'continue_rejected=true' : '',
612
+ loopDetected ? `loop_detected=${loopSignature}` : '',
613
+ loopDetected ? `loop_repeats=${repeatedToolCount}` : '',
614
+ assistantStopReason !== '' ? `stop_reason=${assistantStopReason}` : '',
615
+ assistantError !== '' ? `assistant_error=${assistantError}` : '',
616
+ status === 'failed' && assistantError === '' ? 'empty_agent_turn' : '',
617
+ ].join(' ')
618
+ const output = [
619
+ assistantText,
620
+ loopDetected ? `\n[loop_guard]\nRepeated tool loop detected: ${loopSignature} x${repeatedToolCount}` : '',
621
+ assistantError !== '' ? `\n[assistant_error]\n${assistantError}` : '',
622
+ ].join('').trim()
623
+
624
+ return {
625
+ sessionId: session.sessionId,
626
+ sessionFile: session.sessionFile ?? '',
627
+ status,
628
+ output,
629
+ notes,
630
+ role: '',
631
+ model: requestedModel,
632
+ toolCalls,
633
+ toolErrors,
634
+ messageUpdates,
635
+ stopReason: assistantStopReason,
636
+ loopDetected,
637
+ loopSignature,
638
+ terminalReason,
639
+ }
640
+ } finally {
641
+ unsubscribe()
642
+ if (heartbeatInterval) {
643
+ clearInterval(heartbeatInterval)
644
+ }
645
+ if (session) {
646
+ session.dispose()
647
+ }
648
+ }
649
+ }
650
+
651
+ export async function runSdkTurn(request) {
652
+ const pi = await loadPiSdk()
653
+ return await runSdkTurnWithPi(pi, request)
654
+ }