@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.
- 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/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/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
|
@@ -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
|
}))
|
|
@@ -3,7 +3,7 @@ import z from 'zod/v4'
|
|
|
3
3
|
import { contextEvents } from '~/core/context/state.js'
|
|
4
4
|
import { llmEvents } from '~/core/llm/state.js'
|
|
5
5
|
import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
6
|
-
import type { InferenceRequest } from '~/core/llm/provider.js'
|
|
6
|
+
import type { InferenceRequest, LLMMessage } from '~/core/llm/provider.js'
|
|
7
7
|
import { ModelId } from '~/core/llm/schema.js'
|
|
8
8
|
import type { Preset } from '~/core/preset/index.js'
|
|
9
9
|
import { createTool } from '~/core/tools/definition.js'
|
|
@@ -48,6 +48,33 @@ function createCompactHarness(options: Omit<ConstructorParameters<typeof TestHar
|
|
|
48
48
|
return new TestHarness({ ...options, systemPlugins: [contextCompactPlugin] })
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
function expectToolPairing(messages: LLMMessage[]): void {
|
|
52
|
+
const declaredToolCallIds = new Set<string>()
|
|
53
|
+
|
|
54
|
+
for (let i = 0; i < messages.length; i++) {
|
|
55
|
+
const message = messages[i]
|
|
56
|
+
if (message.role === 'assistant' && message.toolCalls?.length) {
|
|
57
|
+
const expected = new Set(message.toolCalls.map((toolCall) => toolCall.id))
|
|
58
|
+
const seen = new Set<string>()
|
|
59
|
+
for (const toolCall of message.toolCalls) {
|
|
60
|
+
declaredToolCallIds.add(toolCall.id)
|
|
61
|
+
}
|
|
62
|
+
for (let j = i + 1; j < messages.length; j++) {
|
|
63
|
+
const next = messages[j]
|
|
64
|
+
if (next.role !== 'tool') break
|
|
65
|
+
seen.add(next.toolCallId)
|
|
66
|
+
}
|
|
67
|
+
for (const id of expected) {
|
|
68
|
+
expect(seen.has(id)).toBe(true)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (message.role === 'tool') {
|
|
73
|
+
expect(declaredToolCallIds.has(message.toolCallId)).toBe(true)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
51
78
|
// ============================================================================
|
|
52
79
|
// Tests
|
|
53
80
|
// ============================================================================
|
|
@@ -222,6 +249,7 @@ describe('context-compact plugin', () => {
|
|
|
222
249
|
}
|
|
223
250
|
|
|
224
251
|
let capturedAuxRequest: InferenceRequest | undefined
|
|
252
|
+
let capturedToolResultRequest: InferenceRequest | undefined
|
|
225
253
|
|
|
226
254
|
const harness = new TestHarness({
|
|
227
255
|
systemPlugins: [contextCompactPlugin],
|
|
@@ -246,6 +274,7 @@ describe('context-compact plugin', () => {
|
|
|
246
274
|
metrics: MockLLMProvider.defaultMetrics(),
|
|
247
275
|
}
|
|
248
276
|
}
|
|
277
|
+
capturedToolResultRequest = request
|
|
249
278
|
return {
|
|
250
279
|
content: 'Done.',
|
|
251
280
|
toolCalls: [],
|
|
@@ -259,26 +288,14 @@ describe('context-compact plugin', () => {
|
|
|
259
288
|
await session.sendAndWaitForIdle('Please call my_tool')
|
|
260
289
|
|
|
261
290
|
expect(capturedAuxRequest).toBeDefined()
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const expected = new Set(m.toolCalls.map((tc) => tc.id))
|
|
272
|
-
const seen = new Set<string>()
|
|
273
|
-
for (let j = i + 1; j < msgs.length; j++) {
|
|
274
|
-
const next = msgs[j]
|
|
275
|
-
if (next.role !== 'tool') break
|
|
276
|
-
seen.add(next.toolCallId)
|
|
277
|
-
}
|
|
278
|
-
for (const id of expected) {
|
|
279
|
-
expect(seen.has(id)).toBe(true)
|
|
280
|
-
}
|
|
281
|
-
}
|
|
291
|
+
if (!capturedAuxRequest) return
|
|
292
|
+
expectToolPairing(capturedAuxRequest.messages)
|
|
293
|
+
|
|
294
|
+
// The event reducer runs before pending tool results are appended to the
|
|
295
|
+
// regular inference request. Pairing must survive that reconstruction.
|
|
296
|
+
expect(capturedToolResultRequest).toBeDefined()
|
|
297
|
+
if (!capturedToolResultRequest) return
|
|
298
|
+
expectToolPairing(capturedToolResultRequest.messages)
|
|
282
299
|
|
|
283
300
|
// Compaction must have actually succeeded — pre-fix it would Err-out
|
|
284
301
|
// in production (mock accepts it but the assertion above already
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
2
|
+
import { agentEvents } from '~/core/agents/state.js'
|
|
2
3
|
import type { AgentId } from '~/core/agents/schema.js'
|
|
3
4
|
import { generateTestAgentId } from '~/core/agents/schema.js'
|
|
5
|
+
import { contextCompactedEventSchema, contextEvents } from '~/core/context/state.js'
|
|
6
|
+
import { withSessionId } from '~/core/events/test-helpers.js'
|
|
4
7
|
import type { InferenceResponse, LLMMessage } from '~/core/llm/provider.js'
|
|
5
8
|
import { ModelId } from '~/core/llm/schema.js'
|
|
9
|
+
import { estimateMessagesTokens } from '~/core/llm/tokens.js'
|
|
6
10
|
import type { SessionId } from '~/core/sessions/schema.js'
|
|
7
11
|
import { generateSessionId } from '~/core/sessions/schema.js'
|
|
12
|
+
import { coreReducer, createSessionState } from '~/core/sessions/state.js'
|
|
8
13
|
import { generateToolCallId } from '~/core/tools/schema.js'
|
|
9
14
|
import { Err, Ok } from '~/lib/utils/result.js'
|
|
10
15
|
import { silentLogger } from '../../lib/logger/logger.js'
|
|
@@ -315,12 +320,26 @@ describe('createContextCompactedEvent', () => {
|
|
|
315
320
|
expect(event.timestamp).toBeDefined()
|
|
316
321
|
})
|
|
317
322
|
|
|
318
|
-
it('
|
|
323
|
+
it('preserves assistant tool calls and tool results in history', () => {
|
|
319
324
|
const sessionId = generateSessionId()
|
|
320
325
|
const agentId = generateTestAgentId()
|
|
321
326
|
const toolCallId = generateToolCallId()
|
|
322
327
|
const result: CompactionResult = {
|
|
323
|
-
compactedMessages: [
|
|
328
|
+
compactedMessages: [
|
|
329
|
+
{
|
|
330
|
+
role: 'assistant',
|
|
331
|
+
content: 'Checking.',
|
|
332
|
+
toolCalls: [{ id: toolCallId, name: 'read', input: { path: '/src/index.ts' } }],
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
role: 'tool',
|
|
336
|
+
content: 'tool result',
|
|
337
|
+
toolCallId,
|
|
338
|
+
toolName: 'read',
|
|
339
|
+
isError: false,
|
|
340
|
+
timestamp: 123,
|
|
341
|
+
},
|
|
342
|
+
],
|
|
324
343
|
originalMessages: [],
|
|
325
344
|
summary: '',
|
|
326
345
|
originalTokens: 100,
|
|
@@ -330,8 +349,44 @@ describe('createContextCompactedEvent', () => {
|
|
|
330
349
|
|
|
331
350
|
const event = createContextCompactedEvent(sessionId, agentId, result)
|
|
332
351
|
|
|
333
|
-
|
|
334
|
-
|
|
352
|
+
expect(event.newConversationHistory).toEqual(result.compactedMessages)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it('parses and reduces old-shape events exactly as before', () => {
|
|
356
|
+
const sessionId = generateSessionId()
|
|
357
|
+
const agentId = generateTestAgentId()
|
|
358
|
+
const oldPayload = contextCompactedEventSchema.parse({
|
|
359
|
+
agentId,
|
|
360
|
+
compactedContent: 'Old summary',
|
|
361
|
+
newConversationHistory: [
|
|
362
|
+
{ role: 'user', content: 'summary' },
|
|
363
|
+
{ role: 'assistant', content: 'recent response' },
|
|
364
|
+
{ role: 'system', content: 'legacy context' },
|
|
365
|
+
],
|
|
366
|
+
originalTokens: 1000,
|
|
367
|
+
compactedTokens: 100,
|
|
368
|
+
messagesRemoved: 12,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
let state = createSessionState(sessionId, 'test', 0)
|
|
372
|
+
state = coreReducer(state, withSessionId(
|
|
373
|
+
sessionId,
|
|
374
|
+
agentEvents.create('agent_spawned', {
|
|
375
|
+
agentId,
|
|
376
|
+
definitionName: 'test-agent',
|
|
377
|
+
parentId: null,
|
|
378
|
+
}),
|
|
379
|
+
))
|
|
380
|
+
state = coreReducer(state, withSessionId(
|
|
381
|
+
sessionId,
|
|
382
|
+
contextEvents.create('context_compacted', oldPayload),
|
|
383
|
+
))
|
|
384
|
+
|
|
385
|
+
expect(state.agents.get(agentId)?.conversationHistory).toEqual([
|
|
386
|
+
{ role: 'user', content: 'summary', sourceMessageIds: [], cacheControl: undefined },
|
|
387
|
+
{ role: 'assistant', content: 'recent response', toolCalls: undefined, cacheControl: undefined },
|
|
388
|
+
{ role: 'system', content: 'legacy context', cacheControl: undefined },
|
|
389
|
+
])
|
|
335
390
|
})
|
|
336
391
|
})
|
|
337
392
|
|
|
@@ -614,6 +669,97 @@ describe('ContextCompactor with tool calls', () => {
|
|
|
614
669
|
// All 5 original messages should be compacted (none kept except summary)
|
|
615
670
|
expect(result.value.messagesRemoved).toBe(5)
|
|
616
671
|
})
|
|
672
|
+
|
|
673
|
+
it('round-trips a kept tool turn and accounts for its token structure', async () => {
|
|
674
|
+
inference.setResponses([
|
|
675
|
+
{
|
|
676
|
+
content: 'Short summary',
|
|
677
|
+
toolCalls: [],
|
|
678
|
+
finishReason: 'stop',
|
|
679
|
+
metrics: { promptTokens: 50, completionTokens: 20, totalTokens: 70, latencyMs: 100, model: 'mock' },
|
|
680
|
+
},
|
|
681
|
+
])
|
|
682
|
+
|
|
683
|
+
const toolCallId = generateToolCallId()
|
|
684
|
+
const toolTurn: LLMMessage[] = [
|
|
685
|
+
{
|
|
686
|
+
role: 'assistant',
|
|
687
|
+
content: 'I will inspect the file.',
|
|
688
|
+
toolCalls: [{
|
|
689
|
+
id: toolCallId,
|
|
690
|
+
name: 'read',
|
|
691
|
+
input: { path: '/src/index.ts', reason: 'x'.repeat(400) },
|
|
692
|
+
}],
|
|
693
|
+
},
|
|
694
|
+
{
|
|
695
|
+
role: 'tool',
|
|
696
|
+
content: 'export const value = 1',
|
|
697
|
+
toolCallId,
|
|
698
|
+
toolName: 'read',
|
|
699
|
+
isError: false,
|
|
700
|
+
timestamp: 123,
|
|
701
|
+
},
|
|
702
|
+
]
|
|
703
|
+
const messages: LLMMessage[] = [
|
|
704
|
+
{ role: 'user', content: 'old context '.repeat(1000) },
|
|
705
|
+
{ role: 'assistant', content: 'old response '.repeat(1000) },
|
|
706
|
+
...toolTurn,
|
|
707
|
+
]
|
|
708
|
+
const preservingCompactor = new ContextCompactor(silentLogger, {
|
|
709
|
+
maxTokens: 100,
|
|
710
|
+
keepRecentMessages: 2,
|
|
711
|
+
})
|
|
712
|
+
|
|
713
|
+
const result = await preservingCompactor.compact(sessionId, agentId, messages, inference.run)
|
|
714
|
+
expect(result.ok).toBe(true)
|
|
715
|
+
if (!result.ok) return
|
|
716
|
+
|
|
717
|
+
expect(result.value.compactedMessages.slice(1)).toEqual(toolTurn)
|
|
718
|
+
expect(result.value.compactedTokens).toBeLessThan(result.value.originalTokens)
|
|
719
|
+
expect(result.value.compactedTokens).toBeGreaterThan(
|
|
720
|
+
estimateMessagesTokens(result.value.compactedMessages),
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
const event = createContextCompactedEvent(sessionId, agentId, result.value)
|
|
724
|
+
expect(event.newConversationHistory.slice(1)).toEqual(toolTurn)
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
it('keeps an open tool declaration even when keep limits are zero', async () => {
|
|
728
|
+
inference.setResponses([
|
|
729
|
+
{
|
|
730
|
+
content: 'Summary',
|
|
731
|
+
toolCalls: [],
|
|
732
|
+
finishReason: 'stop',
|
|
733
|
+
metrics: { promptTokens: 50, completionTokens: 20, totalTokens: 70, latencyMs: 100, model: 'mock' },
|
|
734
|
+
},
|
|
735
|
+
])
|
|
736
|
+
|
|
737
|
+
const toolCallId = generateToolCallId()
|
|
738
|
+
const openToolCall: LLMMessage = {
|
|
739
|
+
role: 'assistant',
|
|
740
|
+
content: '',
|
|
741
|
+
toolCalls: [{ id: toolCallId, name: 'read', input: { path: '/src/index.ts' } }],
|
|
742
|
+
}
|
|
743
|
+
const zeroKeepCompactor = new ContextCompactor(silentLogger, {
|
|
744
|
+
maxTokens: 100,
|
|
745
|
+
keepRecentMessages: 0,
|
|
746
|
+
keepRecentTokens: 0,
|
|
747
|
+
})
|
|
748
|
+
|
|
749
|
+
const result = await zeroKeepCompactor.compact(
|
|
750
|
+
sessionId,
|
|
751
|
+
agentId,
|
|
752
|
+
[
|
|
753
|
+
{ role: 'user', content: 'old context' },
|
|
754
|
+
openToolCall,
|
|
755
|
+
],
|
|
756
|
+
inference.run,
|
|
757
|
+
)
|
|
758
|
+
expect(result.ok).toBe(true)
|
|
759
|
+
if (!result.ok) return
|
|
760
|
+
|
|
761
|
+
expect(result.value.compactedMessages.at(-1)).toEqual(openToolCall)
|
|
762
|
+
})
|
|
617
763
|
})
|
|
618
764
|
|
|
619
765
|
// ============================================================================
|