@link-assistant/agent 0.8.5 → 0.8.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -408,9 +408,38 @@ export namespace Session {
408
408
  }
409
409
 
410
410
  try {
411
- // Handle undefined/null explicitly - Number() would convert these to 0 or NaN
411
+ // Handle undefined/null gracefully by returning 0
412
+ // These are expected for optional fields like cachedInputTokens, reasoningTokens
413
+ // See: https://github.com/link-assistant/agent/issues/127
412
414
  if (value === undefined || value === null) {
413
- throw new Error(`Cannot convert ${value} to number`);
415
+ if (Flag.OPENCODE_VERBOSE) {
416
+ log.debug(() => ({
417
+ message: 'toNumber received undefined/null, returning 0',
418
+ context,
419
+ valueType: typeof value,
420
+ }));
421
+ }
422
+ return 0;
423
+ }
424
+
425
+ // Handle objects with a 'total' field (e.g., { total: 8707, noCache: 6339, cacheRead: 2368 })
426
+ // Some AI providers return token counts as objects instead of plain numbers
427
+ // See: https://github.com/link-assistant/agent/issues/125
428
+ if (
429
+ typeof value === 'object' &&
430
+ value !== null &&
431
+ 'total' in value &&
432
+ typeof (value as { total: unknown }).total === 'number'
433
+ ) {
434
+ const result = (value as { total: number }).total;
435
+ if (Flag.OPENCODE_VERBOSE) {
436
+ log.debug(() => ({
437
+ message: 'toNumber extracted total from object',
438
+ context,
439
+ result,
440
+ }));
441
+ }
442
+ return result;
414
443
  }
415
444
 
416
445
  // Try to convert to number
@@ -448,6 +477,90 @@ export namespace Session {
448
477
  }
449
478
  };
450
479
 
480
+ /**
481
+ * Safely converts a finishReason value to a string.
482
+ * Some AI providers return finishReason as an object instead of a string.
483
+ *
484
+ * For example, OpenCode provider on certain Bun versions may return:
485
+ * - { type: "stop" } instead of "stop"
486
+ * - { finishReason: "tool-calls" } instead of "tool-calls"
487
+ *
488
+ * This function handles these cases gracefully.
489
+ *
490
+ * @param value - The finishReason value (string, object, or undefined)
491
+ * @returns A string representing the finish reason, or 'unknown' if conversion fails
492
+ * @see https://github.com/link-assistant/agent/issues/125
493
+ */
494
+ export const toFinishReason = (value: unknown): string => {
495
+ // Log input data in verbose mode to help identify issues
496
+ if (Flag.OPENCODE_VERBOSE) {
497
+ log.debug(() => ({
498
+ message: 'toFinishReason input',
499
+ valueType: typeof value,
500
+ value:
501
+ typeof value === 'object' ? JSON.stringify(value) : String(value),
502
+ }));
503
+ }
504
+
505
+ // If it's already a string, return it
506
+ if (typeof value === 'string') {
507
+ return value;
508
+ }
509
+
510
+ // If it's undefined or null, return 'unknown'
511
+ if (value === undefined || value === null) {
512
+ return 'unknown';
513
+ }
514
+
515
+ // If it's an object, try to extract a meaningful string
516
+ if (typeof value === 'object') {
517
+ const obj = value as Record<string, unknown>;
518
+
519
+ // Try common field names that might contain the reason
520
+ if (typeof obj.type === 'string') {
521
+ if (Flag.OPENCODE_VERBOSE) {
522
+ log.debug(() => ({
523
+ message: 'toFinishReason extracted type from object',
524
+ result: obj.type,
525
+ }));
526
+ }
527
+ return obj.type;
528
+ }
529
+
530
+ if (typeof obj.finishReason === 'string') {
531
+ if (Flag.OPENCODE_VERBOSE) {
532
+ log.debug(() => ({
533
+ message: 'toFinishReason extracted finishReason from object',
534
+ result: obj.finishReason,
535
+ }));
536
+ }
537
+ return obj.finishReason;
538
+ }
539
+
540
+ if (typeof obj.reason === 'string') {
541
+ if (Flag.OPENCODE_VERBOSE) {
542
+ log.debug(() => ({
543
+ message: 'toFinishReason extracted reason from object',
544
+ result: obj.reason,
545
+ }));
546
+ }
547
+ return obj.reason;
548
+ }
549
+
550
+ // If we can't extract a specific field, return JSON representation
551
+ if (Flag.OPENCODE_VERBOSE) {
552
+ log.debug(() => ({
553
+ message: 'toFinishReason could not extract string, using JSON',
554
+ result: JSON.stringify(value),
555
+ }));
556
+ }
557
+ return JSON.stringify(value);
558
+ }
559
+
560
+ // For any other type, convert to string
561
+ return String(value);
562
+ };
563
+
451
564
  export const getUsage = fn(
452
565
  z.object({
453
566
  model: z.custom<ModelsDev.Model>(),
@@ -468,9 +581,28 @@ export namespace Session {
468
581
  const safeNum = (n: number): number =>
469
582
  Number.isNaN(n) || !Number.isFinite(n) ? 0 : n;
470
583
 
471
- const cachedInputTokens = safeNum(
584
+ // Extract top-level cachedInputTokens
585
+ const topLevelCachedInputTokens = safeNum(
472
586
  toNumber(input.usage.cachedInputTokens, 'cachedInputTokens')
473
587
  );
588
+
589
+ // Some providers (e.g., opencode/grok-code) nest cacheRead inside inputTokens object
590
+ // e.g., inputTokens: { total: 12703, noCache: 12511, cacheRead: 192 }
591
+ // See: https://github.com/link-assistant/agent/issues/127
592
+ const inputTokensObj = input.usage.inputTokens;
593
+ const nestedCacheRead =
594
+ typeof inputTokensObj === 'object' && inputTokensObj !== null
595
+ ? safeNum(
596
+ toNumber(
597
+ (inputTokensObj as { cacheRead?: unknown }).cacheRead,
598
+ 'inputTokens.cacheRead'
599
+ )
600
+ )
601
+ : 0;
602
+
603
+ // Use top-level if available, otherwise fall back to nested
604
+ const cachedInputTokens = topLevelCachedInputTokens || nestedCacheRead;
605
+
474
606
  const excludesCachedTokens = !!(
475
607
  input.metadata?.['anthropic'] || input.metadata?.['bedrock']
476
608
  );
@@ -491,12 +623,28 @@ export namespace Session {
491
623
  )
492
624
  );
493
625
 
626
+ // Extract reasoning tokens - some providers nest it inside outputTokens
627
+ // e.g., outputTokens: { total: 562, text: -805, reasoning: 1367 }
628
+ // See: https://github.com/link-assistant/agent/issues/127
629
+ const topLevelReasoningTokens = safeNum(
630
+ toNumber(input.usage?.reasoningTokens, 'reasoningTokens')
631
+ );
632
+ const outputTokensObj = input.usage.outputTokens;
633
+ const nestedReasoning =
634
+ typeof outputTokensObj === 'object' && outputTokensObj !== null
635
+ ? safeNum(
636
+ toNumber(
637
+ (outputTokensObj as { reasoning?: unknown }).reasoning,
638
+ 'outputTokens.reasoning'
639
+ )
640
+ )
641
+ : 0;
642
+ const reasoningTokens = topLevelReasoningTokens || nestedReasoning;
643
+
494
644
  const tokens = {
495
645
  input: Math.max(0, adjustedInputTokens), // Ensure non-negative
496
646
  output: safeNum(toNumber(input.usage.outputTokens, 'outputTokens')),
497
- reasoning: safeNum(
498
- toNumber(input.usage?.reasoningTokens, 'reasoningTokens')
499
- ),
647
+ reasoning: reasoningTokens,
500
648
  cache: {
501
649
  write: cacheWriteTokens,
502
650
  read: cachedInputTokens,
@@ -224,12 +224,17 @@ export namespace SessionProcessor {
224
224
  usage: value.usage,
225
225
  metadata: value.providerMetadata,
226
226
  });
227
- input.assistantMessage.finish = value.finishReason;
227
+ // Use toFinishReason to safely convert object/string finishReason to string
228
+ // See: https://github.com/link-assistant/agent/issues/125
229
+ const finishReason = Session.toFinishReason(
230
+ value.finishReason
231
+ );
232
+ input.assistantMessage.finish = finishReason;
228
233
  input.assistantMessage.cost += usage.cost;
229
234
  input.assistantMessage.tokens = usage.tokens;
230
235
  await Session.updatePart({
231
236
  id: Identifier.ascending('part'),
232
- reason: value.finishReason,
237
+ reason: finishReason,
233
238
  snapshot: await Snapshot.track(),
234
239
  messageID: input.assistantMessage.id,
235
240
  sessionID: input.assistantMessage.sessionID,
@@ -274,13 +279,19 @@ export namespace SessionProcessor {
274
279
 
275
280
  case 'text-delta':
276
281
  if (currentText) {
277
- currentText.text += value.text;
282
+ // Handle case where value.text might be an object instead of string
283
+ // See: https://github.com/link-assistant/agent/issues/125
284
+ const textDelta =
285
+ typeof value.text === 'string'
286
+ ? value.text
287
+ : String(value.text);
288
+ currentText.text += textDelta;
278
289
  if (value.providerMetadata)
279
290
  currentText.metadata = value.providerMetadata;
280
291
  if (currentText.text)
281
292
  await Session.updatePart({
282
293
  part: currentText,
283
- delta: value.text,
294
+ delta: textDelta,
284
295
  });
285
296
  }
286
297
  break;