@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/config/catalog-overrides.d.ts +2 -0
  3. package/dist/cost/standard-types.d.ts +1 -1
  4. package/dist/index.d.ts +3 -1
  5. package/dist/index.js +1 -0
  6. package/dist/internal/atomic-write.d.ts +2 -2
  7. package/dist/internal/git-exec.d.ts +1 -1
  8. package/dist/internal/git-exec.js +2 -2
  9. package/dist/internal.d.ts +1 -0
  10. package/dist/internal.js +1 -0
  11. package/dist/native/models.js +1 -0
  12. package/dist/native/session/compaction.js +9 -1
  13. package/dist/native/session/image-context.d.ts +5 -0
  14. package/dist/native/session/image-context.js +46 -0
  15. package/dist/native/session/loop-events/external-handler.d.ts +27 -6
  16. package/dist/native/session/loop-events/external-handler.js +30 -10
  17. package/dist/native/session/loop-events/registry.js +1 -1
  18. package/dist/native/session/loop-events/types.d.ts +33 -0
  19. package/dist/native/session/transcript-images.d.ts +20 -0
  20. package/dist/native/session/transcript-images.js +247 -0
  21. package/dist/native/session/transcript-store.d.ts +6 -0
  22. package/dist/native/session/transcript-store.js +39 -6
  23. package/dist/native/session/turn-compaction-step.js +2 -1
  24. package/dist/native/session/turn-complete-step.js +4 -1
  25. package/dist/native/session/turn-loop.js +83 -4
  26. package/dist/native/session-adapter.d.ts +2 -0
  27. package/dist/native/session-adapter.js +14 -0
  28. package/dist/session/agent-session-errors.d.ts +1 -1
  29. package/dist/session/agent-session-turn.d.ts +2 -1
  30. package/dist/session/agent-session-turn.js +6 -5
  31. package/dist/session/agent-session-types.d.ts +16 -1
  32. package/dist/session/agent-session.js +58 -2
  33. package/dist/session/image-input.d.ts +15 -0
  34. package/dist/session/image-input.js +46 -0
  35. package/dist/session/native-backend.js +1 -0
  36. package/dist/session/session-backend.d.ts +5 -1
  37. package/dist/session/session-types.d.ts +3 -1
  38. 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
- // Atomic (#3): a torn transcript is unrecoverable — every later load throws TRANSCRIPT_CORRUPT.
65
- await writeFileAtomic(transcriptPath(dir, sessionName), JSON.stringify(doc, null, 2));
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(transcriptPath(dir, sessionName), join(dir, `${sessionName}.transcript.failed-${stamp}.json`));
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 rm(transcriptPath(dir, sessionName), { force: true });
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) => rm(f.path, { force: true })));
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
- return deps.complete(wire.messages, tools, options, turnEvents.deltaSink(roundTrip));
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 beforeTurnSeed(patch, prompt) {
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 [{ role: "user", content: prompt }];
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 [{ role: "user", content: prompt }];
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(turnStart.seed, prompt));
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, message) {
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, message) {
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, message));
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;