clanka 0.5.2 → 0.6.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 +195 -16
- package/dist/Acp.js.map +1 -1
- package/dist/AcpImage.test.d.ts +2 -0
- package/dist/AcpImage.test.d.ts.map +1 -0
- package/dist/AcpImage.test.js +470 -0
- package/dist/AcpImage.test.js.map +1 -0
- package/dist/Agent.d.ts +9 -2
- package/dist/Agent.d.ts.map +1 -1
- package/dist/Agent.js +43 -6
- package/dist/Agent.js.map +1 -1
- package/dist/AgentExecutor.d.ts +27 -5
- package/dist/AgentExecutor.d.ts.map +1 -1
- package/dist/AgentExecutor.js +29 -4
- package/dist/AgentExecutor.js.map +1 -1
- package/dist/AgentExecutorImage.test.d.ts +2 -0
- package/dist/AgentExecutorImage.test.d.ts.map +1 -0
- package/dist/AgentExecutorImage.test.js +63 -0
- package/dist/AgentExecutorImage.test.js.map +1 -0
- package/dist/AgentImage.test.d.ts +2 -0
- package/dist/AgentImage.test.d.ts.map +1 -0
- package/dist/AgentImage.test.js +176 -0
- package/dist/AgentImage.test.js.map +1 -0
- package/dist/AgentTools.d.ts +25 -5
- package/dist/AgentTools.d.ts.map +1 -1
- package/dist/AgentTools.js +40 -4
- package/dist/AgentTools.js.map +1 -1
- package/dist/AgentToolsImage.test.d.ts +2 -0
- package/dist/AgentToolsImage.test.d.ts.map +1 -0
- package/dist/AgentToolsImage.test.js +158 -0
- package/dist/AgentToolsImage.test.js.map +1 -0
- package/dist/Codex.js +6 -1
- package/dist/Codex.js.map +1 -1
- package/dist/Compaction.d.ts.map +1 -1
- package/dist/Compaction.js +16 -1
- package/dist/Compaction.js.map +1 -1
- package/dist/CompactionImage.test.d.ts +2 -0
- package/dist/CompactionImage.test.d.ts.map +1 -0
- package/dist/CompactionImage.test.js +105 -0
- package/dist/CompactionImage.test.js.map +1 -0
- package/dist/Copilot.d.ts.map +1 -1
- package/dist/Copilot.js +5 -1
- package/dist/Copilot.js.map +1 -1
- package/dist/Image.d.ts +192 -0
- package/dist/Image.d.ts.map +1 -0
- package/dist/Image.js +586 -0
- package/dist/Image.js.map +1 -0
- package/dist/Image.test.d.ts +2 -0
- package/dist/Image.test.d.ts.map +1 -0
- package/dist/Image.test.js +142 -0
- package/dist/Image.test.js.map +1 -0
- package/dist/fixtures/TestImages.d.ts +73 -0
- package/dist/fixtures/TestImages.d.ts.map +1 -0
- package/dist/fixtures/TestImages.js +263 -0
- package/dist/fixtures/TestImages.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/Acp.ts +274 -21
- package/src/AcpImage.test.ts +676 -0
- package/src/Agent.ts +69 -7
- package/src/AgentExecutor.ts +51 -3
- package/src/AgentExecutorImage.test.ts +103 -0
- package/src/AgentImage.test.ts +295 -0
- package/src/AgentTools.ts +78 -7
- package/src/AgentToolsImage.test.ts +302 -0
- package/src/Codex.ts +6 -1
- package/src/Compaction.ts +16 -1
- package/src/CompactionImage.test.ts +142 -0
- package/src/Copilot.ts +5 -1
- package/src/Image.test.ts +220 -0
- package/src/Image.ts +704 -0
- package/src/fixtures/TestImages.ts +287 -0
- package/src/index.ts +5 -0
package/src/AgentTools.ts
CHANGED
|
@@ -21,7 +21,9 @@ import { pipe } from "effect/Function"
|
|
|
21
21
|
import * as Array from "effect/Array"
|
|
22
22
|
import * as Data from "effect/Data"
|
|
23
23
|
import * as Layer from "effect/Layer"
|
|
24
|
+
import * as Option from "effect/Option"
|
|
24
25
|
import * as SemanticSearch from "./SemanticSearch/Service.ts"
|
|
26
|
+
import * as Image from "./Image.ts"
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* @since 1.0.0
|
|
@@ -59,6 +61,28 @@ export class SubagentExecutor extends Context.Service<
|
|
|
59
61
|
(prompt: string) => Effect.Effect<string>
|
|
60
62
|
>()("clanka/AgentTools/SubagentExecutor") {}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* An image read by `readFile`, handed to the Agent so it can be spliced onto
|
|
66
|
+
* the Prompt for the next model turn.
|
|
67
|
+
*
|
|
68
|
+
* @since 1.0.0
|
|
69
|
+
* @category Models
|
|
70
|
+
*/
|
|
71
|
+
export interface ImageAttachment {
|
|
72
|
+
readonly fileName: string
|
|
73
|
+
readonly mediaType: Image.ImageMediaType
|
|
74
|
+
readonly data: Uint8Array
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @since 1.0.0
|
|
79
|
+
* @category Context
|
|
80
|
+
*/
|
|
81
|
+
export class ImageAttacher extends Context.Service<
|
|
82
|
+
ImageAttacher,
|
|
83
|
+
(image: ImageAttachment) => Effect.Effect<void>
|
|
84
|
+
>()("clanka/AgentTools/ImageAttacher") {}
|
|
85
|
+
|
|
62
86
|
/**
|
|
63
87
|
* @since 1.0.0
|
|
64
88
|
* @category Context
|
|
@@ -68,6 +92,7 @@ export const makeContextNoop = (cwd?: string) =>
|
|
|
68
92
|
Context.add(CurrentDirectory, cwd ?? "/"),
|
|
69
93
|
Context.add(DirectoryChanger, () => Effect.die("Not implemented")),
|
|
70
94
|
Context.add(TaskCompleter, () => Effect.void),
|
|
95
|
+
Context.add(ImageAttacher, () => Effect.void),
|
|
71
96
|
)
|
|
72
97
|
|
|
73
98
|
class TodoItem extends Schema.Opaque<TodoItem>()(
|
|
@@ -92,14 +117,14 @@ export const AgentTools = Toolkit.make(
|
|
|
92
117
|
}),
|
|
93
118
|
Tool.make("readFile", {
|
|
94
119
|
description:
|
|
95
|
-
"Read a file and optionally filter the lines to return. Returns null if the file doesn't exist.",
|
|
120
|
+
"Read a file and optionally filter the lines to return. Returns null if the file doesn't exist. For png/jpeg/gif/webp files the image itself is attached to your next turn and a short marker is returned instead of the bytes; line ranges are not allowed on images.",
|
|
96
121
|
parameters: Schema.Struct({
|
|
97
122
|
path: Schema.String,
|
|
98
123
|
startLine: Schema.optional(Schema.Number),
|
|
99
124
|
endLine: Schema.optional(Schema.Number),
|
|
100
125
|
}),
|
|
101
126
|
success: Schema.NullOr(Schema.String),
|
|
102
|
-
dependencies: [CurrentDirectory],
|
|
127
|
+
dependencies: [CurrentDirectory, ImageAttacher],
|
|
103
128
|
}),
|
|
104
129
|
Tool.make("rg", {
|
|
105
130
|
description: "Search for a pattern in files using ripgrep",
|
|
@@ -300,6 +325,51 @@ export const AgentToolHandlersNoDeps = AgentToolsWithSearch.toLayer(
|
|
|
300
325
|
)
|
|
301
326
|
}, Effect.scoped)
|
|
302
327
|
|
|
328
|
+
/**
|
|
329
|
+
* Image branch of `readFile`: the bytes go to the `ImageAttacher` for
|
|
330
|
+
* the next model turn and the script only sees a marker.
|
|
331
|
+
*/
|
|
332
|
+
const readImage = Effect.fnUntraced(function* (
|
|
333
|
+
path: string,
|
|
334
|
+
extensionType: Image.ImageMediaType,
|
|
335
|
+
options: {
|
|
336
|
+
readonly startLine?: number | undefined
|
|
337
|
+
readonly endLine?: number | undefined
|
|
338
|
+
},
|
|
339
|
+
) {
|
|
340
|
+
if (options.startLine !== undefined || options.endLine !== undefined) {
|
|
341
|
+
return yield* Effect.die(
|
|
342
|
+
new Error(
|
|
343
|
+
`startLine / endLine are not supported for images: ${path}`,
|
|
344
|
+
),
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
const bytes = yield* Image.readBoundedFile(fs, path).pipe(
|
|
348
|
+
Effect.catchReason("PlatformError", "NotFound", () =>
|
|
349
|
+
Effect.succeed(null),
|
|
350
|
+
),
|
|
351
|
+
Effect.orDie,
|
|
352
|
+
)
|
|
353
|
+
if (bytes === null) return null
|
|
354
|
+
// The extension picks the branch; the magic bytes pick the real type.
|
|
355
|
+
const mediaType = Option.getOrElse(
|
|
356
|
+
Image.mediaTypeFromBytes(bytes),
|
|
357
|
+
() => extensionType,
|
|
358
|
+
)
|
|
359
|
+
const prepared = yield* Image.prepare({ data: bytes, mediaType }).pipe(
|
|
360
|
+
Effect.mapError((error) => new Error(`${error.message} (${path})`)),
|
|
361
|
+
Effect.orDie,
|
|
362
|
+
)
|
|
363
|
+
const fileName = pathService.basename(path)
|
|
364
|
+
const attach = yield* ImageAttacher
|
|
365
|
+
yield* attach({
|
|
366
|
+
fileName,
|
|
367
|
+
mediaType: prepared.mediaType,
|
|
368
|
+
data: prepared.data,
|
|
369
|
+
})
|
|
370
|
+
return `Image attached: ${fileName}`
|
|
371
|
+
})
|
|
372
|
+
|
|
303
373
|
return AgentToolsWithSearch.of({
|
|
304
374
|
changeDirectory: Effect.fn("AgentTools.changeDirectory")(
|
|
305
375
|
function* (directory) {
|
|
@@ -315,11 +385,12 @@ export const AgentToolHandlersNoDeps = AgentToolsWithSearch.toLayer(
|
|
|
315
385
|
Effect.annotateLogs(options),
|
|
316
386
|
)
|
|
317
387
|
const cwd = yield* CurrentDirectory
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
388
|
+
const path = pathService.resolve(cwd, options.path)
|
|
389
|
+
const imageType = Image.mediaTypeFromPath(path)
|
|
390
|
+
if (Option.isSome(imageType)) {
|
|
391
|
+
return yield* readImage(path, imageType.value, options)
|
|
392
|
+
}
|
|
393
|
+
let stream = pipe(fs.stream(path), Stream.decodeText, Stream.splitLines)
|
|
323
394
|
if (options.startLine) {
|
|
324
395
|
stream = Stream.drop(stream, options.startLine - 1)
|
|
325
396
|
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import * as Photon from "@silvia-odwyer/photon-node"
|
|
2
|
+
import { assert, describe, it } from "@effect/vitest"
|
|
3
|
+
import { vi } from "vitest"
|
|
4
|
+
import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
|
|
5
|
+
import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
6
|
+
import * as Duration from "effect/Duration"
|
|
7
|
+
import * as Effect from "effect/Effect"
|
|
8
|
+
import * as Exit from "effect/Exit"
|
|
9
|
+
import * as FileSystem from "effect/FileSystem"
|
|
10
|
+
import * as Layer from "effect/Layer"
|
|
11
|
+
import * as Path from "effect/Path"
|
|
12
|
+
import * as Stream from "effect/Stream"
|
|
13
|
+
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
|
|
14
|
+
import * as Model from "effect/unstable/ai/Model"
|
|
15
|
+
import type * as Prompt from "effect/unstable/ai/Prompt"
|
|
16
|
+
import type * as Response from "effect/unstable/ai/Response"
|
|
17
|
+
import * as Agent from "./Agent.ts"
|
|
18
|
+
import * as AgentExecutor from "./AgentExecutor.ts"
|
|
19
|
+
import type * as AgentOutput from "./AgentOutput.ts"
|
|
20
|
+
import {
|
|
21
|
+
bytesOf,
|
|
22
|
+
dimensions,
|
|
23
|
+
encodePng,
|
|
24
|
+
equalBytes,
|
|
25
|
+
tinyPng,
|
|
26
|
+
} from "./fixtures/TestImages.ts"
|
|
27
|
+
|
|
28
|
+
const svgText =
|
|
29
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A temp project directory holding the image fixtures, with a real local
|
|
33
|
+
* executor rooted in it.
|
|
34
|
+
*/
|
|
35
|
+
const withProject = <A, E, R>(f: (project: string) => Effect.Effect<A, E, R>) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const fs = yield* FileSystem.FileSystem
|
|
38
|
+
const path = yield* Path.Path
|
|
39
|
+
const project = yield* fs.makeTempDirectoryScoped()
|
|
40
|
+
yield* fs.writeFile(path.join(project, "shot.png"), tinyPng)
|
|
41
|
+
yield* fs.writeFile(
|
|
42
|
+
path.join(project, "wide.png"),
|
|
43
|
+
encodePng({ width: 2500, height: 100 }),
|
|
44
|
+
)
|
|
45
|
+
yield* fs.writeFileString(path.join(project, "logo.svg"), svgText)
|
|
46
|
+
yield* fs.writeFile(
|
|
47
|
+
path.join(project, "broken.png"),
|
|
48
|
+
new TextEncoder().encode("this is not a png".repeat(20)),
|
|
49
|
+
)
|
|
50
|
+
return yield* f(project).pipe(
|
|
51
|
+
Effect.provide(
|
|
52
|
+
AgentExecutor.layerLocal({ directory: project }).pipe(
|
|
53
|
+
Layer.provide(NodeHttpClient.layerUndici),
|
|
54
|
+
),
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped)
|
|
58
|
+
|
|
59
|
+
const readFile = (params: {
|
|
60
|
+
readonly path: string
|
|
61
|
+
readonly startLine?: number
|
|
62
|
+
readonly endLine?: number
|
|
63
|
+
}) =>
|
|
64
|
+
Effect.gen(function* () {
|
|
65
|
+
const executor = yield* AgentExecutor.AgentExecutor
|
|
66
|
+
return yield* executor.executeUnsafe({ tool: "readFile", params })
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe("readFile images", () => {
|
|
70
|
+
it.effect("rejects startLine / endLine on an image", () =>
|
|
71
|
+
withProject(() =>
|
|
72
|
+
Effect.gen(function* () {
|
|
73
|
+
const withStart = yield* Effect.exit(
|
|
74
|
+
readFile({ path: "shot.png", startLine: 1 }),
|
|
75
|
+
)
|
|
76
|
+
assert.isTrue(Exit.isFailure(withStart))
|
|
77
|
+
const withEnd = yield* Effect.exit(
|
|
78
|
+
readFile({ path: "shot.png", endLine: 5 }),
|
|
79
|
+
)
|
|
80
|
+
assert.isTrue(Exit.isFailure(withEnd))
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
it.effect(
|
|
86
|
+
"rejects undecodable and over-ceiling images without decoding them",
|
|
87
|
+
() =>
|
|
88
|
+
withProject((project) =>
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const fs = yield* FileSystem.FileSystem
|
|
91
|
+
const path = yield* Path.Path
|
|
92
|
+
// Sparse file one byte over the shared 20 MiB input ceiling.
|
|
93
|
+
const huge = path.join(project, "huge.png")
|
|
94
|
+
yield* fs.writeFile(huge, tinyPng)
|
|
95
|
+
yield* fs.truncate(huge, 20 * 1024 * 1024 + 1)
|
|
96
|
+
|
|
97
|
+
const decoder = vi.spyOn(Photon.PhotonImage, "new_from_byteslice")
|
|
98
|
+
try {
|
|
99
|
+
const broken = yield* Effect.exit(readFile({ path: "broken.png" }))
|
|
100
|
+
assert.isTrue(Exit.isFailure(broken))
|
|
101
|
+
const oversized = yield* Effect.exit(readFile({ path: "huge.png" }))
|
|
102
|
+
assert.isTrue(Exit.isFailure(oversized))
|
|
103
|
+
assert.strictEqual(
|
|
104
|
+
decoder.mock.calls.length,
|
|
105
|
+
0,
|
|
106
|
+
"neither garbage nor an over-ceiling file may reach the decoder",
|
|
107
|
+
)
|
|
108
|
+
} finally {
|
|
109
|
+
decoder.mockRestore()
|
|
110
|
+
}
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// Injection into the next model turn, through the real Agent loop
|
|
118
|
+
// =============================================================================
|
|
119
|
+
|
|
120
|
+
interface RecordedCall {
|
|
121
|
+
readonly prompt: Prompt.Prompt
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const runAgent = (options: {
|
|
125
|
+
readonly respond: (
|
|
126
|
+
call: RecordedCall,
|
|
127
|
+
index: number,
|
|
128
|
+
) => Stream.Stream<Response.StreamPartEncoded>
|
|
129
|
+
}) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const calls: Array<RecordedCall> = []
|
|
132
|
+
const outputs: Array<AgentOutput.Output> = []
|
|
133
|
+
const languageModel = yield* LanguageModel.make({
|
|
134
|
+
generateText: () => Effect.succeed([]),
|
|
135
|
+
streamText: (providerOptions) => {
|
|
136
|
+
const call = { prompt: providerOptions.prompt }
|
|
137
|
+
const index = calls.push(call) - 1
|
|
138
|
+
return options.respond(call, index)
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
const modelLayer = Layer.mergeAll(
|
|
142
|
+
Layer.succeed(LanguageModel.LanguageModel, languageModel),
|
|
143
|
+
Layer.succeed(Model.ProviderName, "test-provider"),
|
|
144
|
+
Layer.succeed(Model.ModelName, "test-model"),
|
|
145
|
+
)
|
|
146
|
+
const agent = yield* Agent.make
|
|
147
|
+
const exit = yield* agent.send({ prompt: "read the screenshot" }).pipe(
|
|
148
|
+
Effect.flatMap((stream) =>
|
|
149
|
+
stream.pipe(
|
|
150
|
+
Stream.runForEach((output) =>
|
|
151
|
+
Effect.sync(() => {
|
|
152
|
+
outputs.push(output)
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
Effect.as(""),
|
|
156
|
+
Effect.catchTag("AgentFinished", (finished) =>
|
|
157
|
+
Effect.succeed(finished.summary),
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
Effect.provide(
|
|
162
|
+
Layer.mergeAll(
|
|
163
|
+
modelLayer,
|
|
164
|
+
Agent.ConversationMode.layer(true),
|
|
165
|
+
Agent.layerSubagentModel(modelLayer),
|
|
166
|
+
Layer.succeed(Agent.TurnTimeout, Duration.minutes(5)),
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
Effect.exit,
|
|
170
|
+
)
|
|
171
|
+
return { exit, calls, outputs }
|
|
172
|
+
}).pipe(Effect.scoped)
|
|
173
|
+
|
|
174
|
+
const toolCall = (id: string, script: string): Response.StreamPartEncoded => ({
|
|
175
|
+
type: "tool-call",
|
|
176
|
+
id,
|
|
177
|
+
name: "execute",
|
|
178
|
+
params: { script },
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const text = (content: string): Array<Response.StreamPartEncoded> => [
|
|
182
|
+
{ type: "text-start", id: "t" },
|
|
183
|
+
{ type: "text-delta", id: "t", delta: content },
|
|
184
|
+
{ type: "text-end", id: "t" },
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
const toolResultText = (message: Prompt.Message): string | undefined => {
|
|
188
|
+
if (message.role !== "tool") return undefined
|
|
189
|
+
const part = message.content.find((p) => p.type === "tool-result")
|
|
190
|
+
return part && typeof part.result === "string" ? part.result : undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const fileParts = (message: Prompt.Message): Array<Prompt.FilePart> =>
|
|
194
|
+
message.role === "user"
|
|
195
|
+
? message.content.filter((part) => part.type === "file")
|
|
196
|
+
: []
|
|
197
|
+
|
|
198
|
+
describe("readFile image injection", () => {
|
|
199
|
+
it.effect("injects an image as a file part after the execute result", () =>
|
|
200
|
+
withProject(() =>
|
|
201
|
+
Effect.gen(function* () {
|
|
202
|
+
const { exit, calls, outputs } = yield* runAgent({
|
|
203
|
+
respond: (_, index) =>
|
|
204
|
+
index === 0
|
|
205
|
+
? Stream.make(
|
|
206
|
+
toolCall(
|
|
207
|
+
"call-1",
|
|
208
|
+
'console.log(await readFile({ path: "shot.png" }))',
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
: Stream.fromIterable(text("It is a red rectangle.")),
|
|
212
|
+
})
|
|
213
|
+
assert.isTrue(Exit.isSuccess(exit))
|
|
214
|
+
assert.strictEqual(calls.length, 2)
|
|
215
|
+
|
|
216
|
+
const messages = calls[1]!.prompt.content
|
|
217
|
+
const resultIndex = messages.findIndex((m) =>
|
|
218
|
+
toolResultText(m)?.includes("Image attached: shot.png"),
|
|
219
|
+
)
|
|
220
|
+
assert.notStrictEqual(resultIndex, -1, "script saw the marker")
|
|
221
|
+
|
|
222
|
+
const image = messages
|
|
223
|
+
.slice(resultIndex + 1)
|
|
224
|
+
.flatMap(fileParts)
|
|
225
|
+
.find((part) => part.mediaType === "image/png")
|
|
226
|
+
assert.isDefined(image, "a file part follows the tool result")
|
|
227
|
+
assert.strictEqual(
|
|
228
|
+
calls[1]!.prompt.content.flatMap(fileParts).length,
|
|
229
|
+
1,
|
|
230
|
+
)
|
|
231
|
+
assert.strictEqual(image!.fileName, "shot.png")
|
|
232
|
+
assert.isTrue(equalBytes(bytesOf(image!.data), tinyPng))
|
|
233
|
+
|
|
234
|
+
// The first model call had no image: injection happens after the
|
|
235
|
+
// script runs, not before.
|
|
236
|
+
assert.strictEqual(
|
|
237
|
+
calls[0]!.prompt.content.flatMap(fileParts).length,
|
|
238
|
+
0,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
// The script output shown to the client is the marker, not bytes.
|
|
242
|
+
const scriptOutput = outputs.find(
|
|
243
|
+
(o): o is AgentOutput.ScriptOutput => o._tag === "ScriptOutput",
|
|
244
|
+
)
|
|
245
|
+
assert.isDefined(scriptOutput)
|
|
246
|
+
assert.include(scriptOutput!.output, "Image attached: shot.png")
|
|
247
|
+
assert.isBelow(scriptOutput!.output.length, 200)
|
|
248
|
+
}),
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
it.effect("applies the image limits to readFile images", () =>
|
|
253
|
+
withProject(() =>
|
|
254
|
+
Effect.gen(function* () {
|
|
255
|
+
const { exit, calls } = yield* runAgent({
|
|
256
|
+
respond: (_, index) =>
|
|
257
|
+
index === 0
|
|
258
|
+
? Stream.make(
|
|
259
|
+
toolCall(
|
|
260
|
+
"call-1",
|
|
261
|
+
'console.log(await readFile({ path: "wide.png" }))',
|
|
262
|
+
),
|
|
263
|
+
)
|
|
264
|
+
: Stream.fromIterable(text("done")),
|
|
265
|
+
})
|
|
266
|
+
assert.isTrue(Exit.isSuccess(exit))
|
|
267
|
+
const images = calls[1]!.prompt.content.flatMap(fileParts)
|
|
268
|
+
assert.strictEqual(images.length, 1)
|
|
269
|
+
const size = dimensions(bytesOf(images[0]!.data))
|
|
270
|
+
assert.isAtMost(size.width, 2000)
|
|
271
|
+
assert.isAtMost(size.height, 2000)
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
it.effect("reads SVG as text without injecting an image", () =>
|
|
277
|
+
withProject(() =>
|
|
278
|
+
Effect.gen(function* () {
|
|
279
|
+
const { calls } = yield* runAgent({
|
|
280
|
+
respond: (_, index) =>
|
|
281
|
+
index === 0
|
|
282
|
+
? Stream.make(
|
|
283
|
+
toolCall(
|
|
284
|
+
"call-1",
|
|
285
|
+
'console.log(await readFile({ path: "logo.svg" }))',
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
: Stream.fromIterable(text("done")),
|
|
289
|
+
})
|
|
290
|
+
assert.strictEqual(calls.length, 2)
|
|
291
|
+
assert.strictEqual(
|
|
292
|
+
calls[1]!.prompt.content.flatMap(fileParts).length,
|
|
293
|
+
0,
|
|
294
|
+
)
|
|
295
|
+
const result = calls[1]!.prompt.content
|
|
296
|
+
.map(toolResultText)
|
|
297
|
+
.find((r) => r !== undefined)
|
|
298
|
+
assert.include(result, "<svg")
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
})
|
package/src/Codex.ts
CHANGED
|
@@ -67,7 +67,11 @@ const layerModel = (
|
|
|
67
67
|
OpenAiLanguageModel.layer({
|
|
68
68
|
model,
|
|
69
69
|
config: {
|
|
70
|
-
...Struct.omit(options ?? {}, [
|
|
70
|
+
...Struct.omit(options ?? {}, [
|
|
71
|
+
"reasoning",
|
|
72
|
+
"systemPromptTransform",
|
|
73
|
+
"supportsImages",
|
|
74
|
+
]),
|
|
71
75
|
store: false,
|
|
72
76
|
reasoning: {
|
|
73
77
|
effort: "medium",
|
|
@@ -82,6 +86,7 @@ const layerModel = (
|
|
|
82
86
|
OpenAiLanguageModel.withConfigOverride(effect, {
|
|
83
87
|
instructions: system,
|
|
84
88
|
}),
|
|
89
|
+
supportsImages: options?.supportsImages,
|
|
85
90
|
}),
|
|
86
91
|
// No Compaction.SummarizerTransform: Codex rejects max_output_tokens,
|
|
87
92
|
// so compaction summaries have no configured output-token cap.
|
package/src/Compaction.ts
CHANGED
|
@@ -23,6 +23,7 @@ import * as AiError from "effect/unstable/ai/AiError"
|
|
|
23
23
|
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
|
|
24
24
|
import * as Prompt from "effect/unstable/ai/Prompt"
|
|
25
25
|
import type * as AgentOutput from "./AgentOutput.ts"
|
|
26
|
+
import * as Image from "./Image.ts"
|
|
26
27
|
|
|
27
28
|
// =============================================================================
|
|
28
29
|
// Configuration
|
|
@@ -531,8 +532,22 @@ const renderMessage = (message: Prompt.Message): string => {
|
|
|
531
532
|
case "system":
|
|
532
533
|
return ""
|
|
533
534
|
case "user": {
|
|
535
|
+
// Image bytes never reach the summarizer: stub them in place.
|
|
534
536
|
const text = message.content
|
|
535
|
-
.flatMap((part) =>
|
|
537
|
+
.flatMap((part) => {
|
|
538
|
+
switch (part.type) {
|
|
539
|
+
case "text":
|
|
540
|
+
return [part.text]
|
|
541
|
+
case "file":
|
|
542
|
+
return [
|
|
543
|
+
part.mediaType.startsWith("image/")
|
|
544
|
+
? Image.omittedText(part.fileName)
|
|
545
|
+
: `[file: ${part.fileName ?? part.mediaType} omitted]`,
|
|
546
|
+
]
|
|
547
|
+
default:
|
|
548
|
+
return []
|
|
549
|
+
}
|
|
550
|
+
})
|
|
536
551
|
.join("\n")
|
|
537
552
|
return `[user]\n${text}`
|
|
538
553
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { assert, describe, it } from "@effect/vitest"
|
|
2
|
+
import * as Effect from "effect/Effect"
|
|
3
|
+
import * as Encoding from "effect/Encoding"
|
|
4
|
+
import * as Layer from "effect/Layer"
|
|
5
|
+
import * as Option from "effect/Option"
|
|
6
|
+
import * as Stream from "effect/Stream"
|
|
7
|
+
import * as LanguageModel from "effect/unstable/ai/LanguageModel"
|
|
8
|
+
import * as Prompt from "effect/unstable/ai/Prompt"
|
|
9
|
+
import * as Compaction from "./Compaction.ts"
|
|
10
|
+
import { tinyPng } from "./fixtures/TestImages.ts"
|
|
11
|
+
|
|
12
|
+
const base64 = Encoding.encodeBase64(tinyPng)
|
|
13
|
+
|
|
14
|
+
const user = (text: string) =>
|
|
15
|
+
Prompt.makeMessage("user", {
|
|
16
|
+
content: [Prompt.makePart("text", { text })],
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const userWithImage = (text: string, fileName: string, data: Uint8Array) =>
|
|
20
|
+
Prompt.makeMessage("user", {
|
|
21
|
+
content: [
|
|
22
|
+
Prompt.makePart("text", { text }),
|
|
23
|
+
Prompt.makePart("file", { mediaType: "image/png", fileName, data }),
|
|
24
|
+
],
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const assistantText = (text: string) =>
|
|
28
|
+
Prompt.makeMessage("assistant", {
|
|
29
|
+
content: [Prompt.makePart("text", { text })],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const system = Prompt.makeMessage("system", { content: "You are a test." })
|
|
33
|
+
|
|
34
|
+
const promptJson = (prompt: Prompt.Prompt): string =>
|
|
35
|
+
JSON.stringify(prompt.content)
|
|
36
|
+
|
|
37
|
+
const hasFileParts = (prompt: Prompt.Prompt): boolean =>
|
|
38
|
+
prompt.content.some(
|
|
39
|
+
(message) =>
|
|
40
|
+
message.role === "user" &&
|
|
41
|
+
message.content.some((part) => part.type === "file"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
describe("Compaction.compact with images", () => {
|
|
45
|
+
it.effect("never sends image parts to the summarizer", () =>
|
|
46
|
+
Effect.gen(function* () {
|
|
47
|
+
const summarizerPrompts: Array<Prompt.Prompt> = []
|
|
48
|
+
const languageModel = yield* LanguageModel.make({
|
|
49
|
+
generateText: () => Effect.succeed([]),
|
|
50
|
+
streamText: (options) => {
|
|
51
|
+
summarizerPrompts.push(options.prompt)
|
|
52
|
+
return Stream.fromIterable([
|
|
53
|
+
{ type: "text-start", id: "s" },
|
|
54
|
+
{ type: "text-delta", id: "s", delta: "SUMMARY" },
|
|
55
|
+
{ type: "text-end", id: "s" },
|
|
56
|
+
])
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
// Old image turn to summarize, then a fat text tail so the cut lands
|
|
60
|
+
// after the image.
|
|
61
|
+
const filler = "tail ".repeat(3_000)
|
|
62
|
+
const history = Prompt.fromMessages([
|
|
63
|
+
system,
|
|
64
|
+
userWithImage("look at this", "shot.png", tinyPng),
|
|
65
|
+
Prompt.makeMessage("user", {
|
|
66
|
+
content: [
|
|
67
|
+
Prompt.makePart("file", { mediaType: "image/png", data: base64 }),
|
|
68
|
+
],
|
|
69
|
+
}),
|
|
70
|
+
assistantText("It is a red rectangle."),
|
|
71
|
+
user("now the next thing"),
|
|
72
|
+
assistantText(filler),
|
|
73
|
+
user("and again"),
|
|
74
|
+
assistantText(filler),
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
const result = yield* Compaction.compact({
|
|
78
|
+
prompt: history,
|
|
79
|
+
reason: "threshold",
|
|
80
|
+
}).pipe(
|
|
81
|
+
Effect.provide(
|
|
82
|
+
Layer.mergeAll(
|
|
83
|
+
Layer.succeed(LanguageModel.LanguageModel, languageModel),
|
|
84
|
+
Compaction.CompactionConfig.layer({ keepRecentTokens: 2_000 }),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
const compacted = Option.getOrThrow(result)
|
|
89
|
+
assert.strictEqual(summarizerPrompts.length, 1)
|
|
90
|
+
const sent = promptJson(summarizerPrompts[0]!)
|
|
91
|
+
assert.notInclude(sent, base64)
|
|
92
|
+
assert.isFalse(hasFileParts(summarizerPrompts[0]!))
|
|
93
|
+
assert.include(sent, "[image: shot.png omitted]")
|
|
94
|
+
assert.include(sent, "[image: unnamed omitted]")
|
|
95
|
+
assert.include(sent, "look at this")
|
|
96
|
+
|
|
97
|
+
// The rewritten prompt carries the summary and no stale image bytes
|
|
98
|
+
// from the summarized side.
|
|
99
|
+
const rewritten = promptJson(compacted.prompt)
|
|
100
|
+
assert.include(rewritten, "SUMMARY")
|
|
101
|
+
assert.notInclude(rewritten, base64)
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
it.effect("keeps image parts in the kept tail", () =>
|
|
106
|
+
Effect.gen(function* () {
|
|
107
|
+
const languageModel = yield* LanguageModel.make({
|
|
108
|
+
generateText: () => Effect.succeed([]),
|
|
109
|
+
streamText: () =>
|
|
110
|
+
Stream.fromIterable([
|
|
111
|
+
{ type: "text-start", id: "s" },
|
|
112
|
+
{ type: "text-delta", id: "s", delta: "SUMMARY" },
|
|
113
|
+
{ type: "text-end", id: "s" },
|
|
114
|
+
]),
|
|
115
|
+
})
|
|
116
|
+
const filler = "old ".repeat(3_000)
|
|
117
|
+
const history = Prompt.fromMessages([
|
|
118
|
+
system,
|
|
119
|
+
user("start"),
|
|
120
|
+
assistantText(filler),
|
|
121
|
+
user("more"),
|
|
122
|
+
assistantText(filler),
|
|
123
|
+
// Recent image turn that must survive compaction verbatim.
|
|
124
|
+
userWithImage("latest screenshot", "latest.png", tinyPng),
|
|
125
|
+
])
|
|
126
|
+
const result = yield* Compaction.compact({
|
|
127
|
+
prompt: history,
|
|
128
|
+
reason: "threshold",
|
|
129
|
+
}).pipe(
|
|
130
|
+
Effect.provide(
|
|
131
|
+
Layer.mergeAll(
|
|
132
|
+
Layer.succeed(LanguageModel.LanguageModel, languageModel),
|
|
133
|
+
Compaction.CompactionConfig.layer({ keepRecentTokens: 500 }),
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
const compacted = Option.getOrThrow(result)
|
|
138
|
+
assert.isTrue(hasFileParts(compacted.prompt))
|
|
139
|
+
assert.include(promptJson(compacted.prompt), "latest screenshot")
|
|
140
|
+
}),
|
|
141
|
+
)
|
|
142
|
+
})
|
package/src/Copilot.ts
CHANGED
|
@@ -38,10 +38,14 @@ export const model = (
|
|
|
38
38
|
Layer.mergeAll(
|
|
39
39
|
OpenAiLanguageModel.layer({
|
|
40
40
|
model,
|
|
41
|
-
config: Struct.omit(options ?? {}, [
|
|
41
|
+
config: Struct.omit(options ?? {}, [
|
|
42
|
+
"systemPromptTransform",
|
|
43
|
+
"supportsImages",
|
|
44
|
+
]),
|
|
42
45
|
}),
|
|
43
46
|
AgentModelConfig.layer({
|
|
44
47
|
systemPromptTransform: options?.systemPromptTransform,
|
|
48
|
+
supportsImages: options?.supportsImages,
|
|
45
49
|
}),
|
|
46
50
|
// Cap compaction summaries; Copilot honours max_output_tokens.
|
|
47
51
|
Layer.succeed(Compaction.SummarizerTransform, (effect) =>
|