@tea-agent/loop-agent 0.26.2 → 0.26.4

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.
@@ -1,7 +1,7 @@
1
- import { appendFile, mkdir } from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from './pi-executor.js';
4
- import { serializeSessionEvent } from './pi-event-serializer.js';
1
+ import { appendFile, mkdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { BoundedTextPreview, classifyPiFailure, createPiJsonlStreamCollector, DEFAULT_ABORT_GRACE_MS, DEFAULT_STALL_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, extractAssistantTextFromPiJson, } from "./pi-executor.js";
4
+ import { serializeSessionEvent } from "./pi-event-serializer.js";
5
5
  let sdkSessionFactoryOverride;
6
6
  let sdkImportOverrideForTests;
7
7
  let sdkModuleOverrideForTests;
@@ -54,7 +54,7 @@ export function setPiSdkModuleOverrideForTests(fn) {
54
54
  /** Check whether the Pi SDK optional dependency satisfies the 0.80.10 runtime contract. */
55
55
  export async function checkPiSdkAvailability(_repoRoot) {
56
56
  if (sdkSessionFactoryOverride) {
57
- return { ok: true, detail: 'pi SDK session factory override active' };
57
+ return { ok: true, detail: "pi SDK session factory override active" };
58
58
  }
59
59
  try {
60
60
  const imported = sdkImportOverrideForTests
@@ -62,15 +62,15 @@ export async function checkPiSdkAvailability(_repoRoot) {
62
62
  : await loadPiSdkModule();
63
63
  const sdk = imported;
64
64
  const ModelRuntime = sdk.ModelRuntime;
65
- if (typeof sdk.createAgentSession !== 'function'
66
- || typeof sdk.getAgentDir !== 'function'
67
- || typeof ModelRuntime?.create !== 'function') {
65
+ if (typeof sdk.createAgentSession !== "function" ||
66
+ typeof sdk.getAgentDir !== "function" ||
67
+ typeof ModelRuntime?.create !== "function") {
68
68
  return {
69
69
  ok: false,
70
- detail: 'pi SDK incompatible: requires createAgentSession, getAgentDir, and ModelRuntime.create (0.80.10 contract)',
70
+ detail: "pi SDK incompatible: requires createAgentSession, getAgentDir, and ModelRuntime.create (0.80.10 contract)",
71
71
  };
72
72
  }
73
- return { ok: true, detail: 'pi SDK 0.80.10 contract available' };
73
+ return { ok: true, detail: "pi SDK 0.80.10 contract available" };
74
74
  }
75
75
  catch (error) {
76
76
  const message = error instanceof Error ? error.message : String(error);
@@ -82,7 +82,7 @@ async function resolveModel(modelRuntime, provider, modelId) {
82
82
  if (fromRuntime)
83
83
  return fromRuntime;
84
84
  try {
85
- const piAi = await import('@earendil-works/pi-ai/compat');
85
+ const piAi = (await import("@earendil-works/pi-ai/compat"));
86
86
  return piAi.getModel?.(provider, modelId);
87
87
  }
88
88
  catch {
@@ -93,7 +93,7 @@ async function loadPiSdkModule() {
93
93
  if (sdkModuleOverrideForTests) {
94
94
  return sdkModuleOverrideForTests();
95
95
  }
96
- return await import('@earendil-works/pi-coding-agent');
96
+ return (await import("@earendil-works/pi-coding-agent"));
97
97
  }
98
98
  async function getOrCreateSharedResources(state) {
99
99
  if (state.resources)
@@ -101,13 +101,13 @@ async function getOrCreateSharedResources(state) {
101
101
  const sdk = await loadPiSdkModule();
102
102
  const getAgentDir = sdk.getAgentDir;
103
103
  const ModelRuntime = sdk.ModelRuntime;
104
- if (typeof ModelRuntime?.create !== 'function') {
105
- throw new Error('incompatible pi SDK: ModelRuntime.create is unavailable');
104
+ if (typeof ModelRuntime?.create !== "function") {
105
+ throw new Error("incompatible pi SDK: ModelRuntime.create is unavailable");
106
106
  }
107
107
  const agentDir = getAgentDir();
108
108
  const modelRuntime = await ModelRuntime.create({
109
- authPath: path.join(agentDir, 'auth.json'),
110
- modelsPath: path.join(agentDir, 'models.json'),
109
+ authPath: path.join(agentDir, "auth.json"),
110
+ modelsPath: path.join(agentDir, "models.json"),
111
111
  });
112
112
  state.resources = { agentDir, modelRuntime };
113
113
  return state.resources;
@@ -119,13 +119,14 @@ async function createSdkSession(sdk, input, shared) {
119
119
  const getAgentDir = sdk.getAgentDir;
120
120
  const ModelRuntime = sdk.ModelRuntime;
121
121
  const agentDir = shared?.agentDir ?? getAgentDir();
122
- if (!shared && typeof ModelRuntime?.create !== 'function') {
123
- throw new Error('incompatible pi SDK: ModelRuntime.create is unavailable');
122
+ if (!shared && typeof ModelRuntime?.create !== "function") {
123
+ throw new Error("incompatible pi SDK: ModelRuntime.create is unavailable");
124
124
  }
125
- const modelRuntime = shared?.modelRuntime ?? await ModelRuntime.create({
126
- authPath: path.join(agentDir, 'auth.json'),
127
- modelsPath: path.join(agentDir, 'models.json'),
128
- });
125
+ const modelRuntime = shared?.modelRuntime ??
126
+ (await ModelRuntime.create({
127
+ authPath: path.join(agentDir, "auth.json"),
128
+ modelsPath: path.join(agentDir, "models.json"),
129
+ }));
129
130
  const model = input.provider && input.model
130
131
  ? await resolveModel(modelRuntime, input.provider, input.model)
131
132
  : undefined;
@@ -134,9 +135,18 @@ async function createSdkSession(sdk, input, shared) {
134
135
  agentDir,
135
136
  noContextFiles: true,
136
137
  noSkills: true,
137
- appendSystemPromptOverride: (base) => [...base, input.appendSystemPrompt],
138
+ noExtensions: true,
139
+ appendSystemPromptOverride: (base) => [
140
+ ...base,
141
+ input.appendSystemPrompt,
142
+ ],
138
143
  });
139
144
  await loader.reload();
145
+ if (input.requireWriterCustomTools) {
146
+ if (!Array.isArray(input.customTools) || input.customTools.length === 0) {
147
+ throw new Error("pi writer tool policy missing customTools; refusing to create uncontrolled writer session");
148
+ }
149
+ }
140
150
  const created = await createAgentSession({
141
151
  cwd: input.cwd,
142
152
  sessionManager: SessionManager.inMemory(input.cwd),
@@ -145,6 +155,9 @@ async function createSdkSession(sdk, input, shared) {
145
155
  modelRuntime,
146
156
  ...(model ? { model } : {}),
147
157
  ...(input.thinking ? { thinkingLevel: input.thinking } : {}),
158
+ ...(Array.isArray(input.customTools) && input.customTools.length > 0
159
+ ? { customTools: input.customTools }
160
+ : {}),
148
161
  });
149
162
  return created.session;
150
163
  }
@@ -154,35 +167,41 @@ async function createSdkSession(sdk, input, shared) {
154
167
  * Skips thinking_delta and message_update token floods.
155
168
  */
156
169
  export function shouldPersistSessionEvent(event) {
157
- const type = typeof event.type === 'string' ? event.type : '';
158
- if (type === 'tool_start' || type === 'tool_end'
159
- || type === 'tool_execution_start' || type === 'tool_execution_end'
160
- || type === 'turn_end' || type === 'agent_end'
161
- || type === 'assistant_message' || type === 'message_end') {
170
+ const type = typeof event.type === "string" ? event.type : "";
171
+ if (type === "tool_start" ||
172
+ type === "tool_end" ||
173
+ type === "tool_execution_start" ||
174
+ type === "tool_execution_end" ||
175
+ type === "turn_end" ||
176
+ type === "agent_end" ||
177
+ type === "assistant_message" ||
178
+ type === "message_end") {
162
179
  return true;
163
180
  }
164
- if (type === 'thinking_delta' || type === 'message_update') {
181
+ if (type === "thinking_delta" || type === "message_update") {
165
182
  return false;
166
183
  }
167
- if (type.includes('error') || event.isError === true) {
184
+ if (type.includes("error") || event.isError === true) {
168
185
  return true;
169
186
  }
170
187
  return true;
171
188
  }
172
189
  function classifySdkActivityKind(event) {
173
- if (!event || typeof event !== 'object')
174
- return 'provider';
175
- const type = typeof event.type === 'string'
190
+ if (!event || typeof event !== "object")
191
+ return "provider";
192
+ const type = typeof event.type === "string"
176
193
  ? String(event.type)
177
- : '';
178
- if (type === 'tool_start' || type === 'tool_end'
179
- || type === 'tool_execution_start' || type === 'tool_execution_end') {
180
- return 'tool';
194
+ : "";
195
+ if (type === "tool_start" ||
196
+ type === "tool_end" ||
197
+ type === "tool_execution_start" ||
198
+ type === "tool_execution_end") {
199
+ return "tool";
181
200
  }
182
- if (type === 'thinking_delta' || type === 'message_update') {
201
+ if (type === "thinking_delta" || type === "message_update") {
183
202
  return null;
184
203
  }
185
- return 'provider';
204
+ return "provider";
186
205
  }
187
206
  function createSessionEventAppender(filePath, onSessionEvent) {
188
207
  let chain = Promise.resolve();
@@ -195,7 +214,7 @@ function createSessionEventAppender(filePath, onSessionEvent) {
195
214
  await mkdir(path.dirname(filePath), { recursive: true });
196
215
  dirEnsured = true;
197
216
  }
198
- await appendFile(filePath, `${line}\n`, 'utf-8');
217
+ await appendFile(filePath, `${line}\n`, "utf-8");
199
218
  // Only after successful persistence: surface the persisted session event.
200
219
  // Transport activity is reported synchronously by the subscription so
201
220
  // filtered deltas and slow disk writes cannot trip the stall watchdog.
@@ -216,17 +235,19 @@ async function resolveSdkSessionFactory(reuseScope) {
216
235
  return sdkSessionFactoryOverride;
217
236
  const sdk = await loadPiSdkModule();
218
237
  return async (input) => {
219
- const shared = reuseScope ? await reuseScope.getOrCreateResources() : undefined;
238
+ const shared = reuseScope
239
+ ? await reuseScope.getOrCreateResources()
240
+ : undefined;
220
241
  return createSdkSession(sdk, input, shared);
221
242
  };
222
243
  }
223
244
  function isRecord(value) {
224
- return typeof value === 'object' && value !== null;
245
+ return typeof value === "object" && value !== null;
225
246
  }
226
247
  function readUsageNumber(record, keys) {
227
248
  for (const key of keys) {
228
249
  const value = record[key];
229
- if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
250
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) {
230
251
  return Math.trunc(value);
231
252
  }
232
253
  }
@@ -239,9 +260,19 @@ function extractSdkUsageSample(event) {
239
260
  for (const candidate of usageCandidates) {
240
261
  if (!isRecord(candidate))
241
262
  continue;
242
- const input = readUsageNumber(candidate, ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens']);
243
- const output = readUsageNumber(candidate, ['output_tokens', 'outputTokens', 'completion_tokens', 'completionTokens']);
244
- const total = readUsageNumber(candidate, ['total_tokens', 'totalTokens']);
263
+ const input = readUsageNumber(candidate, [
264
+ "input_tokens",
265
+ "inputTokens",
266
+ "prompt_tokens",
267
+ "promptTokens",
268
+ ]);
269
+ const output = readUsageNumber(candidate, [
270
+ "output_tokens",
271
+ "outputTokens",
272
+ "completion_tokens",
273
+ "completionTokens",
274
+ ]);
275
+ const total = readUsageNumber(candidate, ["total_tokens", "totalTokens"]);
245
276
  if (input !== undefined && output !== undefined) {
246
277
  tokens = input + output;
247
278
  break;
@@ -259,7 +290,7 @@ function extractSdkUsageSample(event) {
259
290
  message?.responseId,
260
291
  message?.id,
261
292
  ];
262
- const responseKey = responseKeyCandidates.find((value) => typeof value === 'string' && value.length > 0);
293
+ const responseKey = responseKeyCandidates.find((value) => typeof value === "string" && value.length > 0);
263
294
  return responseKey ? { responseKey, tokens } : { tokens };
264
295
  }
265
296
  function aggregateSdkTokenUsage(samples) {
@@ -275,7 +306,8 @@ function aggregateSdkTokenUsage(samples) {
275
306
  anonymousMaximum = Math.max(anonymousMaximum, sample.tokens);
276
307
  }
277
308
  }
278
- return anonymousMaximum + Array.from(identified.values()).reduce((sum, tokens) => sum + tokens, 0);
309
+ return (anonymousMaximum +
310
+ Array.from(identified.values()).reduce((sum, tokens) => sum + tokens, 0));
279
311
  }
280
312
  /**
281
313
  * Execute a single Pi step via the SDK.
@@ -287,24 +319,28 @@ export async function executeSingleSdkAttempt(options) {
287
319
  const modelConfig = options.modelConfig;
288
320
  const modelDisplay = modelConfig.provider && modelConfig.model
289
321
  ? `${modelConfig.provider}/${modelConfig.model}`
290
- : modelConfig.model ?? 'default';
322
+ : (modelConfig.model ?? "default");
291
323
  const timeoutMs = options.timeoutMs ?? modelConfig.timeoutMs ?? DEFAULT_TIMEOUT_MS;
292
324
  const stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
293
325
  const abortGraceMs = options.abortGraceMs ?? DEFAULT_ABORT_GRACE_MS;
294
326
  const piSdkArgs = [
295
- '--provider', modelConfig.provider ?? '(default)',
296
- '--model', modelConfig.model ?? '(default)',
297
- ...(modelConfig.thinking ? ['--thinking', modelConfig.thinking] : []),
298
- '--tools', options.toolNames.join(','),
299
- '--append-system-prompt', '<in-memory-system-prompt>',
327
+ "--provider",
328
+ modelConfig.provider ?? "(default)",
329
+ "--model",
330
+ modelConfig.model ?? "(default)",
331
+ ...(modelConfig.thinking ? ["--thinking", modelConfig.thinking] : []),
332
+ "--tools",
333
+ options.toolNames.join(","),
334
+ "--append-system-prompt",
335
+ "<in-memory-system-prompt>",
300
336
  ...options.attachedFiles.map((file) => `@${file}`),
301
337
  options.userMessage,
302
338
  ];
303
339
  const startedAt = Date.now();
304
340
  let timedOut = false;
305
341
  let terminationConfirmed = true;
306
- let stderr = '';
307
- const stdoutPreview = new BoundedTextPreview('stdout');
342
+ let stderr = "";
343
+ const stdoutPreview = new BoundedTextPreview("stdout");
308
344
  const stdoutCollector = createPiJsonlStreamCollector();
309
345
  const usageSamples = [];
310
346
  let session;
@@ -333,17 +369,25 @@ export async function executeSingleSdkAttempt(options) {
333
369
  clearTimeout(graceHandle);
334
370
  if (outcome.ok)
335
371
  return true;
336
- if ('timedOut' in outcome) {
372
+ if ("timedOut" in outcome) {
337
373
  appendStderr(`pi SDK ${label} was not confirmed within ${abortGraceMs}ms`);
338
374
  }
339
375
  else {
340
- const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
376
+ const message = outcome.error instanceof Error
377
+ ? outcome.error.message
378
+ : String(outcome.error);
341
379
  appendStderr(`pi SDK ${label} failed: ${message}`);
342
380
  }
343
381
  return false;
344
382
  };
345
383
  try {
346
384
  const createSession = await resolveSdkSessionFactory(options.reuseScope);
385
+ const writerCustomTools = options.writerToolPolicy?.customTools;
386
+ const requireWriterCustomTools = options.writerToolPolicy?.requireSdk === true;
387
+ if (requireWriterCustomTools &&
388
+ (!Array.isArray(writerCustomTools) || writerCustomTools.length === 0)) {
389
+ throw new Error("pi writer tool policy missing customTools; refusing to create uncontrolled writer session");
390
+ }
347
391
  session = await createSession({
348
392
  cwd: options.repoRoot,
349
393
  toolNames: options.toolNames,
@@ -351,6 +395,10 @@ export async function executeSingleSdkAttempt(options) {
351
395
  provider: modelConfig.provider,
352
396
  model: modelConfig.model,
353
397
  thinking: modelConfig.thinking,
398
+ ...(Array.isArray(writerCustomTools) && writerCustomTools.length > 0
399
+ ? { customTools: writerCustomTools }
400
+ : {}),
401
+ ...(requireWriterCustomTools ? { requireWriterCustomTools: true } : {}),
354
402
  });
355
403
  let resolveStall;
356
404
  const stallPromise = stallTimeoutMs > 0
@@ -363,7 +411,7 @@ export async function executeSingleSdkAttempt(options) {
363
411
  return;
364
412
  if (stallHandle)
365
413
  clearTimeout(stallHandle);
366
- stallHandle = setTimeout(() => resolveStall?.('stall'), stallTimeoutMs);
414
+ stallHandle = setTimeout(() => resolveStall?.("stall"), stallTimeoutMs);
367
415
  };
368
416
  unsubscribe = session.subscribe((event) => {
369
417
  // Every real SDK event proves transport activity, including noisy deltas
@@ -372,7 +420,10 @@ export async function executeSingleSdkAttempt(options) {
372
420
  const activityKind = classifySdkActivityKind(event);
373
421
  if (activityKind) {
374
422
  try {
375
- options.onActivity?.({ kind: activityKind, at: new Date().toISOString() });
423
+ options.onActivity?.({
424
+ kind: activityKind,
425
+ at: new Date().toISOString(),
426
+ });
376
427
  }
377
428
  catch {
378
429
  // best-effort: activity must never change Pi result
@@ -388,7 +439,9 @@ export async function executeSingleSdkAttempt(options) {
388
439
  stdoutCollector.append(`${line}\n`);
389
440
  sessionEventAppender?.append(line, event);
390
441
  });
391
- const filePrefix = options.attachedFiles.map((file) => `@${file}`).join(' ');
442
+ const filePrefix = options.attachedFiles
443
+ .map((file) => `@${file}`)
444
+ .join(" ");
392
445
  const promptMessage = filePrefix
393
446
  ? `${filePrefix}\n${options.userMessage}`
394
447
  : options.userMessage;
@@ -397,23 +450,23 @@ export async function executeSingleSdkAttempt(options) {
397
450
  const timeoutPromise = timeoutMs > 0
398
451
  ? new Promise((resolve) => {
399
452
  timeoutHandle = setTimeout(() => {
400
- resolve('absolute-timeout');
453
+ resolve("absolute-timeout");
401
454
  }, timeoutMs);
402
455
  })
403
456
  : null;
404
457
  const raced = await Promise.race([
405
- promptPromise.then(() => 'done'),
458
+ promptPromise.then(() => "done"),
406
459
  ...(timeoutPromise ? [timeoutPromise] : []),
407
460
  ...(stallPromise ? [stallPromise] : []),
408
461
  ]);
409
- if (raced !== 'done') {
462
+ if (raced !== "done") {
410
463
  timedOut = true;
411
- appendStderr(raced === 'stall'
464
+ appendStderr(raced === "stall"
412
465
  ? `pi SDK step stalled after ${stallTimeoutMs}ms with no provider activity`
413
466
  : `pi SDK step timed out after ${timeoutMs}ms`);
414
- const abortConfirmed = await runSessionActionWithGrace('abort', () => session.abort());
467
+ const abortConfirmed = await runSessionActionWithGrace("abort", () => session.abort());
415
468
  disposeAttempted = true;
416
- const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
469
+ const disposeConfirmed = await runSessionActionWithGrace("dispose", () => session.dispose());
417
470
  terminationConfirmed = abortConfirmed && disposeConfirmed;
418
471
  }
419
472
  }
@@ -429,7 +482,7 @@ export async function executeSingleSdkAttempt(options) {
429
482
  unsubscribe?.();
430
483
  if (session && !disposeAttempted) {
431
484
  disposeAttempted = true;
432
- const disposeConfirmed = await runSessionActionWithGrace('dispose', () => session.dispose());
485
+ const disposeConfirmed = await runSessionActionWithGrace("dispose", () => session.dispose());
433
486
  terminationConfirmed = terminationConfirmed && disposeConfirmed;
434
487
  }
435
488
  if (sessionEventAppender) {
@@ -449,28 +502,34 @@ export async function executeSingleSdkAttempt(options) {
449
502
  : extractAssistantTextFromPiJson(stdout);
450
503
  const assistantText = collected.parsedEvents > 0
451
504
  ? collected.assistantText
452
- : fallbackParsed?.assistantText ?? '';
505
+ : (fallbackParsed?.assistantText ?? "");
453
506
  const parsedEvents = collected.parsedEvents > 0
454
507
  ? collected.parsedEvents
455
- : fallbackParsed?.parsedEvents ?? 0;
508
+ : (fallbackParsed?.parsedEvents ?? 0);
456
509
  const tokensUsed = aggregateSdkTokenUsage(usageSamples);
457
- const failureCategory = terminationConfirmed ? classifyPiFailure({
458
- assistantText,
459
- exitCode: timedOut ? 1 : stderr ? 1 : 0,
460
- outputTooLarge: collected.outputTooLarge,
461
- stderr,
462
- stdout,
463
- timedOut,
464
- }) : 'termination-unconfirmed';
510
+ const failureCategory = terminationConfirmed
511
+ ? classifyPiFailure({
512
+ assistantText,
513
+ exitCode: timedOut ? 1 : stderr ? 1 : 0,
514
+ outputTooLarge: collected.outputTooLarge,
515
+ stderr,
516
+ stdout,
517
+ timedOut,
518
+ })
519
+ : "termination-unconfirmed";
465
520
  return {
466
521
  assistantText,
467
- backend: 'sdk',
468
- command: ['pi-sdk', ...piSdkArgs],
522
+ backend: "sdk",
523
+ command: ["pi-sdk", ...piSdkArgs],
469
524
  durationMs,
470
525
  exitCode: timedOut || stderr ? 1 : 0,
471
526
  failureCategory,
472
527
  modelDisplay,
473
- ok: terminationConfirmed && !timedOut && !stderr && assistantText.length > 0 && !collected.outputTooLarge,
528
+ ok: terminationConfirmed &&
529
+ !timedOut &&
530
+ !stderr &&
531
+ assistantText.length > 0 &&
532
+ !collected.outputTooLarge,
474
533
  parsedEvents,
475
534
  stderr,
476
535
  stdout,