@roj-ai/sdk 0.1.29 → 0.1.30

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.
Files changed (114) hide show
  1. package/dist/core/agents/agent.d.ts +28 -0
  2. package/dist/core/agents/agent.d.ts.map +1 -1
  3. package/dist/core/agents/agent.js +116 -39
  4. package/dist/core/agents/agent.js.map +1 -1
  5. package/dist/core/agents/config.d.ts +9 -0
  6. package/dist/core/agents/config.d.ts.map +1 -1
  7. package/dist/core/agents/state.d.ts +9 -0
  8. package/dist/core/agents/state.d.ts.map +1 -1
  9. package/dist/core/agents/state.js +4 -0
  10. package/dist/core/agents/state.js.map +1 -1
  11. package/dist/core/context/state.d.ts +22 -13
  12. package/dist/core/context/state.d.ts.map +1 -1
  13. package/dist/core/context/state.js +78 -22
  14. package/dist/core/context/state.js.map +1 -1
  15. package/dist/core/error-recovery.integration.test.d.ts +2 -0
  16. package/dist/core/error-recovery.integration.test.d.ts.map +1 -0
  17. package/dist/core/error-recovery.integration.test.js +288 -0
  18. package/dist/core/error-recovery.integration.test.js.map +1 -0
  19. package/dist/core/llm/anthropic.d.ts.map +1 -1
  20. package/dist/core/llm/anthropic.js +6 -1
  21. package/dist/core/llm/anthropic.js.map +1 -1
  22. package/dist/core/llm/anthropic.test.js +7 -4
  23. package/dist/core/llm/anthropic.test.js.map +1 -1
  24. package/dist/core/llm/message-sanitization.d.ts +10 -0
  25. package/dist/core/llm/message-sanitization.d.ts.map +1 -0
  26. package/dist/core/llm/message-sanitization.js +53 -0
  27. package/dist/core/llm/message-sanitization.js.map +1 -0
  28. package/dist/core/llm/message-sanitization.test.d.ts +2 -0
  29. package/dist/core/llm/message-sanitization.test.d.ts.map +1 -0
  30. package/dist/core/llm/message-sanitization.test.js +181 -0
  31. package/dist/core/llm/message-sanitization.test.js.map +1 -0
  32. package/dist/core/llm/middleware.d.ts +3 -3
  33. package/dist/core/llm/middleware.js +3 -3
  34. package/dist/core/llm/openrouter-mapping.test.js +21 -3
  35. package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
  36. package/dist/core/llm/openrouter.d.ts.map +1 -1
  37. package/dist/core/llm/openrouter.js +6 -1
  38. package/dist/core/llm/openrouter.js.map +1 -1
  39. package/dist/core/llm/provider.d.ts +13 -10
  40. package/dist/core/llm/provider.d.ts.map +1 -1
  41. package/dist/core/llm/provider.js.map +1 -1
  42. package/dist/core/llm/state.d.ts +10 -0
  43. package/dist/core/llm/state.d.ts.map +1 -1
  44. package/dist/core/llm/state.js +9 -0
  45. package/dist/core/llm/state.js.map +1 -1
  46. package/dist/core/plugins/plugin-builder.d.ts +2 -0
  47. package/dist/core/plugins/plugin-builder.d.ts.map +1 -1
  48. package/dist/core/plugins/plugin-builder.js.map +1 -1
  49. package/dist/core/sessions/session.d.ts.map +1 -1
  50. package/dist/core/sessions/session.js +6 -0
  51. package/dist/core/sessions/session.js.map +1 -1
  52. package/dist/core/sessions/state.d.ts.map +1 -1
  53. package/dist/core/sessions/state.js +43 -5
  54. package/dist/core/sessions/state.js.map +1 -1
  55. package/dist/plugins/context-compact/context-compact.integration.test.js +36 -20
  56. package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
  57. package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
  58. package/dist/plugins/context-compact/context-compactor.js +54 -9
  59. package/dist/plugins/context-compact/context-compactor.js.map +1 -1
  60. package/dist/plugins/context-compact/context-compactor.test.js +127 -4
  61. package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
  62. package/dist/plugins/limits-guard/config.d.ts +2 -0
  63. package/dist/plugins/limits-guard/config.d.ts.map +1 -1
  64. package/dist/plugins/limits-guard/limit-guard.d.ts +1 -0
  65. package/dist/plugins/limits-guard/limit-guard.d.ts.map +1 -1
  66. package/dist/plugins/limits-guard/limit-guard.js +11 -1
  67. package/dist/plugins/limits-guard/limit-guard.js.map +1 -1
  68. package/dist/plugins/limits-guard/limit-guard.test.js +9 -0
  69. package/dist/plugins/limits-guard/limit-guard.test.js.map +1 -1
  70. package/dist/plugins/limits-guard/limits-guard.integration.test.js +216 -1
  71. package/dist/plugins/limits-guard/limits-guard.integration.test.js.map +1 -1
  72. package/dist/plugins/limits-guard/plugin.d.ts +3 -1
  73. package/dist/plugins/limits-guard/plugin.d.ts.map +1 -1
  74. package/dist/plugins/limits-guard/plugin.js +48 -2
  75. package/dist/plugins/limits-guard/plugin.js.map +1 -1
  76. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  77. package/dist/plugins/uploads/plugin.js +10 -1
  78. package/dist/plugins/uploads/plugin.js.map +1 -1
  79. package/dist/plugins/uploads/uploads.integration.test.js +74 -28
  80. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  81. package/dist/testing/test-harness.d.ts +8 -0
  82. package/dist/testing/test-harness.d.ts.map +1 -1
  83. package/dist/testing/test-harness.js +18 -0
  84. package/dist/testing/test-harness.js.map +1 -1
  85. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  86. package/package.json +2 -2
  87. package/src/core/agents/agent.ts +124 -36
  88. package/src/core/agents/config.ts +6 -0
  89. package/src/core/agents/state.ts +9 -0
  90. package/src/core/context/state.ts +86 -27
  91. package/src/core/error-recovery.integration.test.ts +343 -0
  92. package/src/core/llm/anthropic.test.ts +7 -4
  93. package/src/core/llm/anthropic.ts +6 -1
  94. package/src/core/llm/message-sanitization.test.ts +217 -0
  95. package/src/core/llm/message-sanitization.ts +70 -0
  96. package/src/core/llm/middleware.ts +3 -3
  97. package/src/core/llm/openrouter-mapping.test.ts +24 -3
  98. package/src/core/llm/openrouter.ts +7 -8
  99. package/src/core/llm/provider.ts +14 -10
  100. package/src/core/llm/state.ts +10 -0
  101. package/src/core/plugins/plugin-builder.ts +6 -2
  102. package/src/core/sessions/session.ts +6 -0
  103. package/src/core/sessions/state.ts +48 -9
  104. package/src/plugins/context-compact/context-compact.integration.test.ts +38 -21
  105. package/src/plugins/context-compact/context-compactor.test.ts +150 -4
  106. package/src/plugins/context-compact/context-compactor.ts +64 -10
  107. package/src/plugins/limits-guard/config.ts +2 -0
  108. package/src/plugins/limits-guard/limit-guard.test.ts +13 -0
  109. package/src/plugins/limits-guard/limit-guard.ts +13 -1
  110. package/src/plugins/limits-guard/limits-guard.integration.test.ts +246 -1
  111. package/src/plugins/limits-guard/plugin.ts +53 -3
  112. package/src/plugins/uploads/plugin.ts +11 -1
  113. package/src/plugins/uploads/uploads.integration.test.ts +82 -28
  114. package/src/testing/test-harness.ts +20 -0
@@ -89,6 +89,8 @@ export interface AgentConfig<TInput = unknown> {
89
89
  * full re-uploads. Omit (or '5m') for the standard tier.
90
90
  */
91
91
  cacheTtl?: '5m' | '1h'
92
+ /** Outer backoff between error-resume cycles (see BaseAgentConfig.errorResumeBackoff). */
93
+ errorResumeBackoff?: { baseDelayMs?: number; maxDelayMs?: number }
92
94
  }
93
95
 
94
96
  /**
@@ -154,6 +156,7 @@ export class Agent {
154
156
 
155
157
  // Scheduler state (embedded)
156
158
  private debounceTimer?: ReturnType<typeof setTimeout>
159
+ private errorRetryTimer?: ReturnType<typeof setTimeout>
157
160
  private processing = false
158
161
  private scheduled = false
159
162
  private pendingReschedule = false
@@ -241,18 +244,34 @@ export class Agent {
241
244
  })
242
245
  for (const toolCall of agentState.pendingToolCalls) {
243
246
  await this.executeToolCall(toolCall)
247
+ // A tool hook may have paused the agent — stop the turn here instead of
248
+ // running the remaining tools (they stay pending and run after resume).
249
+ const currentState = this.state
250
+ if (!currentState || currentState.status === 'paused') return
251
+ if (this.abortController.signal.aborted) return
244
252
  }
245
253
  // Schedule re-entry via debounce after tool execution
246
254
  // (allows debounce callback to wait for child responses, etc.)
247
255
  this.scheduleProcessing()
248
256
  return
249
257
 
250
- case 'resume_from_error':
258
+ case 'resume_from_error': {
259
+ // Outer backoff between error-resume cycles: after an inference exhausts
260
+ // its LLM retries on a retryable error, the dequeue token stays unconsumed
261
+ // (preserve-for-retry), so decide() would re-enter infer immediately and
262
+ // spin without limit during a provider outage. Yield instead and come back
263
+ // when the backoff elapses; a session restart resets the episode.
264
+ const backoffRemainingMs = this.errorResumeDelayMs(agentState)
265
+ if (backoffRemainingMs > 0) {
266
+ this.scheduleErrorRetry(backoffRemainingMs)
267
+ return
268
+ }
251
269
  await this.store.emit(withSessionId(
252
270
  this.store.sessionId,
253
271
  agentEvents.create('agent_resumed', { agentId: this.id }),
254
272
  ))
255
273
  continue
274
+ }
256
275
 
257
276
  case 'infer':
258
277
  await this.runInference(agentState)
@@ -377,6 +396,10 @@ export class Agent {
377
396
  // AbortError may be thrown synchronously by abort signal listeners
378
397
  }
379
398
  this.cancelSchedule()
399
+ if (this.errorRetryTimer) {
400
+ clearTimeout(this.errorRetryTimer)
401
+ this.errorRetryTimer = undefined
402
+ }
380
403
  }
381
404
 
382
405
  /**
@@ -477,6 +500,39 @@ export class Agent {
477
500
  return 'idle'
478
501
  }
479
502
 
503
+ private static readonly DEFAULT_ERROR_RESUME_BASE_DELAY_MS = 5_000
504
+ private static readonly DEFAULT_ERROR_RESUME_MAX_DELAY_MS = 60_000
505
+
506
+ /**
507
+ * Remaining wait before an errored agent may resume. Exponential in the number
508
+ * of consecutive failures, measured from the last failure; 0 = resume now.
509
+ * Capped rather than ceilinged: the preserved message keeps retrying at the max
510
+ * delay until the provider recovers — dropping it would lose user work.
511
+ */
512
+ private errorResumeDelayMs(state: AgentState): number {
513
+ const failures = state.consecutiveInferenceFailures ?? 0
514
+ const lastFailureAt = state.lastInferenceFailureAt
515
+ if (failures === 0 || lastFailureAt === undefined) return 0
516
+ const base = this.config.errorResumeBackoff?.baseDelayMs ?? Agent.DEFAULT_ERROR_RESUME_BASE_DELAY_MS
517
+ const max = this.config.errorResumeBackoff?.maxDelayMs ?? Agent.DEFAULT_ERROR_RESUME_MAX_DELAY_MS
518
+ const delay = Math.min(base * 2 ** (failures - 1), max)
519
+ return Math.max(0, lastFailureAt + delay - Date.now())
520
+ }
521
+
522
+ /**
523
+ * Re-run continue() once the error-resume backoff elapses. A concurrent
524
+ * scheduleProcessing() (new message) is harmless — continue() re-checks the
525
+ * remaining backoff and re-arms if it hasn't elapsed yet.
526
+ */
527
+ private scheduleErrorRetry(delayMs: number): void {
528
+ if (this.errorRetryTimer) return
529
+ if (this.store.isClosed()) return
530
+ this.errorRetryTimer = setTimeout(() => {
531
+ this.errorRetryTimer = undefined
532
+ this.scheduleProcessing()
533
+ }, delayMs)
534
+ }
535
+
480
536
  private static readonly MAX_INFERENCE_RETRIES = 3
481
537
 
482
538
  /** Max retries for empty-stop LLM responses (provider glitch). Total attempts = this + 1. */
@@ -671,15 +727,7 @@ export class Agent {
671
727
  // same message into a doomed retry loop. Retryable errors (rate_limit,
672
728
  // server_error, network_error, timeout) keep the preserve-for-retry semantics.
673
729
  if (!isRetryableLLMError(llmResponse.error)) {
674
- const errorAgentState = this.state
675
- if (errorAgentState) {
676
- const errorCtx = this.buildAgentContext(errorAgentState)
677
- for (const dequeued of pluginDequeued) {
678
- if (!dequeued.plugin.dequeue) continue
679
- const pluginCtx = this.buildPluginHookContext(dequeued.plugin, errorCtx)
680
- await dequeued.plugin.dequeue.markConsumed(pluginCtx, dequeued.token)
681
- }
682
- }
730
+ await this.consumeDequeuedTokens(pluginDequeued)
683
731
  }
684
732
 
685
733
  // 4a. Inference failed — emit inference_failed without marking plugin messages
@@ -694,27 +742,22 @@ export class Agent {
694
742
  llmCallId,
695
743
  }),
696
744
  ))
697
- // Notify plugins (e.g. mailbox sends error message to parent)
745
+ // Notify plugins (e.g. mailbox sends error message to parent). For retryable
746
+ // errors, notify only on the first failure of an episode — the preserved
747
+ // token means each error-resume cycle re-fails here, and one parent
748
+ // notification per cycle would flood the parent during a provider outage.
698
749
  const errorState = this.state
699
- if (errorState) {
750
+ const isRepeatRetryableFailure = isRetryableLLMError(llmResponse.error)
751
+ && (errorState?.consecutiveInferenceFailures ?? 1) > 1
752
+ if (errorState && !isRepeatRetryableFailure) {
700
753
  await this.executeOnError(errorState, llmResponse.error.message)
701
754
  }
702
755
  return
703
756
  }
704
757
 
705
- // Mark plugin messages as consumed only after successful inference. They've been
706
- // appended to conversationHistory via the inference_completed reducer below.
707
- {
708
- const currentAgentState = this.state
709
- if (currentAgentState) {
710
- const ctx = this.buildAgentContext(currentAgentState)
711
- for (const dequeued of pluginDequeued) {
712
- if (!dequeued.plugin.dequeue) continue
713
- const pluginCtx = this.buildPluginHookContext(dequeued.plugin, ctx)
714
- await dequeued.plugin.dequeue.markConsumed(pluginCtx, dequeued.token)
715
- }
716
- }
717
- }
758
+ // Consumption of dequeue tokens is deferred to the commit points below — after
759
+ // the afterInference retry decision is final. Consuming here would strand the
760
+ // message if a hook requests a retry (the recursion could no longer re-collect it).
718
761
 
719
762
  // 4c. Sanitize response to prevent hallucination
720
763
  const sanitized = sanitizeLLMResponse(llmResponse.value.content)
@@ -742,12 +785,13 @@ export class Agent {
742
785
  if (postInferenceState) {
743
786
  agentState = postInferenceState
744
787
  }
745
- const afterResult = await this.executeAfterInference(agentState, response)
788
+ const dequeuedSourcePlugins = pluginDequeued.map((dequeued) => dequeued.plugin.name)
789
+ const afterResult = await this.executeAfterInference(agentState, response, dequeuedSourcePlugins)
746
790
  if (afterResult !== null) {
747
791
  if (afterResult.action === 'pause') {
748
- // Inference completed and messages were consumed — commit the turn
749
- // before pausing so pendingMessages move to conversationHistory.
792
+ // Commit the turn before pausing so pendingMessages move to conversationHistory.
750
793
  await this.emitInferenceCompleted(response, llmCallId, llmResponse.value.metrics)
794
+ await this.consumeDequeuedTokens(pluginDequeued)
751
795
  await this.emitHandlerPause(afterResult.reason)
752
796
  return
753
797
  }
@@ -758,6 +802,14 @@ export class Agent {
758
802
  retryCount,
759
803
  })
760
804
  } else {
805
+ // Roll back the staged turn (clears pendingMessages appended by
806
+ // inference_started) so the retry's inference_started doesn't
807
+ // double-append. Tokens are still unconsumed, so the recursion
808
+ // re-collects the same messages — nothing is lost or duplicated.
809
+ await this.store.emit(withSessionId(
810
+ this.store.sessionId,
811
+ llmEvents.create('inference_retried', { agentId: this.id }),
812
+ ))
761
813
  // Retry inference - decrement turn number and recursively call with fresh state
762
814
  this.turnNumber--
763
815
  const freshState = this.state
@@ -773,6 +825,11 @@ export class Agent {
773
825
  // 4d. Inference completed
774
826
  // Tool calls will be executed in the next continue() cycle
775
827
  await this.emitInferenceCompleted(response, llmCallId, llmResponse.value.metrics)
828
+
829
+ // Consume dequeue tokens after the commit: a crash in the gap re-delivers the
830
+ // message on restart (at-least-once) instead of dropping it (consume-first
831
+ // would lose the message — session_restarted wipes the staged pendingMessages).
832
+ await this.consumeDequeuedTokens(pluginDequeued)
776
833
  }
777
834
 
778
835
  /**
@@ -913,17 +970,18 @@ export class Agent {
913
970
  ? { isError: false, content: result.value }
914
971
  : { isError: true, content: result.error.message }
915
972
 
916
- // afterToolCall handler - can modify result or pause
973
+ // afterToolCall handler - can modify result or pause.
974
+ // On 'pause' the agent_paused event was already emitted by the hook dispatch;
975
+ // still fall through to emit the result event below — the tool's side effects
976
+ // already ran, so the committed tool_use must get its tool_result (otherwise
977
+ // the call would re-execute on resume and the history would carry an unpaired
978
+ // tool_use). The tool_completed/tool_failed reducers preserve the paused
979
+ // status, and the tool_exec loop stops before the next tool.
917
980
  const currentAgentState = this.state
918
981
  if (currentAgentState) {
919
982
  const afterResult = await this.executeAfterToolCall(currentAgentState, effectiveToolCall, toolResult)
920
- if (afterResult !== null) {
921
- if (afterResult.action === 'pause') {
922
- return
923
- }
924
- if (afterResult.action === 'modify') {
925
- toolResult = afterResult.result
926
- }
983
+ if (afterResult !== null && afterResult.action === 'modify') {
984
+ toolResult = afterResult.result
927
985
  }
928
986
  }
929
987
 
@@ -1168,6 +1226,7 @@ export class Agent {
1168
1226
  private async executeAfterInference(
1169
1227
  agentState: AgentState,
1170
1228
  response: LLMResponse,
1229
+ dequeuedSourcePlugins: readonly string[],
1171
1230
  ): Promise<AfterInferenceResult> {
1172
1231
  this.logger.debug('Executing afterInference handlers', {
1173
1232
  agentId: this.id,
@@ -1184,6 +1243,7 @@ export class Agent {
1184
1243
  ...this.buildPluginHookContext(plugin, agentContext),
1185
1244
  response,
1186
1245
  turnNumber: this.turnNumber,
1246
+ dequeuedSourcePlugins,
1187
1247
  }
1188
1248
  const result = await plugin.agentHooks.afterInference(ctx)
1189
1249
  if (result === null) {
@@ -1666,6 +1726,34 @@ export class Agent {
1666
1726
 
1667
1727
  return collected
1668
1728
  }
1729
+
1730
+ /**
1731
+ * Mark dequeued plugin tokens consumed. Called at turn commit (after
1732
+ * inference_completed, so a crash in the gap re-delivers instead of losing
1733
+ * the message) and to intentionally drop a doomed message on non-retryable
1734
+ * failures.
1735
+ */
1736
+ private async consumeDequeuedTokens(
1737
+ pluginDequeued: Array<{ plugin: ConfiguredPlugin; messages: LLMMessage[]; token: unknown }>,
1738
+ ): Promise<void> {
1739
+ const currentAgentState = this.state
1740
+ if (!currentAgentState) return
1741
+ const ctx = this.buildAgentContext(currentAgentState)
1742
+ for (const dequeued of pluginDequeued) {
1743
+ if (!dequeued.plugin.dequeue) continue
1744
+ const pluginCtx = this.buildPluginHookContext(dequeued.plugin, ctx)
1745
+ await dequeued.plugin.dequeue.markConsumed(pluginCtx, dequeued.token)
1746
+ }
1747
+ if (pluginDequeued.length > 0) {
1748
+ await this.store.emit(withSessionId(
1749
+ this.store.sessionId,
1750
+ agentEvents.create('agent_input_consumed', {
1751
+ agentId: this.id,
1752
+ sourcePlugins: pluginDequeued.map((dequeued) => dequeued.plugin.name),
1753
+ }),
1754
+ ))
1755
+ }
1756
+ }
1669
1757
  }
1670
1758
 
1671
1759
  // ============================================================================
@@ -59,6 +59,12 @@ export interface BaseAgentConfig<TInput = unknown> {
59
59
  * to the standard 5-minute tier.
60
60
  */
61
61
  cacheTtl?: '5m' | '1h'
62
+ /**
63
+ * Outer backoff between error-resume cycles after an inference exhausts its
64
+ * LLM retries on a retryable error (the message stays queued and is retried).
65
+ * Defaults: baseDelayMs 5000, maxDelayMs 60000.
66
+ */
67
+ errorResumeBackoff?: { baseDelayMs?: number; maxDelayMs?: number }
62
68
  }
63
69
 
64
70
  /**
@@ -65,6 +65,10 @@ export const agentEvents = createEventsFactory({
65
65
  agent_resumed: z4.object({
66
66
  agentId: agentIdSchema,
67
67
  }),
68
+ agent_input_consumed: z4.object({
69
+ agentId: agentIdSchema,
70
+ sourcePlugins: z4.array(z4.string()),
71
+ }),
68
72
  communicator_linked: z4.object({
69
73
  communicatorId: agentIdSchema,
70
74
  orchestratorId: agentIdSchema,
@@ -85,6 +89,7 @@ export type HandlerCompletedEvent = (typeof agentEvents)['Events']['handler_comp
85
89
  export type PreambleAddedEvent = (typeof agentEvents)['Events']['preamble_added']
86
90
  export type AgentPausedEvent = (typeof agentEvents)['Events']['agent_paused']
87
91
  export type AgentResumedEvent = (typeof agentEvents)['Events']['agent_resumed']
92
+ export type AgentInputConsumedEvent = (typeof agentEvents)['Events']['agent_input_consumed']
88
93
  export type CommunicatorLinkedEvent = (typeof agentEvents)['Events']['communicator_linked']
89
94
  export type AgentConversationSplicedEvent = (typeof agentEvents)['Events']['agent_conversation_spliced']
90
95
 
@@ -211,6 +216,10 @@ export interface AgentState {
211
216
  pauseMessage?: string
212
217
  /** Metrics from the most recent completed inference — used by plugins (e.g. context-compact) to size context against the provider-reported truth. */
213
218
  lastInferenceMetrics?: LLMMetrics
219
+ /** Consecutive inference_failed count since the last committed turn — drives the outer error-resume backoff. */
220
+ consecutiveInferenceFailures?: number
221
+ /** Timestamp of the most recent inference_failed — drives the outer error-resume backoff. */
222
+ lastInferenceFailureAt?: number
214
223
  }
215
224
 
216
225
  // ============================================================================
@@ -1,18 +1,98 @@
1
1
  import z4 from 'zod/v4'
2
2
  import { agentIdSchema } from '~/core/agents'
3
+ import type { LLMMessage } from '~/core/agents/state.js'
3
4
  import { createEventsFactory } from '~/core/events/types'
5
+ import { toolCallIdSchema } from '~/core/tools/schema.js'
6
+ import { messageIdSchema } from '~/plugins/mailbox/schema.js'
4
7
 
5
8
  // ============================================================================
6
9
  // Supporting types for context events
7
10
  // ============================================================================
8
11
 
9
12
  /**
10
- * Conversation message stored in event for reconstruction.
13
+ * Conversation message stored in the event for lossless reconstruction.
11
14
  */
12
- export type CompactedConversationMessage = {
13
- role: 'user' | 'assistant' | 'system'
14
- content: string
15
- }
15
+ export type CompactedConversationMessage = LLMMessage
16
+
17
+ const cacheControlSchema = z4.object({
18
+ type: z4.literal('ephemeral'),
19
+ ttl: z4.enum(['5m', '1h']).optional(),
20
+ })
21
+
22
+ const contentItemSchema = z4.discriminatedUnion('type', [
23
+ z4.object({
24
+ type: z4.literal('text'),
25
+ text: z4.string(),
26
+ cacheControl: z4.object({
27
+ type: z4.literal('ephemeral'),
28
+ }).optional(),
29
+ }),
30
+ z4.object({
31
+ type: z4.literal('image_url'),
32
+ imageUrl: z4.object({
33
+ url: z4.string(),
34
+ detail: z4.enum(['auto', 'low', 'high']).optional(),
35
+ }),
36
+ }),
37
+ ])
38
+
39
+ const messageContentSchema = z4.union([
40
+ z4.string(),
41
+ z4.array(contentItemSchema),
42
+ ])
43
+
44
+ export const compactedConversationMessageSchema: z4.ZodType<LLMMessage> = z4.discriminatedUnion('role', [
45
+ z4.object({
46
+ role: z4.literal('user'),
47
+ content: messageContentSchema,
48
+ sourceMessageIds: z4.array(messageIdSchema).optional(),
49
+ cacheControl: cacheControlSchema.optional(),
50
+ }),
51
+ z4.object({
52
+ role: z4.literal('assistant'),
53
+ content: z4.string(),
54
+ toolCalls: z4.array(z4.object({
55
+ id: toolCallIdSchema,
56
+ name: z4.string(),
57
+ input: z4.unknown(),
58
+ })).optional(),
59
+ cacheControl: cacheControlSchema.optional(),
60
+ }),
61
+ z4.object({
62
+ role: z4.literal('tool'),
63
+ content: messageContentSchema,
64
+ toolCallId: toolCallIdSchema,
65
+ toolName: z4.string().optional(),
66
+ isError: z4.boolean().optional(),
67
+ timestamp: z4.number().optional(),
68
+ cacheControl: cacheControlSchema.optional(),
69
+ }),
70
+ z4.object({
71
+ role: z4.literal('system'),
72
+ content: z4.string(),
73
+ cacheControl: cacheControlSchema.optional(),
74
+ }),
75
+ ])
76
+
77
+ export const contextCompactedEventSchema = z4.object({
78
+ agentId: agentIdSchema,
79
+ compactedContent: z4.string(),
80
+ /**
81
+ * Display-only snapshot of the messages that were summarized away (the
82
+ * "input" of the compaction). Not used for conversation reconstruction —
83
+ * that's driven by newConversationHistory. Optional for backward
84
+ * compatibility with events emitted before this field existed.
85
+ */
86
+ originalMessages: z4.array(z4.object({
87
+ role: z4.enum(['user', 'assistant', 'system']),
88
+ content: z4.string(),
89
+ })).optional(),
90
+ newConversationHistory: z4.array(compactedConversationMessageSchema),
91
+ originalTokens: z4.number(),
92
+ compactedTokens: z4.number(),
93
+ messagesRemoved: z4.number(),
94
+ historyPath: z4.string().optional(),
95
+ })
16
96
 
17
97
  // ============================================================================
18
98
  // Context events
@@ -20,28 +100,7 @@ export type CompactedConversationMessage = {
20
100
 
21
101
  export const contextEvents = createEventsFactory({
22
102
  events: {
23
- context_compacted: z4.object({
24
- agentId: agentIdSchema,
25
- compactedContent: z4.string(),
26
- /**
27
- * Display-only snapshot of the messages that were summarized away (the
28
- * "input" of the compaction). Not used for conversation reconstruction —
29
- * that's driven by {@link newConversationHistory}. Optional for backward
30
- * compatibility with events emitted before this field existed.
31
- */
32
- originalMessages: z4.array(z4.object({
33
- role: z4.enum(['user', 'assistant', 'system']),
34
- content: z4.string(),
35
- })).optional(),
36
- newConversationHistory: z4.array(z4.object({
37
- role: z4.enum(['user', 'assistant', 'system']),
38
- content: z4.string(),
39
- })),
40
- originalTokens: z4.number(),
41
- compactedTokens: z4.number(),
42
- messagesRemoved: z4.number(),
43
- historyPath: z4.string().optional(),
44
- }),
103
+ context_compacted: contextCompactedEventSchema,
45
104
  },
46
105
  })
47
106