@nathapp/nax-agent 0.3.1 → 0.84.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/CHANGELOG.md +12 -0
- package/README.md +7 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/internal/atomic-write.d.ts +16 -0
- package/dist/internal/atomic-write.js +34 -0
- package/dist/internal/command-spec/index.js +1 -1
- package/dist/internal/git-env.d.ts +4 -3
- package/dist/internal/git-env.js +11 -4
- package/dist/internal.d.ts +5 -0
- package/dist/internal.js +5 -0
- package/dist/native/auth.d.ts +2 -0
- package/dist/native/auth.js +11 -1
- package/dist/native/index.d.ts +1 -1
- package/dist/native/index.js +1 -1
- package/dist/native/session/handle-invalid-tool-call.js +1 -0
- package/dist/native/session/instruction-file-name.d.ts +4 -0
- package/dist/native/session/instruction-file-name.js +21 -0
- package/dist/native/session/instruction-interaction.d.ts +7 -0
- package/dist/native/session/instruction-interaction.js +22 -0
- package/dist/native/session/instruction-render.d.ts +6 -0
- package/dist/native/session/instruction-render.js +22 -0
- package/dist/native/session/instruction-session.d.ts +4 -0
- package/dist/native/session/instruction-session.js +31 -0
- package/dist/native/session/instruction-transcript.d.ts +4 -0
- package/dist/native/session/instruction-transcript.js +14 -0
- package/dist/native/session/repository-instructions.d.ts +35 -0
- package/dist/native/session/repository-instructions.js +155 -0
- package/dist/native/session/session.d.ts +8 -0
- package/dist/native/session/session.js +29 -3
- package/dist/native/session/transcript-identity.d.ts +2 -0
- package/dist/native/session/transcript-identity.js +1 -1
- package/dist/native/session/transcript-store.js +4 -2
- package/dist/native/session/transcript-types.d.ts +3 -0
- package/dist/native/session/turn-complete-step.js +4 -2
- package/dist/native/session/turn-event-emitter.d.ts +4 -0
- package/dist/native/session/turn-event-emitter.js +6 -4
- package/dist/native/session/turn-loop-round-trip.js +20 -1
- package/dist/native/session/turn-loop.js +1 -0
- package/dist/native/session-adapter.js +21 -6
- package/dist/permissions/approvals-store.d.ts +3 -1
- package/dist/permissions/approvals-store.js +18 -5
- package/dist/sandbox/session-tmp.js +13 -7
- package/dist/session/agent-session-types.d.ts +4 -0
- package/dist/session/native-backend-options.d.ts +4 -0
- package/dist/session/native-backend-options.js +6 -0
- package/dist/session/native-backend.js +16 -4
- package/dist/session/session-ask-port.js +2 -1
- package/dist/session/session-interaction.js +29 -13
- package/dist/session/session-types.d.ts +10 -0
- package/dist/terminal-auth/index.d.ts +4 -0
- package/dist/terminal-auth/index.js +4 -0
- package/dist/terminal-auth/interaction.d.ts +15 -0
- package/dist/terminal-auth/interaction.js +55 -0
- package/dist/terminal-auth/open-url.d.ts +17 -0
- package/dist/terminal-auth/open-url.js +44 -0
- package/dist/terminal-auth/prompt.d.ts +51 -0
- package/dist/terminal-auth/prompt.js +196 -0
- package/dist/tools/instruction-access.d.ts +13 -0
- package/dist/tools/instruction-access.js +30 -0
- package/dist/tools/runtime.js +6 -1
- package/package.json +2 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/** Bounded, repository-local instruction discovery. Each instance belongs to one session. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { realpath } from "node:fs/promises";
|
|
4
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { readPrefix } from "#src/internal/bounded-io";
|
|
6
|
+
import { credentialReadRefusal } from "#src/tools/credential-read-deny";
|
|
7
|
+
import { matchesDenyPaths } from "#src/tools/deny-paths";
|
|
8
|
+
import { DEFAULT_INSTRUCTION_FILE_NAME, instructionFileNameFor } from "./instruction-file-name.js";
|
|
9
|
+
import { MIN_INSTRUCTION_NOTICE_BYTES, renderRepositoryInstructions } from "./instruction-render.js";
|
|
10
|
+
export const _repositoryInstructionDeps = { realpath, readPrefix };
|
|
11
|
+
const MAX_FILE_BYTES = 32_768;
|
|
12
|
+
const MAX_TOTAL_BYTES = 131_072;
|
|
13
|
+
const MAX_FILES = 64;
|
|
14
|
+
const MAX_DEPTH = 8;
|
|
15
|
+
function contained(root, path) {
|
|
16
|
+
const rel = relative(root, path);
|
|
17
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
18
|
+
}
|
|
19
|
+
function safeMarkdown(root, path) {
|
|
20
|
+
const rel = relative(root, path);
|
|
21
|
+
return (contained(root, path) &&
|
|
22
|
+
/\.md$/i.test(path) &&
|
|
23
|
+
!rel.split(sep).some((part) => part.startsWith(".") || /(?:credential|secret|trust[-_]?store)/i.test(part)));
|
|
24
|
+
}
|
|
25
|
+
export class RepositoryInstructions {
|
|
26
|
+
workdir;
|
|
27
|
+
protectedPaths;
|
|
28
|
+
denyPaths;
|
|
29
|
+
loaded = new Map();
|
|
30
|
+
visited = new Set();
|
|
31
|
+
scopes = new Set();
|
|
32
|
+
bytes = 0;
|
|
33
|
+
root;
|
|
34
|
+
limited = false;
|
|
35
|
+
pending = Promise.resolve();
|
|
36
|
+
renderMaxBytes = Number.POSITIVE_INFINITY;
|
|
37
|
+
fileNames;
|
|
38
|
+
constructor(workdir, protectedPaths, denyPaths, fileName) {
|
|
39
|
+
this.workdir = workdir;
|
|
40
|
+
this.protectedPaths = protectedPaths;
|
|
41
|
+
this.denyPaths = denyPaths;
|
|
42
|
+
const selected = instructionFileNameFor(fileName);
|
|
43
|
+
this.fileNames =
|
|
44
|
+
selected === DEFAULT_INSTRUCTION_FILE_NAME
|
|
45
|
+
? ["AGENTS.override.md", DEFAULT_INSTRUCTION_FILE_NAME, "CLAUDE.md"]
|
|
46
|
+
: [selected];
|
|
47
|
+
}
|
|
48
|
+
get directories() {
|
|
49
|
+
return [...this.scopes];
|
|
50
|
+
}
|
|
51
|
+
get sources() {
|
|
52
|
+
return [...this.loaded.values()].map(({ path, scope, hash }) => ({
|
|
53
|
+
path,
|
|
54
|
+
scope,
|
|
55
|
+
hash,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
/** Bytes conservatively upper-bound token count; host instructions consume the same system reserve. */
|
|
59
|
+
setContextBudget(contextWindow, hostPrompt) {
|
|
60
|
+
const reserve = Math.floor((contextWindow ?? 16_384) / 4);
|
|
61
|
+
const hostBytes = Buffer.byteLength(hostPrompt ?? "", "utf8") + 2;
|
|
62
|
+
// The caller owns its host prompt. When that already consumes the reserve,
|
|
63
|
+
// retain only the explicit omission notice rather than silently losing guides.
|
|
64
|
+
this.renderMaxBytes = Math.max(MIN_INSTRUCTION_NOTICE_BYTES, reserve - hostBytes);
|
|
65
|
+
}
|
|
66
|
+
render() {
|
|
67
|
+
return renderRepositoryInstructions([...this.loaded.values()], this.limited, this.renderMaxBytes);
|
|
68
|
+
}
|
|
69
|
+
discover(directory, canRead = () => true, protectedPaths = this.protectedPaths) {
|
|
70
|
+
const next = this.pending.then(() => this.discoverScope(directory, canRead, protectedPaths));
|
|
71
|
+
this.pending = next.catch(() => { });
|
|
72
|
+
return next;
|
|
73
|
+
}
|
|
74
|
+
async discoverScope(directory, canRead, protectedPaths) {
|
|
75
|
+
this.root ??= await _repositoryInstructionDeps.realpath(this.workdir).catch(() => resolve(this.workdir));
|
|
76
|
+
const lexical = resolve(this.workdir, directory);
|
|
77
|
+
if (!contained(resolve(this.workdir), lexical) && !contained(this.root, lexical))
|
|
78
|
+
return;
|
|
79
|
+
const target = await _repositoryInstructionDeps.realpath(lexical).catch(() => lexical);
|
|
80
|
+
if (!contained(this.root, target))
|
|
81
|
+
return;
|
|
82
|
+
const rel = relative(this.root, target);
|
|
83
|
+
if (rel.split(sep).some((part) => part.startsWith(".") && part !== ""))
|
|
84
|
+
return;
|
|
85
|
+
const parts = rel === "" ? [] : rel.split(sep);
|
|
86
|
+
if (parts.length > 32) {
|
|
87
|
+
this.limited = true;
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.scopes.add(rel || ".");
|
|
91
|
+
let dir = this.root;
|
|
92
|
+
for (const part of ["", ...parts]) {
|
|
93
|
+
if (part !== "")
|
|
94
|
+
dir = resolve(dir, part);
|
|
95
|
+
for (const name of this.fileNames) {
|
|
96
|
+
const found = await this.load(resolve(dir, name), relative(this.root, dir) || ".", 0, canRead, protectedPaths);
|
|
97
|
+
if (found)
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async load(path, scope, depth, canRead, protectedPaths) {
|
|
103
|
+
if (this.root === undefined)
|
|
104
|
+
return false;
|
|
105
|
+
if (depth > MAX_DEPTH || this.visited.size >= MAX_FILES || this.bytes >= MAX_TOTAL_BYTES) {
|
|
106
|
+
this.limited = true;
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (!safeMarkdown(this.root, path) ||
|
|
110
|
+
credentialReadRefusal(protectedPaths, path) !== undefined ||
|
|
111
|
+
credentialReadRefusal(this.protectedPaths, path) !== undefined ||
|
|
112
|
+
!canRead(path) ||
|
|
113
|
+
matchesDenyPaths(relative(this.root, path).split(sep).join("/"), this.denyPaths))
|
|
114
|
+
return false;
|
|
115
|
+
const canonical = await _repositoryInstructionDeps.realpath(path).catch(() => undefined);
|
|
116
|
+
if (canonical === undefined ||
|
|
117
|
+
!safeMarkdown(this.root, canonical) ||
|
|
118
|
+
credentialReadRefusal(protectedPaths, canonical) !== undefined ||
|
|
119
|
+
credentialReadRefusal(this.protectedPaths, canonical) !== undefined ||
|
|
120
|
+
!canRead(canonical) ||
|
|
121
|
+
matchesDenyPaths(relative(this.root, canonical).split(sep).join("/"), this.denyPaths))
|
|
122
|
+
return false;
|
|
123
|
+
if (this.visited.has(canonical))
|
|
124
|
+
return true;
|
|
125
|
+
this.visited.add(canonical);
|
|
126
|
+
const text = await _repositoryInstructionDeps
|
|
127
|
+
.readPrefix(canonical, Math.min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - this.bytes))
|
|
128
|
+
.catch(() => undefined);
|
|
129
|
+
if (text === undefined)
|
|
130
|
+
return false;
|
|
131
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
132
|
+
if (bytes > MAX_FILE_BYTES || this.bytes + bytes > MAX_TOTAL_BYTES) {
|
|
133
|
+
this.limited = true;
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
this.bytes += bytes;
|
|
137
|
+
if (text.trim() === "")
|
|
138
|
+
return true;
|
|
139
|
+
const hash = createHash("sha256").update(text).digest("hex");
|
|
140
|
+
this.loaded.set(canonical, {
|
|
141
|
+
path: relative(this.root, canonical),
|
|
142
|
+
scope,
|
|
143
|
+
hash,
|
|
144
|
+
text,
|
|
145
|
+
});
|
|
146
|
+
// Claude-style @path imports: local markdown only, never URLs or home paths.
|
|
147
|
+
for (const match of text.matchAll(/(?:^|\s)@([^\s`<>]+\.md)(?=\s|$)/gm)) {
|
|
148
|
+
const imported = match[1];
|
|
149
|
+
if (imported === undefined || isAbsolute(imported) || imported.startsWith("~") || imported.includes(":"))
|
|
150
|
+
continue;
|
|
151
|
+
await this.load(resolve(dirname(canonical), imported), scope, depth + 1, canRead, protectedPaths);
|
|
152
|
+
}
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -10,6 +10,7 @@ import { type SpinBreaker } from "#src/infra/spin-breaker/index";
|
|
|
10
10
|
import type { AgentStreamEvent } from "#src/session/agent-stream-events";
|
|
11
11
|
import type { OpenSessionOpts, SessionHandle } from "#src/session/session-types";
|
|
12
12
|
import type { ResolvedCompaction } from "./compaction.ts";
|
|
13
|
+
import type { RepositoryInstructions } from "./repository-instructions.ts";
|
|
13
14
|
import type { TranscriptStore } from "./transcript-types.ts";
|
|
14
15
|
import type { TurnRetryConfig } from "./turn-retry.ts";
|
|
15
16
|
/** The runtime hooks handed to openSession. */
|
|
@@ -96,6 +97,12 @@ export interface NativeSessionState {
|
|
|
96
97
|
* maps above: set on open, cleared on close.
|
|
97
98
|
*/
|
|
98
99
|
readonly transcriptOwners: Map<string, string>;
|
|
100
|
+
/**
|
|
101
|
+
* Sessions that keep history written by another model (S5-3), set at open from
|
|
102
|
+
* `OpenSessionOpts.carryHistoryAcrossModels`. Same lifecycle as the maps above:
|
|
103
|
+
* set on open, cleared on close.
|
|
104
|
+
*/
|
|
105
|
+
readonly carryHistoryAcrossModels: Set<string>;
|
|
99
106
|
/** Session name -> resolved compaction settings. Same lifecycle as the maps above. */
|
|
100
107
|
readonly compaction: Map<string, ResolvedCompaction>;
|
|
101
108
|
/**
|
|
@@ -130,6 +137,7 @@ export interface NativeSessionState {
|
|
|
130
137
|
* lifecycle as the maps above: set on open, cleared on close.
|
|
131
138
|
*/
|
|
132
139
|
readonly systemPrompts: Map<string, string>;
|
|
140
|
+
readonly repositoryInstructions: Map<string, RepositoryInstructions>;
|
|
133
141
|
}
|
|
134
142
|
export declare function createNativeSessionState(): NativeSessionState;
|
|
135
143
|
export interface SessionAnchor {
|
|
@@ -10,6 +10,9 @@ import { NaxError } from "#src/infra/index";
|
|
|
10
10
|
import { createSpinBreaker } from "#src/infra/spin-breaker/index";
|
|
11
11
|
import { NATIVE_AGENT } from "../models.js";
|
|
12
12
|
import { nativeSessionId } from "../session-affinity.js";
|
|
13
|
+
import { instructionFileNameFor } from "./instruction-file-name.js";
|
|
14
|
+
import { openRepositoryInstructions } from "./instruction-session.js";
|
|
15
|
+
import { instructionTranscriptStore } from "./instruction-transcript.js";
|
|
13
16
|
import { createFileTranscriptStore } from "./transcript-store.js";
|
|
14
17
|
export function createNativeSessionState() {
|
|
15
18
|
return {
|
|
@@ -20,11 +23,13 @@ export function createNativeSessionState() {
|
|
|
20
23
|
streamHooks: new Map(),
|
|
21
24
|
failed: new Set(),
|
|
22
25
|
transcriptOwners: new Map(),
|
|
26
|
+
carryHistoryAcrossModels: new Set(),
|
|
23
27
|
compaction: new Map(),
|
|
24
28
|
transportRetry: new Map(),
|
|
25
29
|
spinBreakers: new Map(),
|
|
26
30
|
lastUsage: new Map(),
|
|
27
31
|
systemPrompts: new Map(),
|
|
32
|
+
repositoryInstructions: new Map(),
|
|
28
33
|
};
|
|
29
34
|
}
|
|
30
35
|
/**
|
|
@@ -86,23 +91,39 @@ function recordSystemPrompt(state, name, systemPrompt) {
|
|
|
86
91
|
}
|
|
87
92
|
/** The request field for the session's system prompt: `{ system }`, or `{}` when it has none. */
|
|
88
93
|
export function systemFieldFor(state, sessionName) {
|
|
89
|
-
const
|
|
94
|
+
const base = state.systemPrompts.get(sessionName);
|
|
95
|
+
const repository = state.repositoryInstructions.get(sessionName)?.render();
|
|
96
|
+
const system = [base, repository].filter(Boolean).join("\n\n") || undefined;
|
|
90
97
|
return system === undefined ? {} : { system };
|
|
91
98
|
}
|
|
92
99
|
export async function openNativeSession(state, name, opts) {
|
|
100
|
+
instructionFileNameFor(opts.instructionFileName);
|
|
93
101
|
const store = openTranscriptStore(name, opts);
|
|
94
102
|
if (opts.transcriptDir)
|
|
95
103
|
state.transcriptDirs.set(name, opts.transcriptDir);
|
|
96
104
|
else
|
|
97
105
|
state.transcriptDirs.delete(name);
|
|
98
|
-
state.transcripts.set(name, {
|
|
106
|
+
state.transcripts.set(name, {
|
|
107
|
+
store,
|
|
108
|
+
retainOnClose: opts.retainOnClose === true,
|
|
109
|
+
});
|
|
99
110
|
state.timeouts.set(name, opts.timeoutSeconds);
|
|
100
111
|
state.scratchpadRoots.set(name, opts.workdir);
|
|
101
112
|
if (opts.transcriptOwner !== undefined)
|
|
102
113
|
state.transcriptOwners.set(name, opts.transcriptOwner);
|
|
103
114
|
else
|
|
104
115
|
state.transcriptOwners.delete(name);
|
|
116
|
+
if (opts.carryHistoryAcrossModels === true)
|
|
117
|
+
state.carryHistoryAcrossModels.add(name);
|
|
118
|
+
else
|
|
119
|
+
state.carryHistoryAcrossModels.delete(name);
|
|
105
120
|
recordSystemPrompt(state, name, opts.systemPrompt);
|
|
121
|
+
const instructions = await openRepositoryInstructions(name, opts, store);
|
|
122
|
+
state.repositoryInstructions.set(name, instructions);
|
|
123
|
+
state.transcripts.set(name, {
|
|
124
|
+
store: instructionTranscriptStore(store, instructions),
|
|
125
|
+
retainOnClose: opts.retainOnClose === true,
|
|
126
|
+
});
|
|
106
127
|
// `resume` is SessionManager's "this name already has a descriptor in this
|
|
107
128
|
// process" signal, and it had no consumer on this transport (nax#1877) — a
|
|
108
129
|
// native session resumed whatever transcript happened to be on disk. Honouring
|
|
@@ -129,7 +150,10 @@ export async function openNativeSession(state, name, opts) {
|
|
|
129
150
|
// native has no reconnect, so its logical and physical identity genuinely
|
|
130
151
|
// coincide. `nativeSessionId` is a pure hash of the name and deliberately
|
|
131
152
|
// not memoised, so this is exactly the id `sendTurn` later puts on the wire.
|
|
132
|
-
protocolIds: {
|
|
153
|
+
protocolIds: {
|
|
154
|
+
recordId: nativeSessionId(name),
|
|
155
|
+
sessionId: nativeSessionId(name),
|
|
156
|
+
},
|
|
133
157
|
...(opts.modelDef !== undefined ? { modelDef: opts.modelDef } : {}),
|
|
134
158
|
...(opts.modelTier !== undefined ? { modelTier: opts.modelTier } : {}),
|
|
135
159
|
};
|
|
@@ -150,6 +174,7 @@ export function clearNativeSessionState(state, sessionName) {
|
|
|
150
174
|
state.timeouts.delete(sessionName);
|
|
151
175
|
state.scratchpadRoots.delete(sessionName);
|
|
152
176
|
state.transcriptOwners.delete(sessionName);
|
|
177
|
+
state.carryHistoryAcrossModels.delete(sessionName);
|
|
153
178
|
state.streamHooks.delete(sessionName);
|
|
154
179
|
state.failed.delete(sessionName);
|
|
155
180
|
state.compaction.delete(sessionName);
|
|
@@ -157,6 +182,7 @@ export function clearNativeSessionState(state, sessionName) {
|
|
|
157
182
|
state.spinBreakers.delete(sessionName);
|
|
158
183
|
state.lastUsage.delete(sessionName);
|
|
159
184
|
state.systemPrompts.delete(sessionName);
|
|
185
|
+
state.repositoryInstructions.delete(sessionName);
|
|
160
186
|
}
|
|
161
187
|
/**
|
|
162
188
|
* Kept on failure, deleted on success. Every Phase B op is lifetime "fresh", so
|
|
@@ -14,6 +14,8 @@ import type { TranscriptDoc } from "./transcript-types.ts";
|
|
|
14
14
|
export interface TranscriptIdentity {
|
|
15
15
|
readonly owner?: string;
|
|
16
16
|
readonly model?: string;
|
|
17
|
+
/** Keep a document written by another model (S5-3); `model` still names the current one. */
|
|
18
|
+
readonly carryAcrossModels?: boolean;
|
|
17
19
|
}
|
|
18
20
|
/**
|
|
19
21
|
* The model half of `TranscriptIdentity`: the native `provider/model` id with
|
|
@@ -35,7 +35,7 @@ function isForeignTranscript(doc, identity, sessionName) {
|
|
|
35
35
|
});
|
|
36
36
|
return true;
|
|
37
37
|
}
|
|
38
|
-
if (model !== undefined && doc.model !== undefined && doc.model !== model) {
|
|
38
|
+
if (identity.carryAcrossModels !== true && model !== undefined && doc.model !== undefined && doc.model !== model) {
|
|
39
39
|
getLogger().debug("native-session", "Ignoring a transcript written by another model", {
|
|
40
40
|
sessionName,
|
|
41
41
|
storedModel: doc.model,
|
|
@@ -6,9 +6,10 @@
|
|
|
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 { mkdir, readdir, readFile, rename, rm, stat
|
|
9
|
+
import { mkdir, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { getLogger, NaxError } from "#src/infra/index";
|
|
12
|
+
import { writeFileAtomic } from "#src/internal/atomic-write";
|
|
12
13
|
import { historyFromTranscript, transcriptDocFor } from "./transcript-identity.js";
|
|
13
14
|
export { transcriptModelIdentity } from "./transcript-identity.js";
|
|
14
15
|
export function transcriptPath(dir, sessionName) {
|
|
@@ -60,7 +61,8 @@ export async function readTranscriptDoc(dir, sessionName) {
|
|
|
60
61
|
}
|
|
61
62
|
async function writeTranscriptDoc(dir, sessionName, doc) {
|
|
62
63
|
await mkdir(dir, { recursive: true });
|
|
63
|
-
|
|
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));
|
|
64
66
|
}
|
|
65
67
|
/**
|
|
66
68
|
* The file store: `<dir>/<name>.transcript.json`, written as given (the loop's
|
|
@@ -39,6 +39,9 @@ export interface TranscriptDoc {
|
|
|
39
39
|
* (S3 spec 5.5 write order: markTurn(running), turn, markTurn(ended)).
|
|
40
40
|
*/
|
|
41
41
|
readonly turn?: TurnMarker;
|
|
42
|
+
/** Native instruction discovery audit, independent of compacted message history. */
|
|
43
|
+
readonly instructionSources?: readonly import("./repository-instructions.ts").InstructionSource[];
|
|
44
|
+
readonly instructionDirectories?: readonly string[];
|
|
42
45
|
}
|
|
43
46
|
export interface TranscriptStore {
|
|
44
47
|
/** `null` when the session has no document yet. */
|
|
@@ -65,8 +65,10 @@ export async function completeWithRecovery(args) {
|
|
|
65
65
|
// caller's array, the one the turn-end transcript save persists — is returned untouched,
|
|
66
66
|
// so the transcript stays the true record. `boundary` is this step's
|
|
67
67
|
// overflow fact (`compacted`): true only on the post-compaction retry,
|
|
68
|
-
// where a prefix rewrite is free. A model change
|
|
69
|
-
// the transcript store
|
|
68
|
+
// where a prefix rewrite is free. A model change CAN reach this step under
|
|
69
|
+
// carryHistoryAcrossModels (S5-3): the transcript store no longer refuses
|
|
70
|
+
// another model's history, but the per-model compaction anchor is dropped
|
|
71
|
+
// on that mismatch, so applyHistoryPatch honours an undefined anchor.
|
|
70
72
|
const transformed = await loopEvents.dispatch("transform_context", {
|
|
71
73
|
messages: msgs,
|
|
72
74
|
tools,
|
|
@@ -45,4 +45,8 @@ export interface TurnEventEmitter {
|
|
|
45
45
|
flushUnanswered(): void;
|
|
46
46
|
}
|
|
47
47
|
export declare function usageEvent(round: number, usage: TokenUsage, costUsd: number): TurnEvent;
|
|
48
|
+
/** The masking and cap live `tool_call.input` gets; exported for ACP replay (S5-1). */
|
|
49
|
+
export declare function displayToolInput(input: unknown): unknown;
|
|
50
|
+
/** The masking and cap live `tool_result.preview` gets; exported for ACP replay (S5-1). */
|
|
51
|
+
export declare function toolResultPreview(content: string): string;
|
|
48
52
|
export declare function createTurnEventEmitter(sink: TurnEventSink | undefined): TurnEventEmitter;
|
|
@@ -43,7 +43,8 @@ export function usageEvent(round, usage, costUsd) {
|
|
|
43
43
|
costUsd,
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
|
-
|
|
46
|
+
/** The masking and cap live `tool_call.input` gets; exported for ACP replay (S5-1). */
|
|
47
|
+
export function displayToolInput(input) {
|
|
47
48
|
const redacted = redactSecrets(capStrings(input, REDACTION_SCAN_BYTES));
|
|
48
49
|
let json;
|
|
49
50
|
try {
|
|
@@ -56,7 +57,8 @@ function cappedInput(input) {
|
|
|
56
57
|
return redacted;
|
|
57
58
|
return { truncated: true, preview: cutToByteCap(json, TOOL_CALL_INPUT_BYTES) };
|
|
58
59
|
}
|
|
59
|
-
|
|
60
|
+
/** The masking and cap live `tool_result.preview` gets; exported for ACP replay (S5-1). */
|
|
61
|
+
export function toolResultPreview(content) {
|
|
60
62
|
return cutToByteCap(redactSecrets(cutToByteCap(content, REDACTION_SCAN_BYTES)), TOOL_RESULT_PREVIEW_BYTES);
|
|
61
63
|
}
|
|
62
64
|
const NOOP_EMITTER = {
|
|
@@ -103,7 +105,7 @@ export function createTurnEventEmitter(sink) {
|
|
|
103
105
|
// Capped BEFORE the count moves: a throwing input (a hostile getter or
|
|
104
106
|
// proxy) must not leave an outstanding call the matching toolResult then
|
|
105
107
|
// answers with no preceding tool_call.
|
|
106
|
-
const input =
|
|
108
|
+
const input = displayToolInput(recordedInput ?? call.input);
|
|
107
109
|
outstanding.set(call.id, (outstanding.get(call.id) ?? 0) + 1);
|
|
108
110
|
emit({ type: "tool_call", callId: call.id, name: call.name, input });
|
|
109
111
|
},
|
|
@@ -119,7 +121,7 @@ export function createTurnEventEmitter(sink) {
|
|
|
119
121
|
type: "tool_result",
|
|
120
122
|
callId: result.toolCallId,
|
|
121
123
|
isError: result.isError === true,
|
|
122
|
-
preview:
|
|
124
|
+
preview: toolResultPreview(result.content),
|
|
123
125
|
});
|
|
124
126
|
},
|
|
125
127
|
flushUnanswered() {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
*/
|
|
34
34
|
import { inputClassTokens } from "#src/cost/core/index";
|
|
35
35
|
import { getSafeLogger } from "#src/infra/index";
|
|
36
|
+
import { parseModelSpec } from "../models.js";
|
|
36
37
|
import { estimateContextTokens, shouldCompact, } from "./compaction.js";
|
|
37
38
|
import { usageBeat } from "./turn-accumulator.js";
|
|
38
39
|
import { runProactiveCompaction } from "./turn-compaction-step.js";
|
|
@@ -96,6 +97,21 @@ async function maybeCompact(state, params) {
|
|
|
96
97
|
}
|
|
97
98
|
return { summarizeFailed: step.summarizeFailed };
|
|
98
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The catalog provider and model that answer for this handle: the
|
|
102
|
+
* "provider/model[effort]" string, effort stripped, split on the first "/".
|
|
103
|
+
* The same pair parseNativeModel hands client.model(); undefined, never a
|
|
104
|
+
* throw, when there is no usable model.
|
|
105
|
+
*/
|
|
106
|
+
function originOf(raw) {
|
|
107
|
+
if (raw === undefined)
|
|
108
|
+
return undefined;
|
|
109
|
+
const spec = parseModelSpec(raw).model;
|
|
110
|
+
const slash = spec.indexOf("/");
|
|
111
|
+
if (slash <= 0 || slash === spec.length - 1)
|
|
112
|
+
return undefined;
|
|
113
|
+
return { provider: spec.slice(0, slash), model: spec.slice(slash + 1) };
|
|
114
|
+
}
|
|
99
115
|
/**
|
|
100
116
|
* One model call and its bookkeeping: `completeWithRecovery`, the usage/anchor
|
|
101
117
|
* updates, the activity beats, `after_response`, and pushing the settled
|
|
@@ -140,7 +156,6 @@ async function runModelRoundTrip(state, params, summarizeFailed) {
|
|
|
140
156
|
}
|
|
141
157
|
state.roundTrips += 1;
|
|
142
158
|
params.usage.add(res.usage, res.costUsd, res.rates);
|
|
143
|
-
state.output = res.text;
|
|
144
159
|
// nax#1852: the anchor is the whole prompt the provider charged for, not
|
|
145
160
|
// just its uncached portion. Under prompt caching (which the round trip
|
|
146
161
|
// above always requests) the cached prefix arrives in the cache fields,
|
|
@@ -184,15 +199,19 @@ async function runModelRoundTrip(state, params, summarizeFailed) {
|
|
|
184
199
|
// what it records: answering the ORIGINAL calls while recording patched
|
|
185
200
|
// ones would desync the transcript from what actually executed.
|
|
186
201
|
const assistantText = afterResponse.text ?? res.text;
|
|
202
|
+
// The recorded answer IS the patched one: transcript and TurnResult.output must agree (turn-event.ts).
|
|
203
|
+
state.output = assistantText;
|
|
187
204
|
const assistantToolCalls = afterResponse.toolCalls ?? res.toolCalls;
|
|
188
205
|
const assistantThinking = afterResponse.thinking ?? res.thinking;
|
|
189
206
|
// Thinking blocks are appended, not merely representable: Anthropic needs
|
|
190
207
|
// the exact block back to continue a thinking conversation (ADR-028 s8).
|
|
208
|
+
const origin = originOf(handle.modelDef?.model);
|
|
191
209
|
state.messages.push({
|
|
192
210
|
role: "assistant",
|
|
193
211
|
content: assistantText,
|
|
194
212
|
...(assistantToolCalls !== undefined ? { toolCalls: assistantToolCalls } : {}),
|
|
195
213
|
...(assistantThinking !== undefined ? { thinking: assistantThinking } : {}),
|
|
214
|
+
...(origin !== undefined ? { origin } : {}),
|
|
196
215
|
});
|
|
197
216
|
if (assistantToolCalls === undefined || assistantToolCalls.length === 0) {
|
|
198
217
|
state.completedNormally = true;
|
|
@@ -65,6 +65,7 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
|
|
|
65
65
|
const transcriptIdentity = {
|
|
66
66
|
owner: deps.sessionState.transcriptOwners.get(handle.id),
|
|
67
67
|
model: transcriptModelIdentity(handle.modelDef?.model),
|
|
68
|
+
...(deps.sessionState.carryHistoryAcrossModels.has(handle.id) ? { carryAcrossModels: true } : {}),
|
|
68
69
|
};
|
|
69
70
|
let messages = [
|
|
70
71
|
...historyFromTranscript(await store.load(handle.id), transcriptIdentity, handle.id),
|
|
@@ -16,6 +16,7 @@ import { _clientDeps, getNativeClient } from "./client.js";
|
|
|
16
16
|
import { createSessionCredentialStore } from "./credentials/session-source.js";
|
|
17
17
|
import { toAdapterFailure } from "./errors.js";
|
|
18
18
|
import { buildRateCard, NATIVE_AGENT, parseNativeModel, resolveContextWindow, toThinkingLevel } from "./models.js";
|
|
19
|
+
import { createInstructionInteraction } from "./session/instruction-interaction.js";
|
|
19
20
|
import { closeNativeSession, createNativeSessionState, markNativeTurnOutcome, openNativeSession, systemFieldFor, } from "./session/session.js";
|
|
20
21
|
import { buildNativeStreamEvent } from "./session/turn-events.js";
|
|
21
22
|
import { runNativeTurn } from "./session/turn-loop.js";
|
|
@@ -140,6 +141,10 @@ export class NativeSessionAdapter {
|
|
|
140
141
|
const thinking = toThinkingLevel(effort);
|
|
141
142
|
const client = await this.client();
|
|
142
143
|
const resolved = await client.model(provider, model);
|
|
144
|
+
const contextWindow = resolveContextWindow(handle.modelDef?.contextWindow, resolved.contextWindow);
|
|
145
|
+
this.state.repositoryInstructions
|
|
146
|
+
.get(handle.id)
|
|
147
|
+
?.setContextBudget(contextWindow, this.state.systemPrompts.get(handle.id));
|
|
143
148
|
const catalog = client.pricing(resolved);
|
|
144
149
|
const { rates, source: pricingSource } = buildRateCard(catalog, handle.modelDef?.pricing);
|
|
145
150
|
const storedTimeoutSeconds = this.state.timeouts.get(handle.id);
|
|
@@ -155,7 +160,7 @@ export class NativeSessionAdapter {
|
|
|
155
160
|
const sessionId = nativeSessionId(handle.id);
|
|
156
161
|
// S3-4: the session's system prompt (facade `instructions`), or `{}`.
|
|
157
162
|
// Resolved here so the `complete` closure spreads it without a branch.
|
|
158
|
-
const
|
|
163
|
+
const instructionInteraction = createInstructionInteraction(this.state.repositoryInstructions.get(handle.id), opts.interactionHandler);
|
|
159
164
|
// One budget for the whole turn, not one per round-trip. Created here
|
|
160
165
|
// because this is where `timeoutSeconds` is known; consulted by the loop.
|
|
161
166
|
const deadline = createTurnDeadline(timeoutSeconds);
|
|
@@ -204,6 +209,7 @@ export class NativeSessionAdapter {
|
|
|
204
209
|
if (opts.signal !== undefined)
|
|
205
210
|
turnSignals.unshift(opts.signal);
|
|
206
211
|
const turnSignal = AbortSignal.any(turnSignals);
|
|
212
|
+
const turnOpts = { ...opts, interactionHandler: instructionInteraction.handler };
|
|
207
213
|
let result;
|
|
208
214
|
try {
|
|
209
215
|
// US-002: clear the deadline timer at turn settlement, no matter how
|
|
@@ -211,10 +217,10 @@ export class NativeSessionAdapter {
|
|
|
211
217
|
// `AbortSignal.any` above holds a reference to `deadlineController.signal`,
|
|
212
218
|
// so a settled turn must drop its timer to avoid keeping it armed past
|
|
213
219
|
// the turn boundary.
|
|
214
|
-
result = await runNativeTurn(handle, prompt,
|
|
220
|
+
result = await runNativeTurn(handle, prompt, turnOpts, {
|
|
215
221
|
sessionState: this.state,
|
|
216
222
|
deadline,
|
|
217
|
-
contextWindow
|
|
223
|
+
contextWindow,
|
|
218
224
|
...(this.state.compaction.get(handle.id) !== undefined
|
|
219
225
|
? { compaction: this.state.compaction.get(handle.id) }
|
|
220
226
|
: {}),
|
|
@@ -267,7 +273,12 @@ export class NativeSessionAdapter {
|
|
|
267
273
|
});
|
|
268
274
|
const summaryUsage = res.usage;
|
|
269
275
|
const { costUsd, resolvedRates } = priceCall(summaryUsage, rates);
|
|
270
|
-
return {
|
|
276
|
+
return {
|
|
277
|
+
text: res.text,
|
|
278
|
+
usage: summaryUsage,
|
|
279
|
+
costUsd,
|
|
280
|
+
rates: resolvedRates,
|
|
281
|
+
};
|
|
271
282
|
}
|
|
272
283
|
finally {
|
|
273
284
|
if (timer !== undefined)
|
|
@@ -293,11 +304,12 @@ export class NativeSessionAdapter {
|
|
|
293
304
|
// thinking LEVEL rather than a boolean, so normalize at this boundary.
|
|
294
305
|
const requestThinking = requestOptions?.thinking === false ? undefined : thinking;
|
|
295
306
|
try {
|
|
307
|
+
instructionInteraction.acknowledge();
|
|
296
308
|
const res = await streamComplete(client, resolved, {
|
|
297
309
|
messages,
|
|
298
310
|
...(tools.length > 0 ? { tools } : {}),
|
|
299
311
|
sessionId,
|
|
300
|
-
...
|
|
312
|
+
...systemFieldFor(this.state, handle.id),
|
|
301
313
|
signal,
|
|
302
314
|
...(requestThinking !== undefined ? { thinking: requestThinking } : {}),
|
|
303
315
|
...(requestOptions?.temperature !== undefined ? { temperature: requestOptions.temperature } : {}),
|
|
@@ -408,6 +420,9 @@ export class NativeSessionAdapter {
|
|
|
408
420
|
* both carry, so clear by name rather than synthesising a handle.
|
|
409
421
|
*/
|
|
410
422
|
async closePhysicalSession(handle, _workdir, _options) {
|
|
411
|
-
return closeNativeSession(this.state, {
|
|
423
|
+
return closeNativeSession(this.state, {
|
|
424
|
+
id: handle,
|
|
425
|
+
agentName: NATIVE_AGENT,
|
|
426
|
+
});
|
|
412
427
|
}
|
|
413
428
|
}
|
|
@@ -85,7 +85,7 @@ export declare function readApprovalsFileDetailed(path: string): Promise<Approva
|
|
|
85
85
|
*/
|
|
86
86
|
export declare function readApprovalsFile(path: string): Promise<ApprovalsFile>;
|
|
87
87
|
export declare function readApprovals(path: string): Promise<readonly ApprovalEntry[]>;
|
|
88
|
-
/** Serialize the whole store. Callers hold the path lock. */
|
|
88
|
+
/** Serialize the whole store, atomically (#16): a torn store would lose every remembered approval. Callers hold the path lock. */
|
|
89
89
|
export declare function writeApprovalsFile(path: string, file: ApprovalsFile): Promise<void>;
|
|
90
90
|
/**
|
|
91
91
|
* Lock the read-modify-write. Worktree-isolated parallel stories share one
|
|
@@ -96,6 +96,8 @@ export declare function writeApprovalsFile(path: string, file: ApprovalsFile): P
|
|
|
96
96
|
*
|
|
97
97
|
* The taint marker is PRESERVED: a "remember" tapped during a forge-capable run
|
|
98
98
|
* lands in a store that stays untrusted.
|
|
99
|
+
*
|
|
100
|
+
* An unparseable store is refused, never replaced (the removal path does the same).
|
|
99
101
|
*/
|
|
100
102
|
export declare function appendApproval(path: string, entry: ApprovalEntry): Promise<void>;
|
|
101
103
|
/**
|
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
* primitive that surface calls into.
|
|
20
20
|
*/
|
|
21
21
|
import { createHash } from "node:crypto";
|
|
22
|
-
import { mkdir, readFile, stat
|
|
22
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
23
23
|
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
24
|
+
import { NaxError } from "#src/infra/index";
|
|
25
|
+
import { writeFileAtomic } from "#src/internal/atomic-write";
|
|
24
26
|
import { withPathFileLock } from "#src/internal/path-file-lock";
|
|
25
27
|
const EMPTY_FILE = { entries: [], taint: undefined };
|
|
26
28
|
/**
|
|
@@ -146,11 +148,11 @@ export async function readApprovalsFile(path) {
|
|
|
146
148
|
export async function readApprovals(path) {
|
|
147
149
|
return (await readApprovalsFile(path)).entries;
|
|
148
150
|
}
|
|
149
|
-
/** Serialize the whole store. Callers hold the path lock. */
|
|
151
|
+
/** Serialize the whole store, atomically (#16): a torn store would lose every remembered approval. Callers hold the path lock. */
|
|
150
152
|
export async function writeApprovalsFile(path, file) {
|
|
151
153
|
const body = file.taint === undefined ? { entries: file.entries } : { taint: file.taint, entries: file.entries };
|
|
152
154
|
await mkdir(dirname(path), { recursive: true });
|
|
153
|
-
await
|
|
155
|
+
await writeFileAtomic(path, `${JSON.stringify(body, null, 2)}\n`);
|
|
154
156
|
}
|
|
155
157
|
/**
|
|
156
158
|
* Lock the read-modify-write. Worktree-isolated parallel stories share one
|
|
@@ -161,12 +163,23 @@ export async function writeApprovalsFile(path, file) {
|
|
|
161
163
|
*
|
|
162
164
|
* The taint marker is PRESERVED: a "remember" tapped during a forge-capable run
|
|
163
165
|
* lands in a store that stays untrusted.
|
|
166
|
+
*
|
|
167
|
+
* An unparseable store is refused, never replaced (the removal path does the same).
|
|
164
168
|
*/
|
|
165
169
|
export async function appendApproval(path, entry) {
|
|
166
170
|
await mkdir(dirname(path), { recursive: true });
|
|
167
171
|
await withPathFileLock(path, async () => {
|
|
168
|
-
|
|
169
|
-
|
|
172
|
+
// Detailed read, not readApprovalsFile: that one reads failure AS empty (right for a
|
|
173
|
+
// lookup), and writing "empty + this entry" back would destroy the store (#16). A read
|
|
174
|
+
// error propagates; the caller logs it and still allows the approved call.
|
|
175
|
+
const read = await readApprovalsFileDetailed(path);
|
|
176
|
+
if (read.state === "unparseable") {
|
|
177
|
+
throw new NaxError("approvals.json could not be parsed; not rewriting it", "APPROVALS_STORE_UNPARSEABLE", {
|
|
178
|
+
stage: "permissions",
|
|
179
|
+
path,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
await writeApprovalsFile(path, { taint: read.file.taint, entries: [...read.file.entries, entry] });
|
|
170
183
|
});
|
|
171
184
|
}
|
|
172
185
|
/**
|