clanka 0.3.1 → 0.4.0
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/Acp.d.ts.map +1 -1
- package/dist/Acp.js +3 -0
- package/dist/Acp.js.map +1 -1
- package/dist/Acp.test.js +58 -0
- package/dist/Acp.test.js.map +1 -1
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +86 -8
- package/dist/Agent.js.map +1 -1
- package/dist/Agent.test.js +671 -0
- package/dist/Agent.test.js.map +1 -1
- package/dist/AgentExecutor.d.ts +5 -0
- package/dist/AgentExecutor.d.ts.map +1 -1
- package/dist/AgentExecutor.js +22 -0
- package/dist/AgentExecutor.js.map +1 -1
- package/dist/AgentOutput.d.ts +49 -4
- package/dist/AgentOutput.d.ts.map +1 -1
- package/dist/AgentOutput.js +45 -0
- package/dist/AgentOutput.js.map +1 -1
- package/dist/AgentSkills.d.ts +56 -0
- package/dist/AgentSkills.d.ts.map +1 -0
- package/dist/AgentSkills.js +126 -0
- package/dist/AgentSkills.js.map +1 -0
- package/dist/AgentSkills.test.d.ts +2 -0
- package/dist/AgentSkills.test.d.ts.map +1 -0
- package/dist/AgentSkills.test.js +356 -0
- package/dist/AgentSkills.test.js.map +1 -0
- package/dist/Codex.js +1 -1
- package/dist/Codex.js.map +1 -1
- package/dist/Compaction.d.ts +342 -0
- package/dist/Compaction.d.ts.map +1 -0
- package/dist/Compaction.js +566 -0
- package/dist/Compaction.js.map +1 -0
- package/dist/Compaction.test.d.ts +2 -0
- package/dist/Compaction.test.d.ts.map +1 -0
- package/dist/Compaction.test.js +576 -0
- package/dist/Compaction.test.js.map +1 -0
- package/dist/CompactionTransport.test.d.ts +2 -0
- package/dist/CompactionTransport.test.d.ts.map +1 -0
- package/dist/CompactionTransport.test.js +123 -0
- package/dist/CompactionTransport.test.js.map +1 -0
- package/dist/Copilot.d.ts.map +1 -1
- package/dist/Copilot.js +7 -2
- package/dist/Copilot.js.map +1 -1
- package/dist/OutputFormatter.d.ts.map +1 -1
- package/dist/OutputFormatter.js +9 -0
- package/dist/OutputFormatter.js.map +1 -1
- package/dist/cli.js +13 -4
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Acp.test.ts +85 -0
- package/src/Acp.ts +2 -0
- package/src/Agent.test.ts +994 -0
- package/src/Agent.ts +127 -6
- package/src/AgentExecutor.ts +27 -0
- package/src/AgentOutput.ts +58 -0
- package/src/AgentSkills.test.ts +652 -0
- package/src/AgentSkills.ts +155 -0
- package/src/Codex.ts +3 -1
- package/src/Compaction.test.ts +824 -0
- package/src/Compaction.ts +788 -0
- package/src/CompactionTransport.test.ts +228 -0
- package/src/Copilot.ts +8 -1
- package/src/OutputFormatter.ts +9 -0
- package/src/cli.ts +26 -5
- package/src/index.ts +6 -0
package/src/Agent.test.ts
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
import { assert, describe, it } from "@effect/vitest"
|
|
2
2
|
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Duration from "effect/Duration"
|
|
4
|
+
import * as Exit from "effect/Exit"
|
|
3
5
|
import * as Layer from "effect/Layer"
|
|
6
|
+
import * as MutableRef from "effect/MutableRef"
|
|
4
7
|
import * as Option from "effect/Option"
|
|
8
|
+
import * as Schema from "effect/Schema"
|
|
5
9
|
import * as Stream from "effect/Stream"
|
|
10
|
+
import * as AiError from "effect/unstable/ai/AiError"
|
|
6
11
|
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
|
|
7
12
|
import * as Model from "effect/unstable/ai/Model"
|
|
13
|
+
import * as Prompt from "effect/unstable/ai/Prompt"
|
|
14
|
+
import type * as Response from "effect/unstable/ai/Response"
|
|
15
|
+
import * as ResponseIdTracker from "effect/unstable/ai/ResponseIdTracker"
|
|
8
16
|
import * as Agent from "./Agent.ts"
|
|
9
17
|
import * as AgentExecutor from "./AgentExecutor.ts"
|
|
18
|
+
import type * as AgentOutput from "./AgentOutput.ts"
|
|
19
|
+
import * as Compaction from "./Compaction.ts"
|
|
10
20
|
|
|
11
21
|
const capabilities = new AgentExecutor.Capabilities({
|
|
12
22
|
toolsDts: "",
|
|
13
23
|
agentsMd: Option.none(),
|
|
14
24
|
supportsSearch: false,
|
|
25
|
+
skills: [],
|
|
15
26
|
})
|
|
16
27
|
|
|
17
28
|
const makeExecutor = (
|
|
@@ -121,3 +132,986 @@ describe("Agent", () => {
|
|
|
121
132
|
),
|
|
122
133
|
)
|
|
123
134
|
})
|
|
135
|
+
|
|
136
|
+
// =============================================================================
|
|
137
|
+
// Compaction acceptance tests
|
|
138
|
+
//
|
|
139
|
+
// These drive the real Agent loop with a scripted LanguageModel. Every
|
|
140
|
+
// model call is recorded so tests can assert on exactly what the model
|
|
141
|
+
// was sent. The summarizer call is recognised by having no tools.
|
|
142
|
+
// =============================================================================
|
|
143
|
+
|
|
144
|
+
interface RecordedCall {
|
|
145
|
+
readonly method: "generateText" | "streamText"
|
|
146
|
+
readonly prompt: Prompt.Prompt
|
|
147
|
+
readonly isSummarizer: boolean
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type ScriptedResponse = (
|
|
151
|
+
call: RecordedCall,
|
|
152
|
+
index: number,
|
|
153
|
+
) => Stream.Stream<Response.StreamPartEncoded, AiError.AiError>
|
|
154
|
+
|
|
155
|
+
const runAgentCollect = (options: {
|
|
156
|
+
readonly conversationMode?: boolean | undefined
|
|
157
|
+
readonly executor?: AgentExecutor.AgentExecutor["Service"] | undefined
|
|
158
|
+
readonly history?: Prompt.Prompt | undefined
|
|
159
|
+
readonly compaction?: Partial<Compaction.CompactionConfigService> | undefined
|
|
160
|
+
readonly prompt?: string | undefined
|
|
161
|
+
readonly turnTimeout?: Duration.Duration | undefined
|
|
162
|
+
readonly respond: ScriptedResponse
|
|
163
|
+
}) =>
|
|
164
|
+
Effect.scoped(
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const calls: Array<RecordedCall> = []
|
|
167
|
+
const outputs: Array<AgentOutput.Output> = []
|
|
168
|
+
|
|
169
|
+
const languageModel = yield* LanguageModel.make({
|
|
170
|
+
generateText: (providerOptions) => {
|
|
171
|
+
const call: RecordedCall = {
|
|
172
|
+
method: "generateText",
|
|
173
|
+
prompt: providerOptions.prompt,
|
|
174
|
+
isSummarizer: providerOptions.tools.length === 0,
|
|
175
|
+
}
|
|
176
|
+
const index = calls.push(call) - 1
|
|
177
|
+
return options.respond(call, index).pipe(
|
|
178
|
+
Stream.runCollect,
|
|
179
|
+
Effect.map((parts): Array<Response.PartEncoded> => {
|
|
180
|
+
const text = parts
|
|
181
|
+
.flatMap((part) =>
|
|
182
|
+
part.type === "text-delta" ? [part.delta] : [],
|
|
183
|
+
)
|
|
184
|
+
.join("")
|
|
185
|
+
return [
|
|
186
|
+
{ type: "text", text },
|
|
187
|
+
...parts.filter((part) => part.type === "finish"),
|
|
188
|
+
]
|
|
189
|
+
}),
|
|
190
|
+
)
|
|
191
|
+
},
|
|
192
|
+
streamText: (providerOptions) => {
|
|
193
|
+
const call: RecordedCall = {
|
|
194
|
+
method: "streamText",
|
|
195
|
+
prompt: providerOptions.prompt,
|
|
196
|
+
isSummarizer: providerOptions.tools.length === 0,
|
|
197
|
+
}
|
|
198
|
+
const index = calls.push(call) - 1
|
|
199
|
+
return options.respond(call, index)
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
const modelLayer = Layer.mergeAll(
|
|
203
|
+
Layer.succeed(LanguageModel.LanguageModel, languageModel),
|
|
204
|
+
Layer.succeed(Model.ProviderName, "test-provider"),
|
|
205
|
+
Layer.succeed(Model.ModelName, "test-model"),
|
|
206
|
+
)
|
|
207
|
+
const agent = yield* Agent.make.pipe(
|
|
208
|
+
Effect.provideService(
|
|
209
|
+
AgentExecutor.AgentExecutor,
|
|
210
|
+
options.executor ?? makeExecutor(),
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
if (options.history) {
|
|
214
|
+
MutableRef.set(agent.history, options.history)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const exit = yield* agent
|
|
218
|
+
.send({ prompt: options.prompt ?? "hello" })
|
|
219
|
+
.pipe(
|
|
220
|
+
Effect.flatMap((stream) =>
|
|
221
|
+
stream.pipe(
|
|
222
|
+
Stream.runForEach((output) =>
|
|
223
|
+
Effect.sync(() => {
|
|
224
|
+
outputs.push(output)
|
|
225
|
+
}),
|
|
226
|
+
),
|
|
227
|
+
Effect.as(""),
|
|
228
|
+
Effect.catchTag("AgentFinished", (finished) =>
|
|
229
|
+
Effect.succeed(finished.summary),
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
Effect.provide(
|
|
234
|
+
Layer.mergeAll(
|
|
235
|
+
modelLayer,
|
|
236
|
+
Agent.ConversationMode.layer(options.conversationMode ?? true),
|
|
237
|
+
Agent.layerSubagentModel(modelLayer),
|
|
238
|
+
Compaction.CompactionConfig.layer(options.compaction ?? {}),
|
|
239
|
+
Layer.succeed(
|
|
240
|
+
Agent.TurnTimeout,
|
|
241
|
+
options.turnTimeout ?? Duration.minutes(5),
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
Effect.exit,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
return { exit, calls, outputs, history: agent.history.current }
|
|
249
|
+
}),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
// --- fixtures ----------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
const filler = (word: string, n: number) =>
|
|
255
|
+
(word + " ").repeat(Math.ceil(n / (word.length + 1))).slice(0, n)
|
|
256
|
+
|
|
257
|
+
const user = (text: string) =>
|
|
258
|
+
Prompt.makeMessage("user", {
|
|
259
|
+
content: [Prompt.makePart("text", { text })],
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const assistantCall = (id: string, script: string) =>
|
|
263
|
+
Prompt.makeMessage("assistant", {
|
|
264
|
+
content: [
|
|
265
|
+
Prompt.makePart("tool-call", {
|
|
266
|
+
id,
|
|
267
|
+
name: "execute",
|
|
268
|
+
params: { script },
|
|
269
|
+
providerExecuted: false,
|
|
270
|
+
}),
|
|
271
|
+
],
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
const toolResult = (id: string, result: string) =>
|
|
275
|
+
Prompt.makeMessage("tool", {
|
|
276
|
+
content: [
|
|
277
|
+
Prompt.makePart("tool-result", {
|
|
278
|
+
id,
|
|
279
|
+
name: "execute",
|
|
280
|
+
isFailure: false,
|
|
281
|
+
result,
|
|
282
|
+
providerExecuted: false,
|
|
283
|
+
}),
|
|
284
|
+
],
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* A history with two prior execute turns whose results carry unique
|
|
289
|
+
* sentinels, so tests can check what survived compaction. Each result is
|
|
290
|
+
* about 30k chars (~7.5k tokens), so the whole thing is ~15k tokens.
|
|
291
|
+
*/
|
|
292
|
+
const fatHistory = Prompt.fromMessages([
|
|
293
|
+
user("Investigate the failing build"),
|
|
294
|
+
assistantCall("call-1", "await readFile({ path: 'big1.log' })"),
|
|
295
|
+
toolResult("call-1", "SENTINEL-ONE " + filler("first", 30_000)),
|
|
296
|
+
assistantCall("call-2", "await readFile({ path: 'big2.log' })"),
|
|
297
|
+
toolResult("call-2", "SENTINEL-TWO " + filler("second", 30_000)),
|
|
298
|
+
])
|
|
299
|
+
|
|
300
|
+
const toolCall = (id: string, script: string): Response.StreamPartEncoded => ({
|
|
301
|
+
type: "tool-call",
|
|
302
|
+
id,
|
|
303
|
+
name: "execute",
|
|
304
|
+
params: { script },
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
const finish = (
|
|
308
|
+
contextTokens: number,
|
|
309
|
+
reason: "stop" | "tool-calls" = "tool-calls",
|
|
310
|
+
): Response.StreamPartEncoded => ({
|
|
311
|
+
type: "finish",
|
|
312
|
+
reason,
|
|
313
|
+
usage: {
|
|
314
|
+
inputTokens: { total: contextTokens },
|
|
315
|
+
outputTokens: { total: 10 },
|
|
316
|
+
},
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
const text = (content: string): Array<Response.StreamPartEncoded> => [
|
|
320
|
+
{ type: "text-start", id: "t" },
|
|
321
|
+
{ type: "text-delta", id: "t", delta: content },
|
|
322
|
+
{ type: "text-end", id: "t" },
|
|
323
|
+
]
|
|
324
|
+
|
|
325
|
+
const contextLengthError = AiError.make({
|
|
326
|
+
module: "OpenAiLanguageModel",
|
|
327
|
+
method: "streamText",
|
|
328
|
+
reason: new AiError.InvalidRequestError({
|
|
329
|
+
description:
|
|
330
|
+
"Your input exceeds the context window of this model. Please adjust your input and try again. (POST https://chatgpt.com/backend-api/codex/responses) [code: context_length_exceeded]",
|
|
331
|
+
}),
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
const providerError = AiError.make({
|
|
335
|
+
module: "OpenAiLanguageModel",
|
|
336
|
+
method: "streamText",
|
|
337
|
+
reason: new AiError.InternalProviderError({
|
|
338
|
+
description: "Server error",
|
|
339
|
+
}),
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
const encodePrompt = Schema.encodeSync(Prompt.Prompt)
|
|
343
|
+
const promptJson = (prompt: Prompt.Prompt) =>
|
|
344
|
+
JSON.stringify(encodePrompt(prompt))
|
|
345
|
+
|
|
346
|
+
const toolResults = (prompt: Prompt.Prompt): Array<Prompt.ToolResultPart> =>
|
|
347
|
+
prompt.content.flatMap((message) =>
|
|
348
|
+
message.role === "tool"
|
|
349
|
+
? message.content.filter(
|
|
350
|
+
(part): part is Prompt.ToolResultPart => part.type === "tool-result",
|
|
351
|
+
)
|
|
352
|
+
: [],
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
const parts = (message: Prompt.Message): ReadonlyArray<Prompt.Part> =>
|
|
356
|
+
typeof message.content === "string" ? [] : message.content
|
|
357
|
+
|
|
358
|
+
const toolCallIds = (prompt: Prompt.Prompt): Array<string> =>
|
|
359
|
+
prompt.content.flatMap((message) =>
|
|
360
|
+
parts(message).flatMap((part) =>
|
|
361
|
+
part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [],
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
const outputsOfTag = <Tag extends AgentOutput.Output["_tag"]>(
|
|
366
|
+
outputs: ReadonlyArray<AgentOutput.Output>,
|
|
367
|
+
tag: Tag,
|
|
368
|
+
) =>
|
|
369
|
+
outputs.filter(
|
|
370
|
+
(output): output is Extract<AgentOutput.Output, { _tag: Tag }> =>
|
|
371
|
+
output._tag === tag,
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
const assertSummarizerCall = (call: RecordedCall) => {
|
|
375
|
+
assert.isTrue(call.isSummarizer, "expected the summarizer call (no tools)")
|
|
376
|
+
assert.isFalse(
|
|
377
|
+
call.prompt.content.some((m) => m.role === "tool"),
|
|
378
|
+
"summarizer input must not contain raw tool messages",
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const assertCompactedShape = (
|
|
383
|
+
prompt: Prompt.Prompt,
|
|
384
|
+
summary: string,
|
|
385
|
+
): ReadonlyArray<Prompt.Message> => {
|
|
386
|
+
assert.strictEqual(prompt.content[0]?.role, "system", "system message first")
|
|
387
|
+
const summaryMessage = prompt.content[1]
|
|
388
|
+
assert.strictEqual(
|
|
389
|
+
summaryMessage?.role,
|
|
390
|
+
"user",
|
|
391
|
+
"summary user message second",
|
|
392
|
+
)
|
|
393
|
+
const part = parts(summaryMessage!)[0]
|
|
394
|
+
assert.strictEqual(part?.type, "text")
|
|
395
|
+
assert.strictEqual(
|
|
396
|
+
(part as Prompt.TextPart).text,
|
|
397
|
+
Compaction.wrapSummary(summary),
|
|
398
|
+
)
|
|
399
|
+
assert.strictEqual(
|
|
400
|
+
prompt.content.filter((m) => m.role === "system").length,
|
|
401
|
+
1,
|
|
402
|
+
"exactly one system message",
|
|
403
|
+
)
|
|
404
|
+
return prompt.content.slice(2)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// --- tests -------------------------------------------------------------------
|
|
408
|
+
|
|
409
|
+
describe("Agent execute output cap", () => {
|
|
410
|
+
const dump = "DUMP-HEAD " + filler("log", 40_000) + " DUMP-TAIL"
|
|
411
|
+
|
|
412
|
+
const runCapped = (
|
|
413
|
+
compaction?: Partial<Compaction.CompactionConfigService>,
|
|
414
|
+
) =>
|
|
415
|
+
runAgentCollect({
|
|
416
|
+
compaction,
|
|
417
|
+
executor: makeExecutor(() => Stream.succeed(dump)),
|
|
418
|
+
respond: (_call, index) => {
|
|
419
|
+
switch (index) {
|
|
420
|
+
case 0:
|
|
421
|
+
return Stream.fromIterable([
|
|
422
|
+
toolCall("call-1", "await readFile({ path: 'x' })"),
|
|
423
|
+
finish(500),
|
|
424
|
+
])
|
|
425
|
+
case 1:
|
|
426
|
+
return Stream.fromIterable([
|
|
427
|
+
...text("all done"),
|
|
428
|
+
finish(600, "stop"),
|
|
429
|
+
])
|
|
430
|
+
default:
|
|
431
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it.effect("caps a single execute result over 32k chars in history", () =>
|
|
437
|
+
Effect.gen(function* () {
|
|
438
|
+
const { exit, calls, outputs, history } = yield* runCapped()
|
|
439
|
+
assert.isTrue(Exit.isSuccess(exit), "turn should finish")
|
|
440
|
+
assert.strictEqual(calls.length, 2)
|
|
441
|
+
|
|
442
|
+
// What the model saw on the next call
|
|
443
|
+
const [result] = toolResults(calls[1]!.prompt)
|
|
444
|
+
assert.isDefined(result)
|
|
445
|
+
const seen = result.result as string
|
|
446
|
+
assert.isBelow(seen.length, dump.length)
|
|
447
|
+
assert.isAtMost(seen.length, Compaction.executeOutputCapChars + 1_000)
|
|
448
|
+
assert.isTrue(seen.startsWith("DUMP-HEAD"), "head kept")
|
|
449
|
+
assert.isTrue(seen.endsWith("DUMP-TAIL"), "tail kept")
|
|
450
|
+
assert.include(
|
|
451
|
+
seen,
|
|
452
|
+
"startLine",
|
|
453
|
+
"marker tells the model to narrow the read",
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
// What was persisted in the live history (ACP persists this)
|
|
457
|
+
const [persisted] = toolResults(history)
|
|
458
|
+
assert.strictEqual(persisted?.result, seen)
|
|
459
|
+
|
|
460
|
+
// Event for the CLI
|
|
461
|
+
const capped = outputsOfTag(outputs, "ExecuteOutputCapped")
|
|
462
|
+
assert.strictEqual(capped.length, 1)
|
|
463
|
+
assert.strictEqual(capped[0]!.charsBefore, dump.length)
|
|
464
|
+
assert.strictEqual(capped[0]!.charsAfter, seen.length)
|
|
465
|
+
}),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
it.effect("does not touch results under the cap", () =>
|
|
469
|
+
Effect.gen(function* () {
|
|
470
|
+
const small = "small output"
|
|
471
|
+
const { calls, outputs } = yield* runAgentCollect({
|
|
472
|
+
executor: makeExecutor(() => Stream.succeed(small)),
|
|
473
|
+
respond: (_call, index) =>
|
|
474
|
+
index === 0
|
|
475
|
+
? Stream.fromIterable([toolCall("call-1", "ls()"), finish(100)])
|
|
476
|
+
: Stream.fromIterable(text("ok")),
|
|
477
|
+
})
|
|
478
|
+
const [result] = toolResults(calls[1]!.prompt)
|
|
479
|
+
assert.strictEqual(result?.result, small)
|
|
480
|
+
assert.strictEqual(outputsOfTag(outputs, "ExecuteOutputCapped").length, 0)
|
|
481
|
+
}),
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
it.effect("still caps when compaction is disabled", () =>
|
|
485
|
+
Effect.gen(function* () {
|
|
486
|
+
const { exit, calls, outputs } = yield* runCapped({ enabled: false })
|
|
487
|
+
assert.isTrue(Exit.isSuccess(exit))
|
|
488
|
+
const [result] = toolResults(calls[1]!.prompt)
|
|
489
|
+
assert.isAtMost(
|
|
490
|
+
(result!.result as string).length,
|
|
491
|
+
Compaction.executeOutputCapChars + 1_000,
|
|
492
|
+
)
|
|
493
|
+
assert.strictEqual(outputsOfTag(outputs, "ExecuteOutputCapped").length, 1)
|
|
494
|
+
assert.strictEqual(outputsOfTag(outputs, "CompactionStarted").length, 0)
|
|
495
|
+
assert.isTrue(
|
|
496
|
+
calls.every((c) => !c.isSummarizer),
|
|
497
|
+
"no summarizer call",
|
|
498
|
+
)
|
|
499
|
+
}),
|
|
500
|
+
)
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
describe("Agent auto-compaction", () => {
|
|
504
|
+
it.effect(
|
|
505
|
+
"streams summarization without tools and streams the next turn",
|
|
506
|
+
() =>
|
|
507
|
+
Effect.gen(function* () {
|
|
508
|
+
const result = yield* runAgentCollect({
|
|
509
|
+
history: fatHistory,
|
|
510
|
+
compaction: {
|
|
511
|
+
contextWindow: 12_000,
|
|
512
|
+
reserveTokens: 2_000,
|
|
513
|
+
keepRecentTokens: 9_000,
|
|
514
|
+
},
|
|
515
|
+
respond: (call) =>
|
|
516
|
+
Stream.fromIterable(
|
|
517
|
+
text(call.isSummarizer ? "GENERATED-SUMMARY" : "continued"),
|
|
518
|
+
),
|
|
519
|
+
})
|
|
520
|
+
assert.deepStrictEqual(result.exit, Exit.succeed("continued"))
|
|
521
|
+
assert.deepStrictEqual(
|
|
522
|
+
result.calls.map((call) => call.method),
|
|
523
|
+
["streamText", "streamText"],
|
|
524
|
+
)
|
|
525
|
+
assertSummarizerCall(result.calls[0]!)
|
|
526
|
+
assertCompactedShape(result.calls[1]!.prompt, "GENERATED-SUMMARY")
|
|
527
|
+
}),
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
it.live(
|
|
531
|
+
"times out a stalled threshold summarizer and continues with the original prompt",
|
|
532
|
+
() =>
|
|
533
|
+
Effect.gen(function* () {
|
|
534
|
+
let interrupted = false
|
|
535
|
+
const result = yield* runAgentCollect({
|
|
536
|
+
history: fatHistory,
|
|
537
|
+
turnTimeout: Duration.millis(100),
|
|
538
|
+
compaction: {
|
|
539
|
+
contextWindow: 12_000,
|
|
540
|
+
reserveTokens: 2_000,
|
|
541
|
+
keepRecentTokens: 9_000,
|
|
542
|
+
},
|
|
543
|
+
respond: (call, index) => {
|
|
544
|
+
if (index === 0) {
|
|
545
|
+
assertSummarizerCall(call)
|
|
546
|
+
return Stream.fromEffect(Effect.never).pipe(
|
|
547
|
+
Stream.ensuring(
|
|
548
|
+
Effect.sync(() => {
|
|
549
|
+
interrupted = true
|
|
550
|
+
}),
|
|
551
|
+
),
|
|
552
|
+
)
|
|
553
|
+
}
|
|
554
|
+
assert.isFalse(call.isSummarizer)
|
|
555
|
+
assert.include(promptJson(call.prompt), "SENTINEL-ONE")
|
|
556
|
+
assert.isTrue(
|
|
557
|
+
Option.isNone(Compaction.findPreviousSummary(call.prompt)),
|
|
558
|
+
)
|
|
559
|
+
return Stream.fromIterable(text("continued after timeout"))
|
|
560
|
+
},
|
|
561
|
+
}).pipe(Effect.timeoutOption("2 seconds"))
|
|
562
|
+
assert.isTrue(
|
|
563
|
+
Option.isSome(result),
|
|
564
|
+
"threshold summarization must not hang the turn",
|
|
565
|
+
)
|
|
566
|
+
const { exit, calls, outputs } = Option.getOrThrow(result)
|
|
567
|
+
assert.deepStrictEqual(exit, Exit.succeed("continued after timeout"))
|
|
568
|
+
assert.isTrue(interrupted)
|
|
569
|
+
assert.strictEqual(calls.length, 2)
|
|
570
|
+
const ended = outputsOfTag(outputs, "CompactionEnded")
|
|
571
|
+
assert.strictEqual(ended.length, 1)
|
|
572
|
+
assert.strictEqual(ended[0]!.tokensAfter, ended[0]!.tokensBefore)
|
|
573
|
+
}),
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
for (const reason of ["threshold", "overflow"] as const) {
|
|
577
|
+
it.effect(
|
|
578
|
+
`sends the full prompt to an incremental provider after ${reason} compaction`,
|
|
579
|
+
() =>
|
|
580
|
+
Effect.gen(function* () {
|
|
581
|
+
// A Codex websocket session tracks every message it has sent. The
|
|
582
|
+
// rewritten prompt starts with a new summary message, so the
|
|
583
|
+
// tracker must fall back to a full send by itself.
|
|
584
|
+
const tracker = yield* ResponseIdTracker.make
|
|
585
|
+
tracker.markParts(fatHistory.content, "stale-response")
|
|
586
|
+
assert.isTrue(Option.isSome(tracker.prepareUnsafe(fatHistory)))
|
|
587
|
+
let summarized = false
|
|
588
|
+
const result = yield* runAgentCollect({
|
|
589
|
+
history: fatHistory,
|
|
590
|
+
compaction: {
|
|
591
|
+
contextWindow: reason === "threshold" ? 12_000 : 1_000_000,
|
|
592
|
+
reserveTokens: 2_000,
|
|
593
|
+
keepRecentTokens: 9_000,
|
|
594
|
+
},
|
|
595
|
+
respond: (call) => {
|
|
596
|
+
if (call.isSummarizer) {
|
|
597
|
+
summarized = true
|
|
598
|
+
return Stream.fromIterable(text("TRACKER-SUMMARY"))
|
|
599
|
+
}
|
|
600
|
+
if (!summarized) return Stream.fail(contextLengthError)
|
|
601
|
+
assert.isTrue(
|
|
602
|
+
Option.isNone(tracker.prepareUnsafe(call.prompt)),
|
|
603
|
+
"the compacted prompt must be sent in full",
|
|
604
|
+
)
|
|
605
|
+
assertCompactedShape(call.prompt, "TRACKER-SUMMARY")
|
|
606
|
+
return Stream.fromIterable(text("full prompt sent"))
|
|
607
|
+
},
|
|
608
|
+
})
|
|
609
|
+
assert.deepStrictEqual(result.exit, Exit.succeed("full prompt sent"))
|
|
610
|
+
}),
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Small window so the fat history trips the threshold: 20k - 4k = 16k tokens.
|
|
615
|
+
const smallWindow = {
|
|
616
|
+
contextWindow: 20_000,
|
|
617
|
+
reserveTokens: 4_000,
|
|
618
|
+
keepRecentTokens: 9_000,
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
it.effect(
|
|
622
|
+
"compacts before the next call once usage crosses the threshold",
|
|
623
|
+
() =>
|
|
624
|
+
Effect.gen(function* () {
|
|
625
|
+
const dump3 = "SENTINEL-THREE " + filler("third", 30_000)
|
|
626
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
627
|
+
history: fatHistory,
|
|
628
|
+
compaction: smallWindow,
|
|
629
|
+
prompt: "Now read the third log",
|
|
630
|
+
executor: makeExecutor(() => Stream.succeed(dump3)),
|
|
631
|
+
respond: (call, index) => {
|
|
632
|
+
switch (index) {
|
|
633
|
+
case 0:
|
|
634
|
+
// Real call: usage says we are over the threshold.
|
|
635
|
+
return Stream.fromIterable([
|
|
636
|
+
toolCall("call-3", "await readFile({ path: 'big3.log' })"),
|
|
637
|
+
finish(17_000),
|
|
638
|
+
])
|
|
639
|
+
case 1:
|
|
640
|
+
assertSummarizerCall(call)
|
|
641
|
+
return Stream.fromIterable([
|
|
642
|
+
...text("SUMMARY-OF-EARLIER-WORK"),
|
|
643
|
+
finish(6_000, "stop"),
|
|
644
|
+
])
|
|
645
|
+
case 2:
|
|
646
|
+
return Stream.fromIterable([
|
|
647
|
+
...text("continuing after compaction"),
|
|
648
|
+
finish(9_000, "stop"),
|
|
649
|
+
])
|
|
650
|
+
default:
|
|
651
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
652
|
+
}
|
|
653
|
+
},
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
assert.deepStrictEqual(
|
|
657
|
+
exit,
|
|
658
|
+
Exit.succeed("continuing after compaction"),
|
|
659
|
+
)
|
|
660
|
+
assert.strictEqual(calls.length, 3)
|
|
661
|
+
assert.isFalse(calls[0]!.isSummarizer)
|
|
662
|
+
assert.isTrue(calls[1]!.isSummarizer)
|
|
663
|
+
assert.isFalse(calls[2]!.isSummarizer)
|
|
664
|
+
|
|
665
|
+
// Summarizer input: previous turns, execute dumps truncated to 2k.
|
|
666
|
+
const summarizerJson = promptJson(calls[1]!.prompt)
|
|
667
|
+
assert.include(summarizerJson, "Investigate the failing build")
|
|
668
|
+
assert.isBelow(
|
|
669
|
+
summarizerJson.length,
|
|
670
|
+
20_000,
|
|
671
|
+
"execute dumps truncated in summarizer input",
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
// Next real call: system + summary + kept tail, not the original dumps.
|
|
675
|
+
const kept = assertCompactedShape(
|
|
676
|
+
calls[2]!.prompt,
|
|
677
|
+
"SUMMARY-OF-EARLIER-WORK",
|
|
678
|
+
)
|
|
679
|
+
const keptJson = promptJson(Prompt.fromMessages(kept))
|
|
680
|
+
assert.notInclude(
|
|
681
|
+
keptJson,
|
|
682
|
+
"SENTINEL-ONE",
|
|
683
|
+
"oldest dump summarized away",
|
|
684
|
+
)
|
|
685
|
+
assert.include(keptJson, "call-3", "newest execute turn kept")
|
|
686
|
+
assert.include(
|
|
687
|
+
keptJson,
|
|
688
|
+
"Now read the third log",
|
|
689
|
+
"current user prompt kept",
|
|
690
|
+
)
|
|
691
|
+
assert.notInclude(
|
|
692
|
+
promptJson(calls[2]!.prompt),
|
|
693
|
+
Compaction.summaryOpenTag + Compaction.summaryOpenTag,
|
|
694
|
+
)
|
|
695
|
+
|
|
696
|
+
// Call/result pairing is intact after the rewrite.
|
|
697
|
+
const ids = toolCallIds(calls[2]!.prompt)
|
|
698
|
+
for (const id of new Set(ids)) {
|
|
699
|
+
assert.strictEqual(
|
|
700
|
+
ids.filter((x) => x === id).length,
|
|
701
|
+
2,
|
|
702
|
+
`pair intact for ${id}`,
|
|
703
|
+
)
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Events
|
|
707
|
+
const started = outputsOfTag(outputs, "CompactionStarted")
|
|
708
|
+
const ended = outputsOfTag(outputs, "CompactionEnded")
|
|
709
|
+
assert.strictEqual(started.length, 1)
|
|
710
|
+
assert.strictEqual(started[0]!.reason, "threshold")
|
|
711
|
+
assert.strictEqual(ended.length, 1)
|
|
712
|
+
assert.strictEqual(ended[0]!.reason, "threshold")
|
|
713
|
+
assert.isBelow(ended[0]!.tokensAfter, ended[0]!.tokensBefore)
|
|
714
|
+
}),
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
it.effect(
|
|
718
|
+
"estimates from prompt size when no usage has been seen (session load)",
|
|
719
|
+
() =>
|
|
720
|
+
Effect.gen(function* () {
|
|
721
|
+
// fatHistory is ~15k tokens by estimate; threshold is 16k, so bump the window down.
|
|
722
|
+
const { exit, calls } = yield* runAgentCollect({
|
|
723
|
+
history: fatHistory,
|
|
724
|
+
compaction: {
|
|
725
|
+
contextWindow: 12_000,
|
|
726
|
+
reserveTokens: 2_000,
|
|
727
|
+
keepRecentTokens: 9_000,
|
|
728
|
+
},
|
|
729
|
+
prompt: "continue",
|
|
730
|
+
respond: (call, index) => {
|
|
731
|
+
switch (index) {
|
|
732
|
+
case 0:
|
|
733
|
+
assertSummarizerCall(call)
|
|
734
|
+
return Stream.fromIterable(text("LOADED-SUMMARY"))
|
|
735
|
+
case 1:
|
|
736
|
+
return Stream.fromIterable([
|
|
737
|
+
...text("hello again"),
|
|
738
|
+
finish(8_000, "stop"),
|
|
739
|
+
])
|
|
740
|
+
default:
|
|
741
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
})
|
|
745
|
+
assert.deepStrictEqual(exit, Exit.succeed("hello again"))
|
|
746
|
+
assert.strictEqual(calls.length, 2)
|
|
747
|
+
const kept = assertCompactedShape(calls[1]!.prompt, "LOADED-SUMMARY")
|
|
748
|
+
assert.include(promptJson(Prompt.fromMessages(kept)), "continue")
|
|
749
|
+
}),
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
it.effect("folds the previous summary into the next compaction", () =>
|
|
753
|
+
Effect.gen(function* () {
|
|
754
|
+
const alreadyCompacted = Prompt.fromMessages([
|
|
755
|
+
user(Compaction.wrapSummary("FIRST-SUMMARY")),
|
|
756
|
+
...fatHistory.content,
|
|
757
|
+
])
|
|
758
|
+
const { exit, calls } = yield* runAgentCollect({
|
|
759
|
+
history: alreadyCompacted,
|
|
760
|
+
compaction: {
|
|
761
|
+
contextWindow: 12_000,
|
|
762
|
+
reserveTokens: 2_000,
|
|
763
|
+
keepRecentTokens: 9_000,
|
|
764
|
+
},
|
|
765
|
+
prompt: "continue",
|
|
766
|
+
respond: (call, index) => {
|
|
767
|
+
switch (index) {
|
|
768
|
+
case 0:
|
|
769
|
+
assertSummarizerCall(call)
|
|
770
|
+
assert.include(promptJson(call.prompt), "FIRST-SUMMARY")
|
|
771
|
+
return Stream.fromIterable(text("SECOND-SUMMARY"))
|
|
772
|
+
case 1:
|
|
773
|
+
return Stream.fromIterable(text("ok"))
|
|
774
|
+
default:
|
|
775
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
})
|
|
779
|
+
assert.deepStrictEqual(exit, Exit.succeed("ok"))
|
|
780
|
+
const rewritten = calls[1]!.prompt
|
|
781
|
+
assertCompactedShape(rewritten, "SECOND-SUMMARY")
|
|
782
|
+
const wrappedCount =
|
|
783
|
+
promptJson(rewritten).split(Compaction.summaryOpenTag).length - 1
|
|
784
|
+
assert.strictEqual(
|
|
785
|
+
wrappedCount,
|
|
786
|
+
1,
|
|
787
|
+
"only one summary message after re-compaction",
|
|
788
|
+
)
|
|
789
|
+
}),
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
it.effect("is a no-op when the keep tail is the whole prompt", () =>
|
|
793
|
+
Effect.gen(function* () {
|
|
794
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
795
|
+
// Tiny window but a huge keep budget: nothing to summarize.
|
|
796
|
+
compaction: {
|
|
797
|
+
contextWindow: 1_000,
|
|
798
|
+
reserveTokens: 100,
|
|
799
|
+
keepRecentTokens: 1_000_000,
|
|
800
|
+
},
|
|
801
|
+
respond: (_call, index) =>
|
|
802
|
+
index === 0
|
|
803
|
+
? Stream.fromIterable([toolCall("call-1", "ls()"), finish(950)])
|
|
804
|
+
: Stream.fromIterable(text("done")),
|
|
805
|
+
executor: makeExecutor(() => Stream.succeed("files")),
|
|
806
|
+
})
|
|
807
|
+
assert.deepStrictEqual(exit, Exit.succeed("done"))
|
|
808
|
+
assert.isTrue(
|
|
809
|
+
calls.every((c) => !c.isSummarizer),
|
|
810
|
+
"no summarizer call",
|
|
811
|
+
)
|
|
812
|
+
assert.strictEqual(outputsOfTag(outputs, "CompactionStarted").length, 0)
|
|
813
|
+
}),
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
it.effect("does not compact when disabled", () =>
|
|
817
|
+
Effect.gen(function* () {
|
|
818
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
819
|
+
history: fatHistory,
|
|
820
|
+
compaction: { ...smallWindow, enabled: false },
|
|
821
|
+
respond: (_call, index) =>
|
|
822
|
+
index === 0
|
|
823
|
+
? Stream.fromIterable([
|
|
824
|
+
...text("no compaction"),
|
|
825
|
+
finish(17_000, "stop"),
|
|
826
|
+
])
|
|
827
|
+
: Stream.die(`unexpected model call #${index}`),
|
|
828
|
+
})
|
|
829
|
+
assert.deepStrictEqual(exit, Exit.succeed("no compaction"))
|
|
830
|
+
assert.strictEqual(calls.length, 1)
|
|
831
|
+
assert.include(promptJson(calls[0]!.prompt), "SENTINEL-ONE")
|
|
832
|
+
assert.strictEqual(outputsOfTag(outputs, "CompactionStarted").length, 0)
|
|
833
|
+
}),
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
it.effect("skips a failed threshold compaction and continues the turn", () =>
|
|
837
|
+
Effect.gen(function* () {
|
|
838
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
839
|
+
history: fatHistory,
|
|
840
|
+
compaction: {
|
|
841
|
+
contextWindow: 12_000,
|
|
842
|
+
reserveTokens: 2_000,
|
|
843
|
+
keepRecentTokens: 9_000,
|
|
844
|
+
},
|
|
845
|
+
respond: (call, index) => {
|
|
846
|
+
switch (index) {
|
|
847
|
+
case 0:
|
|
848
|
+
assertSummarizerCall(call)
|
|
849
|
+
return Stream.fail(providerError)
|
|
850
|
+
case 1:
|
|
851
|
+
assert.isFalse(call.isSummarizer)
|
|
852
|
+
assert.include(
|
|
853
|
+
promptJson(call.prompt),
|
|
854
|
+
"SENTINEL-ONE",
|
|
855
|
+
"uncompacted prompt sent",
|
|
856
|
+
)
|
|
857
|
+
return Stream.fromIterable(text("carried on"))
|
|
858
|
+
default:
|
|
859
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
})
|
|
863
|
+
assert.deepStrictEqual(exit, Exit.succeed("carried on"))
|
|
864
|
+
assert.strictEqual(calls.length, 2)
|
|
865
|
+
const started = outputsOfTag(outputs, "CompactionStarted")
|
|
866
|
+
const ended = outputsOfTag(outputs, "CompactionEnded")
|
|
867
|
+
assert.strictEqual(started.length, 1)
|
|
868
|
+
assert.strictEqual(
|
|
869
|
+
ended.length,
|
|
870
|
+
1,
|
|
871
|
+
"failed compaction must close its progress event",
|
|
872
|
+
)
|
|
873
|
+
assert.strictEqual(ended[0]!.reason, "threshold")
|
|
874
|
+
assert.strictEqual(ended[0]!.tokensAfter, ended[0]!.tokensBefore)
|
|
875
|
+
}),
|
|
876
|
+
)
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
describe("Agent overflow compaction", () => {
|
|
880
|
+
// Large window: the threshold never fires, only the overflow path can.
|
|
881
|
+
const bigWindow = {
|
|
882
|
+
contextWindow: 1_000_000,
|
|
883
|
+
reserveTokens: 1_000,
|
|
884
|
+
keepRecentTokens: 9_000,
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
it.live(
|
|
888
|
+
"can compact again after a timeout interrupts the first overflow summary",
|
|
889
|
+
() =>
|
|
890
|
+
Effect.gen(function* () {
|
|
891
|
+
let interrupted = false
|
|
892
|
+
const { exit, calls } = yield* runAgentCollect({
|
|
893
|
+
history: fatHistory,
|
|
894
|
+
compaction: bigWindow,
|
|
895
|
+
turnTimeout: Duration.millis(100),
|
|
896
|
+
respond: (call, index) => {
|
|
897
|
+
switch (index) {
|
|
898
|
+
case 0:
|
|
899
|
+
assert.isFalse(call.isSummarizer)
|
|
900
|
+
assert.include(promptJson(call.prompt), "SENTINEL-ONE")
|
|
901
|
+
return Stream.fail(contextLengthError)
|
|
902
|
+
case 1:
|
|
903
|
+
assertSummarizerCall(call)
|
|
904
|
+
return Stream.fromEffect(Effect.never).pipe(
|
|
905
|
+
Stream.ensuring(
|
|
906
|
+
Effect.sync(() => {
|
|
907
|
+
interrupted = true
|
|
908
|
+
}),
|
|
909
|
+
),
|
|
910
|
+
)
|
|
911
|
+
case 2:
|
|
912
|
+
// The stalled summary is retried once without re-sending
|
|
913
|
+
// the fat prompt.
|
|
914
|
+
assertSummarizerCall(call)
|
|
915
|
+
assert.isTrue(interrupted)
|
|
916
|
+
return Stream.fromIterable(text("RECOVERED-SUMMARY"))
|
|
917
|
+
case 3:
|
|
918
|
+
assertCompactedShape(call.prompt, "RECOVERED-SUMMARY")
|
|
919
|
+
return Stream.fromIterable(text("recovered after timeout"))
|
|
920
|
+
default:
|
|
921
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
922
|
+
}
|
|
923
|
+
},
|
|
924
|
+
}).pipe(Effect.timeout("2 seconds"))
|
|
925
|
+
assert.deepStrictEqual(exit, Exit.succeed("recovered after timeout"))
|
|
926
|
+
assert.strictEqual(calls.length, 4)
|
|
927
|
+
}),
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
it.live("compacts once and retries after a context-length error", () =>
|
|
931
|
+
Effect.gen(function* () {
|
|
932
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
933
|
+
history: fatHistory,
|
|
934
|
+
compaction: bigWindow,
|
|
935
|
+
prompt: "continue",
|
|
936
|
+
respond: (call, index) => {
|
|
937
|
+
switch (index) {
|
|
938
|
+
case 0:
|
|
939
|
+
assert.isFalse(call.isSummarizer)
|
|
940
|
+
return Stream.fail(contextLengthError)
|
|
941
|
+
case 1:
|
|
942
|
+
assertSummarizerCall(call)
|
|
943
|
+
return Stream.fromIterable(text("OVERFLOW-SUMMARY"))
|
|
944
|
+
case 2:
|
|
945
|
+
assert.isFalse(call.isSummarizer)
|
|
946
|
+
return Stream.fromIterable([
|
|
947
|
+
...text("recovered"),
|
|
948
|
+
finish(5_000, "stop"),
|
|
949
|
+
])
|
|
950
|
+
default:
|
|
951
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
assert.deepStrictEqual(exit, Exit.succeed("recovered"))
|
|
957
|
+
assert.strictEqual(calls.length, 3)
|
|
958
|
+
const kept = assertCompactedShape(calls[2]!.prompt, "OVERFLOW-SUMMARY")
|
|
959
|
+
assert.notInclude(promptJson(Prompt.fromMessages(kept)), "SENTINEL-ONE")
|
|
960
|
+
assert.include(promptJson(Prompt.fromMessages(kept)), "continue")
|
|
961
|
+
|
|
962
|
+
const started = outputsOfTag(outputs, "CompactionStarted")
|
|
963
|
+
assert.strictEqual(started.length, 1)
|
|
964
|
+
assert.strictEqual(started[0]!.reason, "overflow")
|
|
965
|
+
assert.strictEqual(
|
|
966
|
+
outputsOfTag(outputs, "CompactionEnded")[0]?.reason,
|
|
967
|
+
"overflow",
|
|
968
|
+
)
|
|
969
|
+
}),
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
it.live(
|
|
973
|
+
"fails the turn when the retry after compaction overflows again",
|
|
974
|
+
() =>
|
|
975
|
+
Effect.gen(function* () {
|
|
976
|
+
const { exit, calls } = yield* runAgentCollect({
|
|
977
|
+
history: fatHistory,
|
|
978
|
+
compaction: bigWindow,
|
|
979
|
+
respond: (call, index) => {
|
|
980
|
+
switch (index) {
|
|
981
|
+
case 0:
|
|
982
|
+
return Stream.fail(contextLengthError)
|
|
983
|
+
case 1:
|
|
984
|
+
assertSummarizerCall(call)
|
|
985
|
+
return Stream.fromIterable(text("SUMMARY"))
|
|
986
|
+
case 2:
|
|
987
|
+
return Stream.fail(contextLengthError)
|
|
988
|
+
default:
|
|
989
|
+
return Stream.die(
|
|
990
|
+
`unexpected model call #${index}: must not retry forever`,
|
|
991
|
+
)
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
})
|
|
995
|
+
assert.isTrue(Exit.isFailure(exit), "turn must fail")
|
|
996
|
+
const error = Option.getOrThrow(Exit.findErrorOption(exit))
|
|
997
|
+
assert.strictEqual(error._tag, "AiError")
|
|
998
|
+
assert.strictEqual(error.reason._tag, "InvalidRequestError")
|
|
999
|
+
assert.strictEqual(calls.length, 3)
|
|
1000
|
+
}),
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
it.live(
|
|
1004
|
+
"compacts a retryable context-length error before retrying the original prompt",
|
|
1005
|
+
() =>
|
|
1006
|
+
Effect.gen(function* () {
|
|
1007
|
+
const error = AiError.make({
|
|
1008
|
+
module: "OpenAiLanguageModel",
|
|
1009
|
+
method: "streamText",
|
|
1010
|
+
reason: new AiError.InternalProviderError({
|
|
1011
|
+
description: "maximum context length is 236000 tokens",
|
|
1012
|
+
}),
|
|
1013
|
+
})
|
|
1014
|
+
assert.isTrue(error.isRetryable)
|
|
1015
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
1016
|
+
history: fatHistory,
|
|
1017
|
+
compaction: bigWindow,
|
|
1018
|
+
respond: (_call, index) => {
|
|
1019
|
+
switch (index) {
|
|
1020
|
+
case 0:
|
|
1021
|
+
return Stream.fail(error)
|
|
1022
|
+
case 1:
|
|
1023
|
+
return Stream.fromIterable(text("RETRYABLE-SUMMARY"))
|
|
1024
|
+
case 2:
|
|
1025
|
+
return Stream.fromIterable(text("recovered"))
|
|
1026
|
+
default:
|
|
1027
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
1028
|
+
}
|
|
1029
|
+
},
|
|
1030
|
+
})
|
|
1031
|
+
assert.deepStrictEqual(
|
|
1032
|
+
calls.map((call) => call.isSummarizer),
|
|
1033
|
+
[false, true, false],
|
|
1034
|
+
"overflow compaction must precede any retry of the original prompt",
|
|
1035
|
+
)
|
|
1036
|
+
assert.deepStrictEqual(exit, Exit.succeed("recovered"))
|
|
1037
|
+
assert.include(promptJson(calls[0]!.prompt), "SENTINEL-ONE")
|
|
1038
|
+
assertCompactedShape(calls[2]!.prompt, "RETRYABLE-SUMMARY")
|
|
1039
|
+
assert.notInclude(promptJson(calls[2]!.prompt), "SENTINEL-ONE")
|
|
1040
|
+
assert.strictEqual(outputsOfTag(outputs, "ErrorRetry").length, 0)
|
|
1041
|
+
assert.strictEqual(
|
|
1042
|
+
outputsOfTag(outputs, "CompactionStarted")[0]?.reason,
|
|
1043
|
+
"overflow",
|
|
1044
|
+
)
|
|
1045
|
+
}),
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
it.live("fails the turn when the overflow compaction itself fails", () =>
|
|
1049
|
+
Effect.gen(function* () {
|
|
1050
|
+
const { exit, calls } = yield* runAgentCollect({
|
|
1051
|
+
history: fatHistory,
|
|
1052
|
+
compaction: bigWindow,
|
|
1053
|
+
respond: (call, index) => {
|
|
1054
|
+
switch (index) {
|
|
1055
|
+
case 0:
|
|
1056
|
+
return Stream.fail(contextLengthError)
|
|
1057
|
+
case 1:
|
|
1058
|
+
assertSummarizerCall(call)
|
|
1059
|
+
return Stream.fail(providerError)
|
|
1060
|
+
default:
|
|
1061
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
1062
|
+
}
|
|
1063
|
+
},
|
|
1064
|
+
})
|
|
1065
|
+
assert.isTrue(Exit.isFailure(exit), "turn must fail")
|
|
1066
|
+
assert.strictEqual(calls.length, 2)
|
|
1067
|
+
}),
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
it.live("fails without compacting when the kill switch is on", () =>
|
|
1071
|
+
Effect.gen(function* () {
|
|
1072
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
1073
|
+
history: fatHistory,
|
|
1074
|
+
compaction: { ...bigWindow, enabled: false },
|
|
1075
|
+
respond: (_call, index) =>
|
|
1076
|
+
index === 0
|
|
1077
|
+
? Stream.fail(contextLengthError)
|
|
1078
|
+
: Stream.die(`unexpected model call #${index}`),
|
|
1079
|
+
})
|
|
1080
|
+
assert.isTrue(Exit.isFailure(exit))
|
|
1081
|
+
assert.strictEqual(calls.length, 1)
|
|
1082
|
+
assert.strictEqual(outputsOfTag(outputs, "CompactionStarted").length, 0)
|
|
1083
|
+
}),
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
it.live("retries other retryable errors without compacting", () =>
|
|
1087
|
+
Effect.gen(function* () {
|
|
1088
|
+
const { exit, calls, outputs } = yield* runAgentCollect({
|
|
1089
|
+
history: fatHistory,
|
|
1090
|
+
compaction: bigWindow,
|
|
1091
|
+
respond: (call, index) => {
|
|
1092
|
+
switch (index) {
|
|
1093
|
+
case 0:
|
|
1094
|
+
return Stream.fail(providerError)
|
|
1095
|
+
case 1:
|
|
1096
|
+
assert.isFalse(
|
|
1097
|
+
call.isSummarizer,
|
|
1098
|
+
"must not summarize on a provider error",
|
|
1099
|
+
)
|
|
1100
|
+
assert.include(
|
|
1101
|
+
promptJson(call.prompt),
|
|
1102
|
+
"SENTINEL-ONE",
|
|
1103
|
+
"same prompt retried",
|
|
1104
|
+
)
|
|
1105
|
+
return Stream.fromIterable(text("second try worked"))
|
|
1106
|
+
default:
|
|
1107
|
+
return Stream.die(`unexpected model call #${index}`)
|
|
1108
|
+
}
|
|
1109
|
+
},
|
|
1110
|
+
})
|
|
1111
|
+
assert.deepStrictEqual(exit, Exit.succeed("second try worked"))
|
|
1112
|
+
assert.strictEqual(calls.length, 2)
|
|
1113
|
+
assert.strictEqual(outputsOfTag(outputs, "ErrorRetry").length, 1)
|
|
1114
|
+
assert.strictEqual(outputsOfTag(outputs, "CompactionStarted").length, 0)
|
|
1115
|
+
}),
|
|
1116
|
+
)
|
|
1117
|
+
})
|