@sebastianandreasson/pi-autonomous-agents 0.15.0 → 0.15.2

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/README.md CHANGED
@@ -82,6 +82,14 @@ Start from [templates/pi.config.example.json](./templates/pi.config.example.json
82
82
 
83
83
  Request telemetry is enabled by default for SDK runs. `pi-harness` writes a managed Pi extension package under `.pi/extensions/pi-harness-request-telemetry/` in the consuming repo, with a `package.json` manifest and `index.mjs` shim that Pi auto-discovers on the next resource reload. Disable that with `PI_REQUEST_TELEMETRY_ENABLED=0` or `"piRequestTelemetryEnabled": false`.
84
84
 
85
+ By default the extension now stores compact request telemetry only:
86
+ - `requests.jsonl` with exact request totals and summarized tool/file attribution
87
+ - `spans.jsonl` with byte counts and attribution metadata, but not full prompt text
88
+
89
+ Verbose hook traces and raw span text are opt-in for debugging:
90
+ - `PI_REQUEST_TELEMETRY_STORE_HOOKS=1` or `"piRequestTelemetryStoreHooks": true`
91
+ - `PI_REQUEST_TELEMETRY_STORE_SPAN_TEXT=1` or `"piRequestTelemetryStoreSpanText": true`
92
+
85
93
  ## CLI
86
94
 
87
95
  ```bash
@@ -4,14 +4,28 @@ This document describes the repo-local Pi extension prototype under [pi-extensio
4
4
 
5
5
  In normal `pi-harness` SDK runs, this extension is auto-enabled by installing a managed extension package under `.pi/extensions/pi-harness-request-telemetry/` in the consuming repo before Pi reloads resources. That package contains a `package.json` Pi manifest plus the generated `index.mjs` shim. Opt out with `PI_REQUEST_TELEMETRY_ENABLED=0` or `"piRequestTelemetryEnabled": false`.
6
6
 
7
+ The default storage mode is compact:
8
+
9
+ - `requests.jsonl` is always kept
10
+ - `spans.jsonl` keeps attribution metadata and byte counts, but not full prompt text
11
+ - `hooks.jsonl` is disabled by default
12
+
13
+ Enable deeper debug capture only when needed:
14
+
15
+ - `PI_REQUEST_TELEMETRY_STORE_HOOKS=1` or `"piRequestTelemetryStoreHooks": true`
16
+ - `PI_REQUEST_TELEMETRY_STORE_SPAN_TEXT=1` or `"piRequestTelemetryStoreSpanText": true`
17
+
7
18
  The purpose of this extension is to gather request-level data directly from Pi extension hooks before we decide whether to patch `@mariozechner/pi-coding-agent` or `pi-ai`.
8
19
 
9
20
  ## Produced Artifacts
10
21
 
11
- - `pi-output/request-telemetry/hooks.jsonl`
12
22
  - `pi-output/request-telemetry/requests.jsonl`
13
23
  - `pi-output/request-telemetry/spans.jsonl`
14
24
 
25
+ Optional when hook tracing is enabled:
26
+
27
+ - `pi-output/request-telemetry/hooks.jsonl`
28
+
15
29
  These artifacts are intentionally separate from the existing `pi-output/token-usage/*` files.
16
30
 
17
31
  `token-usage` remains the harness-level normalized output.
@@ -109,7 +123,7 @@ Interpretation:
109
123
  - `message_end`
110
124
  - `turn_end`
111
125
 
112
- Use it to debug request association problems or confirm whether Pi emitted provider-boundary hooks for a specific run.
126
+ Use it to debug request association problems or confirm whether Pi emitted provider-boundary hooks for a specific run. This file is off by default because it grows quickly and is only useful for telemetry debugging.
113
127
 
114
128
  ## Span Artifact Schema
115
129
 
@@ -131,6 +145,9 @@ Each row in `spans.jsonl` contains one extracted prompt span:
131
145
  - `primaryPath`
132
146
  - `charCount`
133
147
  - `byteCount`
148
+
149
+ Optional only when raw span text capture is enabled:
150
+
134
151
  - `text`
135
152
  - `preview`
136
153
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sebastianandreasson/pi-autonomous-agents",
3
3
  "private": false,
4
- "version": "0.15.0",
4
+ "version": "0.15.2",
5
5
  "type": "module",
6
6
  "description": "Portable unattended PI harness for developer/tester/visual-review loops.",
7
7
  "license": "MIT",
@@ -4,10 +4,13 @@ This is a repo-local Pi extension prototype for capturing lower-level request te
4
4
 
5
5
  It writes:
6
6
 
7
- - `pi-output/request-telemetry/hooks.jsonl`
8
7
  - `pi-output/request-telemetry/requests.jsonl`
9
8
  - `pi-output/request-telemetry/spans.jsonl`
10
9
 
10
+ Optional when debug tracing is enabled:
11
+
12
+ - `pi-output/request-telemetry/hooks.jsonl`
13
+
11
14
  The goal is to capture exact request boundaries and exact prompt composition before deciding whether we need to patch `pi-mono` itself.
12
15
 
13
16
  ## What It Captures
@@ -20,12 +23,16 @@ The goal is to capture exact request boundaries and exact prompt composition bef
20
23
  - provider payload summary from `before_provider_request`
21
24
  - response status and headers from `after_provider_response`
22
25
  - final assistant-message usage if Pi exposes it on `message.usage`
23
- - lifecycle hook traces in `hooks.jsonl` so you can debug request association
24
26
  - `spanSource` on each request so consumers can distinguish:
25
27
  - `provider_payload`
26
28
  - `context`
27
29
  - `session_history`
28
30
 
31
+ Default storage is compact:
32
+
33
+ - `spans.jsonl` keeps attribution metadata and byte counts, not full prompt text
34
+ - `hooks.jsonl` is off by default because it is mainly for telemetry debugging
35
+
29
36
  ## What It Does Not Claim Yet
30
37
 
31
38
  - exact per-file token spend
@@ -50,6 +57,13 @@ Disable that path with:
50
57
  - `PI_REQUEST_TELEMETRY_ENABLED=0`
51
58
  - `"piRequestTelemetryEnabled": false` in `pi.config.json`
52
59
 
60
+ Enable deeper telemetry capture only when needed:
61
+
62
+ - `PI_REQUEST_TELEMETRY_STORE_HOOKS=1`
63
+ - `"piRequestTelemetryStoreHooks": true`
64
+ - `PI_REQUEST_TELEMETRY_STORE_SPAN_TEXT=1`
65
+ - `"piRequestTelemetryStoreSpanText": true`
66
+
53
67
  ## Running It From This Repo
54
68
 
55
69
  Use the extension file directly:
@@ -5,11 +5,13 @@ import {
5
5
  appendRequestTelemetryArtifacts,
6
6
  collectMessageSpans,
7
7
  collectProviderPayloadSpans,
8
+ collectToolHookSpans,
8
9
  deriveToolPaths,
9
10
  extractMessagesFromProviderPayload,
10
11
  extractUsageFromMessage,
11
12
  getRequestTelemetryPaths,
12
13
  readRequestTelemetryContextFromEnv,
14
+ readRequestTelemetryStorageOptionsFromEnv,
13
15
  summarizeProviderPayload,
14
16
  summarizeRequestSpans,
15
17
  } from '../../src/pi-request-telemetry.mjs'
@@ -49,6 +51,7 @@ function createTurnState({ turnIndex = 0, startedAt = '', model = '' } = {}) {
49
51
  contextMessageCount: 0,
50
52
  contextSpanCount: 0,
51
53
  lastAssistantMessage: null,
54
+ recentToolEvents: [],
52
55
  }
53
56
  }
54
57
 
@@ -118,6 +121,55 @@ function mergeSpanSummary(requestState) {
118
121
  }
119
122
  }
120
123
 
124
+ function getSpanSignal(requestState) {
125
+ const spans = Array.isArray(requestState?.spans) ? requestState.spans : []
126
+ let toolSpanCount = 0
127
+
128
+ for (const span of spans) {
129
+ const kind = String(span?.spanKind ?? '').trim()
130
+ if (kind === 'tool_call' || kind === 'tool_result') {
131
+ toolSpanCount += 1
132
+ }
133
+ }
134
+
135
+ return {
136
+ spanCount: spans.length,
137
+ toolSpanCount,
138
+ toolNameCount: requestState?.toolNames?.size ?? 0,
139
+ fileCount: requestState?.files?.size ?? 0,
140
+ }
141
+ }
142
+
143
+ function shouldPreferSnapshot(currentState, candidateState) {
144
+ const current = getSpanSignal(currentState)
145
+ const candidate = getSpanSignal(candidateState)
146
+
147
+ const currentHasToolContext = current.toolSpanCount > 0 || current.toolNameCount > 0 || current.fileCount > 0
148
+ const candidateHasToolContext = candidate.toolSpanCount > 0 || candidate.toolNameCount > 0 || candidate.fileCount > 0
149
+
150
+ if (candidateHasToolContext && !currentHasToolContext) {
151
+ return true
152
+ }
153
+
154
+ if (candidate.fileCount !== current.fileCount) {
155
+ return candidate.fileCount > current.fileCount
156
+ }
157
+
158
+ if (candidate.toolSpanCount !== current.toolSpanCount) {
159
+ return candidate.toolSpanCount > current.toolSpanCount
160
+ }
161
+
162
+ if (candidate.toolNameCount !== current.toolNameCount) {
163
+ return candidate.toolNameCount > current.toolNameCount
164
+ }
165
+
166
+ if (current.spanCount === 0 && candidate.spanCount > 0) {
167
+ return true
168
+ }
169
+
170
+ return false
171
+ }
172
+
121
173
  function applyAssistantMessage(requestState, message) {
122
174
  requestState.provider = String(message?.provider ?? requestState.provider ?? '').trim()
123
175
  requestState.model = String(message?.model ?? requestState.model ?? '').trim()
@@ -133,6 +185,7 @@ function applyAssistantMessage(requestState, message) {
133
185
 
134
186
  export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
135
187
  const artifacts = getRequestTelemetryPaths({ cwd })
188
+ const storage = readRequestTelemetryStorageOptionsFromEnv()
136
189
  const state = {
137
190
  sessionId: randomUUID(),
138
191
  currentModel: '',
@@ -164,6 +217,10 @@ export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
164
217
  }
165
218
 
166
219
  function trace(type, detail = {}) {
220
+ if (!storage.storeHooks) {
221
+ return Promise.resolve()
222
+ }
223
+
167
224
  state.hookSequence += 1
168
225
  const activeRequest = getLatestPendingRequest()
169
226
  const currentTurn = state.currentTurn
@@ -277,6 +334,32 @@ export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
277
334
  return false
278
335
  }
279
336
 
337
+ function applyToolHookSnapshot(requestState) {
338
+ const currentTurn = getCurrentTurn()
339
+ const toolEvents = Array.isArray(currentTurn.recentToolEvents) ? currentTurn.recentToolEvents : []
340
+ if (toolEvents.length === 0) {
341
+ return false
342
+ }
343
+
344
+ const spans = collectToolHookSpans({
345
+ requestId: requestState.requestId,
346
+ sessionId: requestState.sessionId,
347
+ turnIndex: requestState.turnIndex,
348
+ toolEvents,
349
+ })
350
+ if (spans.length === 0) {
351
+ return false
352
+ }
353
+
354
+ applySpanSnapshot(requestState, {
355
+ messages: [],
356
+ spans,
357
+ source: 'tool_hooks',
358
+ })
359
+ mergeSpanSummary(requestState)
360
+ return true
361
+ }
362
+
280
363
  function createProviderRequest(overrides = {}) {
281
364
  const currentTurn = getCurrentTurn()
282
365
  return createRequestState({
@@ -289,6 +372,23 @@ export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
289
372
  })
290
373
  }
291
374
 
375
+ function createComparableRequestState(baseState) {
376
+ return createRequestState({
377
+ sessionId: baseState.sessionId,
378
+ turnIndex: baseState.turnIndex,
379
+ startedAt: baseState.startedAt,
380
+ model: baseState.model,
381
+ metadata: {
382
+ runId: baseState.runId,
383
+ iteration: baseState.iteration,
384
+ phase: baseState.phase,
385
+ role: baseState.role,
386
+ kind: baseState.kind,
387
+ task: baseState.task,
388
+ },
389
+ })
390
+ }
391
+
292
392
  function createFallbackRequest() {
293
393
  const requestState = createProviderRequest()
294
394
 
@@ -345,6 +445,8 @@ export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
345
445
  ...requestState.usage,
346
446
  },
347
447
  spans: requestState.spans,
448
+ }, {
449
+ includeSpanText: storage.storeSpanText,
348
450
  })
349
451
  }
350
452
 
@@ -416,11 +518,57 @@ export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
416
518
  })
417
519
 
418
520
  pi.on('before_provider_request', (event) => {
521
+ const currentTurn = getCurrentTurn()
419
522
  const requestState = createProviderRequest()
420
- if (!applyProviderPayloadSnapshot(requestState, event?.payload) && !applyContextSnapshot(requestState)) {
523
+ const providerApplied = applyProviderPayloadSnapshot(requestState, event?.payload)
524
+
525
+ const contextCandidate = createComparableRequestState(requestState)
526
+ const contextApplied = applyContextSnapshot(contextCandidate)
527
+ if (contextApplied && shouldPreferSnapshot(requestState, contextCandidate)) {
528
+ requestState.contextMessages = contextCandidate.contextMessages
529
+ requestState.spans = contextCandidate.spans
530
+ requestState.spanSource = contextCandidate.spanSource
531
+ requestState.contextMessageCount = contextCandidate.contextMessageCount
532
+ requestState.spanCount = contextCandidate.spanCount
533
+ requestState.textChars = contextCandidate.textChars
534
+ requestState.textBytes = contextCandidate.textBytes
535
+ requestState.toolNames = contextCandidate.toolNames
536
+ requestState.files = contextCandidate.files
537
+ }
538
+
539
+ const sessionHistoryCandidate = createComparableRequestState(requestState)
540
+ const sessionHistoryApplied = applySessionHistorySnapshot(sessionHistoryCandidate)
541
+ if (sessionHistoryApplied && shouldPreferSnapshot(requestState, sessionHistoryCandidate)) {
542
+ requestState.contextMessages = sessionHistoryCandidate.contextMessages
543
+ requestState.spans = sessionHistoryCandidate.spans
544
+ requestState.spanSource = sessionHistoryCandidate.spanSource
545
+ requestState.contextMessageCount = sessionHistoryCandidate.contextMessageCount
546
+ requestState.spanCount = sessionHistoryCandidate.spanCount
547
+ requestState.textChars = sessionHistoryCandidate.textChars
548
+ requestState.textBytes = sessionHistoryCandidate.textBytes
549
+ requestState.toolNames = sessionHistoryCandidate.toolNames
550
+ requestState.files = sessionHistoryCandidate.files
551
+ }
552
+
553
+ const toolHookCandidate = createComparableRequestState(requestState)
554
+ const toolHookApplied = applyToolHookSnapshot(toolHookCandidate)
555
+ if (toolHookApplied && shouldPreferSnapshot(requestState, toolHookCandidate)) {
556
+ requestState.contextMessages = toolHookCandidate.contextMessages
557
+ requestState.spans = toolHookCandidate.spans
558
+ requestState.spanSource = toolHookCandidate.spanSource
559
+ requestState.contextMessageCount = toolHookCandidate.contextMessageCount
560
+ requestState.spanCount = toolHookCandidate.spanCount
561
+ requestState.textChars = toolHookCandidate.textChars
562
+ requestState.textBytes = toolHookCandidate.textBytes
563
+ requestState.toolNames = toolHookCandidate.toolNames
564
+ requestState.files = toolHookCandidate.files
565
+ }
566
+
567
+ if (!providerApplied && requestState.spans.length === 0 && !contextApplied && !sessionHistoryApplied && !toolHookApplied) {
421
568
  applySessionHistorySnapshot(requestState)
422
569
  }
423
570
  state.pendingRequests.push(requestState)
571
+ currentTurn.recentToolEvents = []
424
572
 
425
573
  void trace('before_provider_request', {
426
574
  requestId: requestState.requestId,
@@ -448,15 +596,28 @@ export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
448
596
  })
449
597
 
450
598
  pi.on('tool_execution_start', (event) => {
599
+ const currentTurn = getCurrentTurn()
451
600
  const toolCallId = String(event?.toolCallId ?? '').trim()
452
601
  const toolName = String(event?.toolName ?? '').trim()
453
602
  const paths = deriveToolPaths(toolName, event?.args)
454
603
  if (toolCallId !== '') {
455
604
  state.toolCallIndex.set(toolCallId, { toolName, paths })
456
605
  }
606
+ currentTurn.recentToolEvents = [
607
+ ...(currentTurn.recentToolEvents ?? []).filter((item) => String(item?.toolCallId ?? '') !== toolCallId),
608
+ {
609
+ toolCallId,
610
+ toolName,
611
+ args: event?.args,
612
+ details: undefined,
613
+ content: [],
614
+ timestamp: new Date().toISOString(),
615
+ },
616
+ ]
457
617
  })
458
618
 
459
619
  pi.on('tool_result', (event) => {
620
+ const currentTurn = getCurrentTurn()
460
621
  const toolCallId = String(event?.toolCallId ?? '').trim()
461
622
  if (toolCallId === '') {
462
623
  return
@@ -475,6 +636,17 @@ export function createRequestTelemetryExtension({ cwd = process.cwd() } = {}) {
475
636
  toolName: current.toolName,
476
637
  paths: [...new Set([...current.paths, ...resultPaths])],
477
638
  })
639
+ currentTurn.recentToolEvents = [
640
+ ...(currentTurn.recentToolEvents ?? []).filter((item) => String(item?.toolCallId ?? '') !== toolCallId),
641
+ {
642
+ toolCallId,
643
+ toolName: current.toolName,
644
+ args: event?.input,
645
+ details: event?.details,
646
+ content: Array.isArray(event?.content) ? event.content : [],
647
+ timestamp: new Date().toISOString(),
648
+ },
649
+ ]
478
650
  })
479
651
 
480
652
  pi.on('message_end', async (event) => {
package/src/index.mjs CHANGED
@@ -31,12 +31,14 @@ export {
31
31
  appendRequestTelemetryArtifacts,
32
32
  collectMessageSpans,
33
33
  collectProviderPayloadSpans,
34
+ compactRequestSpanRecord,
34
35
  createEmptyRequestTelemetryBreakdown,
35
36
  createEmptyRequestUsage,
36
37
  deriveRequestTelemetryAnalytics,
37
38
  deriveRequestTelemetryBreakdown,
38
39
  deriveToolPaths,
39
40
  readRequestTelemetryContextFromEnv,
41
+ readRequestTelemetryStorageOptionsFromEnv,
40
42
  readRequestTelemetryRecords,
41
43
  extractMessagesFromProviderPayload,
42
44
  extractUsageFromMessage,
package/src/pi-client.mjs CHANGED
@@ -234,6 +234,8 @@ async function runSdkTransportTurn({ config, model, sessionId, sessionFile, prom
234
234
  thinking: config.piThinking,
235
235
  noExtensions: config.piNoExtensions,
236
236
  requestTelemetryEnabled: config.piRequestTelemetryEnabled,
237
+ requestTelemetryStoreHooks: config.piRequestTelemetryStoreHooks,
238
+ requestTelemetryStoreSpanText: config.piRequestTelemetryStoreSpanText,
237
239
  noSkills: config.piNoSkills,
238
240
  noPromptTemplates: config.piNoPromptTemplates,
239
241
  noThemes: config.piNoThemes,
package/src/pi-config.mjs CHANGED
@@ -266,6 +266,8 @@ export function loadConfig(mode = 'once') {
266
266
  piThinking: readString('PI_THINKING', file.piThinking, ''),
267
267
  piNoExtensions: readBool('PI_NO_EXTENSIONS', file.piNoExtensions, false),
268
268
  piRequestTelemetryEnabled: readBool('PI_REQUEST_TELEMETRY_ENABLED', file.piRequestTelemetryEnabled, true),
269
+ piRequestTelemetryStoreHooks: readBool('PI_REQUEST_TELEMETRY_STORE_HOOKS', file.piRequestTelemetryStoreHooks, false),
270
+ piRequestTelemetryStoreSpanText: readBool('PI_REQUEST_TELEMETRY_STORE_SPAN_TEXT', file.piRequestTelemetryStoreSpanText, false),
269
271
  piNoSkills: readBool('PI_NO_SKILLS', file.piNoSkills, false),
270
272
  piNoPromptTemplates: readBool('PI_NO_PROMPT_TEMPLATES', file.piNoPromptTemplates, false),
271
273
  piNoThemes: readBool('PI_NO_THEMES', file.piNoThemes, true),
@@ -24,6 +24,7 @@ export function collectHistoryTargets(config) {
24
24
  config.lastIterationSummaryFile,
25
25
  config.tokenUsageEventsFile,
26
26
  config.tokenUsageSummaryFile,
27
+ path.join(config.cwd, 'pi-output/request-telemetry'),
27
28
  config.piRuntimeDir,
28
29
  config.visualFeedbackFile,
29
30
  config.testerFeedbackFile,
@@ -12,6 +12,10 @@ export const REQUEST_TELEMETRY_ENV_KEYS = Object.freeze({
12
12
  kind: 'PI_REQUEST_KIND',
13
13
  task: 'PI_REQUEST_TASK',
14
14
  })
15
+ export const REQUEST_TELEMETRY_STORAGE_ENV_KEYS = Object.freeze({
16
+ storeHooks: 'PI_REQUEST_TELEMETRY_STORE_HOOKS',
17
+ storeSpanText: 'PI_REQUEST_TELEMETRY_STORE_SPAN_TEXT',
18
+ })
15
19
 
16
20
  const scriptDir = path.dirname(fileURLToPath(import.meta.url))
17
21
  const packageRoot = path.resolve(scriptDir, '..')
@@ -94,6 +98,24 @@ function asObject(value) {
94
98
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
95
99
  }
96
100
 
101
+ function parseBooleanFlag(value, fallback = false) {
102
+ if (value === undefined || value === null || value === '') {
103
+ return fallback
104
+ }
105
+ if (typeof value === 'boolean') {
106
+ return value
107
+ }
108
+
109
+ const normalized = String(value).trim().toLowerCase()
110
+ if (normalized === '1' || normalized === 'true' || normalized === 'yes') {
111
+ return true
112
+ }
113
+ if (normalized === '0' || normalized === 'false' || normalized === 'no') {
114
+ return false
115
+ }
116
+ return fallback
117
+ }
118
+
97
119
  function createBucketMap() {
98
120
  return new Map()
99
121
  }
@@ -279,6 +301,13 @@ export function readRequestTelemetryContextFromEnv(env = process.env) {
279
301
  }
280
302
  }
281
303
 
304
+ export function readRequestTelemetryStorageOptionsFromEnv(env = process.env) {
305
+ return {
306
+ storeHooks: parseBooleanFlag(env?.[REQUEST_TELEMETRY_STORAGE_ENV_KEYS.storeHooks], false),
307
+ storeSpanText: parseBooleanFlag(env?.[REQUEST_TELEMETRY_STORAGE_ENV_KEYS.storeSpanText], false),
308
+ }
309
+ }
310
+
282
311
  export function normalizeRequestUsage(value) {
283
312
  if (!value || typeof value !== 'object') {
284
313
  return createEmptyRequestUsage()
@@ -427,6 +456,84 @@ function parseStructuredTextArray(items) {
427
456
  return parts
428
457
  }
429
458
 
459
+ function normalizeProviderToolCalls(toolCalls) {
460
+ if (!Array.isArray(toolCalls)) {
461
+ return []
462
+ }
463
+
464
+ const parts = []
465
+ for (const item of toolCalls) {
466
+ const object = asObject(item)
467
+ const functionObject = asObject(object.function)
468
+ const id = normalizeString(object.id ?? object.call_id, '')
469
+ const name = normalizeString(
470
+ functionObject.name
471
+ ?? object.name,
472
+ ''
473
+ )
474
+ const argumentsValue = parseJsonLikeString(
475
+ functionObject.arguments
476
+ ?? object.arguments
477
+ )
478
+
479
+ if (name === '' && id === '' && argumentsValue === undefined) {
480
+ continue
481
+ }
482
+
483
+ parts.push({
484
+ type: 'toolCall',
485
+ id,
486
+ name,
487
+ arguments: argumentsValue,
488
+ })
489
+ }
490
+
491
+ return parts
492
+ }
493
+
494
+ function normalizeMessageContent(content) {
495
+ if (typeof content === 'string') {
496
+ return [{ type: 'text', text: content }]
497
+ }
498
+
499
+ if (Array.isArray(content)) {
500
+ return parseStructuredTextArray(content)
501
+ }
502
+
503
+ return []
504
+ }
505
+
506
+ function normalizeProviderRoleMessage(object) {
507
+ const role = normalizeString(object.role, '')
508
+ if (role === '') {
509
+ return null
510
+ }
511
+
512
+ if (role === 'tool') {
513
+ return {
514
+ role: 'toolResult',
515
+ toolCallId: normalizeString(object.tool_call_id ?? object.call_id ?? object.id, ''),
516
+ toolName: normalizeString(object.name, ''),
517
+ details: object,
518
+ content: [{
519
+ type: 'text',
520
+ text: typeof object.content === 'string' ? object.content : safeJson(object.content),
521
+ }],
522
+ }
523
+ }
524
+
525
+ const content = normalizeMessageContent(object.content)
526
+ const toolCalls = normalizeProviderToolCalls(object.tool_calls)
527
+
528
+ return {
529
+ role,
530
+ content: [...content, ...toolCalls],
531
+ toolCallId: normalizeString(object.toolCallId ?? object.tool_call_id ?? object.call_id, ''),
532
+ toolName: normalizeString(object.toolName ?? object.name, ''),
533
+ details: object.details,
534
+ }
535
+ }
536
+
430
537
  function convertProviderInputItemToMessage(item) {
431
538
  const object = asObject(item)
432
539
 
@@ -470,20 +577,7 @@ function convertProviderInputItemToMessage(item) {
470
577
  }
471
578
 
472
579
  if (object.role) {
473
- if (typeof object.content === 'string') {
474
- return {
475
- role: normalizeString(object.role, ''),
476
- content: [{ type: 'text', text: object.content }],
477
- }
478
- }
479
-
480
- return {
481
- role: normalizeString(object.role, ''),
482
- content: parseStructuredTextArray(object.content),
483
- toolCallId: normalizeString(object.toolCallId, ''),
484
- toolName: normalizeString(object.toolName, ''),
485
- details: object.details,
486
- }
580
+ return normalizeProviderRoleMessage(object)
487
581
  }
488
582
 
489
583
  const normalized = normalizeTextPart(object)
@@ -505,6 +599,22 @@ export function extractMessagesFromProviderPayload(payload) {
505
599
 
506
600
  if (Array.isArray(object.messages)) {
507
601
  return object.messages
602
+ .map((item) => {
603
+ if (typeof item === 'string') {
604
+ return {
605
+ role: 'user',
606
+ content: [{ type: 'text', text: item }],
607
+ }
608
+ }
609
+ if (!item || typeof item !== 'object') {
610
+ return null
611
+ }
612
+ if (item.role) {
613
+ return normalizeProviderRoleMessage(asObject(item))
614
+ }
615
+ return convertProviderInputItemToMessage(item)
616
+ })
617
+ .filter((message) => normalizeString(message?.role, '') !== '')
508
618
  }
509
619
 
510
620
  if (typeof object.input === 'string') {
@@ -562,6 +672,17 @@ export function normalizeRequestSpanRecord(record) {
562
672
  }
563
673
  }
564
674
 
675
+ export function compactRequestSpanRecord(record, { includeText = false, includePreview = false } = {}) {
676
+ const normalized = normalizeRequestSpanRecord(record)
677
+ if (!includeText) {
678
+ delete normalized.text
679
+ }
680
+ if (!includeText || !includePreview) {
681
+ delete normalized.preview
682
+ }
683
+ return normalized
684
+ }
685
+
565
686
  export function createEmptyRequestTelemetryBreakdown() {
566
687
  return createEmptyBreakdownShape('request_telemetry')
567
688
  }
@@ -724,6 +845,84 @@ export function collectProviderPayloadSpans({
724
845
  }
725
846
  }
726
847
 
848
+ function joinToolContentText(content = []) {
849
+ if (!Array.isArray(content)) {
850
+ return ''
851
+ }
852
+
853
+ return content
854
+ .map((item) => {
855
+ const object = asObject(item)
856
+ if (normalizeString(object.type, '') === 'text') {
857
+ return String(object.text ?? '')
858
+ }
859
+ return ''
860
+ })
861
+ .filter(Boolean)
862
+ .join('\n')
863
+ }
864
+
865
+ export function collectToolHookSpans({
866
+ requestId = '',
867
+ sessionId = '',
868
+ turnIndex = 0,
869
+ toolEvents = [],
870
+ timestamp = now(),
871
+ } = {}) {
872
+ const spans = []
873
+
874
+ for (let index = 0; index < (Array.isArray(toolEvents) ? toolEvents.length : 0); index += 1) {
875
+ const event = asObject(toolEvents[index])
876
+ const toolCallId = normalizeString(event.toolCallId, '')
877
+ const toolName = normalizeString(event.toolName, '')
878
+ const args = parseJsonLikeString(event.args ?? event.input)
879
+ const details = parseJsonLikeString(event.details)
880
+ const contentText = joinToolContentText(event.content)
881
+ const detailText = safeJson(details)
882
+ const resultText = [contentText, detailText].filter(Boolean).join('\n')
883
+ const paths = normalizeStringList([
884
+ ...deriveToolPaths(toolName, args),
885
+ ...deriveToolPaths(toolName, details),
886
+ ])
887
+ const base = createSpanBase({
888
+ requestId,
889
+ sessionId,
890
+ turnIndex,
891
+ timestamp: event.timestamp ?? timestamp,
892
+ role: 'toolResult',
893
+ messageIndex: index,
894
+ spanIndex: 0,
895
+ source: 'tool_hooks',
896
+ })
897
+
898
+ if (args !== undefined) {
899
+ spans.push(createTextSpan(base, {
900
+ spanIndex: 0,
901
+ spanKind: 'tool_call',
902
+ toolCallId,
903
+ toolName,
904
+ paths,
905
+ primaryPath: paths[0] ?? '',
906
+ text: safeJson(args),
907
+ }))
908
+ }
909
+
910
+ if (resultText !== '' || details !== undefined || Array.isArray(event.content)) {
911
+ spans.push(createTextSpan(base, {
912
+ spanIndex: args !== undefined ? 1 : 0,
913
+ spanKind: 'tool_result',
914
+ toolCallId,
915
+ toolName,
916
+ paths,
917
+ primaryPath: paths[0] ?? '',
918
+ text: resultText,
919
+ }))
920
+ }
921
+ }
922
+
923
+ return spans
924
+ }
925
+
727
926
  export function summarizeRequestSpans(spans = []) {
728
927
  const toolNames = new Set()
729
928
  const files = new Set()
@@ -915,12 +1114,14 @@ async function readJsonlRecords(filePath, normalize) {
915
1114
  }
916
1115
  }
917
1116
 
918
- export async function ensureRequestTelemetryFiles(paths) {
919
- const hooksFile = String(paths?.hooksFile ?? '').trim()
920
- const requestsFile = String(paths?.requestsFile ?? '').trim()
921
- const spansFile = String(paths?.spansFile ?? '').trim()
1117
+ export async function ensureRequestTelemetryFiles(paths, options = {}) {
1118
+ const targets = [
1119
+ options?.includeHooks === true ? String(paths?.hooksFile ?? '').trim() : '',
1120
+ options?.includeRequests !== false ? String(paths?.requestsFile ?? '').trim() : '',
1121
+ options?.includeSpans !== false ? String(paths?.spansFile ?? '').trim() : '',
1122
+ ]
922
1123
 
923
- for (const filePath of [hooksFile, requestsFile, spansFile]) {
1124
+ for (const filePath of targets) {
924
1125
  if (filePath === '') {
925
1126
  continue
926
1127
  }
@@ -952,16 +1153,28 @@ export async function appendRequestTelemetryHook(paths, event) {
952
1153
  detail: asObject(event?.detail),
953
1154
  }
954
1155
 
955
- await ensureRequestTelemetryFiles(paths)
1156
+ await ensureRequestTelemetryFiles(paths, {
1157
+ includeHooks: true,
1158
+ includeRequests: false,
1159
+ includeSpans: false,
1160
+ })
956
1161
  await fs.appendFile(hooksFile, `${JSON.stringify(normalizedEvent)}\n`, 'utf8')
957
1162
  return normalizedEvent
958
1163
  }
959
1164
 
960
- export async function appendRequestTelemetryArtifacts(paths, { request, spans = [] } = {}) {
1165
+ export async function appendRequestTelemetryArtifacts(paths, { request, spans = [] } = {}, options = {}) {
961
1166
  const normalizedRequest = normalizeRequestTelemetryRecord(request)
962
- const normalizedSpans = (Array.isArray(spans) ? spans : []).map((span) => normalizeRequestSpanRecord(span))
1167
+ const normalizedSpans = (Array.isArray(spans) ? spans : [])
1168
+ .map((span) => compactRequestSpanRecord(span, {
1169
+ includeText: options?.includeSpanText === true,
1170
+ includePreview: options?.includeSpanPreview === true,
1171
+ }))
963
1172
 
964
- await ensureRequestTelemetryFiles(paths)
1173
+ await ensureRequestTelemetryFiles(paths, {
1174
+ includeHooks: false,
1175
+ includeRequests: String(paths?.requestsFile ?? '').trim() !== '',
1176
+ includeSpans: String(paths?.spansFile ?? '').trim() !== '' && normalizedSpans.length > 0,
1177
+ })
965
1178
 
966
1179
  if (String(paths?.requestsFile ?? '').trim() !== '') {
967
1180
  await fs.appendFile(paths.requestsFile, `${JSON.stringify(normalizedRequest)}\n`, 'utf8')
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import process from 'node:process'
3
+ import { setMaxListeners } from 'node:events'
3
4
  import { pathToFileURL } from 'node:url'
4
5
  import {
5
6
  formatHeartbeatReason,
@@ -15,6 +16,7 @@ import {
15
16
  } from './pi-token-analysis.mjs'
16
17
  import {
17
18
  REQUEST_TELEMETRY_ENV_KEYS,
19
+ REQUEST_TELEMETRY_STORAGE_ENV_KEYS,
18
20
  ensureBundledRequestTelemetryExtension,
19
21
  readRequestTelemetryContextFromEnv,
20
22
  } from './pi-request-telemetry.mjs'
@@ -123,6 +125,10 @@ function addTokenUsage(total, value) {
123
125
 
124
126
  function applyRequestTelemetryEnv(request) {
125
127
  const previous = readRequestTelemetryContextFromEnv()
128
+ const previousStorage = {
129
+ storeHooks: String(process.env[REQUEST_TELEMETRY_STORAGE_ENV_KEYS.storeHooks] ?? '').trim(),
130
+ storeSpanText: String(process.env[REQUEST_TELEMETRY_STORAGE_ENV_KEYS.storeSpanText] ?? '').trim(),
131
+ }
126
132
  const nextValues = {
127
133
  [REQUEST_TELEMETRY_ENV_KEYS.runId]: String(process.env.PI_RUN_ID ?? '').trim(),
128
134
  [REQUEST_TELEMETRY_ENV_KEYS.iteration]: Number.isFinite(Number(request?.metadata?.iteration))
@@ -132,6 +138,8 @@ function applyRequestTelemetryEnv(request) {
132
138
  [REQUEST_TELEMETRY_ENV_KEYS.role]: String(request?.role ?? '').trim(),
133
139
  [REQUEST_TELEMETRY_ENV_KEYS.kind]: String(request?.kind ?? '').trim(),
134
140
  [REQUEST_TELEMETRY_ENV_KEYS.task]: String(request?.task ?? '').trim(),
141
+ [REQUEST_TELEMETRY_STORAGE_ENV_KEYS.storeHooks]: request?.requestTelemetryStoreHooks === true ? '1' : '0',
142
+ [REQUEST_TELEMETRY_STORAGE_ENV_KEYS.storeSpanText]: request?.requestTelemetryStoreSpanText === true ? '1' : '0',
135
143
  }
136
144
 
137
145
  for (const [key, value] of Object.entries(nextValues)) {
@@ -151,9 +159,28 @@ function applyRequestTelemetryEnv(request) {
151
159
  }
152
160
  process.env[key] = previousValue
153
161
  }
162
+
163
+ for (const [field, key] of Object.entries(REQUEST_TELEMETRY_STORAGE_ENV_KEYS)) {
164
+ const previousValue = String(previousStorage?.[field] ?? '').trim()
165
+ if (previousValue === '') {
166
+ delete process.env[key]
167
+ continue
168
+ }
169
+ process.env[key] = previousValue
170
+ }
154
171
  }
155
172
  }
156
173
 
174
+ function relaxAbortSignalListenerLimit(signal) {
175
+ if (!signal || typeof signal !== 'object') {
176
+ return
177
+ }
178
+
179
+ try {
180
+ setMaxListeners(0, signal)
181
+ } catch {}
182
+ }
183
+
157
184
  function deriveTokenAttributionKind({ activeToolName, pendingToolNames, pendingFiles, lastAssistantActivity }) {
158
185
  if (String(activeToolName ?? '').trim() !== '') {
159
186
  return 'tool_running'
@@ -484,6 +511,7 @@ export async function runSdkTurnWithPi(pi, request) {
484
511
  let tokenUsageEvents = 0
485
512
  let tokenUsage = createEmptyTokenUsage()
486
513
  let lastAssistantActivity = ''
514
+ let abortSignalLimitRelaxed = false
487
515
  const pendingToolNames = new Set()
488
516
  const pendingFiles = new Set()
489
517
  const events = []
@@ -569,6 +597,10 @@ export async function runSdkTurnWithPi(pi, request) {
569
597
  lastEventAt = Date.now()
570
598
 
571
599
  if (event.type === 'agent_start') {
600
+ if (!abortSignalLimitRelaxed) {
601
+ relaxAbortSignalListenerLimit(session?.agent?.signal)
602
+ abortSignalLimitRelaxed = true
603
+ }
572
604
  agentStarted = true
573
605
  emitLiveFeed(request, {
574
606
  type: 'agent_start',
@@ -9,6 +9,8 @@
9
9
  "largeSpecWarningLines": 300,
10
10
  "piModel": "local/text-model",
11
11
  "piRequestTelemetryEnabled": true,
12
+ "piRequestTelemetryStoreHooks": false,
13
+ "piRequestTelemetryStoreSpanText": false,
12
14
  "models": {
13
15
  "local/text-model": {
14
16
  "baseUrl": "http://localhost:8000/v1",