@nathapp/nax-agent 0.3.1 → 0.85.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 +18 -0
- package/README.md +34 -1
- package/dist/config/catalog-overrides.d.ts +12 -0
- 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 +6 -0
- package/dist/internal.js +6 -0
- package/dist/mcp/connect.d.ts +3 -0
- package/dist/mcp/connect.js +144 -0
- package/dist/mcp/errors.d.ts +9 -0
- package/dist/mcp/errors.js +16 -0
- package/dist/mcp/index.d.ts +9 -0
- package/dist/mcp/index.js +8 -0
- package/dist/mcp/result-text.d.ts +8 -0
- package/dist/mcp/result-text.js +38 -0
- package/dist/mcp/stdio-close.d.ts +1 -0
- package/dist/mcp/stdio-close.js +31 -0
- package/dist/mcp/transport.d.ts +14 -0
- package/dist/mcp/transport.js +41 -0
- package/dist/mcp/types.d.ts +48 -0
- package/dist/mcp/types.js +1 -0
- package/dist/native/auth.d.ts +2 -0
- package/dist/native/auth.js +11 -1
- package/dist/native/index.d.ts +2 -1
- package/dist/native/index.js +2 -1
- package/dist/native/provider-models.d.ts +13 -0
- package/dist/native/provider-models.js +22 -0
- 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/denial-redirect.js +1 -1
- package/dist/tools/git.js +21 -5
- 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 +7 -2
|
@@ -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
|
/**
|
|
@@ -12,9 +12,11 @@
|
|
|
12
12
|
* The parent is `/tmp/nax`, one shared directory that is easier to find and
|
|
13
13
|
* list than a flat `/tmp/nax-<runId>`. It is shared, so it cannot be created
|
|
14
14
|
* once and trusted: a `/tmp/nax` made by one OS user is not writable by
|
|
15
|
-
* another. The parent is therefore re-resolved on EVERY call from
|
|
16
|
-
* facts the `_sessionTmpDeps` seam exposes
|
|
17
|
-
*
|
|
15
|
+
* another. The parent is therefore re-resolved on EVERY call from four host
|
|
16
|
+
* facts the `_sessionTmpDeps` seam exposes — including the uid that owns it,
|
|
17
|
+
* because a world-writable directory another user created is still theirs to
|
|
18
|
+
* plant symlinks in — with no cache, and anything nax cannot vouch for falls
|
|
19
|
+
* back to a per-user `/tmp/nax-<uid>`.
|
|
18
20
|
*
|
|
19
21
|
* The root is the literal `/tmp` (not `os.tmpdir()`): callers record and assert
|
|
20
22
|
* this machine-stable location, and the OS inherits the rest.
|
|
@@ -38,16 +40,20 @@ export const _sessionTmpDeps = {
|
|
|
38
40
|
uid: () => process.getuid?.() ?? 0,
|
|
39
41
|
};
|
|
40
42
|
/**
|
|
41
|
-
* True when `/tmp/nax` is a real directory the current user can write
|
|
42
|
-
* search. Absence is NOT this case: an absent parent is a usable shared
|
|
43
|
-
* (the launcher creates it), and every other failure means the path is
|
|
44
|
-
* a shape nax cannot vouch for.
|
|
43
|
+
* True when `/tmp/nax` is a real directory the current user OWNS and can write
|
|
44
|
+
* and search. Absence is NOT this case: an absent parent is a usable shared
|
|
45
|
+
* parent (the launcher creates it), and every other failure means the path is
|
|
46
|
+
* there in a shape nax cannot vouch for.
|
|
45
47
|
*/
|
|
46
48
|
function isUsableSharedParent() {
|
|
47
49
|
try {
|
|
48
50
|
const stats = _sessionTmpDeps.lstat(SHARED_TMP_PARENT);
|
|
49
51
|
if (!stats.isDirectory() || stats.isSymbolicLink())
|
|
50
52
|
return false;
|
|
53
|
+
// Writability is not trust: a 0777 /tmp/nax another user created lets that user
|
|
54
|
+
// pre-create the run directories or read what sandboxed commands write there.
|
|
55
|
+
if (stats.uid !== _sessionTmpDeps.uid())
|
|
56
|
+
return false;
|
|
51
57
|
}
|
|
52
58
|
catch (err) {
|
|
53
59
|
// ENOENT is the one failure that means "create /tmp/nax".
|
|
@@ -117,6 +117,8 @@ export type SessionEventBody = {
|
|
|
117
117
|
readonly command?: string;
|
|
118
118
|
readonly reason: string;
|
|
119
119
|
readonly expiresAt: string;
|
|
120
|
+
/** `false`: decided already (by the profile); informational. Absent: a person's answer is awaited. */
|
|
121
|
+
readonly answerable?: false;
|
|
120
122
|
} | {
|
|
121
123
|
readonly type: "approval_resolved";
|
|
122
124
|
readonly requestId: string;
|
|
@@ -127,6 +129,8 @@ export type SessionEventBody = {
|
|
|
127
129
|
readonly requestId: string;
|
|
128
130
|
readonly text: string;
|
|
129
131
|
readonly expiresAt: string;
|
|
132
|
+
/** `false`: informational (`noteQuestion`); `answer()` on it does nothing. Absent: an answer is awaited. */
|
|
133
|
+
readonly answerable?: false;
|
|
130
134
|
} | {
|
|
131
135
|
readonly type: "usage";
|
|
132
136
|
readonly round: number;
|
|
@@ -5,12 +5,16 @@ import type { LoopHandlerSet } from "#src/native/session/loop-events/types";
|
|
|
5
5
|
import type { AgentSessionHostPorts, AgentSessionProfile } from "./agent-session-types.ts";
|
|
6
6
|
export interface NativeBackendOptions {
|
|
7
7
|
readonly model: string;
|
|
8
|
+
/** Repository instruction basename; defaults to AGENTS.md. */
|
|
9
|
+
readonly instructionFileName?: string;
|
|
8
10
|
readonly credentials?: CredentialSource;
|
|
9
11
|
readonly catalogOverrides?: NativeCatalogOverrides;
|
|
10
12
|
readonly loopHandlers?: LoopHandlerSet;
|
|
11
13
|
readonly hostPorts?: AgentSessionHostPorts;
|
|
12
14
|
readonly bashApproval?: BashApprovalMode;
|
|
13
15
|
readonly allowUnsandboxed?: boolean;
|
|
16
|
+
/** Keep a resumed or continued conversation when the model differs (ACP server model switch, S5-3). */
|
|
17
|
+
readonly carryHistoryAcrossModels?: boolean;
|
|
14
18
|
}
|
|
15
19
|
export interface ResolvedNativeOptions {
|
|
16
20
|
/** The caller's object, unchanged. */
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { parseNativeModel } from "#src/native/models";
|
|
9
|
+
import { isInstructionFileName } from "#src/native/session/instruction-file-name";
|
|
9
10
|
import { AgentSessionError } from "./agent-session-errors.js";
|
|
10
11
|
function isRecord(value) {
|
|
11
12
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -16,6 +17,10 @@ function hasMethods(names) {
|
|
|
16
17
|
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
17
18
|
const OptionsSchema = z.strictObject({
|
|
18
19
|
model: z.string().min(1),
|
|
20
|
+
instructionFileName: z
|
|
21
|
+
.string()
|
|
22
|
+
.refine(isInstructionFileName, "must be a nonhidden Markdown basename without path separators, colon or control characters")
|
|
23
|
+
.optional(),
|
|
19
24
|
credentials: z
|
|
20
25
|
.custom((value) => isRecord(value) && (value.kind === "memory" || value.kind === "exec"), "must be a memory or exec source")
|
|
21
26
|
.optional(),
|
|
@@ -31,6 +36,7 @@ const OptionsSchema = z.strictObject({
|
|
|
31
36
|
.optional(),
|
|
32
37
|
bashApproval: z.enum(["raw", "gated", "escalate"]).optional(),
|
|
33
38
|
allowUnsandboxed: z.boolean().optional(),
|
|
39
|
+
carryHistoryAcrossModels: z.boolean().optional(),
|
|
34
40
|
});
|
|
35
41
|
function invalid(message, context = {}) {
|
|
36
42
|
return new AgentSessionError(`Invalid agent session options: ${message}`, "AGENT_SESSION_INVALID_OPTIONS", context);
|
|
@@ -15,7 +15,10 @@ import { createSessionInteractionHandler, embedderToolDescriptor } from "./sessi
|
|
|
15
15
|
import { buildSessionToolSupport, defaultProtectedPaths, resolveSessionLauncher } from "./session-tool-support.js";
|
|
16
16
|
export const NATIVE_BACKEND_KIND = "native";
|
|
17
17
|
/** nax's agent.native.transportRetry default (S3 spec 5.3). */
|
|
18
|
-
const SESSION_TRANSPORT_RETRY = {
|
|
18
|
+
const SESSION_TRANSPORT_RETRY = {
|
|
19
|
+
maxAttempts: 3,
|
|
20
|
+
baseDelayMs: 2000,
|
|
21
|
+
};
|
|
19
22
|
function createAdapter(raw) {
|
|
20
23
|
const overrides = raw.catalogOverrides ?? [];
|
|
21
24
|
const owns = overrides.length > 0 || raw.credentials !== undefined;
|
|
@@ -32,10 +35,13 @@ function checkResumeModel(doc, sessionId, model) {
|
|
|
32
35
|
}
|
|
33
36
|
async function openNative(resolved, ctx) {
|
|
34
37
|
const raw = resolved.raw;
|
|
35
|
-
if (ctx.resume !== undefined)
|
|
38
|
+
if (ctx.resume !== undefined && raw.carryHistoryAcrossModels !== true)
|
|
36
39
|
checkResumeModel(ctx.resume.doc, ctx.sessionId, raw.model);
|
|
37
40
|
const { bashApproval, allowUnsandboxed } = nativeProfileRules(ctx.profile, raw);
|
|
38
|
-
const protectedPaths = {
|
|
41
|
+
const protectedPaths = {
|
|
42
|
+
...defaultProtectedPaths(raw.credentials !== undefined),
|
|
43
|
+
...raw.hostPorts?.protectedPaths,
|
|
44
|
+
};
|
|
39
45
|
const launcher = await resolveSessionLauncher({
|
|
40
46
|
profile: ctx.profile,
|
|
41
47
|
root: ctx.workdir,
|
|
@@ -68,6 +74,8 @@ async function openNative(resolved, ctx) {
|
|
|
68
74
|
const handle = await adapter.openSession(ctx.sessionId, {
|
|
69
75
|
agentName: NATIVE_AGENT,
|
|
70
76
|
workdir: ctx.workdir,
|
|
77
|
+
instructionProtectedPaths: protectedPaths,
|
|
78
|
+
instructionFileName: raw.instructionFileName,
|
|
71
79
|
resolvedPermissions: { mode: "default", toolGrants: grants, bashApproval },
|
|
72
80
|
modelDef: { provider: resolved.provider, model: raw.model },
|
|
73
81
|
timeoutSeconds: ctx.turnTimeoutSeconds,
|
|
@@ -76,6 +84,7 @@ async function openNative(resolved, ctx) {
|
|
|
76
84
|
resume: ctx.resume !== undefined,
|
|
77
85
|
spinBreaker: DEFAULT_SPIN_BREAKER_SETTINGS,
|
|
78
86
|
transportRetry: SESSION_TRANSPORT_RETRY,
|
|
87
|
+
...(raw.carryHistoryAcrossModels === true ? { carryHistoryAcrossModels: true } : {}),
|
|
79
88
|
...(ctx.instructions !== undefined && ctx.instructions !== "" ? { systemPrompt: ctx.instructions } : {}),
|
|
80
89
|
});
|
|
81
90
|
const codingTools = [...support.tools, ...ctx.tools.map(embedderToolDescriptor)];
|
|
@@ -102,5 +111,8 @@ async function openNative(resolved, ctx) {
|
|
|
102
111
|
}
|
|
103
112
|
export function nativeBackend(options) {
|
|
104
113
|
const resolved = parseNativeBackendOptions(options);
|
|
105
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
kind: NATIVE_BACKEND_KIND,
|
|
116
|
+
open: (ctx) => openNative(resolved, ctx),
|
|
117
|
+
};
|
|
106
118
|
}
|
|
@@ -41,6 +41,7 @@ export function createSessionAskPort(deps) {
|
|
|
41
41
|
summary: req.summary,
|
|
42
42
|
reason: req.reason,
|
|
43
43
|
expiresAt: now(),
|
|
44
|
+
answerable: false,
|
|
44
45
|
});
|
|
45
46
|
deps.emit({ type: "approval_resolved", requestId, decision, decidedBy: "profile" });
|
|
46
47
|
},
|
|
@@ -58,7 +59,7 @@ export function createSessionAskPort(deps) {
|
|
|
58
59
|
if (deps.turn() === undefined)
|
|
59
60
|
return;
|
|
60
61
|
const { requestId } = deps.table.issue("question", ALREADY_ABORTED);
|
|
61
|
-
deps.emit({ type: "question", requestId, text, expiresAt: now() });
|
|
62
|
+
deps.emit({ type: "question", requestId, text, expiresAt: now(), answerable: false });
|
|
62
63
|
},
|
|
63
64
|
};
|
|
64
65
|
}
|