@pencil-agent/nano-pencil 1.14.5 → 1.14.6

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,6 +1,6 @@
1
1
  {
2
- "version": "1.14.5",
3
- "commitHash": "5845e2c",
2
+ "version": "1.14.6",
3
+ "commitHash": "111a12b",
4
4
  "branch": "main",
5
- "builtAt": "2026-05-28T06:36:42.739Z"
5
+ "builtAt": "2026-05-28T14:39:06.773Z"
6
6
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * [WHO]: Provides waitForAbortableOperation(), waitForAssistantStream(), waitForAssistantStreamEvent()
3
+ * [FROM]: Depends on @pencil-agent/ai AssistantMessageEvent/AssistantMessageEventStream contracts.
4
+ * [TO]: Consumed by standard and structured-adaptive agent loops.
5
+ * [HERE]: packages/agent-core/src/agent-loop-stream-events.ts within agent-core; shared abortable operation and assistant-stream iterator utilities.
6
+ */
7
+ import type { AssistantMessageEvent, AssistantMessageEventStream } from "@pencil-agent/ai";
8
+ export type AssistantStreamNext = IteratorResult<AssistantMessageEvent> | "aborted";
9
+ export type AssistantStreamStart = AssistantMessageEventStream | "aborted";
10
+ export type AbortableOperationResult<T> = {
11
+ type: "resolved";
12
+ value: T;
13
+ } | {
14
+ type: "aborted";
15
+ };
16
+ export declare function waitForAbortableOperation<T>(valueOrPromise: T | Promise<T>, signal?: AbortSignal): Promise<AbortableOperationResult<T>>;
17
+ export declare function waitForAssistantStream(streamOrPromise: AssistantMessageEventStream | Promise<AssistantMessageEventStream>, signal?: AbortSignal): Promise<AssistantStreamStart>;
18
+ export declare function waitForAssistantStreamEvent(iterator: AsyncIterator<AssistantMessageEvent>, signal?: AbortSignal): Promise<AssistantStreamNext>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * [WHO]: Provides waitForAbortableOperation(), waitForAssistantStream(), waitForAssistantStreamEvent()
3
+ * [FROM]: Depends on @pencil-agent/ai AssistantMessageEvent/AssistantMessageEventStream contracts.
4
+ * [TO]: Consumed by standard and structured-adaptive agent loops.
5
+ * [HERE]: packages/agent-core/src/agent-loop-stream-events.ts within agent-core; shared abortable operation and assistant-stream iterator utilities.
6
+ */
7
+ export function waitForAbortableOperation(valueOrPromise, signal) {
8
+ if (signal?.aborted)
9
+ return Promise.resolve({ type: "aborted" });
10
+ return new Promise((resolve, reject) => {
11
+ const cleanup = () => {
12
+ signal?.removeEventListener("abort", onAbort);
13
+ };
14
+ const onAbort = () => {
15
+ cleanup();
16
+ resolve({ type: "aborted" });
17
+ };
18
+ signal?.addEventListener("abort", onAbort, { once: true });
19
+ Promise.resolve(valueOrPromise).then((value) => {
20
+ cleanup();
21
+ resolve({ type: "resolved", value });
22
+ }, (error) => {
23
+ cleanup();
24
+ reject(error);
25
+ });
26
+ });
27
+ }
28
+ export function waitForAssistantStream(streamOrPromise, signal) {
29
+ return waitForAbortableOperation(streamOrPromise, signal).then((result) => {
30
+ if (result.type === "aborted")
31
+ return "aborted";
32
+ return result.value;
33
+ });
34
+ }
35
+ export function waitForAssistantStreamEvent(iterator, signal) {
36
+ if (signal?.aborted)
37
+ return Promise.resolve("aborted");
38
+ return new Promise((resolve, reject) => {
39
+ const cleanup = () => {
40
+ signal?.removeEventListener("abort", onAbort);
41
+ };
42
+ const onAbort = () => {
43
+ cleanup();
44
+ resolve("aborted");
45
+ };
46
+ signal?.addEventListener("abort", onAbort, { once: true });
47
+ iterator.next().then((result) => {
48
+ cleanup();
49
+ resolve(result);
50
+ }, (error) => {
51
+ cleanup();
52
+ reject(error);
53
+ });
54
+ });
55
+ }
@@ -14,6 +14,7 @@ import { computeRecoveryMaxTokens, createOutputTokenRecoveryMessage, createToken
14
14
  import { createInterruptedToolResults, createSkippedToolCallLimitResults, enforceToolResultBatchSize, } from "./agent-loop-tool-results.js";
15
15
  import { flushReadyToolUseSummaries, startToolUseSummary, } from "./agent-loop-tool-summaries.js";
16
16
  import { buildAgentRunPolicy, resolveAgentRunLoopFramework } from "./agent-run-result.js";
17
+ import { waitForAbortableOperation, waitForAssistantStream, waitForAssistantStreamEvent, } from "./agent-loop-stream-events.js";
17
18
  const DEFAULT_MAX_TURNS_PER_PROMPT = 64;
18
19
  const DEFAULT_MAX_TOOL_CALLS_PER_PROMPT = 128;
19
20
  const DEFAULT_MAX_STOP_HOOK_CONTINUATIONS = 3;
@@ -150,7 +151,21 @@ async function runLoop(currentContext, newMessages, config, signal, stream, stre
150
151
  let stopHookActive = false;
151
152
  let stopHookContinuationCount = 0;
152
153
  // Check for steering messages at start (user may have typed while waiting)
153
- let pendingMessages = (await config.getSteeringMessages?.()) || [];
154
+ const initialSteeringMessages = await waitForAbortableOperation(config.getSteeringMessages ? config.getSteeringMessages() : [], signal);
155
+ if (initialSteeringMessages.type === "aborted") {
156
+ finishStandardLoopWithAbortedTurn(stream, currentContext, newMessages, {
157
+ config,
158
+ turnCount,
159
+ toolCallCount,
160
+ startedAt,
161
+ usage,
162
+ permissionDenials,
163
+ transitions,
164
+ lastTransition,
165
+ });
166
+ return;
167
+ }
168
+ let pendingMessages = initialSteeringMessages.value || [];
154
169
  // Outer loop: continues when queued follow-up messages arrive after agent would stop
155
170
  while (true) {
156
171
  let hasMoreToolCalls = true;
@@ -341,12 +356,26 @@ async function runLoop(currentContext, newMessages, config, signal, stream, stre
341
356
  }
342
357
  if (!hasMoreToolCalls && config.runStopHooks && !stopHookActive) {
343
358
  stopHookActive = true;
344
- const stopHookResult = await config.runStopHooks({
359
+ const stopHookResult = await waitForAbortableOperation(config.runStopHooks({
345
360
  message,
346
361
  messages: currentContext.messages,
347
- });
362
+ }), signal);
348
363
  stopHookActive = false;
349
- if (stopHookResult.action === "continue" && stopHookResult.messages.length > 0) {
364
+ if (stopHookResult.type === "aborted") {
365
+ finishStandardLoopWithAbortedTurn(stream, currentContext, newMessages, {
366
+ config,
367
+ turnCount,
368
+ toolCallCount,
369
+ startedAt,
370
+ usage,
371
+ permissionDenials,
372
+ transitions,
373
+ lastTransition,
374
+ });
375
+ return;
376
+ }
377
+ const resolvedStopHookResult = stopHookResult.value;
378
+ if (resolvedStopHookResult.action === "continue" && resolvedStopHookResult.messages.length > 0) {
350
379
  if (stopHookContinuationCount >= maxStopHookContinuations) {
351
380
  const limitMessage = createLoopLimitMessage(config, `stop_hook_limit_reached: stopped after ${maxStopHookContinuations} stop-hook continuation turns.`);
352
381
  currentContext.messages.push(limitMessage);
@@ -374,7 +403,7 @@ async function runLoop(currentContext, newMessages, config, signal, stream, stre
374
403
  return;
375
404
  }
376
405
  stopHookContinuationCount += 1;
377
- pendingMessages = stopHookResult.messages;
406
+ pendingMessages = resolvedStopHookResult.messages;
378
407
  recordTransition({
379
408
  reason: "stop_hook_blocking",
380
409
  continuationCount: stopHookContinuationCount,
@@ -406,7 +435,21 @@ async function runLoop(currentContext, newMessages, config, signal, stream, stre
406
435
  }
407
436
  }
408
437
  // Agent would stop here. Check for follow-up messages.
409
- const followUpMessages = (await config.getFollowUpMessages?.()) || [];
438
+ const followUpMessagesResult = await waitForAbortableOperation(config.getFollowUpMessages ? config.getFollowUpMessages() : [], signal);
439
+ if (followUpMessagesResult.type === "aborted") {
440
+ finishStandardLoopWithAbortedTurn(stream, currentContext, newMessages, {
441
+ config,
442
+ turnCount,
443
+ toolCallCount,
444
+ startedAt,
445
+ usage,
446
+ permissionDenials,
447
+ transitions,
448
+ lastTransition,
449
+ });
450
+ return;
451
+ }
452
+ const followUpMessages = followUpMessagesResult.value || [];
410
453
  if (followUpMessages.length > 0) {
411
454
  // Set as pending so inner loop processes them
412
455
  pendingMessages = followUpMessages;
@@ -436,10 +479,18 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
436
479
  // Apply context transform if configured (AgentMessage[] → AgentMessage[])
437
480
  let messages = context.messages;
438
481
  if (config.transformContext) {
439
- messages = await config.transformContext(messages, signal);
482
+ const transformedMessages = await waitForAbortableOperation(config.transformContext(messages, signal), signal);
483
+ if (transformedMessages.type === "aborted") {
484
+ return pushAbortedAssistantMessage(context, stream, config);
485
+ }
486
+ messages = transformedMessages.value;
440
487
  }
441
488
  // Convert to LLM-compatible messages (AgentMessage[] → Message[])
442
- const llmMessages = await config.convertToLlm(messages);
489
+ const convertedMessages = await waitForAbortableOperation(config.convertToLlm(messages), signal);
490
+ if (convertedMessages.type === "aborted") {
491
+ return pushAbortedAssistantMessage(context, stream, config);
492
+ }
493
+ const llmMessages = convertedMessages.value;
443
494
  // Build LLM context
444
495
  const llmContext = {
445
496
  systemPrompt: context.systemPrompt,
@@ -448,7 +499,11 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
448
499
  };
449
500
  const streamFunction = streamFn || streamSimple;
450
501
  // Resolve API key (important for expiring tokens)
451
- const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
502
+ const apiKeyResult = await waitForAbortableOperation(config.getApiKey ? config.getApiKey(config.model.provider) : undefined, signal);
503
+ if (apiKeyResult.type === "aborted") {
504
+ return pushAbortedAssistantMessage(context, stream, config);
505
+ }
506
+ const resolvedApiKey = apiKeyResult.value || config.apiKey;
452
507
  stream.push({
453
508
  type: "stream_request_start",
454
509
  model: config.model.id,
@@ -457,15 +512,58 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
457
512
  messageCount: llmMessages.length,
458
513
  maxTokens: maxTokensOverride ?? config.maxTokens,
459
514
  });
460
- const response = await streamFunction(config.model, llmContext, {
515
+ const response = await waitForAssistantStream(streamFunction(config.model, llmContext, {
461
516
  ...config,
462
517
  maxTokens: maxTokensOverride ?? config.maxTokens,
463
518
  apiKey: resolvedApiKey,
464
519
  signal,
465
- });
520
+ }), signal);
521
+ if (response === "aborted") {
522
+ const finalMessage = createLoopLimitMessage(config, "Request was aborted");
523
+ finalMessage.stopReason = "aborted";
524
+ context.messages.push(finalMessage);
525
+ stream.push({ type: "message_start", message: { ...finalMessage } });
526
+ stream.push({ type: "message_end", message: finalMessage });
527
+ return finalMessage;
528
+ }
466
529
  let partialMessage = null;
467
530
  let addedPartial = false;
468
- for await (const event of response) {
531
+ const responseIterator = response[Symbol.asyncIterator]();
532
+ while (true) {
533
+ let nextEvent;
534
+ try {
535
+ nextEvent = await waitForAssistantStreamEvent(responseIterator, signal);
536
+ }
537
+ catch (error) {
538
+ const finalMessage = createLoopLimitMessage(config, error instanceof Error ? error.message : String(error));
539
+ if (addedPartial) {
540
+ context.messages[context.messages.length - 1] = finalMessage;
541
+ }
542
+ else {
543
+ context.messages.push(finalMessage);
544
+ stream.push({ type: "message_start", message: { ...finalMessage } });
545
+ }
546
+ stream.push({ type: "message_end", message: finalMessage });
547
+ return finalMessage;
548
+ }
549
+ if (nextEvent === "aborted") {
550
+ void responseIterator.return?.();
551
+ const finalMessage = createLoopLimitMessage(config, "Request was aborted");
552
+ finalMessage.stopReason = "aborted";
553
+ if (addedPartial) {
554
+ context.messages[context.messages.length - 1] = finalMessage;
555
+ }
556
+ else {
557
+ context.messages.push(finalMessage);
558
+ stream.push({ type: "message_start", message: { ...finalMessage } });
559
+ }
560
+ stream.push({ type: "message_end", message: finalMessage });
561
+ return finalMessage;
562
+ }
563
+ if (nextEvent.done) {
564
+ break;
565
+ }
566
+ const event = nextEvent.value;
469
567
  switch (event.type) {
470
568
  case "start":
471
569
  partialMessage = event.partial;
@@ -494,7 +592,7 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
494
592
  break;
495
593
  case "done":
496
594
  case "error": {
497
- const finalMessage = await response.result();
595
+ const finalMessage = event.type === "done" ? event.message : event.error;
498
596
  if (addedPartial) {
499
597
  context.messages[context.messages.length - 1] = finalMessage;
500
598
  }
@@ -509,7 +607,8 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
509
607
  }
510
608
  }
511
609
  }
512
- const finalMessage = await response.result();
610
+ const finalMessage = response.resultIfResolved() ??
611
+ createLoopLimitMessage(config, "Provider stream ended without a final assistant message");
513
612
  if (addedPartial) {
514
613
  context.messages[context.messages.length - 1] = finalMessage;
515
614
  }
@@ -520,6 +619,29 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
520
619
  stream.push({ type: "message_end", message: finalMessage });
521
620
  return finalMessage;
522
621
  }
622
+ function pushAbortedAssistantMessage(context, stream, config) {
623
+ const finalMessage = createLoopLimitMessage(config, "Request was aborted");
624
+ finalMessage.stopReason = "aborted";
625
+ context.messages.push(finalMessage);
626
+ stream.push({ type: "message_start", message: { ...finalMessage } });
627
+ stream.push({ type: "message_end", message: finalMessage });
628
+ return finalMessage;
629
+ }
630
+ function finishStandardLoopWithAbortedTurn(stream, context, newMessages, options) {
631
+ const finalMessage = createLoopLimitMessage(options.config, "Request was aborted");
632
+ finalMessage.stopReason = "aborted";
633
+ context.messages.push(finalMessage);
634
+ newMessages.push(finalMessage);
635
+ stream.push({ type: "message_start", message: { ...finalMessage } });
636
+ stream.push({ type: "message_end", message: finalMessage });
637
+ stream.push({ type: "turn_end", message: finalMessage, toolResults: [] });
638
+ finishStandardLoop(stream, newMessages, {
639
+ ...options,
640
+ stopReason: "aborted",
641
+ errorMessage: finalMessage.errorMessage,
642
+ errorSubtype: "aborted",
643
+ });
644
+ }
523
645
  function finishStandardLoop(stream, newMessages, options) {
524
646
  stream.push({
525
647
  type: "agent_result",
@@ -17,6 +17,7 @@ import { computeRecoveryMaxTokens, createOutputTokenRecoveryMessage, createToken
17
17
  import { createInterruptedToolResults, createSkippedToolCallLimitResults, enforceToolResultBatchSize, } from "./agent-loop-tool-results.js";
18
18
  import { flushReadyToolUseSummaries, startToolUseSummary, } from "./agent-loop-tool-summaries.js";
19
19
  import { buildAgentRunPolicy, resolveAgentRunLoopFramework } from "./agent-run-result.js";
20
+ import { waitForAbortableOperation, waitForAssistantStream, waitForAssistantStreamEvent, } from "./agent-loop-stream-events.js";
20
21
  const DEFAULT_MAX_TURNS_PER_PROMPT = 64;
21
22
  const DEFAULT_MAX_TOOL_CALLS_PER_PROMPT = 128;
22
23
  const DEFAULT_MAX_OUTPUT_TOKEN_RECOVERY_ATTEMPTS = 1;
@@ -76,13 +77,14 @@ async function runStructuredAdaptiveQueryLoop(currentContext, newMessages, confi
76
77
  const maxOutputTokenRecoveryAttempts = config.maxOutputTokenRecoveryAttempts ?? DEFAULT_MAX_OUTPUT_TOKEN_RECOVERY_ATTEMPTS;
77
78
  const maxStopHookContinuations = config.maxStopHookContinuations ?? DEFAULT_MAX_STOP_HOOK_CONTINUATIONS;
78
79
  const maxModelErrorRecoveryAttempts = config.maxModelErrorRecoveryAttempts ?? DEFAULT_MAX_MODEL_ERROR_RECOVERY_ATTEMPTS;
80
+ const initialSteeringMessages = await waitForAbortableOperation(config.getSteeringMessages ? config.getSteeringMessages() : [], signal);
79
81
  const state = {
80
82
  config,
81
83
  turnCount: 0,
82
84
  toolCallCount: 0,
83
85
  transition: { reason: "start" },
84
86
  transitions: [],
85
- pendingMessages: (await config.getSteeringMessages?.()) || [],
87
+ pendingMessages: initialSteeringMessages.type === "resolved" ? initialSteeringMessages.value || [] : [],
86
88
  pendingToolUseSummaries: [],
87
89
  stopHookActive: false,
88
90
  stopHookContinuationCount: 0,
@@ -94,6 +96,10 @@ async function runStructuredAdaptiveQueryLoop(currentContext, newMessages, confi
94
96
  usage: emptyUsage(),
95
97
  permissionDenials: [],
96
98
  };
99
+ if (initialSteeringMessages.type === "aborted") {
100
+ finishStructuredAdaptiveWithAbortedTurn(stream, currentContext, newMessages, state);
101
+ return;
102
+ }
97
103
  let firstTurn = true;
98
104
  while (true) {
99
105
  if (!firstTurn) {
@@ -208,12 +214,17 @@ async function runStructuredAdaptiveQueryLoop(currentContext, newMessages, confi
208
214
  }
209
215
  if (config.runStopHooks && !state.stopHookActive) {
210
216
  state.stopHookActive = true;
211
- const stopHookResult = await config.runStopHooks({
217
+ const stopHookResult = await waitForAbortableOperation(config.runStopHooks({
212
218
  message,
213
219
  messages: currentContext.messages,
214
- });
220
+ }), signal);
215
221
  state.stopHookActive = false;
216
- if (stopHookResult.action === "continue" && stopHookResult.messages.length > 0) {
222
+ if (stopHookResult.type === "aborted") {
223
+ finishStructuredAdaptiveWithAbortedTurn(stream, currentContext, newMessages, state);
224
+ return;
225
+ }
226
+ const resolvedStopHookResult = stopHookResult.value;
227
+ if (resolvedStopHookResult.action === "continue" && resolvedStopHookResult.messages.length > 0) {
217
228
  if (state.stopHookContinuationCount >= maxStopHookContinuations) {
218
229
  const limitMessage = createLoopLimitMessage(config, `stop_hook_limit_reached: stopped after ${maxStopHookContinuations} stop-hook continuation turns.`);
219
230
  currentContext.messages.push(limitMessage);
@@ -233,7 +244,7 @@ async function runStructuredAdaptiveQueryLoop(currentContext, newMessages, confi
233
244
  return;
234
245
  }
235
246
  state.stopHookContinuationCount += 1;
236
- state.pendingMessages = stopHookResult.messages;
247
+ state.pendingMessages = resolvedStopHookResult.messages;
237
248
  recordTransition(state, {
238
249
  reason: "stop_hook_blocking",
239
250
  continuationCount: state.stopHookContinuationCount,
@@ -253,7 +264,12 @@ async function runStructuredAdaptiveQueryLoop(currentContext, newMessages, confi
253
264
  });
254
265
  continue;
255
266
  }
256
- const followUpMessages = (await config.getFollowUpMessages?.()) || [];
267
+ const followUpMessagesResult = await waitForAbortableOperation(config.getFollowUpMessages ? config.getFollowUpMessages() : [], signal);
268
+ if (followUpMessagesResult.type === "aborted") {
269
+ finishStructuredAdaptiveWithAbortedTurn(stream, currentContext, newMessages, state);
270
+ return;
271
+ }
272
+ const followUpMessages = followUpMessagesResult.value || [];
257
273
  if (followUpMessages.length === 0) {
258
274
  break;
259
275
  }
@@ -329,16 +345,28 @@ async function runStructuredAdaptiveQueryLoop(currentContext, newMessages, confi
329
345
  async function streamAssistantResponse(context, config, signal, stream, streamFn, maxTokensOverride, streamingToolExecutor) {
330
346
  let messages = context.messages;
331
347
  if (config.transformContext) {
332
- messages = await config.transformContext(messages, signal);
348
+ const transformedMessages = await waitForAbortableOperation(config.transformContext(messages, signal), signal);
349
+ if (transformedMessages.type === "aborted") {
350
+ return pushAbortedAssistantMessage(context, stream, config);
351
+ }
352
+ messages = transformedMessages.value;
353
+ }
354
+ const convertedMessages = await waitForAbortableOperation(config.convertToLlm(messages), signal);
355
+ if (convertedMessages.type === "aborted") {
356
+ return pushAbortedAssistantMessage(context, stream, config);
333
357
  }
334
- const llmMessages = await config.convertToLlm(messages);
358
+ const llmMessages = convertedMessages.value;
335
359
  const llmContext = {
336
360
  systemPrompt: context.systemPrompt,
337
361
  messages: llmMessages,
338
362
  tools: context.tools,
339
363
  };
340
364
  const streamFunction = streamFn || streamSimple;
341
- const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
365
+ const apiKeyResult = await waitForAbortableOperation(config.getApiKey ? config.getApiKey(config.model.provider) : undefined, signal);
366
+ if (apiKeyResult.type === "aborted") {
367
+ return pushAbortedAssistantMessage(context, stream, config);
368
+ }
369
+ const resolvedApiKey = apiKeyResult.value || config.apiKey;
342
370
  stream.push({
343
371
  type: "stream_request_start",
344
372
  model: config.model.id,
@@ -347,15 +375,58 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
347
375
  messageCount: llmMessages.length,
348
376
  maxTokens: maxTokensOverride ?? config.maxTokens,
349
377
  });
350
- const response = await streamFunction(config.model, llmContext, {
378
+ const response = await waitForAssistantStream(streamFunction(config.model, llmContext, {
351
379
  ...config,
352
380
  maxTokens: maxTokensOverride ?? config.maxTokens,
353
381
  apiKey: resolvedApiKey,
354
382
  signal,
355
- });
383
+ }), signal);
384
+ if (response === "aborted") {
385
+ const finalMessage = createLoopLimitMessage(config, "Request was aborted");
386
+ finalMessage.stopReason = "aborted";
387
+ context.messages.push(finalMessage);
388
+ stream.push({ type: "message_start", message: { ...finalMessage } });
389
+ stream.push({ type: "message_end", message: finalMessage });
390
+ return finalMessage;
391
+ }
356
392
  let partialMessage = null;
357
393
  let addedPartial = false;
358
- for await (const event of response) {
394
+ const responseIterator = response[Symbol.asyncIterator]();
395
+ while (true) {
396
+ let nextEvent;
397
+ try {
398
+ nextEvent = await waitForAssistantStreamEvent(responseIterator, signal);
399
+ }
400
+ catch (error) {
401
+ const finalMessage = createLoopLimitMessage(config, error instanceof Error ? error.message : String(error));
402
+ if (addedPartial) {
403
+ context.messages[context.messages.length - 1] = finalMessage;
404
+ }
405
+ else {
406
+ context.messages.push(finalMessage);
407
+ stream.push({ type: "message_start", message: { ...finalMessage } });
408
+ }
409
+ stream.push({ type: "message_end", message: finalMessage });
410
+ return finalMessage;
411
+ }
412
+ if (nextEvent === "aborted") {
413
+ void responseIterator.return?.();
414
+ const finalMessage = createLoopLimitMessage(config, "Request was aborted");
415
+ finalMessage.stopReason = "aborted";
416
+ if (addedPartial) {
417
+ context.messages[context.messages.length - 1] = finalMessage;
418
+ }
419
+ else {
420
+ context.messages.push(finalMessage);
421
+ stream.push({ type: "message_start", message: { ...finalMessage } });
422
+ }
423
+ stream.push({ type: "message_end", message: finalMessage });
424
+ return finalMessage;
425
+ }
426
+ if (nextEvent.done) {
427
+ break;
428
+ }
429
+ const event = nextEvent.value;
359
430
  switch (event.type) {
360
431
  case "start":
361
432
  partialMessage = event.partial;
@@ -395,7 +466,7 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
395
466
  break;
396
467
  case "done":
397
468
  case "error": {
398
- const finalMessage = await response.result();
469
+ const finalMessage = event.type === "done" ? event.message : event.error;
399
470
  if (addedPartial) {
400
471
  context.messages[context.messages.length - 1] = finalMessage;
401
472
  }
@@ -410,7 +481,8 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
410
481
  }
411
482
  }
412
483
  }
413
- const finalMessage = await response.result();
484
+ const finalMessage = response.resultIfResolved() ??
485
+ createLoopLimitMessage(config, "Provider stream ended without a final assistant message");
414
486
  if (addedPartial) {
415
487
  context.messages[context.messages.length - 1] = finalMessage;
416
488
  }
@@ -421,6 +493,27 @@ async function streamAssistantResponse(context, config, signal, stream, streamFn
421
493
  stream.push({ type: "message_end", message: finalMessage });
422
494
  return finalMessage;
423
495
  }
496
+ function pushAbortedAssistantMessage(context, stream, config) {
497
+ const finalMessage = createLoopLimitMessage(config, "Request was aborted");
498
+ finalMessage.stopReason = "aborted";
499
+ context.messages.push(finalMessage);
500
+ stream.push({ type: "message_start", message: { ...finalMessage } });
501
+ stream.push({ type: "message_end", message: finalMessage });
502
+ return finalMessage;
503
+ }
504
+ function finishStructuredAdaptiveWithAbortedTurn(stream, context, newMessages, state) {
505
+ const finalMessage = createLoopLimitMessage(state.config, "Request was aborted");
506
+ finalMessage.stopReason = "aborted";
507
+ context.messages.push(finalMessage);
508
+ newMessages.push(finalMessage);
509
+ stream.push({ type: "message_start", message: { ...finalMessage } });
510
+ stream.push({ type: "message_end", message: finalMessage });
511
+ stream.push({ type: "turn_end", message: finalMessage, toolResults: [] });
512
+ state.finalStopReason = "aborted";
513
+ state.finalErrorMessage = finalMessage.errorMessage;
514
+ state.finalErrorSubtype = "aborted";
515
+ finish(stream, newMessages, state);
516
+ }
424
517
  function createLoopLimitMessage(config, errorMessage) {
425
518
  return {
426
519
  role: "assistant",
@@ -113,6 +113,54 @@ function createStreamErrorMessage(model, error) {
113
113
  function createMissingStreamResultMessage(model) {
114
114
  return createStreamErrorMessage(model, new Error("Provider stream ended without a final assistant message"));
115
115
  }
116
+ function createAbortMessage(model) {
117
+ return createStreamErrorMessage(model, new Error("Request was aborted"));
118
+ }
119
+ function emitAbortError(stream, model) {
120
+ stream.push({ type: "error", reason: "error", error: createAbortMessage(model) });
121
+ }
122
+ function waitForRetryDelay(delayMs, signal) {
123
+ if (signal?.aborted)
124
+ return Promise.resolve("aborted");
125
+ return new Promise((resolve) => {
126
+ let timeout;
127
+ const cleanup = () => {
128
+ if (timeout !== undefined)
129
+ clearTimeout(timeout);
130
+ signal?.removeEventListener("abort", onAbort);
131
+ };
132
+ const onAbort = () => {
133
+ cleanup();
134
+ resolve("aborted");
135
+ };
136
+ timeout = setTimeout(() => {
137
+ cleanup();
138
+ resolve("elapsed");
139
+ }, delayMs);
140
+ signal?.addEventListener("abort", onAbort, { once: true });
141
+ });
142
+ }
143
+ function waitForStreamEvent(iterator, signal) {
144
+ if (signal?.aborted)
145
+ return Promise.resolve("aborted");
146
+ return new Promise((resolve, reject) => {
147
+ const cleanup = () => {
148
+ signal?.removeEventListener("abort", onAbort);
149
+ };
150
+ const onAbort = () => {
151
+ cleanup();
152
+ resolve("aborted");
153
+ };
154
+ signal?.addEventListener("abort", onAbort, { once: true });
155
+ iterator.next().then((result) => {
156
+ cleanup();
157
+ resolve(result);
158
+ }, (error) => {
159
+ cleanup();
160
+ reject(error);
161
+ });
162
+ });
163
+ }
116
164
  // =============================================================================
117
165
  // Provider Resolution
118
166
  // =============================================================================
@@ -163,18 +211,7 @@ function wrapWithRetry(model, createStream, retryOptions, signal) {
163
211
  let attempt = 0;
164
212
  while (attempt <= retryOptions.maxRetries) {
165
213
  if (signal?.aborted) {
166
- const errorMessage = {
167
- role: "assistant",
168
- content: [],
169
- api: model.api,
170
- provider: model.provider,
171
- model: model.id,
172
- stopReason: "error",
173
- errorMessage: "Request was aborted",
174
- usage: emptyUsage(),
175
- timestamp: Date.now(),
176
- };
177
- outerStream.push({ type: "error", reason: "error", error: errorMessage });
214
+ emitAbortError(outerStream, model);
178
215
  return;
179
216
  }
180
217
  let innerStream;
@@ -186,7 +223,10 @@ function wrapWithRetry(model, createStream, retryOptions, signal) {
186
223
  const delayMs = getRetryDelayMs(errorMessage, attempt, retryOptions);
187
224
  if (delayMs !== undefined) {
188
225
  attempt++;
189
- await new Promise((resolve) => setTimeout(resolve, delayMs));
226
+ if ((await waitForRetryDelay(delayMs, signal)) === "aborted") {
227
+ emitAbortError(outerStream, model);
228
+ return;
229
+ }
190
230
  continue;
191
231
  }
192
232
  outerStream.push({ type: "error", reason: "error", error: errorMessage });
@@ -194,7 +234,35 @@ function wrapWithRetry(model, createStream, retryOptions, signal) {
194
234
  }
195
235
  // Forward all events from inner to outer, but intercept the final result
196
236
  let lastMessage = null;
197
- for await (const event of innerStream) {
237
+ const innerIterator = innerStream[Symbol.asyncIterator]();
238
+ while (true) {
239
+ let nextEvent;
240
+ try {
241
+ nextEvent = await waitForStreamEvent(innerIterator, signal);
242
+ }
243
+ catch (error) {
244
+ lastMessage = createStreamErrorMessage(model, error);
245
+ const delayMs = getRetryDelayMs(lastMessage, attempt, retryOptions);
246
+ if (delayMs !== undefined) {
247
+ attempt++;
248
+ if ((await waitForRetryDelay(delayMs, signal)) === "aborted") {
249
+ emitAbortError(outerStream, model);
250
+ return;
251
+ }
252
+ break;
253
+ }
254
+ outerStream.push({ type: "error", reason: "error", error: lastMessage });
255
+ return;
256
+ }
257
+ if (nextEvent === "aborted") {
258
+ void innerIterator.return?.();
259
+ emitAbortError(outerStream, model);
260
+ return;
261
+ }
262
+ if (nextEvent.done) {
263
+ break;
264
+ }
265
+ const event = nextEvent.value;
198
266
  if (event.type === "done") {
199
267
  lastMessage = event.message;
200
268
  outerStream.push(event);
@@ -206,7 +274,10 @@ function wrapWithRetry(model, createStream, retryOptions, signal) {
206
274
  const delayMs = getRetryDelayMs(lastMessage, attempt, retryOptions);
207
275
  if (delayMs !== undefined) {
208
276
  attempt++;
209
- await new Promise((resolve) => setTimeout(resolve, delayMs));
277
+ if ((await waitForRetryDelay(delayMs, signal)) === "aborted") {
278
+ emitAbortError(outerStream, model);
279
+ return;
280
+ }
210
281
  break; // Break inner loop, retry outer loop
211
282
  }
212
283
  // Non-retriable or max retries exhausted — forward error
@@ -226,7 +297,10 @@ function wrapWithRetry(model, createStream, retryOptions, signal) {
226
297
  const delayMs = getRetryDelayMs(lastMessage, attempt, retryOptions);
227
298
  if (delayMs !== undefined) {
228
299
  attempt++;
229
- await new Promise((resolve) => setTimeout(resolve, delayMs));
300
+ if ((await waitForRetryDelay(delayMs, signal)) === "aborted") {
301
+ emitAbortError(outerStream, model);
302
+ return;
303
+ }
230
304
  continue;
231
305
  }
232
306
  if (lastMessage.stopReason === "error" || lastMessage.stopReason === "aborted") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pencil-agent/nano-pencil",
3
- "version": "1.14.5",
3
+ "version": "1.14.6",
4
4
  "description": "CLI writing agent with read, bash, edit, write tools and session management. Supports DashScope and Ali Token Plan. Soul enabled by default for AI personality evolution.",
5
5
  "type": "module",
6
6
  "bin": {