@roj-ai/sdk 0.1.28 → 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 (127) 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 +12 -2
  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/services/plugin.d.ts.map +1 -1
  77. package/dist/plugins/services/plugin.js +4 -1
  78. package/dist/plugins/services/plugin.js.map +1 -1
  79. package/dist/plugins/services/prompt.js +3 -3
  80. package/dist/plugins/services/prompt.js.map +1 -1
  81. package/dist/plugins/services/schema.d.ts +8 -0
  82. package/dist/plugins/services/schema.d.ts.map +1 -1
  83. package/dist/plugins/services/services.integration.test.js +112 -0
  84. package/dist/plugins/services/services.integration.test.js.map +1 -1
  85. package/dist/plugins/uploads/plugin.d.ts.map +1 -1
  86. package/dist/plugins/uploads/plugin.js +10 -1
  87. package/dist/plugins/uploads/plugin.js.map +1 -1
  88. package/dist/plugins/uploads/uploads.integration.test.js +74 -28
  89. package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
  90. package/dist/testing/test-harness.d.ts +8 -0
  91. package/dist/testing/test-harness.d.ts.map +1 -1
  92. package/dist/testing/test-harness.js +18 -0
  93. package/dist/testing/test-harness.js.map +1 -1
  94. package/dist/transport/http/middleware/error-handler.d.ts +1 -1
  95. package/package.json +2 -2
  96. package/src/core/agents/agent.ts +124 -36
  97. package/src/core/agents/config.ts +6 -0
  98. package/src/core/agents/state.ts +9 -0
  99. package/src/core/context/state.ts +86 -27
  100. package/src/core/error-recovery.integration.test.ts +343 -0
  101. package/src/core/llm/anthropic.test.ts +7 -4
  102. package/src/core/llm/anthropic.ts +6 -1
  103. package/src/core/llm/message-sanitization.test.ts +217 -0
  104. package/src/core/llm/message-sanitization.ts +70 -0
  105. package/src/core/llm/middleware.ts +3 -3
  106. package/src/core/llm/openrouter-mapping.test.ts +24 -3
  107. package/src/core/llm/openrouter.ts +7 -8
  108. package/src/core/llm/provider.ts +14 -10
  109. package/src/core/llm/state.ts +10 -0
  110. package/src/core/plugins/plugin-builder.ts +6 -2
  111. package/src/core/sessions/session.ts +13 -3
  112. package/src/core/sessions/state.ts +48 -9
  113. package/src/plugins/context-compact/context-compact.integration.test.ts +38 -21
  114. package/src/plugins/context-compact/context-compactor.test.ts +150 -4
  115. package/src/plugins/context-compact/context-compactor.ts +64 -10
  116. package/src/plugins/limits-guard/config.ts +2 -0
  117. package/src/plugins/limits-guard/limit-guard.test.ts +13 -0
  118. package/src/plugins/limits-guard/limit-guard.ts +13 -1
  119. package/src/plugins/limits-guard/limits-guard.integration.test.ts +246 -1
  120. package/src/plugins/limits-guard/plugin.ts +53 -3
  121. package/src/plugins/services/plugin.ts +4 -1
  122. package/src/plugins/services/prompt.ts +3 -3
  123. package/src/plugins/services/schema.ts +8 -0
  124. package/src/plugins/services/services.integration.test.ts +129 -0
  125. package/src/plugins/uploads/plugin.ts +11 -1
  126. package/src/plugins/uploads/uploads.integration.test.ts +82 -28
  127. package/src/testing/test-harness.ts +20 -0
@@ -0,0 +1,217 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { LLMMessage } from '~/core/agents/state.js'
3
+ import { ToolCallId } from '~/core/tools/schema.js'
4
+ import type { LogContext, Logger } from '~/lib/logger/logger.js'
5
+ import { AnthropicProvider } from './anthropic.js'
6
+ import { sanitizeProviderMessages } from './message-sanitization.js'
7
+ import { OpenRouterProvider } from './openrouter.js'
8
+ import type { RawInferenceRequest } from './provider.js'
9
+ import { ModelId } from './schema.js'
10
+
11
+ type CapturedWarning = {
12
+ message: string
13
+ context?: LogContext
14
+ }
15
+
16
+ const createCapturingLogger = (): { logger: Logger; warnings: CapturedWarning[] } => {
17
+ const warnings: CapturedWarning[] = []
18
+ const logger: Logger = {
19
+ debug: () => {},
20
+ info: () => {},
21
+ warn: (message, context) => warnings.push({ message, context }),
22
+ error: () => {},
23
+ child: () => logger,
24
+ level: 'debug',
25
+ }
26
+ return { logger, warnings }
27
+ }
28
+
29
+ const createOpenRouterProvider = (logger?: Logger) =>
30
+ new OpenRouterProvider({
31
+ apiKey: 'test-key',
32
+ imageProcessor: { resolveContent: async (content) => content },
33
+ logger,
34
+ })
35
+
36
+ const createAnthropicProvider = (logger?: Logger) =>
37
+ new AnthropicProvider({
38
+ apiKey: 'test-key',
39
+ imageProcessor: { resolveContent: async (content) => content },
40
+ logger,
41
+ })
42
+
43
+ const buildRequest = (messages: LLMMessage[]): RawInferenceRequest => ({
44
+ model: ModelId('test-model'),
45
+ systemPrompt: 'You are helpful.',
46
+ messages,
47
+ })
48
+
49
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
50
+ typeof value === 'object' && value !== null
51
+
52
+ const getBodyMessages = (body: unknown): Record<string, unknown>[] => {
53
+ if (!isRecord(body) || !Array.isArray(body.messages)) {
54
+ throw new Error('Expected a request body with messages')
55
+ }
56
+ return body.messages.map((message) => {
57
+ if (!isRecord(message)) {
58
+ throw new Error('Expected each request message to be an object')
59
+ }
60
+ return message
61
+ })
62
+ }
63
+
64
+ const getContentBlocks = (message: Record<string, unknown>): Record<string, unknown>[] => {
65
+ if (!Array.isArray(message.content)) {
66
+ throw new Error('Expected message content blocks')
67
+ }
68
+ return message.content.map((block) => {
69
+ if (!isRecord(block)) {
70
+ throw new Error('Expected each content block to be an object')
71
+ }
72
+ return block
73
+ })
74
+ }
75
+
76
+ const wellFormedToolHistory = (): LLMMessage[] => [
77
+ { role: 'user', content: 'Check the weather' },
78
+ {
79
+ role: 'assistant',
80
+ content: '',
81
+ toolCalls: [{ id: ToolCallId('call_1'), name: 'get_weather', input: { city: 'Prague' } }],
82
+ },
83
+ {
84
+ role: 'tool',
85
+ toolCallId: ToolCallId('call_1'),
86
+ toolName: 'get_weather',
87
+ content: 'Sunny',
88
+ },
89
+ ]
90
+
91
+ const orphanedToolHistory = (): LLMMessage[] => [
92
+ { role: 'user', content: 'Check the weather' },
93
+ { role: 'assistant', content: 'I will check.' },
94
+ {
95
+ role: 'tool',
96
+ toolCallId: ToolCallId('orphan_1'),
97
+ toolName: 'get_weather',
98
+ content: 'Sunny',
99
+ cacheControl: { type: 'ephemeral' },
100
+ },
101
+ ]
102
+
103
+ describe('sanitizeProviderMessages', () => {
104
+ test('returns a well-formed history unchanged', () => {
105
+ const messages = wellFormedToolHistory()
106
+ const result = sanitizeProviderMessages(messages, 'test')
107
+
108
+ expect(result).toBe(messages)
109
+ expect(result).toEqual(messages)
110
+ })
111
+
112
+ test('preserves an orphaned tool result as a marked user message and warns', () => {
113
+ const { logger, warnings } = createCapturingLogger()
114
+ const result = sanitizeProviderMessages(orphanedToolHistory(), 'test', logger)
115
+
116
+ expect(result[result.length - 1]).toEqual({
117
+ role: 'user',
118
+ content: '[Orphaned tool result from "get_weather"; tool call ID: orphan_1]\nSunny',
119
+ cacheControl: { type: 'ephemeral' },
120
+ })
121
+ expect(warnings).toEqual([{
122
+ message: 'Orphaned tool result converted to user message',
123
+ context: {
124
+ provider: 'test',
125
+ messageIndex: 2,
126
+ toolCallId: 'orphan_1',
127
+ toolName: 'get_weather',
128
+ },
129
+ }])
130
+ })
131
+
132
+ test('rejects a history ending in an assistant message', () => {
133
+ expect(() => sanitizeProviderMessages([
134
+ { role: 'user', content: 'Hello' },
135
+ { role: 'assistant', content: 'Hello back' },
136
+ ], 'test')).toThrow('test request history ends with an assistant message after sanitation')
137
+ })
138
+ })
139
+
140
+ describe('OpenRouterProvider message sanitation', () => {
141
+ test('converts a trailing orphan to user without moving its cache breakpoint', async () => {
142
+ const { logger, warnings } = createCapturingLogger()
143
+ const request = await createOpenRouterProvider(logger).buildHttpRequest(buildRequest(orphanedToolHistory()))
144
+ const messages = getBodyMessages(request.body)
145
+ const last = messages[messages.length - 1]
146
+ const blocks = getContentBlocks(last)
147
+
148
+ expect(last.role).toBe('user')
149
+ expect(blocks).toEqual([{
150
+ type: 'text',
151
+ text: '[Orphaned tool result from "get_weather"; tool call ID: orphan_1]\nSunny',
152
+ cache_control: { type: 'ephemeral' },
153
+ }])
154
+ expect(warnings).toHaveLength(1)
155
+ })
156
+
157
+ test('keeps a matched tool result as a tool message', async () => {
158
+ const request = await createOpenRouterProvider().buildHttpRequest(buildRequest(wellFormedToolHistory()))
159
+ const messages = getBodyMessages(request.body)
160
+ const last = messages[messages.length - 1]
161
+
162
+ expect(last).toEqual({
163
+ role: 'tool',
164
+ content: 'Sunny',
165
+ tool_call_id: 'call_1',
166
+ })
167
+ })
168
+
169
+ test('rejects an assistant tail before issuing a request', async () => {
170
+ const promise = createOpenRouterProvider().buildHttpRequest(buildRequest([
171
+ { role: 'user', content: 'Hello' },
172
+ { role: 'assistant', content: 'Hello back' },
173
+ ]))
174
+
175
+ await expect(promise).rejects.toThrow('openrouter request history ends with an assistant message after sanitation')
176
+ })
177
+ })
178
+
179
+ describe('AnthropicProvider message sanitation', () => {
180
+ test('converts a trailing orphan to user without moving its cache breakpoint', async () => {
181
+ const { logger, warnings } = createCapturingLogger()
182
+ const request = await createAnthropicProvider(logger).buildHttpRequest(buildRequest(orphanedToolHistory()))
183
+ const messages = getBodyMessages(request.body)
184
+ const last = messages[messages.length - 1]
185
+ const blocks = getContentBlocks(last)
186
+
187
+ expect(last.role).toBe('user')
188
+ expect(blocks).toEqual([{
189
+ type: 'text',
190
+ text: '[Orphaned tool result from "get_weather"; tool call ID: orphan_1]\nSunny',
191
+ cache_control: { type: 'ephemeral' },
192
+ }])
193
+ expect(warnings).toHaveLength(1)
194
+ })
195
+
196
+ test('keeps a matched tool result as a tool_result block', async () => {
197
+ const request = await createAnthropicProvider().buildHttpRequest(buildRequest(wellFormedToolHistory()))
198
+ const messages = getBodyMessages(request.body)
199
+ const last = messages[messages.length - 1]
200
+
201
+ expect(last.role).toBe('user')
202
+ expect(getContentBlocks(last)).toEqual([{
203
+ type: 'tool_result',
204
+ tool_use_id: 'call_1',
205
+ content: 'Sunny',
206
+ }])
207
+ })
208
+
209
+ test('rejects an assistant tail before issuing a request', async () => {
210
+ const promise = createAnthropicProvider().buildHttpRequest(buildRequest([
211
+ { role: 'user', content: 'Hello' },
212
+ { role: 'assistant', content: 'Hello back' },
213
+ ]))
214
+
215
+ await expect(promise).rejects.toThrow('anthropic request history ends with an assistant message after sanitation')
216
+ })
217
+ })
@@ -0,0 +1,70 @@
1
+ import type { LLMMessage, ToolLLMMessage, UserLLMMessage } from '~/core/agents/state.js'
2
+ import type { ChatMessageContentItem } from '~/core/llm/llm-log-types.js'
3
+ import type { Logger } from '~/lib/logger/logger.js'
4
+
5
+ export class ProviderMessageValidationError extends Error {
6
+ constructor(message: string) {
7
+ super(message)
8
+ this.name = 'ProviderMessageValidationError'
9
+ }
10
+ }
11
+
12
+ const convertOrphanedToolResult = (message: ToolLLMMessage): UserLLMMessage => {
13
+ const toolName = message.toolName ? ` from "${message.toolName}"` : ''
14
+ const errorMarker = message.isError ? ' (error)' : ''
15
+ const marker = `[Orphaned tool result${toolName}; tool call ID: ${message.toolCallId}${errorMarker}]`
16
+ const markerItem: ChatMessageContentItem = { type: 'text', text: marker }
17
+ const content = typeof message.content === 'string'
18
+ ? `${marker}\n${message.content}`
19
+ : [markerItem, ...message.content]
20
+
21
+ return {
22
+ role: 'user',
23
+ content,
24
+ cacheControl: message.cacheControl,
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Preserve orphaned tool results as user messages and reject assistant-prefill requests.
30
+ */
31
+ export const sanitizeProviderMessages = (
32
+ messages: LLMMessage[],
33
+ provider: string,
34
+ logger?: Logger,
35
+ ): LLMMessage[] => {
36
+ const declaredToolCallIds = new Set<string>()
37
+ let sanitizedMessages: LLMMessage[] | undefined
38
+
39
+ for (const [messageIndex, message] of messages.entries()) {
40
+ if (message.role === 'assistant') {
41
+ for (const toolCall of message.toolCalls ?? []) {
42
+ declaredToolCallIds.add(toolCall.id)
43
+ }
44
+ }
45
+
46
+ if (message.role === 'tool' && !declaredToolCallIds.has(message.toolCallId)) {
47
+ sanitizedMessages ??= messages.slice(0, messageIndex)
48
+ sanitizedMessages.push(convertOrphanedToolResult(message))
49
+ logger?.warn('Orphaned tool result converted to user message', {
50
+ provider,
51
+ messageIndex,
52
+ toolCallId: message.toolCallId,
53
+ toolName: message.toolName,
54
+ })
55
+ continue
56
+ }
57
+
58
+ sanitizedMessages?.push(message)
59
+ }
60
+
61
+ const result = sanitizedMessages ?? messages
62
+ const lastMessage = result[result.length - 1]
63
+ if (lastMessage?.role === 'assistant') {
64
+ throw new ProviderMessageValidationError(
65
+ `${provider} request history ends with an assistant message after sanitation`,
66
+ )
67
+ }
68
+
69
+ return result
70
+ }
@@ -109,11 +109,11 @@ export function useProvider(name: string): LLMMiddleware {
109
109
  *
110
110
  * @example
111
111
  * ```ts
112
- * // Exclude Google providers:
113
- * llmMiddleware: [withOpenRouter({ providers: { deny: ['Google'] } })]
112
+ * // Exclude Amazon Bedrock while retaining provider fallbacks:
113
+ * llmMiddleware: [withOpenRouter({ providers: { ignore: ['amazon-bedrock'], allow_fallbacks: true } })]
114
114
  *
115
115
  * // Prefer specific providers:
116
- * llmMiddleware: [withOpenRouter({ providers: { order: ['Anthropic', 'AWS Bedrock'] } })]
116
+ * llmMiddleware: [withOpenRouter({ providers: { order: ['anthropic', 'google-vertex'] } })]
117
117
  * ```
118
118
  */
119
119
  export function withOpenRouter(opts: OpenRouterRequestOptions): LLMMiddleware {
@@ -47,7 +47,26 @@ const asBlocks = (content: string | AnyBlock[]): AnyBlock[] => {
47
47
  return content
48
48
  }
49
49
 
50
- describe('OpenRouterProvider buildHttpRequest cache placement', () => {
50
+ describe('OpenRouterProvider buildHttpRequest', () => {
51
+ test('includes inline provider routing preferences', async () => {
52
+ const request = buildRequest([{ role: 'user', content: 'Hello' }])
53
+ request.openrouter = {
54
+ providers: {
55
+ ignore: ['amazon-bedrock'],
56
+ allow_fallbacks: true,
57
+ },
58
+ }
59
+
60
+ const http = await createProvider().buildHttpRequest(request)
61
+
62
+ expect(http.body).toEqual(expect.objectContaining({
63
+ provider: {
64
+ ignore: ['amazon-bedrock'],
65
+ allow_fallbacks: true,
66
+ },
67
+ }))
68
+ })
69
+
51
70
  test('pure tool_result target: cache_control lands on the tool role message content', async () => {
52
71
  const messages = applyCacheBreakpoint([
53
72
  { role: 'user', content: 'What is the weather?' },
@@ -87,12 +106,14 @@ describe('OpenRouterProvider buildHttpRequest cache placement', () => {
87
106
  { id: ToolCallId('call_2'), name: 'search', input: { query: 'weather' } },
88
107
  ],
89
108
  },
90
- ], 0)
109
+ { role: 'tool', toolCallId: ToolCallId('call_1'), content: 'Sunny' },
110
+ { role: 'tool', toolCallId: ToolCallId('call_2'), content: 'Found results' },
111
+ ], 2)
91
112
 
92
113
  const http = await createProvider().buildHttpRequest(buildRequest(messages))
93
114
  const msgs = getBodyMessages(http.body)
94
115
 
95
- const assistant = msgs[msgs.length - 1]
116
+ const assistant = msgs[msgs.length - 3]
96
117
  expect(assistant.role).toBe('assistant')
97
118
  expect(assistant.tool_calls).toBeDefined()
98
119
  // String content got wrapped so cache_control has a place to live
@@ -13,9 +13,11 @@ import type {
13
13
  LLMMessage,
14
14
  LLMMetrics,
15
15
  LLMProvider,
16
+ OpenRouterProviderRouting,
16
17
  ProviderHttpRequest,
17
18
  RawInferenceRequest,
18
19
  } from './provider.js'
20
+ import { ProviderMessageValidationError, sanitizeProviderMessages } from './message-sanitization.js'
19
21
 
20
22
  // ============================================================================
21
23
  // Configuration
@@ -125,13 +127,6 @@ interface OpenRouterToolDefinition {
125
127
  }
126
128
  }
127
129
 
128
- interface OpenRouterProviderRouting {
129
- order?: string[]
130
- allow?: string[]
131
- deny?: string[]
132
- quantizations?: string[]
133
- }
134
-
135
130
  interface OpenRouterRequestBody {
136
131
  model: string
137
132
  messages: OpenRouterMessage[]
@@ -263,7 +258,8 @@ export class OpenRouterProvider implements LLMProvider {
263
258
  }
264
259
 
265
260
  async buildHttpRequest(request: RawInferenceRequest, context?: InferenceContext): Promise<ProviderHttpRequest> {
266
- const mappedMessages = await Promise.all(request.messages.map((m) => this.mapMessage(m, context)))
261
+ const sanitizedMessages = sanitizeProviderMessages(request.messages, this.name, this.logger)
262
+ const mappedMessages = await Promise.all(sanitizedMessages.map((m) => this.mapMessage(m, context)))
267
263
 
268
264
  const messages: OpenRouterMessage[] = [
269
265
  { role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control: { type: 'ephemeral' } }] },
@@ -447,6 +443,9 @@ export class OpenRouterProvider implements LLMProvider {
447
443
  }
448
444
 
449
445
  private mapError(err: unknown): LLMError {
446
+ if (err instanceof ProviderMessageValidationError) {
447
+ return { type: 'invalid_request', message: err.message }
448
+ }
450
449
  if (err instanceof Error && err.name === 'AbortError') {
451
450
  return { type: 'aborted', message: 'Request was aborted' }
452
451
  }
@@ -28,18 +28,22 @@ export interface RawToolSpec {
28
28
  *
29
29
  * @see https://openrouter.ai/docs/api-reference/overview
30
30
  */
31
+ export interface OpenRouterProviderRouting {
32
+ /** Preferred provider ordering */
33
+ order?: string[]
34
+ /** Only allow these providers */
35
+ only?: string[]
36
+ /** Exclude these providers */
37
+ ignore?: string[]
38
+ /** Whether to allow backup providers */
39
+ allow_fallbacks?: boolean
40
+ /** Allowed quantizations (e.g. "bf16", "int8") */
41
+ quantizations?: string[]
42
+ }
43
+
31
44
  export interface OpenRouterRequestOptions {
32
45
  /** Provider routing preferences */
33
- providers?: {
34
- /** Preferred provider ordering */
35
- order?: string[]
36
- /** Only allow these providers */
37
- allow?: string[]
38
- /** Exclude these providers */
39
- deny?: string[]
40
- /** Allowed quantizations (e.g. "bf16", "int8") */
41
- quantizations?: string[]
42
- }
46
+ providers?: OpenRouterProviderRouting
43
47
  /** Routing strategy */
44
48
  route?: 'fallback'
45
49
  /** Model transforms */
@@ -97,6 +97,15 @@ export const llmEvents = createEventsFactory({
97
97
  error: z4.string(),
98
98
  llmCallId: llmCallIdSchema.optional(),
99
99
  }),
100
+ /**
101
+ * A plugin's afterInference hook requested a retry of the turn. Rolls back
102
+ * the staged turn (pendingMessages from inference_started) so the retried
103
+ * inference can restage the same messages without double-appending; dequeue
104
+ * tokens stay unconsumed until the turn actually commits.
105
+ */
106
+ inference_retried: z4.object({
107
+ agentId: agentIdSchema,
108
+ }),
100
109
  },
101
110
  })
102
111
 
@@ -104,3 +113,4 @@ export type InferenceStartedEvent = (typeof llmEvents)['Events']['inference_star
104
113
  export type InferenceCompletedEvent = (typeof llmEvents)['Events']['inference_completed']
105
114
  export type AuxiliaryInferenceCompletedEvent = (typeof llmEvents)['Events']['auxiliary_inference_completed']
106
115
  export type InferenceFailedEvent = (typeof llmEvents)['Events']['inference_failed']
116
+ export type InferenceRetriedEvent = (typeof llmEvents)['Events']['inference_retried']
@@ -228,7 +228,9 @@ type PluginSelf<TMethods extends Record<string, MethodEntry>> = {
228
228
  type HookMap<TCtx> = {
229
229
  onStart: (ctx: TCtx) => Promise<OnStartResult>
230
230
  beforeInference: (ctx: TCtx & { pendingMessages: MailboxMessage[]; turnNumber: number }) => Promise<BeforeInferenceResult>
231
- afterInference: (ctx: TCtx & { response: LLMResponse; turnNumber: number }) => Promise<AfterInferenceResult>
231
+ afterInference: (
232
+ ctx: TCtx & { response: LLMResponse; turnNumber: number; dequeuedSourcePlugins: readonly string[] },
233
+ ) => Promise<AfterInferenceResult>
232
234
  beforeToolCall: (ctx: TCtx & { toolCall: ToolCall }) => Promise<BeforeToolCallResult>
233
235
  afterToolCall: (ctx: TCtx & { toolCall: ToolCall; result: { isError: boolean; content: ToolResultContent } }) => Promise<AfterToolCallResult>
234
236
  onComplete: (ctx: TCtx) => Promise<OnCompleteResult>
@@ -298,7 +300,9 @@ export type BaseMethodHandlerContext = SessionContext & {
298
300
  type ErasedAgentHookMap = {
299
301
  onStart: (ctx: BasePluginHookContext) => Promise<OnStartResult>
300
302
  beforeInference: (ctx: BasePluginHookContext & { pendingMessages: MailboxMessage[]; turnNumber: number }) => Promise<BeforeInferenceResult>
301
- afterInference: (ctx: BasePluginHookContext & { response: LLMResponse; turnNumber: number }) => Promise<AfterInferenceResult>
303
+ afterInference: (
304
+ ctx: BasePluginHookContext & { response: LLMResponse; turnNumber: number; dequeuedSourcePlugins: readonly string[] },
305
+ ) => Promise<AfterInferenceResult>
302
306
  beforeToolCall: (ctx: BasePluginHookContext & { toolCall: ToolCall }) => Promise<BeforeToolCallResult>
303
307
  afterToolCall: (
304
308
  ctx: BasePluginHookContext & { toolCall: ToolCall; result: { isError: boolean; content: ToolResultContent } },
@@ -234,6 +234,9 @@ export class Session {
234
234
  * Schedule agent processing (with debounce).
235
235
  */
236
236
  scheduleAgent(agentId: AgentId): void {
237
+ const agentState = this.store.getAgentState(agentId)
238
+ if (!agentState || agentState.status === 'paused') return
239
+
237
240
  const agent = this.agents.get(agentId)
238
241
  if (agent) {
239
242
  agent.scheduleProcessing()
@@ -800,13 +803,17 @@ export class Session {
800
803
  */
801
804
  private getAgentConfig(definitionName: string): AgentConfig {
802
805
  const withServicePluginConfig = (
803
- config: { services?: { type: string }[]; plugins?: AgentPluginConfig[] },
806
+ config: { services?: { type: string; agentVisible?: boolean }[]; plugins?: AgentPluginConfig[] },
804
807
  ): AgentPluginConfig[] | undefined => {
805
808
  const base = config.plugins ?? []
806
- if (!config.services || config.services.length === 0) return base.length > 0 ? base : undefined
809
+ // agentVisible: false services stay registered at the session level (SessionManager
810
+ // collects them from agent definitions) but are excluded from the agent's own
811
+ // visibility list — no service_* tools, no session-context status line.
812
+ const visible = (config.services ?? []).filter(s => s.agentVisible !== false)
813
+ if (visible.length === 0) return base.length > 0 ? base : undefined
807
814
  // Merge services config into plugins array (if not already present)
808
815
  if (base.some(c => c.pluginName === 'services')) return base
809
- return [...base, { pluginName: 'services', config: { services: config.services.map(s => s.type) } }]
816
+ return [...base, { pluginName: 'services', config: { services: visible.map(s => s.type) } }]
810
817
  }
811
818
 
812
819
  if (definitionName === ORCHESTRATOR_ROLE) {
@@ -822,6 +829,7 @@ export class Session {
822
829
  input: orch.input,
823
830
  plugins: withServicePluginConfig(orch),
824
831
  cacheTtl: orch.cacheTtl,
832
+ errorResumeBackoff: orch.errorResumeBackoff,
825
833
  }
826
834
  }
827
835
 
@@ -838,6 +846,7 @@ export class Session {
838
846
  input: comm.input,
839
847
  plugins: withServicePluginConfig(comm),
840
848
  cacheTtl: comm.cacheTtl,
849
+ errorResumeBackoff: comm.errorResumeBackoff,
841
850
  }
842
851
  }
843
852
 
@@ -857,6 +866,7 @@ export class Session {
857
866
  input: agentDef.input,
858
867
  plugins: withServicePluginConfig(agentDef),
859
868
  cacheTtl: agentDef.cacheTtl,
869
+ errorResumeBackoff: agentDef.errorResumeBackoff,
860
870
  }
861
871
  }
862
872
  }
@@ -202,12 +202,16 @@ export const coreReducer = createTypedReducer(
202
202
 
203
203
  return {
204
204
  ...agent,
205
- status: hasToolCalls ? 'tool_exec' : 'pending',
205
+ // Preserve a pause that landed during the in-flight inference — the pause
206
+ // defers (tool calls stay pending until resume) instead of being clobbered.
207
+ status: agent.status === 'paused' ? 'paused' : hasToolCalls ? 'tool_exec' : 'pending',
206
208
  conversationHistory: [...agent.conversationHistory, ...agent.pendingMessages, assistantMessage],
207
209
  pendingToolCalls: toolCalls,
208
210
  pendingMessages: [],
209
211
  pendingToolResults: [],
210
212
  lastInferenceMetrics: event.metrics,
213
+ consecutiveInferenceFailures: 0,
214
+ lastInferenceFailureAt: undefined,
211
215
  }
212
216
  })
213
217
  }
@@ -221,6 +225,19 @@ export const coreReducer = createTypedReducer(
221
225
  ...agent,
222
226
  status: 'errored',
223
227
  pendingMessages: [],
228
+ consecutiveInferenceFailures: (agent.consecutiveInferenceFailures ?? 0) + 1,
229
+ lastInferenceFailureAt: event.timestamp,
230
+ }))
231
+
232
+ case 'inference_retried':
233
+ // Plugin-requested retry: roll back the staged turn (like inference_failed)
234
+ // so the retried inference_started doesn't double-append pendingMessages.
235
+ // Dequeue tokens are still unconsumed (consumption happens at commit), so
236
+ // the retry re-collects the same messages.
237
+ return updateAgent(state, event.agentId, (agent) => ({
238
+ ...agent,
239
+ status: agent.status === 'paused' ? 'paused' : 'pending',
240
+ pendingMessages: [],
224
241
  }))
225
242
 
226
243
  case 'tool_completed': {
@@ -242,7 +259,9 @@ export const coreReducer = createTypedReducer(
242
259
  ...agent,
243
260
  pendingToolCalls: remaining,
244
261
  pendingToolResults: [...agent.pendingToolResults, pendingToolResult],
245
- status: remaining.length === 0 ? 'pending' : 'tool_exec',
262
+ // Preserve a pause raised by a tool hook — recomputing the status here
263
+ // would silently un-pause the agent mid-turn.
264
+ status: agent.status === 'paused' ? 'paused' : remaining.length === 0 ? 'pending' : 'tool_exec',
246
265
  executingToolCall: undefined,
247
266
  }
248
267
  })
@@ -267,7 +286,7 @@ export const coreReducer = createTypedReducer(
267
286
  ...agent,
268
287
  pendingToolCalls: remaining,
269
288
  pendingToolResults: [...agent.pendingToolResults, pendingToolResult],
270
- status: remaining.length === 0 ? 'pending' : 'tool_exec',
289
+ status: agent.status === 'paused' ? 'paused' : remaining.length === 0 ? 'pending' : 'tool_exec',
271
290
  executingToolCall: undefined,
272
291
  }
273
292
  })
@@ -276,23 +295,37 @@ export const coreReducer = createTypedReducer(
276
295
  case 'context_compacted':
277
296
  return updateAgent(state, event.agentId, (agent) => ({
278
297
  ...agent,
279
- conversationHistory: event.newConversationHistory.map((m) => {
298
+ conversationHistory: event.newConversationHistory.map((m): LLMMessage => {
280
299
  switch (m.role) {
281
300
  case 'user':
282
301
  return {
283
- role: 'user' as const,
302
+ role: 'user',
284
303
  content: m.content,
285
- sourceMessageIds: [],
304
+ sourceMessageIds: m.sourceMessageIds ?? [],
305
+ cacheControl: m.cacheControl,
286
306
  }
287
307
  case 'assistant':
288
308
  return {
289
- role: 'assistant' as const,
309
+ role: 'assistant',
310
+ content: m.content,
311
+ toolCalls: m.toolCalls,
312
+ cacheControl: m.cacheControl,
313
+ }
314
+ case 'tool':
315
+ return {
316
+ role: 'tool',
290
317
  content: m.content,
318
+ toolCallId: m.toolCallId,
319
+ toolName: m.toolName,
320
+ isError: m.isError,
321
+ timestamp: m.timestamp,
322
+ cacheControl: m.cacheControl,
291
323
  }
292
324
  case 'system':
293
325
  return {
294
- role: 'system' as const,
326
+ role: 'system',
295
327
  content: m.content,
328
+ cacheControl: m.cacheControl,
296
329
  }
297
330
  }
298
331
  }),
@@ -320,6 +353,10 @@ export const coreReducer = createTypedReducer(
320
353
  ...updated,
321
354
  status: 'pending' as const,
322
355
  pendingMessages: [],
356
+ // Restart is an explicit external trigger — start a fresh error episode
357
+ // so the outer error-resume backoff doesn't delay the first attempt.
358
+ consecutiveInferenceFailures: 0,
359
+ lastInferenceFailureAt: undefined,
323
360
  }
324
361
  changed = true
325
362
  }
@@ -403,7 +440,9 @@ export const coreReducer = createTypedReducer(
403
440
  case 'agent_resumed':
404
441
  return updateAgent(state, event.agentId, (agent) => ({
405
442
  ...agent,
406
- status: 'pending',
443
+ // Resume into tool_exec when the pause interrupted a tool turn — going to
444
+ // 'pending' would run inference with unresolved tool calls (unpaired tool_use).
445
+ status: agent.pendingToolCalls.length > 0 ? 'tool_exec' : 'pending',
407
446
  pauseReason: undefined,
408
447
  pauseMessage: undefined,
409
448
  }))