@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.
- 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 +12 -2
- 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/services/plugin.d.ts.map +1 -1
- package/dist/plugins/services/plugin.js +4 -1
- package/dist/plugins/services/plugin.js.map +1 -1
- package/dist/plugins/services/prompt.js +3 -3
- package/dist/plugins/services/prompt.js.map +1 -1
- package/dist/plugins/services/schema.d.ts +8 -0
- package/dist/plugins/services/schema.d.ts.map +1 -1
- package/dist/plugins/services/services.integration.test.js +112 -0
- package/dist/plugins/services/services.integration.test.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 +13 -3
- 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/services/plugin.ts +4 -1
- package/src/plugins/services/prompt.ts +3 -3
- package/src/plugins/services/schema.ts +8 -0
- package/src/plugins/services/services.integration.test.ts +129 -0
- 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
|
@@ -1,18 +1,98 @@
|
|
|
1
1
|
import z4 from 'zod/v4'
|
|
2
2
|
import { agentIdSchema } from '~/core/agents'
|
|
3
|
+
import type { LLMMessage } from '~/core/agents/state.js'
|
|
3
4
|
import { createEventsFactory } from '~/core/events/types'
|
|
5
|
+
import { toolCallIdSchema } from '~/core/tools/schema.js'
|
|
6
|
+
import { messageIdSchema } from '~/plugins/mailbox/schema.js'
|
|
4
7
|
|
|
5
8
|
// ============================================================================
|
|
6
9
|
// Supporting types for context events
|
|
7
10
|
// ============================================================================
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
* Conversation message stored in event for reconstruction.
|
|
13
|
+
* Conversation message stored in the event for lossless reconstruction.
|
|
11
14
|
*/
|
|
12
|
-
export type CompactedConversationMessage =
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
export type CompactedConversationMessage = LLMMessage
|
|
16
|
+
|
|
17
|
+
const cacheControlSchema = z4.object({
|
|
18
|
+
type: z4.literal('ephemeral'),
|
|
19
|
+
ttl: z4.enum(['5m', '1h']).optional(),
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const contentItemSchema = z4.discriminatedUnion('type', [
|
|
23
|
+
z4.object({
|
|
24
|
+
type: z4.literal('text'),
|
|
25
|
+
text: z4.string(),
|
|
26
|
+
cacheControl: z4.object({
|
|
27
|
+
type: z4.literal('ephemeral'),
|
|
28
|
+
}).optional(),
|
|
29
|
+
}),
|
|
30
|
+
z4.object({
|
|
31
|
+
type: z4.literal('image_url'),
|
|
32
|
+
imageUrl: z4.object({
|
|
33
|
+
url: z4.string(),
|
|
34
|
+
detail: z4.enum(['auto', 'low', 'high']).optional(),
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
])
|
|
38
|
+
|
|
39
|
+
const messageContentSchema = z4.union([
|
|
40
|
+
z4.string(),
|
|
41
|
+
z4.array(contentItemSchema),
|
|
42
|
+
])
|
|
43
|
+
|
|
44
|
+
export const compactedConversationMessageSchema: z4.ZodType<LLMMessage> = z4.discriminatedUnion('role', [
|
|
45
|
+
z4.object({
|
|
46
|
+
role: z4.literal('user'),
|
|
47
|
+
content: messageContentSchema,
|
|
48
|
+
sourceMessageIds: z4.array(messageIdSchema).optional(),
|
|
49
|
+
cacheControl: cacheControlSchema.optional(),
|
|
50
|
+
}),
|
|
51
|
+
z4.object({
|
|
52
|
+
role: z4.literal('assistant'),
|
|
53
|
+
content: z4.string(),
|
|
54
|
+
toolCalls: z4.array(z4.object({
|
|
55
|
+
id: toolCallIdSchema,
|
|
56
|
+
name: z4.string(),
|
|
57
|
+
input: z4.unknown(),
|
|
58
|
+
})).optional(),
|
|
59
|
+
cacheControl: cacheControlSchema.optional(),
|
|
60
|
+
}),
|
|
61
|
+
z4.object({
|
|
62
|
+
role: z4.literal('tool'),
|
|
63
|
+
content: messageContentSchema,
|
|
64
|
+
toolCallId: toolCallIdSchema,
|
|
65
|
+
toolName: z4.string().optional(),
|
|
66
|
+
isError: z4.boolean().optional(),
|
|
67
|
+
timestamp: z4.number().optional(),
|
|
68
|
+
cacheControl: cacheControlSchema.optional(),
|
|
69
|
+
}),
|
|
70
|
+
z4.object({
|
|
71
|
+
role: z4.literal('system'),
|
|
72
|
+
content: z4.string(),
|
|
73
|
+
cacheControl: cacheControlSchema.optional(),
|
|
74
|
+
}),
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
export const contextCompactedEventSchema = z4.object({
|
|
78
|
+
agentId: agentIdSchema,
|
|
79
|
+
compactedContent: z4.string(),
|
|
80
|
+
/**
|
|
81
|
+
* Display-only snapshot of the messages that were summarized away (the
|
|
82
|
+
* "input" of the compaction). Not used for conversation reconstruction —
|
|
83
|
+
* that's driven by newConversationHistory. Optional for backward
|
|
84
|
+
* compatibility with events emitted before this field existed.
|
|
85
|
+
*/
|
|
86
|
+
originalMessages: z4.array(z4.object({
|
|
87
|
+
role: z4.enum(['user', 'assistant', 'system']),
|
|
88
|
+
content: z4.string(),
|
|
89
|
+
})).optional(),
|
|
90
|
+
newConversationHistory: z4.array(compactedConversationMessageSchema),
|
|
91
|
+
originalTokens: z4.number(),
|
|
92
|
+
compactedTokens: z4.number(),
|
|
93
|
+
messagesRemoved: z4.number(),
|
|
94
|
+
historyPath: z4.string().optional(),
|
|
95
|
+
})
|
|
16
96
|
|
|
17
97
|
// ============================================================================
|
|
18
98
|
// Context events
|
|
@@ -20,28 +100,7 @@ export type CompactedConversationMessage = {
|
|
|
20
100
|
|
|
21
101
|
export const contextEvents = createEventsFactory({
|
|
22
102
|
events: {
|
|
23
|
-
context_compacted:
|
|
24
|
-
agentId: agentIdSchema,
|
|
25
|
-
compactedContent: z4.string(),
|
|
26
|
-
/**
|
|
27
|
-
* Display-only snapshot of the messages that were summarized away (the
|
|
28
|
-
* "input" of the compaction). Not used for conversation reconstruction —
|
|
29
|
-
* that's driven by {@link newConversationHistory}. Optional for backward
|
|
30
|
-
* compatibility with events emitted before this field existed.
|
|
31
|
-
*/
|
|
32
|
-
originalMessages: z4.array(z4.object({
|
|
33
|
-
role: z4.enum(['user', 'assistant', 'system']),
|
|
34
|
-
content: z4.string(),
|
|
35
|
-
})).optional(),
|
|
36
|
-
newConversationHistory: z4.array(z4.object({
|
|
37
|
-
role: z4.enum(['user', 'assistant', 'system']),
|
|
38
|
-
content: z4.string(),
|
|
39
|
-
})),
|
|
40
|
-
originalTokens: z4.number(),
|
|
41
|
-
compactedTokens: z4.number(),
|
|
42
|
-
messagesRemoved: z4.number(),
|
|
43
|
-
historyPath: z4.string().optional(),
|
|
44
|
-
}),
|
|
103
|
+
context_compacted: contextCompactedEventSchema,
|
|
45
104
|
},
|
|
46
105
|
})
|
|
47
106
|
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
import z from 'zod/v4'
|
|
3
|
+
import type { AgentStatus } from '~/core/agents/schema.js'
|
|
4
|
+
import { agentEvents } from '~/core/agents/state.js'
|
|
5
|
+
import { MockLLMProvider } from '~/core/llm/mock.js'
|
|
6
|
+
import { llmEvents } from '~/core/llm/state.js'
|
|
7
|
+
import { definePlugin } from '~/core/plugins/plugin-builder.js'
|
|
8
|
+
import { createTool } from '~/core/tools/definition.js'
|
|
9
|
+
import { ToolCallId } from '~/core/tools/schema.js'
|
|
10
|
+
import { toolEvents } from '~/core/tools/state.js'
|
|
11
|
+
import { Ok } from '~/lib/utils/result.js'
|
|
12
|
+
import type { TestSession } from '~/testing/index.js'
|
|
13
|
+
import { createTestPreset, TestHarness } from '~/testing/index.js'
|
|
14
|
+
|
|
15
|
+
/** Poll until the entry agent reaches the given status (or time out). */
|
|
16
|
+
async function waitForAgentStatus(session: TestSession, status: AgentStatus, timeoutMs = 5000): Promise<void> {
|
|
17
|
+
const agentId = session.getEntryAgentId()!
|
|
18
|
+
const deadline = Date.now() + timeoutMs
|
|
19
|
+
while (Date.now() < deadline) {
|
|
20
|
+
if (session.state.agents.get(agentId)?.status === status) return
|
|
21
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Agent did not reach status '${status}' within ${timeoutMs}ms`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Count conversation-history messages whose content mentions the given text. */
|
|
27
|
+
function countHistoryMentions(session: TestSession, text: string): number {
|
|
28
|
+
const agentId = session.getEntryAgentId()!
|
|
29
|
+
const history = session.state.agents.get(agentId)!.conversationHistory
|
|
30
|
+
return history.filter((m) => JSON.stringify(m.content).includes(text)).length
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('core: error recovery', () => {
|
|
34
|
+
// =========================================================================
|
|
35
|
+
// Retryable LLM errors — outer error-resume backoff
|
|
36
|
+
// =========================================================================
|
|
37
|
+
|
|
38
|
+
describe('retryable LLM error exhaustion', () => {
|
|
39
|
+
it('backs off between error-resume cycles, notifies parent hook once, and recovers without losing the message', async () => {
|
|
40
|
+
// Fail 10 LLM calls (2 inference cycles × 5 withLLMRetry attempts), then succeed.
|
|
41
|
+
let llmCalls = 0
|
|
42
|
+
const llmProvider = new MockLLMProvider(() => {
|
|
43
|
+
llmCalls++
|
|
44
|
+
if (llmCalls <= 10) {
|
|
45
|
+
throw { type: 'server_error', message: 'boom', retryAfterMs: 1 }
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
content: 'Recovered',
|
|
49
|
+
toolCalls: [],
|
|
50
|
+
finishReason: 'stop' as const,
|
|
51
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
let onErrorCalls = 0
|
|
56
|
+
const errorTracker = definePlugin('error-tracker')
|
|
57
|
+
.hook('onError', async () => {
|
|
58
|
+
onErrorCalls++
|
|
59
|
+
return null
|
|
60
|
+
})
|
|
61
|
+
.build()
|
|
62
|
+
|
|
63
|
+
const preset = createTestPreset()
|
|
64
|
+
preset.orchestrator.errorResumeBackoff = { baseDelayMs: 50, maxDelayMs: 100 }
|
|
65
|
+
|
|
66
|
+
const harness = new TestHarness({
|
|
67
|
+
presets: [preset],
|
|
68
|
+
llmProvider,
|
|
69
|
+
systemPlugins: [errorTracker],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const session = await harness.createSession('test')
|
|
73
|
+
await session.sendAndWaitForIdle('Trigger outage', { timeoutMs: 10_000 })
|
|
74
|
+
|
|
75
|
+
// The message survived the outage: delivered to the LLM exactly once, answered.
|
|
76
|
+
expect(countHistoryMentions(session, 'Trigger outage')).toBe(1)
|
|
77
|
+
expect(countHistoryMentions(session, 'Recovered')).toBe(1)
|
|
78
|
+
expect(llmCalls).toBe(11)
|
|
79
|
+
|
|
80
|
+
// Two failed cycles, each resumed exactly once — no unbounded spin.
|
|
81
|
+
const failed = await session.getEventsByType(llmEvents, 'inference_failed')
|
|
82
|
+
expect(failed).toHaveLength(2)
|
|
83
|
+
const resumed = await session.getEventsByType(agentEvents, 'agent_resumed')
|
|
84
|
+
expect(resumed).toHaveLength(2)
|
|
85
|
+
|
|
86
|
+
// The resume waited for the backoff: 50ms after the 1st failure, 100ms after the 2nd.
|
|
87
|
+
expect(resumed[0].timestamp - failed[0].timestamp).toBeGreaterThanOrEqual(45)
|
|
88
|
+
expect(resumed[1].timestamp - failed[1].timestamp).toBeGreaterThanOrEqual(90)
|
|
89
|
+
|
|
90
|
+
// onError hooks fired once per episode, not once per cycle.
|
|
91
|
+
expect(onErrorCalls).toBe(1)
|
|
92
|
+
|
|
93
|
+
await harness.shutdown()
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
// =========================================================================
|
|
98
|
+
// afterInference retry — token consumption deferred to commit
|
|
99
|
+
// =========================================================================
|
|
100
|
+
|
|
101
|
+
describe('afterInference retry', () => {
|
|
102
|
+
it('pure-message turn: retry re-delivers the message instead of stranding the agent', async () => {
|
|
103
|
+
const retryOnV1 = definePlugin('retry-on-v1')
|
|
104
|
+
.hook('afterInference', async (ctx) => {
|
|
105
|
+
if (ctx.response.content === 'v1') return { action: 'retry' as const }
|
|
106
|
+
return null
|
|
107
|
+
})
|
|
108
|
+
.build()
|
|
109
|
+
|
|
110
|
+
const harness = new TestHarness({
|
|
111
|
+
presets: [createTestPreset()],
|
|
112
|
+
llmProvider: MockLLMProvider.withSequence([
|
|
113
|
+
{ content: 'v1' },
|
|
114
|
+
{ content: 'v2' },
|
|
115
|
+
]),
|
|
116
|
+
systemPlugins: [retryOnV1],
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const session = await harness.createSession('test')
|
|
120
|
+
// Before the fix this stranded the agent in 'inferring' (mapped to idle) forever.
|
|
121
|
+
await session.sendAndWaitForIdle('Retry me')
|
|
122
|
+
|
|
123
|
+
expect(harness.llmProvider.getCallCount()).toBe(2)
|
|
124
|
+
const retried = await session.getEventsByType(llmEvents, 'inference_retried')
|
|
125
|
+
expect(retried).toHaveLength(1)
|
|
126
|
+
|
|
127
|
+
// The user message was staged exactly once and the retried response won.
|
|
128
|
+
expect(countHistoryMentions(session, 'Retry me')).toBe(1)
|
|
129
|
+
expect(countHistoryMentions(session, 'v2')).toBe(1)
|
|
130
|
+
expect(countHistoryMentions(session, 'v1')).toBe(0)
|
|
131
|
+
|
|
132
|
+
await harness.shutdown()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('tool-result turn: retry does not duplicate tool results in history or in the LLM request', async () => {
|
|
136
|
+
const retryOnV1 = definePlugin('retry-on-v1')
|
|
137
|
+
.hook('afterInference', async (ctx) => {
|
|
138
|
+
if (ctx.response.content === 'v1') return { action: 'retry' as const }
|
|
139
|
+
return null
|
|
140
|
+
})
|
|
141
|
+
.build()
|
|
142
|
+
|
|
143
|
+
const harness = new TestHarness({
|
|
144
|
+
presets: [createTestPreset()],
|
|
145
|
+
llmProvider: MockLLMProvider.withSequence([
|
|
146
|
+
{ toolCalls: [{ id: ToolCallId('tc1'), name: 'tell_user', input: { message: 'Hi!' } }] },
|
|
147
|
+
{ content: 'v1' },
|
|
148
|
+
{ content: 'v2' },
|
|
149
|
+
]),
|
|
150
|
+
systemPlugins: [retryOnV1],
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const session = await harness.createSession('test')
|
|
154
|
+
await session.sendAndWaitForIdle('Run a tool')
|
|
155
|
+
|
|
156
|
+
expect(harness.llmProvider.getCallCount()).toBe(3)
|
|
157
|
+
|
|
158
|
+
// The retried request carries the tool result exactly once.
|
|
159
|
+
const lastRequest = harness.llmProvider.getLastRequest()!
|
|
160
|
+
expect(lastRequest.messages.filter((m) => m.role === 'tool')).toHaveLength(1)
|
|
161
|
+
|
|
162
|
+
// And so does the committed history — duplicates would 400 on the next turn.
|
|
163
|
+
const agentId = session.getEntryAgentId()!
|
|
164
|
+
const history = session.state.agents.get(agentId)!.conversationHistory
|
|
165
|
+
expect(history.filter((m) => m.role === 'tool')).toHaveLength(1)
|
|
166
|
+
expect(countHistoryMentions(session, 'Run a tool')).toBe(1)
|
|
167
|
+
|
|
168
|
+
await harness.shutdown()
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// =========================================================================
|
|
173
|
+
// Pause from tool hooks — turn stops, nothing re-executes
|
|
174
|
+
// =========================================================================
|
|
175
|
+
|
|
176
|
+
describe('pause from tool hooks', () => {
|
|
177
|
+
const makeCountingTool = (name: string, counter: { count: number }) =>
|
|
178
|
+
createTool({
|
|
179
|
+
name,
|
|
180
|
+
description: `Counting tool ${name}`,
|
|
181
|
+
input: z.unknown(),
|
|
182
|
+
execute: async () => {
|
|
183
|
+
counter.count++
|
|
184
|
+
return Ok(`${name} done`)
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('beforeToolCall pause stops the multi-tool turn; resume runs every tool exactly once', async () => {
|
|
189
|
+
const aRuns = { count: 0 }
|
|
190
|
+
const bRuns = { count: 0 }
|
|
191
|
+
|
|
192
|
+
let pausedOnce = false
|
|
193
|
+
const pauser = definePlugin('pauser')
|
|
194
|
+
.hook('beforeToolCall', async (ctx) => {
|
|
195
|
+
if (ctx.toolCall.name === 'tool_a' && !pausedOnce) {
|
|
196
|
+
pausedOnce = true
|
|
197
|
+
return { action: 'pause' as const, reason: 'gate' }
|
|
198
|
+
}
|
|
199
|
+
return null
|
|
200
|
+
})
|
|
201
|
+
.build()
|
|
202
|
+
|
|
203
|
+
const preset = createTestPreset()
|
|
204
|
+
preset.orchestrator.tools = [makeCountingTool('tool_a', aRuns), makeCountingTool('tool_b', bRuns)]
|
|
205
|
+
|
|
206
|
+
const harness = new TestHarness({
|
|
207
|
+
presets: [preset],
|
|
208
|
+
llmProvider: MockLLMProvider.withSequence([
|
|
209
|
+
{
|
|
210
|
+
toolCalls: [
|
|
211
|
+
{ id: ToolCallId('ta'), name: 'tool_a', input: {} },
|
|
212
|
+
{ id: ToolCallId('tb'), name: 'tool_b', input: {} },
|
|
213
|
+
],
|
|
214
|
+
},
|
|
215
|
+
{ content: 'Done', toolCalls: [] },
|
|
216
|
+
]),
|
|
217
|
+
systemPlugins: [pauser],
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const session = await harness.createSession('test')
|
|
221
|
+
await session.sendMessage('Run tools')
|
|
222
|
+
await waitForAgentStatus(session, 'paused')
|
|
223
|
+
|
|
224
|
+
// Neither tool ran: tool_a was gated pre-start, tool_b must not run while paused.
|
|
225
|
+
const agentId = session.getEntryAgentId()!
|
|
226
|
+
expect(aRuns.count).toBe(0)
|
|
227
|
+
expect(bRuns.count).toBe(0)
|
|
228
|
+
expect(session.state.agents.get(agentId)!.pendingToolCalls).toHaveLength(2)
|
|
229
|
+
|
|
230
|
+
await session.resumeAgent(agentId)
|
|
231
|
+
await session.waitForIdle()
|
|
232
|
+
|
|
233
|
+
// After resume the whole turn ran exactly once per tool.
|
|
234
|
+
expect(aRuns.count).toBe(1)
|
|
235
|
+
expect(bRuns.count).toBe(1)
|
|
236
|
+
const completed = await session.getEventsByType(toolEvents, 'tool_completed')
|
|
237
|
+
expect(completed).toHaveLength(2)
|
|
238
|
+
|
|
239
|
+
await harness.shutdown()
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('afterToolCall pause commits the executed tool result and does not re-run it on resume', async () => {
|
|
243
|
+
const aRuns = { count: 0 }
|
|
244
|
+
const bRuns = { count: 0 }
|
|
245
|
+
|
|
246
|
+
let pausedOnce = false
|
|
247
|
+
const pauser = definePlugin('pauser')
|
|
248
|
+
.hook('afterToolCall', async (ctx) => {
|
|
249
|
+
if (ctx.toolCall.name === 'tool_a' && !pausedOnce) {
|
|
250
|
+
pausedOnce = true
|
|
251
|
+
return { action: 'pause' as const, reason: 'checkpoint' }
|
|
252
|
+
}
|
|
253
|
+
return null
|
|
254
|
+
})
|
|
255
|
+
.build()
|
|
256
|
+
|
|
257
|
+
const preset = createTestPreset()
|
|
258
|
+
preset.orchestrator.tools = [makeCountingTool('tool_a', aRuns), makeCountingTool('tool_b', bRuns)]
|
|
259
|
+
|
|
260
|
+
const harness = new TestHarness({
|
|
261
|
+
presets: [preset],
|
|
262
|
+
llmProvider: MockLLMProvider.withSequence([
|
|
263
|
+
{
|
|
264
|
+
toolCalls: [
|
|
265
|
+
{ id: ToolCallId('ta'), name: 'tool_a', input: {} },
|
|
266
|
+
{ id: ToolCallId('tb'), name: 'tool_b', input: {} },
|
|
267
|
+
],
|
|
268
|
+
},
|
|
269
|
+
{ content: 'Done', toolCalls: [] },
|
|
270
|
+
]),
|
|
271
|
+
systemPlugins: [pauser],
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const session = await harness.createSession('test')
|
|
275
|
+
await session.sendMessage('Run tools')
|
|
276
|
+
await waitForAgentStatus(session, 'paused')
|
|
277
|
+
|
|
278
|
+
// tool_a ran and its result committed (paired tool_use); tool_b is still pending.
|
|
279
|
+
const agentId = session.getEntryAgentId()!
|
|
280
|
+
const agentState = session.state.agents.get(agentId)!
|
|
281
|
+
expect(aRuns.count).toBe(1)
|
|
282
|
+
expect(bRuns.count).toBe(0)
|
|
283
|
+
expect(agentState.pendingToolCalls.map((tc) => tc.name)).toEqual(['tool_b'])
|
|
284
|
+
expect(agentState.pendingToolResults.map((r) => r.toolName)).toEqual(['tool_a'])
|
|
285
|
+
|
|
286
|
+
await session.resumeAgent(agentId)
|
|
287
|
+
await session.waitForIdle()
|
|
288
|
+
|
|
289
|
+
// tool_a's side effects were NOT repeated after resume.
|
|
290
|
+
expect(aRuns.count).toBe(1)
|
|
291
|
+
expect(bRuns.count).toBe(1)
|
|
292
|
+
|
|
293
|
+
await harness.shutdown()
|
|
294
|
+
})
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// =========================================================================
|
|
298
|
+
// Manual pause during an in-flight inference
|
|
299
|
+
// =========================================================================
|
|
300
|
+
|
|
301
|
+
describe('manual pause during inference', () => {
|
|
302
|
+
it('pause landing mid-inference is preserved after the turn commits', async () => {
|
|
303
|
+
let firstCall = true
|
|
304
|
+
const llmProvider = new MockLLMProvider(async () => {
|
|
305
|
+
if (firstCall) {
|
|
306
|
+
firstCall = false
|
|
307
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
308
|
+
return { content: 'Late reply', toolCalls: [], finishReason: 'stop' as const, metrics: MockLLMProvider.defaultMetrics() }
|
|
309
|
+
}
|
|
310
|
+
return { content: 'After resume', toolCalls: [], finishReason: 'stop' as const, metrics: MockLLMProvider.defaultMetrics() }
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
const harness = new TestHarness({
|
|
314
|
+
presets: [createTestPreset()],
|
|
315
|
+
llmProvider,
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const session = await harness.createSession('test')
|
|
319
|
+
await session.sendMessage('Slow question')
|
|
320
|
+
await waitForAgentStatus(session, 'inferring')
|
|
321
|
+
|
|
322
|
+
const agentId = session.getEntryAgentId()!
|
|
323
|
+
await session.pauseAgent(agentId, 'user hit pause')
|
|
324
|
+
|
|
325
|
+
// Let the in-flight LLM call resolve and the turn commit.
|
|
326
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
327
|
+
|
|
328
|
+
// The pause survived the inference_completed reducer — previously it was
|
|
329
|
+
// silently clobbered and the agent kept running.
|
|
330
|
+
const agentState = session.state.agents.get(agentId)!
|
|
331
|
+
expect(agentState.status).toBe('paused')
|
|
332
|
+
// The turn itself committed (the response is in history, message consumed).
|
|
333
|
+
expect(countHistoryMentions(session, 'Late reply')).toBe(1)
|
|
334
|
+
expect(countHistoryMentions(session, 'Slow question')).toBe(1)
|
|
335
|
+
|
|
336
|
+
await session.resumeAgent(agentId)
|
|
337
|
+
await session.waitForIdle()
|
|
338
|
+
expect(session.state.agents.get(agentId)!.status).toBe('pending')
|
|
339
|
+
|
|
340
|
+
await harness.shutdown()
|
|
341
|
+
})
|
|
342
|
+
})
|
|
343
|
+
})
|
|
@@ -354,12 +354,14 @@ describe('AnthropicProvider buildHttpRequest cache placement', () => {
|
|
|
354
354
|
{ id: ToolCallId('call_2'), name: 'search', input: { query: 'weather' } },
|
|
355
355
|
],
|
|
356
356
|
},
|
|
357
|
-
|
|
357
|
+
{ role: 'tool', toolCallId: ToolCallId('call_1'), content: 'Sunny' },
|
|
358
|
+
{ role: 'tool', toolCallId: ToolCallId('call_2'), content: 'Found results' },
|
|
359
|
+
], 2)
|
|
358
360
|
|
|
359
361
|
const http = await createProvider().buildHttpRequest(buildRequest(messages))
|
|
360
362
|
const msgs = getBodyMessages(http.body)
|
|
361
363
|
|
|
362
|
-
const assistant = msgs[msgs.length -
|
|
364
|
+
const assistant = msgs[msgs.length - 2]
|
|
363
365
|
expect(assistant.role).toBe('assistant')
|
|
364
366
|
expect(assistant.content.length).toBe(3) // text + 2 tool_use
|
|
365
367
|
expect(assistant.content[0].type).toBe('text')
|
|
@@ -411,8 +413,9 @@ describe('AnthropicProvider buildHttpRequest cache placement', () => {
|
|
|
411
413
|
{ role: 'assistant', content: 'preamble part B' }, // last preamble msg → prefix breakpoint
|
|
412
414
|
{ role: 'user', content: 'conversation turn' },
|
|
413
415
|
{ role: 'assistant', content: 'tail reply' }, // tail breakpoint
|
|
416
|
+
{ role: 'user', content: 'tail continuation' },
|
|
414
417
|
],
|
|
415
|
-
|
|
418
|
+
1, // one uncached user suffix → tailIdx = 3
|
|
416
419
|
undefined,
|
|
417
420
|
2, // cachedPrefixCount → prefixIdx = 1
|
|
418
421
|
)
|
|
@@ -420,7 +423,7 @@ describe('AnthropicProvider buildHttpRequest cache placement', () => {
|
|
|
420
423
|
const http = await createProvider().buildHttpRequest(buildRequest(messages))
|
|
421
424
|
const msgs = getBodyMessages(http.body)
|
|
422
425
|
|
|
423
|
-
expect(msgs.length).toBe(
|
|
426
|
+
expect(msgs.length).toBe(5)
|
|
424
427
|
// Prefix breakpoint on the last preamble message
|
|
425
428
|
const prefix = msgs[1]
|
|
426
429
|
expect(prefix.role).toBe('assistant')
|
|
@@ -17,6 +17,7 @@ import type {
|
|
|
17
17
|
RawInferenceRequest,
|
|
18
18
|
RawToolSpec,
|
|
19
19
|
} from './provider.js'
|
|
20
|
+
import { ProviderMessageValidationError, sanitizeProviderMessages } from './message-sanitization.js'
|
|
20
21
|
import type { RoutableLLMProvider } from './routing-provider.js'
|
|
21
22
|
|
|
22
23
|
// ============================================================================
|
|
@@ -321,7 +322,8 @@ export class AnthropicProvider implements RoutableLLMProvider {
|
|
|
321
322
|
}
|
|
322
323
|
|
|
323
324
|
async buildHttpRequest(request: RawInferenceRequest, context?: InferenceContext): Promise<ProviderHttpRequest> {
|
|
324
|
-
const
|
|
325
|
+
const sanitizedMessages = sanitizeProviderMessages(request.messages, this.name, this.logger)
|
|
326
|
+
const mappedMessages = await Promise.all(sanitizedMessages.map((m) => this.mapMessage(m, context)))
|
|
325
327
|
const mergedMessages = this.mergeConsecutiveMessages(mappedMessages)
|
|
326
328
|
|
|
327
329
|
const body: AnthropicRequestBody = {
|
|
@@ -574,6 +576,9 @@ export class AnthropicProvider implements RoutableLLMProvider {
|
|
|
574
576
|
}
|
|
575
577
|
|
|
576
578
|
private mapError(err: unknown): LLMError {
|
|
579
|
+
if (err instanceof ProviderMessageValidationError) {
|
|
580
|
+
return { type: 'invalid_request', message: err.message }
|
|
581
|
+
}
|
|
577
582
|
if (err instanceof Error && err.name === 'AbortError') {
|
|
578
583
|
return { type: 'aborted', message: 'Request was aborted' }
|
|
579
584
|
}
|