@roj-ai/sdk 0.1.29 → 0.1.31
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/dist/core/agents/agent.d.ts +28 -0
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js +116 -39
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/agents/config.d.ts +9 -0
- package/dist/core/agents/config.d.ts.map +1 -1
- package/dist/core/agents/state.d.ts +9 -0
- package/dist/core/agents/state.d.ts.map +1 -1
- package/dist/core/agents/state.js +4 -0
- package/dist/core/agents/state.js.map +1 -1
- package/dist/core/context/state.d.ts +22 -13
- package/dist/core/context/state.d.ts.map +1 -1
- package/dist/core/context/state.js +78 -22
- package/dist/core/context/state.js.map +1 -1
- package/dist/core/error-recovery.integration.test.d.ts +2 -0
- package/dist/core/error-recovery.integration.test.d.ts.map +1 -0
- package/dist/core/error-recovery.integration.test.js +288 -0
- package/dist/core/error-recovery.integration.test.js.map +1 -0
- package/dist/core/llm/anthropic.d.ts.map +1 -1
- package/dist/core/llm/anthropic.js +6 -1
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/anthropic.test.js +7 -4
- package/dist/core/llm/anthropic.test.js.map +1 -1
- package/dist/core/llm/message-sanitization.d.ts +10 -0
- package/dist/core/llm/message-sanitization.d.ts.map +1 -0
- package/dist/core/llm/message-sanitization.js +53 -0
- package/dist/core/llm/message-sanitization.js.map +1 -0
- package/dist/core/llm/message-sanitization.test.d.ts +2 -0
- package/dist/core/llm/message-sanitization.test.d.ts.map +1 -0
- package/dist/core/llm/message-sanitization.test.js +181 -0
- package/dist/core/llm/message-sanitization.test.js.map +1 -0
- package/dist/core/llm/middleware.d.ts +3 -3
- package/dist/core/llm/middleware.js +3 -3
- package/dist/core/llm/openrouter-mapping.test.js +21 -3
- package/dist/core/llm/openrouter-mapping.test.js.map +1 -1
- package/dist/core/llm/openrouter.d.ts.map +1 -1
- package/dist/core/llm/openrouter.js +6 -1
- package/dist/core/llm/openrouter.js.map +1 -1
- package/dist/core/llm/provider.d.ts +13 -10
- package/dist/core/llm/provider.d.ts.map +1 -1
- package/dist/core/llm/provider.js.map +1 -1
- package/dist/core/llm/state.d.ts +10 -0
- package/dist/core/llm/state.d.ts.map +1 -1
- package/dist/core/llm/state.js +9 -0
- package/dist/core/llm/state.js.map +1 -1
- package/dist/core/plugins/plugin-builder.d.ts +2 -0
- package/dist/core/plugins/plugin-builder.d.ts.map +1 -1
- package/dist/core/plugins/plugin-builder.js.map +1 -1
- package/dist/core/sessions/session.d.ts.map +1 -1
- package/dist/core/sessions/session.js +6 -0
- package/dist/core/sessions/session.js.map +1 -1
- package/dist/core/sessions/state.d.ts.map +1 -1
- package/dist/core/sessions/state.js +43 -5
- package/dist/core/sessions/state.js.map +1 -1
- package/dist/plugins/agents/agents.integration.test.js +147 -0
- package/dist/plugins/agents/agents.integration.test.js.map +1 -1
- package/dist/plugins/agents/plugin.d.ts.map +1 -1
- package/dist/plugins/agents/plugin.js +35 -2
- package/dist/plugins/agents/plugin.js.map +1 -1
- package/dist/plugins/context-compact/context-compact.integration.test.js +36 -20
- package/dist/plugins/context-compact/context-compact.integration.test.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.d.ts.map +1 -1
- package/dist/plugins/context-compact/context-compactor.js +54 -9
- package/dist/plugins/context-compact/context-compactor.js.map +1 -1
- package/dist/plugins/context-compact/context-compactor.test.js +127 -4
- package/dist/plugins/context-compact/context-compactor.test.js.map +1 -1
- package/dist/plugins/limits-guard/config.d.ts +2 -0
- package/dist/plugins/limits-guard/config.d.ts.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.d.ts +1 -0
- package/dist/plugins/limits-guard/limit-guard.d.ts.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.js +11 -1
- package/dist/plugins/limits-guard/limit-guard.js.map +1 -1
- package/dist/plugins/limits-guard/limit-guard.test.js +9 -0
- package/dist/plugins/limits-guard/limit-guard.test.js.map +1 -1
- package/dist/plugins/limits-guard/limits-guard.integration.test.js +216 -1
- package/dist/plugins/limits-guard/limits-guard.integration.test.js.map +1 -1
- package/dist/plugins/limits-guard/plugin.d.ts +3 -1
- package/dist/plugins/limits-guard/plugin.d.ts.map +1 -1
- package/dist/plugins/limits-guard/plugin.js +48 -2
- package/dist/plugins/limits-guard/plugin.js.map +1 -1
- package/dist/plugins/uploads/plugin.d.ts.map +1 -1
- package/dist/plugins/uploads/plugin.js +10 -1
- package/dist/plugins/uploads/plugin.js.map +1 -1
- package/dist/plugins/uploads/uploads.integration.test.js +74 -28
- package/dist/plugins/uploads/uploads.integration.test.js.map +1 -1
- package/dist/testing/test-harness.d.ts +8 -0
- package/dist/testing/test-harness.d.ts.map +1 -1
- package/dist/testing/test-harness.js +18 -0
- package/dist/testing/test-harness.js.map +1 -1
- package/dist/transport/http/middleware/error-handler.d.ts +1 -1
- package/package.json +2 -2
- package/src/core/agents/agent.ts +124 -36
- package/src/core/agents/config.ts +6 -0
- package/src/core/agents/state.ts +9 -0
- package/src/core/context/state.ts +86 -27
- package/src/core/error-recovery.integration.test.ts +343 -0
- package/src/core/llm/anthropic.test.ts +7 -4
- package/src/core/llm/anthropic.ts +6 -1
- package/src/core/llm/message-sanitization.test.ts +217 -0
- package/src/core/llm/message-sanitization.ts +70 -0
- package/src/core/llm/middleware.ts +3 -3
- package/src/core/llm/openrouter-mapping.test.ts +24 -3
- package/src/core/llm/openrouter.ts +7 -8
- package/src/core/llm/provider.ts +14 -10
- package/src/core/llm/state.ts +10 -0
- package/src/core/plugins/plugin-builder.ts +6 -2
- package/src/core/sessions/session.ts +6 -0
- package/src/core/sessions/state.ts +48 -9
- package/src/plugins/agents/agents.integration.test.ts +168 -0
- package/src/plugins/agents/plugin.ts +39 -2
- package/src/plugins/context-compact/context-compact.integration.test.ts +38 -21
- package/src/plugins/context-compact/context-compactor.test.ts +150 -4
- package/src/plugins/context-compact/context-compactor.ts +64 -10
- package/src/plugins/limits-guard/config.ts +2 -0
- package/src/plugins/limits-guard/limit-guard.test.ts +13 -0
- package/src/plugins/limits-guard/limit-guard.ts +13 -1
- package/src/plugins/limits-guard/limits-guard.integration.test.ts +246 -1
- package/src/plugins/limits-guard/plugin.ts +53 -3
- package/src/plugins/uploads/plugin.ts +11 -1
- package/src/plugins/uploads/uploads.integration.test.ts +82 -28
- 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
|
|
113
|
-
* llmMiddleware: [withOpenRouter({ providers: {
|
|
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: ['
|
|
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
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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
|
}
|
package/src/core/llm/provider.ts
CHANGED
|
@@ -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 */
|
package/src/core/llm/state.ts
CHANGED
|
@@ -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: (
|
|
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: (
|
|
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()
|
|
@@ -826,6 +829,7 @@ export class Session {
|
|
|
826
829
|
input: orch.input,
|
|
827
830
|
plugins: withServicePluginConfig(orch),
|
|
828
831
|
cacheTtl: orch.cacheTtl,
|
|
832
|
+
errorResumeBackoff: orch.errorResumeBackoff,
|
|
829
833
|
}
|
|
830
834
|
}
|
|
831
835
|
|
|
@@ -842,6 +846,7 @@ export class Session {
|
|
|
842
846
|
input: comm.input,
|
|
843
847
|
plugins: withServicePluginConfig(comm),
|
|
844
848
|
cacheTtl: comm.cacheTtl,
|
|
849
|
+
errorResumeBackoff: comm.errorResumeBackoff,
|
|
845
850
|
}
|
|
846
851
|
}
|
|
847
852
|
|
|
@@ -861,6 +866,7 @@ export class Session {
|
|
|
861
866
|
input: agentDef.input,
|
|
862
867
|
plugins: withServicePluginConfig(agentDef),
|
|
863
868
|
cacheTtl: agentDef.cacheTtl,
|
|
869
|
+
errorResumeBackoff: agentDef.errorResumeBackoff,
|
|
864
870
|
}
|
|
865
871
|
}
|
|
866
872
|
}
|
|
@@ -202,12 +202,16 @@ export const coreReducer = createTypedReducer(
|
|
|
202
202
|
|
|
203
203
|
return {
|
|
204
204
|
...agent,
|
|
205
|
-
|
|
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
|
-
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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
|
}))
|