@nathapp/nax-agent 0.91.7-canary.9 → 0.91.7
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/CHANGELOG.md +16 -0
- package/dist/config/catalog-overrides.d.ts +2 -0
- package/dist/cost/standard-types.d.ts +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/internal/atomic-write.d.ts +2 -2
- package/dist/internal/git-exec.d.ts +1 -1
- package/dist/internal/git-exec.js +2 -2
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/native/models.js +1 -0
- package/dist/native/session/compaction.js +9 -1
- package/dist/native/session/image-context.d.ts +5 -0
- package/dist/native/session/image-context.js +46 -0
- package/dist/native/session/loop-events/external-handler.d.ts +27 -6
- package/dist/native/session/loop-events/external-handler.js +30 -10
- package/dist/native/session/loop-events/registry.js +1 -1
- package/dist/native/session/loop-events/types.d.ts +33 -0
- package/dist/native/session/transcript-images.d.ts +20 -0
- package/dist/native/session/transcript-images.js +247 -0
- package/dist/native/session/transcript-store.d.ts +6 -0
- package/dist/native/session/transcript-store.js +39 -6
- package/dist/native/session/turn-compaction-step.js +2 -1
- package/dist/native/session/turn-complete-step.js +4 -1
- package/dist/native/session/turn-loop.js +83 -4
- package/dist/native/session-adapter.d.ts +2 -0
- package/dist/native/session-adapter.js +14 -0
- package/dist/session/agent-session-errors.d.ts +1 -1
- package/dist/session/agent-session-turn.d.ts +2 -1
- package/dist/session/agent-session-turn.js +6 -5
- package/dist/session/agent-session-types.d.ts +16 -1
- package/dist/session/agent-session.js +58 -2
- package/dist/session/image-input.d.ts +15 -0
- package/dist/session/image-input.js +46 -0
- package/dist/session/native-backend.js +1 -0
- package/dist/session/session-backend.d.ts +5 -1
- package/dist/session/session-types.d.ts +3 -1
- package/package.json +3 -3
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, readFile, realpath, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { NaxError } from "#src/infra/index";
|
|
6
|
+
import { writeFileAtomic } from "#src/internal/atomic-write";
|
|
7
|
+
/** Filesystem operations separate from the atomic writer's write/rename seam. */
|
|
8
|
+
export const _transcriptImagesDeps = {
|
|
9
|
+
link,
|
|
10
|
+
mkdir,
|
|
11
|
+
open,
|
|
12
|
+
realpath,
|
|
13
|
+
lstat,
|
|
14
|
+
readFile: (path) => readFile(path),
|
|
15
|
+
rm,
|
|
16
|
+
stat: (path) => stat(path),
|
|
17
|
+
writeFileAtomic,
|
|
18
|
+
};
|
|
19
|
+
const extensions = {
|
|
20
|
+
"image/png": "png",
|
|
21
|
+
"image/jpeg": "jpg",
|
|
22
|
+
"image/gif": "gif",
|
|
23
|
+
"image/webp": "webp",
|
|
24
|
+
};
|
|
25
|
+
export function transcriptImagesPath(path) {
|
|
26
|
+
return path.replace(/\.json$/, ".images");
|
|
27
|
+
}
|
|
28
|
+
function extension(mimeType) {
|
|
29
|
+
const ext = Object.hasOwn(extensions, mimeType) ? extensions[mimeType] : undefined;
|
|
30
|
+
if (ext !== undefined)
|
|
31
|
+
return ext;
|
|
32
|
+
throw new NaxError(`Unsupported transcript image type: ${mimeType}`, "TRANSCRIPT_IMAGE_TYPE_UNSUPPORTED", {
|
|
33
|
+
stage: "native-session",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** A hard link publishes complete bytes atomically, failing rather than replacing a winner. */
|
|
37
|
+
async function publishImageOnce(path, bytes) {
|
|
38
|
+
const staged = `${path}.${randomUUID()}.tmp`;
|
|
39
|
+
try {
|
|
40
|
+
// Keep atomic staging (and its rename seam), but never rename over the final name.
|
|
41
|
+
await _transcriptImagesDeps.writeFileAtomic(staged, bytes);
|
|
42
|
+
try {
|
|
43
|
+
await _transcriptImagesDeps.link(staged, path);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code !== "EEXIST")
|
|
47
|
+
throw error;
|
|
48
|
+
// Another writer published this content-addressed image first; leave it untouched.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
await _transcriptImagesDeps.rm(staged, { force: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function assertSafeImageFolder(folder) {
|
|
56
|
+
if ((await canonicalImageFolder(folder)) === null) {
|
|
57
|
+
throw new NaxError("Unsafe transcript image sidecar", "TRANSCRIPT_IMAGE_PATH_UNSAFE", {
|
|
58
|
+
stage: "native-session",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async function publishImage(folder, image) {
|
|
63
|
+
await assertSafeImageFolder(folder);
|
|
64
|
+
const bytes = Buffer.from(image.data, "base64");
|
|
65
|
+
const ref = createHash("sha256").update(bytes).digest("hex");
|
|
66
|
+
const path = join(folder, `${ref}.${extension(image.mimeType)}`);
|
|
67
|
+
try {
|
|
68
|
+
await _transcriptImagesDeps.stat(path);
|
|
69
|
+
const entry = await _transcriptImagesDeps.lstat(path);
|
|
70
|
+
if (!entry.isFile()) {
|
|
71
|
+
throw new NaxError("Unsafe transcript image entry", "TRANSCRIPT_IMAGE_PATH_UNSAFE", {
|
|
72
|
+
stage: "native-session",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
if (error.code !== "ENOENT")
|
|
78
|
+
throw error;
|
|
79
|
+
await publishImageOnce(path, bytes);
|
|
80
|
+
}
|
|
81
|
+
if ((await readImageBytes(folder, basename(path))) === null) {
|
|
82
|
+
throw new NaxError("Unsafe transcript image entry", "TRANSCRIPT_IMAGE_PATH_UNSAFE", {
|
|
83
|
+
stage: "native-session",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const { data: _data, ...metadata } = image;
|
|
87
|
+
return { ...metadata, ref };
|
|
88
|
+
}
|
|
89
|
+
/** Preflight the WHOLE document before even creating the image directory. */
|
|
90
|
+
export async function splitTranscriptImages(path, doc) {
|
|
91
|
+
let hasImages = false;
|
|
92
|
+
for (const message of doc.messages) {
|
|
93
|
+
if (message.role !== "user")
|
|
94
|
+
continue;
|
|
95
|
+
for (const image of message.images ?? []) {
|
|
96
|
+
extension(image.mimeType);
|
|
97
|
+
hasImages = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (!hasImages)
|
|
101
|
+
return doc;
|
|
102
|
+
const folder = transcriptImagesPath(path);
|
|
103
|
+
await _transcriptImagesDeps.mkdir(folder, { recursive: true });
|
|
104
|
+
await assertSafeImageFolder(folder);
|
|
105
|
+
const messages = [];
|
|
106
|
+
// Sequential publication deduplicates repeated images within this save too.
|
|
107
|
+
for (const message of doc.messages) {
|
|
108
|
+
if (message.role !== "user" || !message.images?.length) {
|
|
109
|
+
messages.push(message);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const images = [];
|
|
113
|
+
for (const image of message.images)
|
|
114
|
+
images.push(await publishImage(folder, image));
|
|
115
|
+
messages.push({ ...message, images });
|
|
116
|
+
}
|
|
117
|
+
return { ...doc, schemaVersion: 2, messages };
|
|
118
|
+
}
|
|
119
|
+
/** Reject redirected sidecars as well as symlinked image entries. */
|
|
120
|
+
async function canonicalImageFolder(folder) {
|
|
121
|
+
if (!(await _transcriptImagesDeps.lstat(folder)).isDirectory())
|
|
122
|
+
return null;
|
|
123
|
+
const parent = await _transcriptImagesDeps.realpath(dirname(folder));
|
|
124
|
+
const expected = join(parent, basename(folder));
|
|
125
|
+
return (await _transcriptImagesDeps.realpath(folder)) === expected ? expected : null;
|
|
126
|
+
}
|
|
127
|
+
async function readImageBytes(folder, name) {
|
|
128
|
+
const canonical = await canonicalImageFolder(folder);
|
|
129
|
+
if (canonical === null)
|
|
130
|
+
return null;
|
|
131
|
+
const path = join(canonical, name);
|
|
132
|
+
// O_NOFOLLOW closes the lstat/open race for the final path component.
|
|
133
|
+
const handle = await _transcriptImagesDeps.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
134
|
+
try {
|
|
135
|
+
// Verify containment and identity AFTER open, before consuming any bytes.
|
|
136
|
+
const resolved = await _transcriptImagesDeps.realpath(path);
|
|
137
|
+
if (dirname(resolved) !== canonical || (await canonicalImageFolder(folder)) !== canonical)
|
|
138
|
+
return null;
|
|
139
|
+
const opened = await handle.stat();
|
|
140
|
+
const current = await _transcriptImagesDeps.lstat(path);
|
|
141
|
+
if (!opened.isFile() || !current.isFile() || opened.dev !== current.dev || opened.ino !== current.ino)
|
|
142
|
+
return null;
|
|
143
|
+
return await _transcriptImagesDeps.readFile(handle);
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await handle.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
async function readStoredImage(folder, image) {
|
|
150
|
+
const { ref, ...metadata } = image;
|
|
151
|
+
// Validate both fields before deriving a path or touching the filesystem.
|
|
152
|
+
if (typeof ref !== "string" || !/^[a-f0-9]{64}$/.test(ref) || !Object.hasOwn(extensions, image.mimeType)) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
const bytes = await readImageBytes(folder, `${ref}.${extensions[image.mimeType]}`);
|
|
157
|
+
return bytes === null ? null : { ...metadata, data: bytes.toString("base64") };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
const code = error.code;
|
|
161
|
+
if (code === "ENOENT" || code === "ELOOP" || code === "ENOTDIR")
|
|
162
|
+
return null;
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** A note the loader writes in place of an image it could not read back: numbered, or bare. */
|
|
167
|
+
const MISSING_IMAGE_NOTE = /^\[image missing(?: #(\d+))?\]$/;
|
|
168
|
+
const LINE_BREAK = /\r\n|[\r\n]/;
|
|
169
|
+
/** A `schemaVersion` 2 document whose image list is not a list: unreadable, not merely lossy. */
|
|
170
|
+
function corruptImageList() {
|
|
171
|
+
return new NaxError("transcript image list is not an array", "TRANSCRIPT_CORRUPT", {
|
|
172
|
+
stage: "native-session",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The 1-based slots a stored message's content already notes as lost.
|
|
177
|
+
*
|
|
178
|
+
* A later save keeps the notes an earlier load wrote — they travel with the content — and
|
|
179
|
+
* stores only the images that were readable. The array the next load enumerates is therefore
|
|
180
|
+
* the survivors of an earlier one, and a lost image must still be numbered by the slot it
|
|
181
|
+
* held in the original message rather than by its place among the survivors. Notes are read
|
|
182
|
+
* from the trailing run only, exactly as replay strips them, so a user's own line in the
|
|
183
|
+
* middle of a message is never mistaken for one, and a bare note (an older build's, which
|
|
184
|
+
* recorded no position) claims no slot here.
|
|
185
|
+
*/
|
|
186
|
+
function notedSlots(content) {
|
|
187
|
+
const slots = new Set();
|
|
188
|
+
if (typeof content !== "string")
|
|
189
|
+
return slots;
|
|
190
|
+
const lines = content.split(LINE_BREAK);
|
|
191
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
192
|
+
const note = MISSING_IMAGE_NOTE.exec(lines[index] ?? "");
|
|
193
|
+
if (note === null)
|
|
194
|
+
break;
|
|
195
|
+
const slot = note[1];
|
|
196
|
+
if (slot !== undefined)
|
|
197
|
+
slots.add(Number(slot));
|
|
198
|
+
}
|
|
199
|
+
return slots;
|
|
200
|
+
}
|
|
201
|
+
/** The slot the `index`-th stored image holds: the next 1-based position no note claims. */
|
|
202
|
+
function slotOf(index, noted) {
|
|
203
|
+
let remaining = index + 1;
|
|
204
|
+
let slot = 0;
|
|
205
|
+
while (remaining > 0) {
|
|
206
|
+
slot += 1;
|
|
207
|
+
if (!noted.has(slot))
|
|
208
|
+
remaining -= 1;
|
|
209
|
+
}
|
|
210
|
+
return slot;
|
|
211
|
+
}
|
|
212
|
+
/** Decode the private disk format; callers only ever see inline attachments. */
|
|
213
|
+
export async function rehydrateTranscriptImages(path, value) {
|
|
214
|
+
const doc = value;
|
|
215
|
+
if (typeof doc.schemaVersion === "number" && doc.schemaVersion > 2) {
|
|
216
|
+
throw new NaxError(`Unsupported transcript schemaVersion ${doc.schemaVersion}`, "TRANSCRIPT_SCHEMA_UNSUPPORTED", {
|
|
217
|
+
stage: "native-session",
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (doc.schemaVersion !== 2)
|
|
221
|
+
return value;
|
|
222
|
+
const { schemaVersion: _version, ...rest } = doc;
|
|
223
|
+
const messages = [];
|
|
224
|
+
for (const message of doc.messages) {
|
|
225
|
+
if (message.role !== "user" || !message.images?.length) {
|
|
226
|
+
messages.push(message);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (!Array.isArray(message.images))
|
|
230
|
+
throw corruptImageList();
|
|
231
|
+
const images = [];
|
|
232
|
+
let content = message.content;
|
|
233
|
+
const noted = notedSlots(content);
|
|
234
|
+
for (const [index, image] of message.images.entries()) {
|
|
235
|
+
const hydrated = await readStoredImage(transcriptImagesPath(path), image);
|
|
236
|
+
if (hydrated)
|
|
237
|
+
images.push(hydrated);
|
|
238
|
+
else {
|
|
239
|
+
const marker = `[image missing #${slotOf(index, noted)}]`;
|
|
240
|
+
content = content ? `${content}\n${marker}` : marker;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const { images: _storedImages, ...text } = message;
|
|
244
|
+
messages.push({ ...text, content, ...(images.length ? { images } : {}) });
|
|
245
|
+
}
|
|
246
|
+
return { ...rest, messages };
|
|
247
|
+
}
|
|
@@ -6,9 +6,15 @@
|
|
|
6
6
|
* nax stored nothing; SessionDescriptor still has no message field, and gains
|
|
7
7
|
* none. See ADR-028 sections 2 and 3.
|
|
8
8
|
*/
|
|
9
|
+
import { rename, rm } from "node:fs/promises";
|
|
9
10
|
import type { ConversationMessage } from "@nathapp/nax-ai";
|
|
10
11
|
import { type TranscriptIdentity } from "./transcript-identity.ts";
|
|
11
12
|
import type { TranscriptDoc, TranscriptStore } from "./transcript-types.ts";
|
|
13
|
+
/** Folder lifecycle seam; the JSON operations retain their existing behavior. */
|
|
14
|
+
export declare const _transcriptLifecycleDeps: {
|
|
15
|
+
rename: typeof rename;
|
|
16
|
+
rm: typeof rm;
|
|
17
|
+
};
|
|
12
18
|
export { type TranscriptIdentity, transcriptModelIdentity } from "./transcript-identity.ts";
|
|
13
19
|
export declare function transcriptPath(dir: string, sessionName: string): string;
|
|
14
20
|
/**
|
|
@@ -11,6 +11,9 @@ import { join } from "node:path";
|
|
|
11
11
|
import { getLogger, NaxError } from "#src/infra/index";
|
|
12
12
|
import { writeFileAtomic } from "#src/internal/atomic-write";
|
|
13
13
|
import { historyFromTranscript, transcriptDocFor } from "./transcript-identity.js";
|
|
14
|
+
import { rehydrateTranscriptImages, splitTranscriptImages, transcriptImagesPath } from "./transcript-images.js";
|
|
15
|
+
/** Folder lifecycle seam; the JSON operations retain their existing behavior. */
|
|
16
|
+
export const _transcriptLifecycleDeps = { rename, rm };
|
|
14
17
|
export { transcriptModelIdentity } from "./transcript-identity.js";
|
|
15
18
|
export function transcriptPath(dir, sessionName) {
|
|
16
19
|
return join(dir, `${sessionName}.transcript.json`);
|
|
@@ -56,13 +59,15 @@ export async function readTranscriptDoc(dir, sessionName) {
|
|
|
56
59
|
return { savedAt: LEGACY_SAVED_AT, messages: parsed };
|
|
57
60
|
if (typeof parsed !== "object" || parsed === null)
|
|
58
61
|
throw corruptTranscript(sessionName, "not a transcript document");
|
|
59
|
-
const doc = parsed;
|
|
62
|
+
const doc = await rehydrateTranscriptImages(transcriptPath(dir, sessionName), parsed);
|
|
60
63
|
return { ...doc, messages: doc.messages ?? [] };
|
|
61
64
|
}
|
|
62
65
|
async function writeTranscriptDoc(dir, sessionName, doc) {
|
|
66
|
+
const path = transcriptPath(dir, sessionName);
|
|
67
|
+
const stored = await splitTranscriptImages(path, doc);
|
|
63
68
|
await mkdir(dir, { recursive: true });
|
|
64
|
-
//
|
|
65
|
-
await writeFileAtomic(
|
|
69
|
+
// Publish JSON only after all image files have been published successfully.
|
|
70
|
+
await writeFileAtomic(path, JSON.stringify(stored, null, 2));
|
|
66
71
|
}
|
|
67
72
|
/**
|
|
68
73
|
* The file store: `<dir>/<name>.transcript.json`, written as given (the loop's
|
|
@@ -111,17 +116,45 @@ export async function saveTranscript(dir, sessionName, messages, identity = {})
|
|
|
111
116
|
*/
|
|
112
117
|
export async function retainTranscript(dir, sessionName) {
|
|
113
118
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
119
|
+
const live = transcriptPath(dir, sessionName);
|
|
120
|
+
const retained = join(dir, `${sessionName}.transcript.failed-${stamp}.json`);
|
|
114
121
|
try {
|
|
115
|
-
await rename(
|
|
122
|
+
await rename(live, retained);
|
|
116
123
|
}
|
|
117
124
|
catch (err) {
|
|
118
125
|
if (err.code === "ENOENT")
|
|
119
126
|
return;
|
|
120
127
|
throw err;
|
|
121
128
|
}
|
|
129
|
+
try {
|
|
130
|
+
await _transcriptLifecycleDeps.rename(transcriptImagesPath(live), transcriptImagesPath(retained));
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (err.code === "ENOENT")
|
|
134
|
+
return;
|
|
135
|
+
// Restore the live JSON on failure so its still-live sidecar remains loadable
|
|
136
|
+
// and a later retention attempt can move the pair again.
|
|
137
|
+
try {
|
|
138
|
+
await _transcriptLifecycleDeps.rename(retained, live);
|
|
139
|
+
}
|
|
140
|
+
catch (rollbackError) {
|
|
141
|
+
throw new NaxError("Transcript retention rollback failed", "TRANSCRIPT_RETENTION_ROLLBACK_FAILED", {
|
|
142
|
+
stage: "native-session",
|
|
143
|
+
cause: err,
|
|
144
|
+
rollbackError,
|
|
145
|
+
live,
|
|
146
|
+
retained,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function deleteTranscriptFile(path) {
|
|
153
|
+
await rm(path, { force: true });
|
|
154
|
+
await _transcriptLifecycleDeps.rm(transcriptImagesPath(path), { recursive: true, force: true });
|
|
122
155
|
}
|
|
123
156
|
export async function deleteTranscript(dir, sessionName) {
|
|
124
|
-
await
|
|
157
|
+
await deleteTranscriptFile(transcriptPath(dir, sessionName));
|
|
125
158
|
}
|
|
126
159
|
/**
|
|
127
160
|
* Cap on transcripts retained per feature's `sessions/` directory. Transcripts
|
|
@@ -180,7 +213,7 @@ export async function pruneRetainedTranscripts(dir, maxRetained = MAX_RETAINED_T
|
|
|
180
213
|
// Oldest first, so the slice below drops exactly the excess over the cap.
|
|
181
214
|
withMtimes.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
182
215
|
const toDelete = withMtimes.slice(0, withMtimes.length - maxRetained);
|
|
183
|
-
await Promise.all(toDelete.map((f) =>
|
|
216
|
+
await Promise.all(toDelete.map((f) => deleteTranscriptFile(f.path)));
|
|
184
217
|
if (!hasWarnedAboutTranscriptTruncation) {
|
|
185
218
|
hasWarnedAboutTranscriptTruncation = true;
|
|
186
219
|
getLogger().warn("native-session", "Transcript retention cap reached — oldest kept-on-failure transcripts pruned", {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import { getSafeLogger } from "#src/infra/index";
|
|
24
24
|
import { applyCompaction, estimateContextTokens, keepBudget, prepareCompaction, } from "./compaction.js";
|
|
25
|
+
import { imagesAsNotes } from "./image-context.js";
|
|
25
26
|
import { usageBeat } from "./turn-accumulator.js";
|
|
26
27
|
/**
|
|
27
28
|
* The half both branches share: summarize the dropped span, then apply the
|
|
@@ -31,7 +32,7 @@ import { usageBeat } from "./turn-accumulator.js";
|
|
|
31
32
|
async function summarizeAndApply(messages, plan, deps, source = {}) {
|
|
32
33
|
const summary = source.replacementSummary !== undefined
|
|
33
34
|
? { text: source.replacementSummary, usage: { inputTokens: 0, outputTokens: 0 }, costUsd: 0 }
|
|
34
|
-
: await deps.summarize(plan.toSummarize, plan.previousSummary);
|
|
35
|
+
: await deps.summarize(imagesAsNotes(plan.toSummarize), plan.previousSummary);
|
|
35
36
|
// Rebound, not spliced in place: `messages` is a local accumulator and
|
|
36
37
|
// rebinding it keeps the compacted array a fresh value.
|
|
37
38
|
const compacted = applyCompaction(messages, plan, summary.text);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* which recovery a thrown round trip gets.
|
|
20
20
|
*/
|
|
21
21
|
import { getSafeLogger } from "#src/infra/index";
|
|
22
|
+
import { capRequestImages } from "./image-context.js";
|
|
22
23
|
import { applyHistoryPatch } from "./loop-events/cache-boundary.js";
|
|
23
24
|
import { takePartialSpend } from "./partial-spend.js";
|
|
24
25
|
import { usageBeat } from "./turn-accumulator.js";
|
|
@@ -89,7 +90,9 @@ export async function completeWithRecovery(args) {
|
|
|
89
90
|
// successful attempt is the last one to reach this line — `honoured`
|
|
90
91
|
// describes the wire the provider actually answered (spec 3.6).
|
|
91
92
|
honoured = wire.honoured;
|
|
92
|
-
|
|
93
|
+
// Cap after history patches, on every attempt; saved history stays untouched.
|
|
94
|
+
// Dropping an image may stale the prompt-cache prefix/anchor estimate.
|
|
95
|
+
return deps.complete(capRequestImages(wire.messages), tools, options, turnEvents.deltaSink(roundTrip));
|
|
93
96
|
};
|
|
94
97
|
// A failed round trip was still billed for what it streamed. Folding it in
|
|
95
98
|
// here, per attempt, is what makes a retry count both attempts and an
|
|
@@ -33,6 +33,41 @@ import { createTurnEventEmitter } from "./turn-event-emitter.js";
|
|
|
33
33
|
import { runRoundTripLoop, runTurnEndPhase } from "./turn-loop-round-trip.js";
|
|
34
34
|
import { buildTurnResult, logTurnTailWarnings } from "./turn-result.js";
|
|
35
35
|
import { recordNativeTurnFailureUsage } from "./turn-types.js";
|
|
36
|
+
/**
|
|
37
|
+
* What a refusal says when the patch carries no usable reason.
|
|
38
|
+
*
|
|
39
|
+
* A plugin's `loop-handlers` are raw JavaScript: TypeScript's
|
|
40
|
+
* `{ reason: string }` is not enforced on them, so `{ block: {} }`,
|
|
41
|
+
* `{ block: { reason: undefined } }`, `{ block: { reason: " " } }` and a
|
|
42
|
+
* non-object `block` all reach this point. The turn still fails closed — a
|
|
43
|
+
* malformed block is a block — but the reason is the whole turn-error line the
|
|
44
|
+
* user reads (`NATIVE_TURN_BLOCKED: <reason>` in chat, a failed turn in a run),
|
|
45
|
+
* so an empty message would say nothing about why the prompt vanished.
|
|
46
|
+
*/
|
|
47
|
+
const BLOCK_REASON_FALLBACK = "before_turn handler blocked the turn without a reason";
|
|
48
|
+
/** The reason a `block` patch refuses the turn with — never empty (US-001). */
|
|
49
|
+
function blockRefusalReason(block) {
|
|
50
|
+
const reason = typeof block === "object" && block !== null ? block.reason : undefined;
|
|
51
|
+
if (typeof reason === "string" && reason.trim() !== "")
|
|
52
|
+
return reason;
|
|
53
|
+
getSafeLogger()?.warn("native-loop-events", "before_turn block patch carries no reason text; refusing the turn anyway", {
|
|
54
|
+
blockKind: typeof block,
|
|
55
|
+
});
|
|
56
|
+
return BLOCK_REASON_FALLBACK;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* A `block` patch refuses the turn (US-001).
|
|
60
|
+
*
|
|
61
|
+
* Thrown BEFORE `messages.push` and before any provider request, so a blocked
|
|
62
|
+
* prompt is neither sent nor persisted. `reason` is the message, which is what
|
|
63
|
+
* makes the chat's turn-error line read `NATIVE_TURN_BLOCKED: <reason>`
|
|
64
|
+
* (`applyTurnEnd` in nax's transcript-model renders `CODE: message`).
|
|
65
|
+
*/
|
|
66
|
+
function assertTurnNotBlocked(block, sessionName) {
|
|
67
|
+
if (block === undefined)
|
|
68
|
+
return;
|
|
69
|
+
throw new NaxError(blockRefusalReason(block), "NATIVE_TURN_BLOCKED", { stage: "native-session", sessionName });
|
|
70
|
+
}
|
|
36
71
|
/**
|
|
37
72
|
* The messages `before_turn` appends as the turn's seed: the handler's patch
|
|
38
73
|
* when it is a non-empty user-role array (spec 6.1's stop rule), otherwise the
|
|
@@ -41,13 +76,52 @@ import { recordNativeTurnFailureUsage } from "./turn-types.js";
|
|
|
41
76
|
* empty or speaks with another role hands the model a conversation it cannot
|
|
42
77
|
* answer as itself.
|
|
43
78
|
*/
|
|
44
|
-
function
|
|
79
|
+
function seededMessages(patch, prompt, images) {
|
|
80
|
+
const seed = {
|
|
81
|
+
role: "user",
|
|
82
|
+
content: prompt,
|
|
83
|
+
...(images?.length ? { images } : {}),
|
|
84
|
+
};
|
|
45
85
|
if (patch === undefined)
|
|
46
|
-
return [
|
|
86
|
+
return [seed];
|
|
47
87
|
if (patch.length > 0 && patch.every((m) => m.role === "user"))
|
|
48
88
|
return [...patch];
|
|
49
89
|
getSafeLogger()?.warn("native-loop-events", "before_turn seed patch rejected: seed must be a non-empty user-role array", { seedLength: patch.length });
|
|
50
|
-
return [
|
|
90
|
+
return [seed];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* `promptContext` is PREFIXED to the seed's first message (US-001).
|
|
94
|
+
*
|
|
95
|
+
* Not a `seed` patch: that one replaces the prompt message, and the caller's
|
|
96
|
+
* images go with it. Only `content` changes here, so a message's images,
|
|
97
|
+
* toolCallId and role survive untouched. An empty context is no context.
|
|
98
|
+
*
|
|
99
|
+
* A plugin's `loop-handlers` are raw JavaScript (see `blockRefusalReason`), so a
|
|
100
|
+
* non-string context reaches here too; it is dropped with a warning rather than
|
|
101
|
+
* stringified into the prompt as `[object Object]`.
|
|
102
|
+
*/
|
|
103
|
+
function withPromptContext(seed, promptContext) {
|
|
104
|
+
if (promptContext === undefined || promptContext === "")
|
|
105
|
+
return [...seed];
|
|
106
|
+
if (typeof promptContext !== "string") {
|
|
107
|
+
getSafeLogger()?.warn("native-loop-events", "before_turn promptContext is not a string; prompt unchanged", {
|
|
108
|
+
promptContextKind: promptContext === null ? "null" : typeof promptContext,
|
|
109
|
+
});
|
|
110
|
+
return [...seed];
|
|
111
|
+
}
|
|
112
|
+
const [first, ...rest] = seed;
|
|
113
|
+
if (first === undefined)
|
|
114
|
+
return [];
|
|
115
|
+
return [{ ...first, content: `${promptContext}\n\n${first.content}` }, ...rest];
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* What the turn actually appends. `block` is checked FIRST — a patch carrying
|
|
119
|
+
* both a block and a context refuses the turn, because the reason is the whole
|
|
120
|
+
* answer and there is no turn left for the context to sit in front of.
|
|
121
|
+
*/
|
|
122
|
+
function beforeTurnSeed({ patch, prompt, images, sessionName }) {
|
|
123
|
+
assertTurnNotBlocked(patch?.block, sessionName);
|
|
124
|
+
return withPromptContext(seededMessages(patch?.seed, prompt, images), patch?.promptContext);
|
|
51
125
|
}
|
|
52
126
|
/** Keep billed spend on the original error for both loop and persistence failures. */
|
|
53
127
|
function recordFailureSpend(err, usage) {
|
|
@@ -126,7 +200,12 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
|
|
|
126
200
|
event: "before_turn",
|
|
127
201
|
}).messages,
|
|
128
202
|
];
|
|
129
|
-
messages.push(...beforeTurnSeed(
|
|
203
|
+
messages.push(...beforeTurnSeed({
|
|
204
|
+
patch: turnStart,
|
|
205
|
+
prompt,
|
|
206
|
+
images: opts.images,
|
|
207
|
+
sessionName: handle.id,
|
|
208
|
+
}));
|
|
130
209
|
const codingTools = opts.codingTools ?? [];
|
|
131
210
|
const codingToolNames = new Set(codingTools.map((t) => t.name));
|
|
132
211
|
// Native spends the budget ONLY on ask_human exchanges. Unlike ACP it is
|
|
@@ -54,6 +54,8 @@ export declare class NativeSessionAdapter implements AgentSessionAdapter {
|
|
|
54
54
|
*/
|
|
55
55
|
hasCredentials(): Promise<boolean>;
|
|
56
56
|
openSession(name: string, opts: OpenSessionOpts): Promise<SessionHandle>;
|
|
57
|
+
/** Resolve the current handle's model; absent input metadata is text-only. */
|
|
58
|
+
imageInput(handle: SessionHandle): Promise<boolean>;
|
|
57
59
|
sendTurn(handle: SessionHandle, prompt: string, opts: SendTurnOpts): Promise<TurnResult>;
|
|
58
60
|
closeSession(handle: SessionHandle): Promise<void>;
|
|
59
61
|
/**
|
|
@@ -59,6 +59,11 @@ function perTurnDeps(opts) {
|
|
|
59
59
|
...(opts.onTurnEvent !== undefined ? { onTurnEvent: opts.onTurnEvent } : {}),
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
|
+
function assertImageInput(handle, resolved, images) {
|
|
63
|
+
if (images?.length && resolved.input?.includes("image") !== true) {
|
|
64
|
+
throw new NaxError(`Native model "${resolved.provider}/${resolved.id}" does not support image input`, "NATIVE_IMAGE_INPUT_UNSUPPORTED", { stage: "native-session", sessionName: handle.id, provider: resolved.provider, model: resolved.id });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
62
67
|
const adapterStates = new WeakMap();
|
|
63
68
|
/** The adapter's per-session state. `/internal` only: tests and the S3 facade read it; nax never does. */
|
|
64
69
|
export function nativeSessionStateOf(adapter) {
|
|
@@ -140,11 +145,20 @@ export class NativeSessionAdapter {
|
|
|
140
145
|
openSession(name, opts) {
|
|
141
146
|
return openNativeSession(this.state, name, opts);
|
|
142
147
|
}
|
|
148
|
+
/** Resolve the current handle's model; absent input metadata is text-only. */
|
|
149
|
+
async imageInput(handle) {
|
|
150
|
+
const { provider, model } = parseNativeModel(handle.modelDef?.model ?? "");
|
|
151
|
+
const client = await this.client();
|
|
152
|
+
const resolved = await client.model(provider, model);
|
|
153
|
+
return resolved.input?.includes("image") === true;
|
|
154
|
+
}
|
|
143
155
|
async sendTurn(handle, prompt, opts) {
|
|
144
156
|
const { provider, model, effort } = parseNativeModel(handle.modelDef?.model ?? "");
|
|
145
157
|
const thinking = toThinkingLevel(effort);
|
|
146
158
|
const client = await this.client();
|
|
147
159
|
const resolved = await client.model(provider, model);
|
|
160
|
+
// Refuse before runNativeTurn: its error path deliberately saves history.
|
|
161
|
+
assertImageInput(handle, resolved, opts.images);
|
|
148
162
|
const contextWindow = resolveContextWindow(handle.modelDef?.contextWindow, resolved.contextWindow);
|
|
149
163
|
this.state.repositoryInstructions
|
|
150
164
|
.get(handle.id)
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* or registerCodingTool's TOOL_NAME_RESERVED.
|
|
5
5
|
*/
|
|
6
6
|
import { NaxError } from "#src/infra/nax-error";
|
|
7
|
-
export type AgentSessionErrorCode = "AGENT_SESSION_INVALID_OPTIONS" | "AGENT_SESSION_EXISTS" | "AGENT_SESSION_BUSY" | "AGENT_SESSION_CLOSED" | "AGENT_SESSION_INVALID_ANSWER" | "AGENT_SESSION_NOT_FOUND" | "AGENT_SESSION_SCHEMA_UNSUPPORTED" | "AGENT_SESSION_MODEL_MISMATCH" | "AGENT_SESSION_SANDBOX_UNAVAILABLE" | "AGENT_SESSION_TOOL_NAME_RESERVED" | "AGENT_SESSION_BACKEND_UNAVAILABLE" | "AGENT_SESSION_AUTH_REQUIRED" | "AGENT_SESSION_RATE_LIMITED" | "AGENT_SESSION_CAPABILITY_UNSUPPORTED" | "AGENT_SESSION_BACKEND_MISMATCH";
|
|
7
|
+
export type AgentSessionErrorCode = "AGENT_SESSION_INVALID_OPTIONS" | "AGENT_SESSION_INVALID_INPUT" | "AGENT_SESSION_EXISTS" | "AGENT_SESSION_BUSY" | "AGENT_SESSION_CLOSED" | "AGENT_SESSION_INVALID_ANSWER" | "AGENT_SESSION_NOT_FOUND" | "AGENT_SESSION_SCHEMA_UNSUPPORTED" | "AGENT_SESSION_MODEL_MISMATCH" | "AGENT_SESSION_SANDBOX_UNAVAILABLE" | "AGENT_SESSION_TOOL_NAME_RESERVED" | "AGENT_SESSION_BACKEND_UNAVAILABLE" | "AGENT_SESSION_AUTH_REQUIRED" | "AGENT_SESSION_RATE_LIMITED" | "AGENT_SESSION_CAPABILITY_UNSUPPORTED" | "AGENT_SESSION_BACKEND_MISMATCH";
|
|
8
8
|
export declare class AgentSessionError extends NaxError {
|
|
9
9
|
readonly code: AgentSessionErrorCode;
|
|
10
10
|
constructor(message: string, code: AgentSessionErrorCode, context?: Record<string, unknown>);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { TranscriptStore } from "#src/native/session/transcript-types";
|
|
2
2
|
import type { SessionEvent, SessionEventBody, TurnEndStatus } from "./agent-session-types.ts";
|
|
3
|
+
import type { SendInput } from "./image-input.ts";
|
|
3
4
|
import type { TurnContribution } from "./session-backend.ts";
|
|
4
5
|
import { type AgentSessionAdapter, type SessionHandle, type TurnResult } from "./session-types.ts";
|
|
5
6
|
/** The ask_human budget per turn: nax's agent.maxInteractionTurns default. */
|
|
@@ -27,7 +28,7 @@ export interface ClaimedTurn {
|
|
|
27
28
|
readonly settled: Promise<void>;
|
|
28
29
|
cancel(reason: string): void;
|
|
29
30
|
}
|
|
30
|
-
export interface ClaimTurnHooks {
|
|
31
|
+
export interface ClaimTurnHooks extends SendInput {
|
|
31
32
|
readonly onStart: (live: LiveTurn) => void;
|
|
32
33
|
/** Runs before turn_end is emitted. `status` is undefined for a voided claim. */
|
|
33
34
|
readonly onSettle: (turnId: string, status: TurnEndStatus | undefined) => void;
|
|
@@ -81,9 +81,10 @@ function fromError(err, signal) {
|
|
|
81
81
|
return { ...base, status: "cancelled" };
|
|
82
82
|
return { ...base, status: "errored", error: errorOf(err) };
|
|
83
83
|
}
|
|
84
|
-
function sendTurn(ctx, live,
|
|
85
|
-
return ctx.adapter.sendTurn(ctx.handle, message, {
|
|
84
|
+
function sendTurn(ctx, live, input) {
|
|
85
|
+
return ctx.adapter.sendTurn(ctx.handle, input.message, {
|
|
86
86
|
...ctx.turnOpts(),
|
|
87
|
+
...(input.images?.length ? { images: input.images } : {}),
|
|
87
88
|
maxInteractions: ASK_HUMAN_BUDGET,
|
|
88
89
|
turnId: live.turnId,
|
|
89
90
|
signal: live.signal,
|
|
@@ -104,14 +105,14 @@ async function markTurn(ctx, turnId, state) {
|
|
|
104
105
|
return { error };
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
|
-
async function runTurn(ctx, live,
|
|
108
|
+
async function runTurn(ctx, live, input) {
|
|
108
109
|
live.emit({ type: "turn_start" });
|
|
109
110
|
const started = await markTurn(ctx, live.turnId, "running");
|
|
110
111
|
if (started !== undefined)
|
|
111
112
|
return fromError(started.error, live.signal);
|
|
112
113
|
let end;
|
|
113
114
|
try {
|
|
114
|
-
end = turnEndFromResult(await sendTurn(ctx, live,
|
|
115
|
+
end = turnEndFromResult(await sendTurn(ctx, live, input));
|
|
115
116
|
}
|
|
116
117
|
catch (err) {
|
|
117
118
|
end = fromError(err, live.signal);
|
|
@@ -167,7 +168,7 @@ export function claimTurn(ctx, message, hooks) {
|
|
|
167
168
|
state = "running";
|
|
168
169
|
const timer = _agentSessionDeps.setTimeout(() => controller.abort(abortReason(TIMED_OUT, "turn deadline")), ctx.turnTimeoutSeconds * 1000);
|
|
169
170
|
hooks.onStart(live);
|
|
170
|
-
void runTurn(ctx, live, message)
|
|
171
|
+
void runTurn(ctx, live, { message, images: hooks.images })
|
|
171
172
|
.catch((err) => fromError(err, controller.signal))
|
|
172
173
|
.then((end) => {
|
|
173
174
|
_agentSessionDeps.clearTimeout(timer);
|
|
@@ -9,6 +9,7 @@ import type { TokenUsage } from "#src/cost/standard-types";
|
|
|
9
9
|
import type { TranscriptStore } from "#src/native/session/transcript-types";
|
|
10
10
|
import type { BackgroundCommandPort } from "#src/tools/bash-background";
|
|
11
11
|
import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
|
|
12
|
+
import type { SendInput } from "./image-input.ts";
|
|
12
13
|
import type { BackendInfo, SessionBackend } from "./session-backend.ts";
|
|
13
14
|
import type { JSONSchema } from "./tool-descriptor.ts";
|
|
14
15
|
/**
|
|
@@ -179,7 +180,19 @@ export interface CommandRunResult {
|
|
|
179
180
|
readonly content: string;
|
|
180
181
|
readonly isError: boolean;
|
|
181
182
|
}
|
|
183
|
+
/** What `AgentSession.configure` changes on a live session; at least one field, never an empty string. */
|
|
184
|
+
export interface SessionConfigureInput {
|
|
185
|
+
readonly model?: string;
|
|
186
|
+
readonly effort?: string;
|
|
187
|
+
}
|
|
182
188
|
export interface AgentSession {
|
|
189
|
+
/**
|
|
190
|
+
* Sets the model and/or effort on the live session without a new process. Refuses, in order:
|
|
191
|
+
* `AGENT_SESSION_CLOSED`, `AGENT_SESSION_BUSY` (a turn, a command or another configure is in
|
|
192
|
+
* flight), `AGENT_SESSION_INVALID_OPTIONS` (neither field, or an empty string), then
|
|
193
|
+
* `AGENT_SESSION_CAPABILITY_UNSUPPORTED` (the backend cannot). The facade always provides this method.
|
|
194
|
+
*/
|
|
195
|
+
configure?(input: SessionConfigureInput): Promise<void>;
|
|
183
196
|
/** Run through the session's Bash tool without a model turn. The facade always provides this method. */
|
|
184
197
|
runCommand?(command: string, onEvent: (event: SessionEvent) => void): Promise<CommandRunResult>;
|
|
185
198
|
readonly id: string;
|
|
@@ -191,7 +204,9 @@ export interface AgentSession {
|
|
|
191
204
|
readonly status: TurnEndStatus;
|
|
192
205
|
} | undefined;
|
|
193
206
|
/** Claims the session's single turn slot synchronously; the turn starts on the first `next()`. */
|
|
194
|
-
send(message: string): AsyncIterable<SessionEvent>;
|
|
207
|
+
send(message: string, input?: SendInput): AsyncIterable<SessionEvent>;
|
|
208
|
+
/** Whether the current backend/model reads images. The facade always provides this method. */
|
|
209
|
+
acceptsImages?(): Promise<boolean>;
|
|
195
210
|
answer(requestId: string, reply: AnswerReply): AnswerStatus;
|
|
196
211
|
/** Aborts the running turn; a no-op when none runs. */
|
|
197
212
|
cancel(reason?: string): void;
|