@roj-ai/sdk 0.1.30 → 0.1.32
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 +5 -1
- package/dist/core/agents/agent.d.ts.map +1 -1
- package/dist/core/agents/agent.js.map +1 -1
- package/dist/core/agents/config.d.ts +5 -1
- package/dist/core/agents/config.d.ts.map +1 -1
- package/dist/core/llm/anthropic.d.ts.map +1 -1
- package/dist/core/llm/anthropic.js +7 -1
- package/dist/core/llm/anthropic.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.d.ts +39 -5
- package/dist/core/llm/cache-breakpoints.d.ts.map +1 -1
- package/dist/core/llm/cache-breakpoints.js +22 -8
- package/dist/core/llm/cache-breakpoints.js.map +1 -1
- package/dist/core/llm/cache-breakpoints.test.js +32 -8
- package/dist/core/llm/cache-breakpoints.test.js.map +1 -1
- package/dist/core/llm/openrouter-mapping.test.js +29 -0
- 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 +28 -8
- package/dist/core/llm/openrouter.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.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/result-eviction/plugin.d.ts.map +1 -1
- package/dist/plugins/result-eviction/plugin.js +24 -9
- package/dist/plugins/result-eviction/plugin.js.map +1 -1
- package/dist/plugins/result-eviction/result-eviction.integration.test.js +77 -0
- package/dist/plugins/result-eviction/result-eviction.integration.test.js.map +1 -1
- package/package.json +2 -2
- package/src/core/agents/agent.ts +5 -2
- package/src/core/agents/config.ts +5 -1
- package/src/core/llm/anthropic.ts +7 -1
- package/src/core/llm/cache-breakpoints.test.ts +38 -8
- package/src/core/llm/cache-breakpoints.ts +54 -9
- package/src/core/llm/openrouter-mapping.test.ts +37 -1
- package/src/core/llm/openrouter.ts +35 -9
- package/src/index.ts +1 -0
- package/src/plugins/agents/agents.integration.test.ts +168 -0
- package/src/plugins/agents/plugin.ts +39 -2
- package/src/plugins/result-eviction/plugin.ts +26 -11
- package/src/plugins/result-eviction/result-eviction.integration.test.ts +88 -0
|
@@ -89,11 +89,17 @@ interface OpenRouterErrorResponse {
|
|
|
89
89
|
// Request body types
|
|
90
90
|
// ============================================================================
|
|
91
91
|
|
|
92
|
+
interface OpenRouterCacheControl {
|
|
93
|
+
type: 'ephemeral'
|
|
94
|
+
/** Omitted = the provider default 5-minute tier. */
|
|
95
|
+
ttl?: '5m' | '1h'
|
|
96
|
+
}
|
|
97
|
+
|
|
92
98
|
interface OpenRouterContentItem {
|
|
93
99
|
type: string
|
|
94
100
|
text?: string
|
|
95
101
|
image_url?: { url: string; detail?: string }
|
|
96
|
-
cache_control?:
|
|
102
|
+
cache_control?: OpenRouterCacheControl
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
interface OpenRouterMessage {
|
|
@@ -104,18 +110,34 @@ interface OpenRouterMessage {
|
|
|
104
110
|
}
|
|
105
111
|
|
|
106
112
|
/**
|
|
107
|
-
* Add `cache_control
|
|
108
|
-
*
|
|
109
|
-
*
|
|
113
|
+
* Add `cache_control` to the LAST content block of an OpenRouterMessage,
|
|
114
|
+
* converting string content to an array text block first so the mark has a
|
|
115
|
+
* place to live.
|
|
116
|
+
*
|
|
117
|
+
* `ttl` reaches Anthropic through the Chat Completions surface. It is NOT
|
|
118
|
+
* expressible on the Responses API, where a breakpoint is downgraded to the
|
|
119
|
+
* default 5-minute tier — so this must stay on `/chat/completions`.
|
|
110
120
|
*/
|
|
111
|
-
function applyCacheControlToLastBlock(msg: OpenRouterMessage): void {
|
|
121
|
+
function applyCacheControlToLastBlock(msg: OpenRouterMessage, cacheControl: OpenRouterCacheControl): void {
|
|
112
122
|
if (typeof msg.content === 'string') {
|
|
113
|
-
msg.content = [{ type: 'text', text: msg.content, cache_control:
|
|
123
|
+
msg.content = [{ type: 'text', text: msg.content, cache_control: cacheControl }]
|
|
114
124
|
return
|
|
115
125
|
}
|
|
116
126
|
if (msg.content.length === 0) return
|
|
117
127
|
const lastIdx = msg.content.length - 1
|
|
118
|
-
msg.content[lastIdx] = { ...msg.content[lastIdx], cache_control:
|
|
128
|
+
msg.content[lastIdx] = { ...msg.content[lastIdx], cache_control: cacheControl }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Longest TTL requested anywhere in the message list, or undefined when none.
|
|
133
|
+
*
|
|
134
|
+
* The system prompt precedes every message, and Anthropic requires a longer TTL
|
|
135
|
+
* to come BEFORE a shorter one. So when any breakpoint asks for 1h, the system
|
|
136
|
+
* block has to carry 1h too — otherwise the order is 5m → 1h → 5m and the
|
|
137
|
+
* longer entry is invalid.
|
|
138
|
+
*/
|
|
139
|
+
function longestCacheTtl(messages: LLMMessage[]): '1h' | undefined {
|
|
140
|
+
return messages.some((m) => m.cacheControl?.ttl === '1h') ? '1h' : undefined
|
|
119
141
|
}
|
|
120
142
|
|
|
121
143
|
interface OpenRouterToolDefinition {
|
|
@@ -261,8 +283,10 @@ export class OpenRouterProvider implements LLMProvider {
|
|
|
261
283
|
const sanitizedMessages = sanitizeProviderMessages(request.messages, this.name, this.logger)
|
|
262
284
|
const mappedMessages = await Promise.all(sanitizedMessages.map((m) => this.mapMessage(m, context)))
|
|
263
285
|
|
|
286
|
+
const systemTtl = longestCacheTtl(sanitizedMessages)
|
|
287
|
+
const systemCacheControl: OpenRouterCacheControl = systemTtl ? { type: 'ephemeral', ttl: systemTtl } : { type: 'ephemeral' }
|
|
264
288
|
const messages: OpenRouterMessage[] = [
|
|
265
|
-
{ role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control:
|
|
289
|
+
{ role: 'system', content: [{ type: 'text', text: request.systemPrompt, cache_control: systemCacheControl }] },
|
|
266
290
|
...mappedMessages,
|
|
267
291
|
]
|
|
268
292
|
|
|
@@ -317,7 +341,9 @@ export class OpenRouterProvider implements LLMProvider {
|
|
|
317
341
|
private async mapMessage(msg: LLMMessage, context?: InferenceContext): Promise<OpenRouterMessage> {
|
|
318
342
|
const mapped = await this.mapMessageContent(msg, context)
|
|
319
343
|
if (msg.cacheControl) {
|
|
320
|
-
|
|
344
|
+
const cacheControl: OpenRouterCacheControl = { type: 'ephemeral' }
|
|
345
|
+
if (msg.cacheControl.ttl) cacheControl.ttl = msg.cacheControl.ttl
|
|
346
|
+
applyCacheControlToLastBlock(mapped, cacheControl)
|
|
321
347
|
}
|
|
322
348
|
return mapped
|
|
323
349
|
}
|
package/src/index.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type { AgentPauseReason, AgentState, LLMMessage } from '~/core/agents/sta
|
|
|
53
53
|
export type { DomainEvent } from '~/core/events/types.js'
|
|
54
54
|
export type { FactoryEventType } from '~/core/events/types.js'
|
|
55
55
|
export { isDomainEvent } from '~/core/events/types.js'
|
|
56
|
+
export type { CacheTtl, CacheTtlConfig } from '~/core/llm/cache-breakpoints.js'
|
|
56
57
|
export type { ChatMessageContentItem, LLMCallLogEntry, LLMCallMessage, ToolResultContent } from '~/core/llm/llm-log-types.js'
|
|
57
58
|
export { contentToString } from '~/core/llm/llm-log-types.js'
|
|
58
59
|
export { applyMiddleware, useProvider, withAnthropic, withMaxTokens, withOpenRouter, withTemperature } from '~/core/llm/middleware.js'
|
|
@@ -805,4 +805,172 @@ describe('agents plugin', () => {
|
|
|
805
805
|
await harness.shutdown()
|
|
806
806
|
})
|
|
807
807
|
})
|
|
808
|
+
|
|
809
|
+
// =========================================================================
|
|
810
|
+
// resume_agent tool
|
|
811
|
+
// =========================================================================
|
|
812
|
+
|
|
813
|
+
describe('resume_agent tool', () => {
|
|
814
|
+
// A paused child never becomes idle, so waitForAllAgentsIdle can't be used here —
|
|
815
|
+
// wait for the effect instead.
|
|
816
|
+
const waitFor = async (predicate: () => boolean, timeoutMs = 5000): Promise<void> => {
|
|
817
|
+
const deadline = Date.now() + timeoutMs
|
|
818
|
+
while (Date.now() < deadline) {
|
|
819
|
+
if (predicate()) return
|
|
820
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
821
|
+
}
|
|
822
|
+
throw new Error('waitFor timed out')
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const workerPreset = () => createMultiAgentPreset([
|
|
826
|
+
{ name: 'worker', system: 'Worker agent.', tools: [], agents: [] },
|
|
827
|
+
], { orchestratorSystem: 'Orchestrator agent.' })
|
|
828
|
+
|
|
829
|
+
it('is offered to an agent that can spawn, and not to a leaf', async () => {
|
|
830
|
+
const toolNames = new Map<string, string[]>()
|
|
831
|
+
|
|
832
|
+
const harness = new TestHarness({
|
|
833
|
+
presets: [workerPreset()],
|
|
834
|
+
mockHandler: (request) => {
|
|
835
|
+
const role = request.systemPrompt.includes('Orchestrator') ? 'orchestrator' : 'worker'
|
|
836
|
+
toolNames.set(role, (request.tools ?? []).map(tool => tool.name))
|
|
837
|
+
if (role === 'orchestrator' && !toolNames.has('worker')) {
|
|
838
|
+
return {
|
|
839
|
+
content: null,
|
|
840
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Work' } }],
|
|
841
|
+
finishReason: 'stop',
|
|
842
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
846
|
+
},
|
|
847
|
+
})
|
|
848
|
+
|
|
849
|
+
const session = await harness.createSession('test')
|
|
850
|
+
await session.sendAndWaitForIdle('Start')
|
|
851
|
+
|
|
852
|
+
expect(toolNames.get('orchestrator')).toContain('resume_agent')
|
|
853
|
+
expect(toolNames.get('worker')).not.toContain('resume_agent')
|
|
854
|
+
|
|
855
|
+
await harness.shutdown()
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
it('orchestrator resumes its paused child', async () => {
|
|
859
|
+
let started = false
|
|
860
|
+
let resumeIssued = false
|
|
861
|
+
|
|
862
|
+
const harness = new TestHarness({
|
|
863
|
+
presets: [workerPreset()],
|
|
864
|
+
mockHandler: (request) => {
|
|
865
|
+
if (request.systemPrompt.includes('Orchestrator')) {
|
|
866
|
+
const asked = JSON.stringify(request.messages).includes('Resume the worker')
|
|
867
|
+
if (asked && !resumeIssued) {
|
|
868
|
+
resumeIssued = true
|
|
869
|
+
return {
|
|
870
|
+
content: null,
|
|
871
|
+
toolCalls: [{ id: ToolCallId('tc2'), name: 'resume_agent', input: { agentId: 'worker_1' } }],
|
|
872
|
+
finishReason: 'stop',
|
|
873
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (!started) {
|
|
877
|
+
started = true
|
|
878
|
+
return {
|
|
879
|
+
content: null,
|
|
880
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker', input: { message: 'Work' } }],
|
|
881
|
+
finishReason: 'stop',
|
|
882
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
886
|
+
}
|
|
887
|
+
return { content: 'Worker done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
888
|
+
},
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
const session = await harness.createSession('test')
|
|
892
|
+
await session.sendAndWaitForIdle('Start')
|
|
893
|
+
|
|
894
|
+
const pauseResult = await session.callPluginMethod('agents.pause', { agentId: 'worker_1' })
|
|
895
|
+
expect(pauseResult.ok).toBe(true)
|
|
896
|
+
expect(session.state.agents.get(AgentId('worker_1'))!.status).toBe('paused')
|
|
897
|
+
|
|
898
|
+
await session.sendMessage('Resume the worker')
|
|
899
|
+
await waitFor(() => session.state.agents.get(AgentId('worker_1'))!.status !== 'paused')
|
|
900
|
+
|
|
901
|
+
const resumedEvents = await session.getEventsByType(agentEvents, 'agent_resumed')
|
|
902
|
+
expect(resumedEvents.filter(e => e.agentId === AgentId('worker_1'))).toHaveLength(1)
|
|
903
|
+
expect(session.state.agents.get(AgentId('worker_1'))!.status).not.toBe('paused')
|
|
904
|
+
|
|
905
|
+
await harness.shutdown()
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
it('refuses an agent that is not the caller\'s child', async () => {
|
|
909
|
+
let delegated = false
|
|
910
|
+
let subDelegated = false
|
|
911
|
+
let resumeIssued = false
|
|
912
|
+
let toolResult = ''
|
|
913
|
+
|
|
914
|
+
const harness = new TestHarness({
|
|
915
|
+
presets: [createMultiAgentPreset([
|
|
916
|
+
{ name: 'worker_a', system: 'Worker A agent.', tools: [], agents: ['worker_b'] },
|
|
917
|
+
{ name: 'worker_b', system: 'Worker B agent.', tools: [], agents: [] },
|
|
918
|
+
], { orchestratorSystem: 'Orchestrator agent.' })],
|
|
919
|
+
mockHandler: (request) => {
|
|
920
|
+
if (request.systemPrompt.includes('Orchestrator')) {
|
|
921
|
+
const last = request.messages[request.messages.length - 1]
|
|
922
|
+
if (last?.role === 'tool' && resumeIssued) toolResult = JSON.stringify(last.content)
|
|
923
|
+
|
|
924
|
+
const asked = JSON.stringify(request.messages).includes('Resume the grandchild')
|
|
925
|
+
if (asked && !resumeIssued) {
|
|
926
|
+
resumeIssued = true
|
|
927
|
+
// worker_b is a grandchild — spawned by worker_a, not by the orchestrator
|
|
928
|
+
return {
|
|
929
|
+
content: null,
|
|
930
|
+
toolCalls: [{ id: ToolCallId('tc3'), name: 'resume_agent', input: { agentId: 'worker_b_1' } }],
|
|
931
|
+
finishReason: 'stop',
|
|
932
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
if (!delegated) {
|
|
936
|
+
delegated = true
|
|
937
|
+
return {
|
|
938
|
+
content: null,
|
|
939
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'start_worker_a', input: { message: 'Delegate' } }],
|
|
940
|
+
finishReason: 'stop',
|
|
941
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
if (request.systemPrompt.includes('Worker A')) {
|
|
948
|
+
if (subDelegated) {
|
|
949
|
+
return { content: 'Worker A done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
950
|
+
}
|
|
951
|
+
subDelegated = true
|
|
952
|
+
return {
|
|
953
|
+
content: null,
|
|
954
|
+
toolCalls: [{ id: ToolCallId('tc2'), name: 'start_worker_b', input: { message: 'Sub-task' } }],
|
|
955
|
+
finishReason: 'stop',
|
|
956
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return { content: 'Worker done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
960
|
+
},
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
const session = await harness.createSession('test')
|
|
964
|
+
await session.sendAndWaitForIdle('Start', { timeoutMs: 15_000 })
|
|
965
|
+
|
|
966
|
+
await session.callPluginMethod('agents.pause', { agentId: 'worker_b_1' })
|
|
967
|
+
await session.sendMessage('Resume the grandchild')
|
|
968
|
+
await waitFor(() => toolResult !== '')
|
|
969
|
+
|
|
970
|
+
expect(toolResult).toContain('not your child')
|
|
971
|
+
expect(session.state.agents.get(AgentId('worker_b_1'))!.status).toBe('paused')
|
|
972
|
+
|
|
973
|
+
await harness.shutdown()
|
|
974
|
+
}, 20_000)
|
|
975
|
+
})
|
|
808
976
|
})
|
|
@@ -420,7 +420,7 @@ export const agentsPlugin = definePlugin('agents')
|
|
|
420
420
|
- **New task** → spawn a new agent using \`start_<agent_name>\`. You will receive the agent's ID in the result — use it with \`send_message\` for follow-up communication.
|
|
421
421
|
- **Follow-up on an existing task** → send a message to the existing agent via \`send_message\` with the agent's ID. Do NOT spawn a new agent for feedback, corrections, or additional instructions on a task already assigned.
|
|
422
422
|
- Spawned agents communicate back to you via \`send_message\`. Check your incoming messages for their results and progress updates.
|
|
423
|
-
- If a child pauses early it sends you a \`<child-paused agent="…">reason</child-paused>\` message (e.g. it hit a cost/limit budget). Decide what to do: resume it
|
|
423
|
+
- If a child pauses early it sends you a \`<child-paused agent="…">reason</child-paused>\` message (e.g. it hit a cost/limit budget). Decide what to do: resume it with \`resume_agent\` **after addressing the cause**, reassign or drop the work, or stop. Resuming does not grant more budget — a child that hit a cost or token limit will stop again immediately, so don't retry it in a loop.`
|
|
424
424
|
|
|
425
425
|
// Only include supervision instructions if supervision is actually enabled
|
|
426
426
|
// for this session — otherwise the section is misleading bloat.
|
|
@@ -438,7 +438,41 @@ Do NOT act on a supervision tick unless something is genuinely wrong (a child ha
|
|
|
438
438
|
const spawnableAgents = ctx.agentConfig.spawnableAgents
|
|
439
439
|
const agentDefs = ctx.pluginConfig.agentDefinitions
|
|
440
440
|
|
|
441
|
-
|
|
441
|
+
// The parent is told (see the `<child-paused>` line in the system prompt above) that it
|
|
442
|
+
// may resume a paused child. Without this tool that instruction had nothing behind it:
|
|
443
|
+
// `resume` existed only as a plugin method, reachable from outside the session via
|
|
444
|
+
// `agents.resume`, so a child that paused stayed paused until something external
|
|
445
|
+
// intervened. Only offered to agents that can actually have children.
|
|
446
|
+
const resumeTool = createTool({
|
|
447
|
+
name: 'resume_agent',
|
|
448
|
+
description:
|
|
449
|
+
'Resume one of your own child agents after it paused (you receive a <child-paused> message when that happens). '
|
|
450
|
+
+ 'Address the cause first — resuming does not grant more budget, so a child that hit a cost or token limit '
|
|
451
|
+
+ 'will stop again immediately unless the limit itself is raised.',
|
|
452
|
+
input: z.object({
|
|
453
|
+
agentId: agentIdSchema.describe('ID of the paused child agent, as given in the <child-paused> message'),
|
|
454
|
+
}),
|
|
455
|
+
execute: async (input, context) => {
|
|
456
|
+
const target = context.sessionState.agents.get(input.agentId)
|
|
457
|
+
if (!target) {
|
|
458
|
+
return Err({ message: `No agent "${input.agentId}" in this session.`, recoverable: false })
|
|
459
|
+
}
|
|
460
|
+
if (target.parentId !== context.agentId) {
|
|
461
|
+
return Err({ message: `Agent "${input.agentId}" is not your child — you can only resume agents you spawned.`, recoverable: false })
|
|
462
|
+
}
|
|
463
|
+
if (target.status !== 'paused' && target.status !== 'errored') {
|
|
464
|
+
return Err({ message: `Agent "${input.agentId}" is ${target.status}, not paused — nothing to resume.`, recoverable: false })
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const result = await ctx.self.resume({ agentId: input.agentId })
|
|
468
|
+
if (!result.ok) {
|
|
469
|
+
return Err({ message: result.error.message, recoverable: false })
|
|
470
|
+
}
|
|
471
|
+
return Ok(`Agent "${input.agentId}" resumed.`)
|
|
472
|
+
},
|
|
473
|
+
})
|
|
474
|
+
|
|
475
|
+
const startTools = spawnableAgents.map((agentName) => {
|
|
442
476
|
const agentInfo = agentDefs.get(agentName) ?? { name: agentName }
|
|
443
477
|
const toolName = `start_${agentInfo.name}`
|
|
444
478
|
const description = agentInfo.description
|
|
@@ -469,5 +503,8 @@ Do NOT act on a supervision tick unless something is genuinely wrong (a child ha
|
|
|
469
503
|
},
|
|
470
504
|
})
|
|
471
505
|
})
|
|
506
|
+
|
|
507
|
+
// An agent with nothing to spawn has no children to resume either.
|
|
508
|
+
return spawnableAgents.length > 0 ? [...startTools, resumeTool] : []
|
|
472
509
|
})
|
|
473
510
|
.build()
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* head + tail + file path is returned instead.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { type ChatMessageContentItem, contentToString, type ToolResultContent } from '~/core/llm/llm-log-types.js'
|
|
9
10
|
import { truncateByTokens } from '~/core/llm/tokens.js'
|
|
10
11
|
import { definePlugin } from '~/core/plugins/plugin-builder.js'
|
|
11
12
|
|
|
@@ -23,6 +24,21 @@ export interface EvictionAgentConfig {
|
|
|
23
24
|
|
|
24
25
|
const DEFAULT_MAX_TOKENS = 20_000
|
|
25
26
|
|
|
27
|
+
const isImage = (item: ChatMessageContentItem): boolean => item.type === 'image_url'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Rebuild a multi-part result with its text collapsed to `preview` and every
|
|
31
|
+
* image part preserved in order.
|
|
32
|
+
*
|
|
33
|
+
* Images are kept rather than truncated because there is no partial image — the
|
|
34
|
+
* alternative to keeping it is dropping the observation entirely. Text is the
|
|
35
|
+
* part that both dominates the payload and survives being summarised.
|
|
36
|
+
*/
|
|
37
|
+
const withTextReplaced = (content: ChatMessageContentItem[], preview: string): ChatMessageContentItem[] => [
|
|
38
|
+
{ type: 'text', text: preview },
|
|
39
|
+
...content.filter(isImage),
|
|
40
|
+
]
|
|
41
|
+
|
|
26
42
|
export const resultEvictionPlugin = definePlugin('result-eviction')
|
|
27
43
|
.agentConfig<EvictionAgentConfig>()
|
|
28
44
|
.hook('afterToolCall', async (ctx) => {
|
|
@@ -34,31 +50,30 @@ export const resultEvictionPlugin = definePlugin('result-eviction')
|
|
|
34
50
|
}
|
|
35
51
|
|
|
36
52
|
const { content } = ctx.result
|
|
37
|
-
|
|
38
|
-
// Only evict string content
|
|
39
|
-
if (typeof content !== 'string') {
|
|
40
|
-
return null
|
|
41
|
-
}
|
|
42
|
-
|
|
43
53
|
const maxTokens = agentConfig?.config?.maxTokens ?? DEFAULT_MAX_TOKENS
|
|
44
54
|
|
|
45
|
-
|
|
55
|
+
// Multi-part results (e.g. a page snapshot returning JSON + a screenshot)
|
|
56
|
+
// used to bail out here, so no threshold could ever evict them however
|
|
57
|
+
// large the text grew. Measure the text, evict the text, keep the images.
|
|
58
|
+
const text = contentToString(content)
|
|
59
|
+
const truncation = truncateByTokens(text, maxTokens)
|
|
46
60
|
if (!truncation) {
|
|
47
61
|
return null
|
|
48
62
|
}
|
|
49
63
|
|
|
50
|
-
// Write full
|
|
64
|
+
// Write full text to file via FileStore
|
|
51
65
|
const fileName = `${ctx.toolCall.id}.txt`
|
|
52
66
|
const filePath = `.results/${fileName}`
|
|
53
|
-
await ctx.files.session.write(filePath,
|
|
67
|
+
await ctx.files.session.write(filePath, text)
|
|
54
68
|
|
|
55
|
-
const
|
|
69
|
+
const preview = `${truncation.content}\n\n[Full output saved to: ${filePath}]`
|
|
70
|
+
const evicted: ToolResultContent = typeof content === 'string' ? preview : withTextReplaced(content, preview)
|
|
56
71
|
|
|
57
72
|
return {
|
|
58
73
|
action: 'modify',
|
|
59
74
|
result: {
|
|
60
75
|
isError: false,
|
|
61
|
-
content:
|
|
76
|
+
content: evicted,
|
|
62
77
|
},
|
|
63
78
|
}
|
|
64
79
|
})
|
|
@@ -19,6 +19,18 @@ const bigOutputPlugin = definePlugin('big-output')
|
|
|
19
19
|
input: z.object({ size: z.number() }),
|
|
20
20
|
execute: async (input) => Ok('x'.repeat(input.size)),
|
|
21
21
|
}),
|
|
22
|
+
// Mirrors a page-snapshot style result: a large JSON/text block plus an
|
|
23
|
+
// image part. These used to bypass eviction entirely.
|
|
24
|
+
createTool({
|
|
25
|
+
name: 'generate_snapshot',
|
|
26
|
+
description: 'Generate a text + image result of a given text size',
|
|
27
|
+
input: z.object({ size: z.number() }),
|
|
28
|
+
execute: async (input) =>
|
|
29
|
+
Ok([
|
|
30
|
+
{ type: 'text' as const, text: 'x'.repeat(input.size) },
|
|
31
|
+
{ type: 'image_url' as const, imageUrl: { url: 'data:image/png;base64,AAAA' } },
|
|
32
|
+
]),
|
|
33
|
+
}),
|
|
22
34
|
])
|
|
23
35
|
.build()
|
|
24
36
|
|
|
@@ -100,6 +112,82 @@ describe('result-eviction plugin', () => {
|
|
|
100
112
|
await harness.shutdown()
|
|
101
113
|
})
|
|
102
114
|
|
|
115
|
+
it('large multi-part output → text evicted, image part kept', async () => {
|
|
116
|
+
let toolContent: unknown = null
|
|
117
|
+
const harness = createEvictionHarness({
|
|
118
|
+
presets: [createEvictionPreset()],
|
|
119
|
+
mockHandler: (request) => {
|
|
120
|
+
const callCount = harness.llmProvider.getCallCount()
|
|
121
|
+
if (callCount === 1) {
|
|
122
|
+
return {
|
|
123
|
+
content: null,
|
|
124
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 250_000 } }],
|
|
125
|
+
finishReason: 'stop',
|
|
126
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const msg of request.messages) {
|
|
130
|
+
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
131
|
+
toolContent = msg.content
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const session = await harness.createSession('test')
|
|
139
|
+
await session.sendAndWaitForIdle('Generate large snapshot')
|
|
140
|
+
|
|
141
|
+
expect(Array.isArray(toolContent)).toBe(true)
|
|
142
|
+
const parts = toolContent as Array<{ type: string; text?: string }>
|
|
143
|
+
|
|
144
|
+
// Text collapsed to a preview that names the spill file...
|
|
145
|
+
const textParts = parts.filter((p) => p.type === 'text')
|
|
146
|
+
expect(textParts).toHaveLength(1)
|
|
147
|
+
expect(textParts[0].text).toContain('truncated')
|
|
148
|
+
expect(textParts[0].text).toContain('.results/')
|
|
149
|
+
expect(textParts[0].text!.length).toBeLessThan(250_000)
|
|
150
|
+
|
|
151
|
+
// ...and the image survives, because there is no partial image: the only
|
|
152
|
+
// alternative to keeping it is losing the observation.
|
|
153
|
+
expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
|
|
154
|
+
|
|
155
|
+
await harness.shutdown()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('small multi-part output → returned unchanged', async () => {
|
|
159
|
+
let toolContent: unknown = null
|
|
160
|
+
const harness = createEvictionHarness({
|
|
161
|
+
presets: [createEvictionPreset()],
|
|
162
|
+
mockHandler: (request) => {
|
|
163
|
+
const callCount = harness.llmProvider.getCallCount()
|
|
164
|
+
if (callCount === 1) {
|
|
165
|
+
return {
|
|
166
|
+
content: null,
|
|
167
|
+
toolCalls: [{ id: ToolCallId('tc1'), name: 'generate_snapshot', input: { size: 100 } }],
|
|
168
|
+
finishReason: 'stop',
|
|
169
|
+
metrics: MockLLMProvider.defaultMetrics(),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
for (const msg of request.messages) {
|
|
173
|
+
if (msg.role === 'tool' && Array.isArray(msg.content)) {
|
|
174
|
+
toolContent = msg.content
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { content: 'Done', toolCalls: [], finishReason: 'stop', metrics: MockLLMProvider.defaultMetrics() }
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const session = await harness.createSession('test')
|
|
182
|
+
await session.sendAndWaitForIdle('Generate small snapshot')
|
|
183
|
+
|
|
184
|
+
const parts = toolContent as Array<{ type: string; text?: string }>
|
|
185
|
+
expect(parts.filter((p) => p.type === 'text')[0].text).toBe('x'.repeat(100))
|
|
186
|
+
expect(parts.filter((p) => p.type === 'image_url')).toHaveLength(1)
|
|
187
|
+
|
|
188
|
+
await harness.shutdown()
|
|
189
|
+
})
|
|
190
|
+
|
|
103
191
|
it('full output saved to .results/<toolCallId>.txt', async () => {
|
|
104
192
|
const harness = createEvictionHarness({
|
|
105
193
|
presets: [createEvictionPreset()],
|