@nathapp/nax-agent 0.91.7 → 0.91.8-canary.2
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 +8 -0
- package/dist/config/catalog-overrides.d.ts +13 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/internal/atomic-write.d.ts +22 -7
- package/dist/internal/atomic-write.js +107 -12
- package/dist/internal/pinned-directory-worker.d.ts +52 -0
- package/dist/internal/pinned-directory-worker.js +105 -0
- package/dist/internal/pinned-directory.d.ts +48 -0
- package/dist/internal/pinned-directory.js +168 -0
- package/dist/internal.d.ts +2 -0
- package/dist/internal.js +2 -0
- package/dist/memory/delivery.d.ts +38 -0
- package/dist/memory/delivery.js +28 -0
- package/dist/memory/index-render.d.ts +55 -0
- package/dist/memory/index-render.js +225 -0
- package/dist/memory/index.d.ts +12 -0
- package/dist/memory/index.js +11 -0
- package/dist/memory/local-provider.d.ts +3 -0
- package/dist/memory/local-provider.js +127 -0
- package/dist/memory/mirror-lock.d.ts +109 -0
- package/dist/memory/mirror-lock.js +334 -0
- package/dist/memory/note.d.ts +15 -0
- package/dist/memory/note.js +131 -0
- package/dist/memory/operations.d.ts +123 -0
- package/dist/memory/operations.js +471 -0
- package/dist/memory/outbox.d.ts +35 -0
- package/dist/memory/outbox.js +289 -0
- package/dist/memory/provider-types.d.ts +50 -0
- package/dist/memory/provider-types.js +1 -0
- package/dist/memory/records.d.ts +41 -0
- package/dist/memory/records.js +93 -0
- package/dist/memory/refusal.d.ts +36 -0
- package/dist/memory/refusal.js +25 -0
- package/dist/memory/search.d.ts +17 -0
- package/dist/memory/search.js +112 -0
- package/dist/memory/store.d.ts +106 -0
- package/dist/memory/store.js +459 -0
- package/dist/memory/support.d.ts +63 -0
- package/dist/memory/support.js +98 -0
- package/dist/memory/sync-flight.d.ts +25 -0
- package/dist/memory/sync-flight.js +65 -0
- package/dist/memory/sync.d.ts +82 -0
- package/dist/memory/sync.js +344 -0
- package/dist/memory/types.d.ts +108 -0
- package/dist/memory/types.js +51 -0
- package/dist/memory/validate.d.ts +31 -0
- package/dist/memory/validate.js +104 -0
- package/dist/native/adapter-deps.d.ts +9 -0
- package/dist/native/adapter-deps.js +9 -0
- package/dist/native/models.js +3 -0
- package/dist/native/session/compaction.d.ts +15 -1
- package/dist/native/session/compaction.js +90 -23
- package/dist/native/session/manual-compaction.d.ts +57 -0
- package/dist/native/session/manual-compaction.js +72 -0
- package/dist/native/session/session-summarizer.d.ts +75 -0
- package/dist/native/session/session-summarizer.js +73 -0
- package/dist/native/session/transcript-identity.d.ts +12 -0
- package/dist/native/session/transcript-identity.js +16 -0
- package/dist/native/session/turn-compaction-step.d.ts +30 -2
- package/dist/native/session/turn-compaction-step.js +7 -1
- package/dist/native/session/turn-loop-round-trip.js +71 -17
- package/dist/native/session/turn-loop.js +2 -6
- package/dist/native/session-adapter.d.ts +19 -0
- package/dist/native/session-adapter.js +42 -64
- package/dist/sandbox/srt-backend.js +9 -16
- package/dist/session/agent-session-options.js +1 -0
- package/dist/session/agent-session-turn.d.ts +6 -0
- package/dist/session/agent-session-turn.js +5 -4
- package/dist/session/agent-session-types.d.ts +38 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +163 -20
- package/dist/session/native-backend.js +8 -0
- package/dist/session/session-backend.d.ts +19 -1
- package/dist/session/session-types.d.ts +6 -0
- package/dist/session/steer-channel.d.ts +36 -0
- package/dist/session/steer-channel.js +39 -0
- package/dist/session-store/index.d.ts +1 -1
- package/dist/session-store/index.js +1 -1
- package/dist/session-store/session-lock.d.ts +2 -2
- package/dist/session-store/session-lock.js +4 -2
- package/dist/tools/ask-request.d.ts +8 -0
- package/dist/tools/ask-request.js +34 -1
- package/dist/tools/provider-adapt.js +5 -1
- package/dist/tools/provider-types.d.ts +7 -0
- package/dist/tools/types.d.ts +16 -0
- package/package.json +4 -4
|
@@ -52,7 +52,6 @@ function customConfig(policy) {
|
|
|
52
52
|
export function createSrtBackend(network) {
|
|
53
53
|
let initialized;
|
|
54
54
|
let loaded;
|
|
55
|
-
let inFlight = 0;
|
|
56
55
|
function initialize() {
|
|
57
56
|
initialized ??= (async () => {
|
|
58
57
|
const mod = await _srtBackendDeps.load();
|
|
@@ -75,15 +74,9 @@ export function createSrtBackend(network) {
|
|
|
75
74
|
},
|
|
76
75
|
async wrap(req) {
|
|
77
76
|
const mod = await initialize();
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return argv;
|
|
82
|
-
}
|
|
83
|
-
catch (err) {
|
|
84
|
-
inFlight -= 1;
|
|
85
|
-
throw err;
|
|
86
|
-
}
|
|
77
|
+
// A wrap that throws undoes srt's own count, so it needs no cleanup call.
|
|
78
|
+
const { argv } = await mod.SandboxManager.wrapWithSandboxArgv(req.command, req.shell, customConfig(req.policy), undefined, req.cwd, { commandId: req.commandId });
|
|
79
|
+
return argv;
|
|
87
80
|
},
|
|
88
81
|
annotate(commandId, stderr) {
|
|
89
82
|
if (loaded === undefined)
|
|
@@ -92,18 +85,18 @@ export function createSrtBackend(network) {
|
|
|
92
85
|
return annotated.startsWith(stderr) ? annotated.slice(stderr.length).trim() : "";
|
|
93
86
|
},
|
|
94
87
|
commandFinished() {
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
// Removes bwrap mount placeholders (Linux: `.bashrc`, `.profile`,
|
|
89
|
+
// `.claude/commands`, ...). srt counts every wrap itself and defers the
|
|
90
|
+
// deletion while another wrapped command still runs, so it must hear about
|
|
91
|
+
// EVERY finished command. A second, nax-side gate here leaked srt's count
|
|
92
|
+
// after any overlap and left the placeholders in the user's directory.
|
|
93
|
+
loaded?.SandboxManager.cleanupAfterCommand();
|
|
100
94
|
},
|
|
101
95
|
async reset() {
|
|
102
96
|
if (loaded !== undefined)
|
|
103
97
|
await loaded.SandboxManager.reset();
|
|
104
98
|
initialized = undefined;
|
|
105
99
|
loaded = undefined;
|
|
106
|
-
inFlight = 0;
|
|
107
100
|
},
|
|
108
101
|
};
|
|
109
102
|
}
|
|
@@ -31,6 +31,7 @@ const EmbedderToolSchema = z.object({
|
|
|
31
31
|
run: z.custom(isFunction, "must be a function"),
|
|
32
32
|
});
|
|
33
33
|
const OptionsSchema = z.strictObject({
|
|
34
|
+
openSignal: z.instanceof(AbortSignal).optional(),
|
|
34
35
|
backend: z.custom((value) => isRecord(value) && typeof value.kind === "string" && value.kind.length > 0 && typeof value.open === "function", "must be a SessionBackend (for example nativeBackend({ model }))"),
|
|
35
36
|
sessionId: z
|
|
36
37
|
.string()
|
|
@@ -3,6 +3,7 @@ import type { SessionEvent, SessionEventBody, TurnEndStatus } from "./agent-sess
|
|
|
3
3
|
import type { SendInput } from "./image-input.ts";
|
|
4
4
|
import type { TurnContribution } from "./session-backend.ts";
|
|
5
5
|
import { type AgentSessionAdapter, type SessionHandle, type TurnResult } from "./session-types.ts";
|
|
6
|
+
import type { SteerChannel } from "./steer-channel.ts";
|
|
6
7
|
/** The ask_human budget per turn: nax's agent.maxInteractionTurns default. */
|
|
7
8
|
export declare const ASK_HUMAN_BUDGET = 10;
|
|
8
9
|
export interface TurnRunContext {
|
|
@@ -32,6 +33,11 @@ export interface ClaimTurnHooks extends SendInput {
|
|
|
32
33
|
readonly onStart: (live: LiveTurn) => void;
|
|
33
34
|
/** Runs before turn_end is emitted. `status` is undefined for a voided claim. */
|
|
34
35
|
readonly onSettle: (turnId: string, status: TurnEndStatus | undefined) => void;
|
|
36
|
+
/**
|
|
37
|
+
* US-001: this turn's steer channel, when the backend is steerable. Absent
|
|
38
|
+
* otherwise, so the adapter's `sendTurn` options carry no `steers` key at all.
|
|
39
|
+
*/
|
|
40
|
+
readonly steers?: SteerChannel;
|
|
35
41
|
}
|
|
36
42
|
type TurnEndBody = Extract<SessionEventBody, {
|
|
37
43
|
readonly type: "turn_end";
|
|
@@ -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, input) {
|
|
84
|
+
function sendTurn(ctx, live, input, steers) {
|
|
85
85
|
return ctx.adapter.sendTurn(ctx.handle, input.message, {
|
|
86
86
|
...ctx.turnOpts(),
|
|
87
|
+
...(steers !== undefined ? { steers } : {}),
|
|
87
88
|
...(input.images?.length ? { images: input.images } : {}),
|
|
88
89
|
maxInteractions: ASK_HUMAN_BUDGET,
|
|
89
90
|
turnId: live.turnId,
|
|
@@ -105,14 +106,14 @@ async function markTurn(ctx, turnId, state) {
|
|
|
105
106
|
return { error };
|
|
106
107
|
}
|
|
107
108
|
}
|
|
108
|
-
async function runTurn(ctx, live, input) {
|
|
109
|
+
async function runTurn(ctx, live, input, steers) {
|
|
109
110
|
live.emit({ type: "turn_start" });
|
|
110
111
|
const started = await markTurn(ctx, live.turnId, "running");
|
|
111
112
|
if (started !== undefined)
|
|
112
113
|
return fromError(started.error, live.signal);
|
|
113
114
|
let end;
|
|
114
115
|
try {
|
|
115
|
-
end = turnEndFromResult(await sendTurn(ctx, live, input));
|
|
116
|
+
end = turnEndFromResult(await sendTurn(ctx, live, input, steers));
|
|
116
117
|
}
|
|
117
118
|
catch (err) {
|
|
118
119
|
end = fromError(err, live.signal);
|
|
@@ -168,7 +169,7 @@ export function claimTurn(ctx, message, hooks) {
|
|
|
168
169
|
state = "running";
|
|
169
170
|
const timer = _agentSessionDeps.setTimeout(() => controller.abort(abortReason(TIMED_OUT, "turn deadline")), ctx.turnTimeoutSeconds * 1000);
|
|
170
171
|
hooks.onStart(live);
|
|
171
|
-
void runTurn(ctx, live, { message, images: hooks.images })
|
|
172
|
+
void runTurn(ctx, live, { message, images: hooks.images }, hooks.steers)
|
|
172
173
|
.catch((err) => fromError(err, controller.signal))
|
|
173
174
|
.then((end) => {
|
|
174
175
|
_agentSessionDeps.clearTimeout(timer);
|
|
@@ -53,6 +53,8 @@ export interface AgentSessionHostPorts {
|
|
|
53
53
|
export interface CreateAgentSessionOptions {
|
|
54
54
|
/** The driver: `nativeBackend({ model })`, or an ACP backend. Native-only options live on the backend. */
|
|
55
55
|
readonly backend: SessionBackend;
|
|
56
|
+
/** Cancels creation/resume only while opening; ignored once the backend has opened. */
|
|
57
|
+
readonly openSignal?: AbortSignal;
|
|
56
58
|
/** The store key. Generated when absent. Letters, digits, `.`, `_`, `-`; starts with a letter or digit; at most 128 characters. */
|
|
57
59
|
readonly sessionId?: string;
|
|
58
60
|
readonly profile: AgentSessionProfile;
|
|
@@ -185,6 +187,23 @@ export interface SessionConfigureInput {
|
|
|
185
187
|
readonly model?: string;
|
|
186
188
|
readonly effort?: string;
|
|
187
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* What one manual compaction cost and what it bought (US-002).
|
|
192
|
+
*
|
|
193
|
+
* `tokensBefore`/`tokensAfter` are estimates over the stored history
|
|
194
|
+
* (`estimateContextTokens`), so they are comparable with each other and with the
|
|
195
|
+
* compaction threshold, not with a provider's billed count. `usage` and
|
|
196
|
+
* `costUsd` are the summarizer call's own, unchanged: nothing else was spent.
|
|
197
|
+
* A no-op compaction reports `compacted: false`, equal token counts, zero usage
|
|
198
|
+
* and zero cost — no model call was made, so zero is the honest answer.
|
|
199
|
+
*/
|
|
200
|
+
export interface CompactResult {
|
|
201
|
+
readonly compacted: boolean;
|
|
202
|
+
readonly tokensBefore: number;
|
|
203
|
+
readonly tokensAfter: number;
|
|
204
|
+
readonly usage: TokenUsage;
|
|
205
|
+
readonly costUsd: number;
|
|
206
|
+
}
|
|
188
207
|
export interface AgentSession {
|
|
189
208
|
/**
|
|
190
209
|
* Sets the model and/or effort on the live session without a new process. Refuses, in order:
|
|
@@ -193,8 +212,27 @@ export interface AgentSession {
|
|
|
193
212
|
* `AGENT_SESSION_CAPABILITY_UNSUPPORTED` (the backend cannot). The facade always provides this method.
|
|
194
213
|
*/
|
|
195
214
|
configure?(input: SessionConfigureInput): Promise<void>;
|
|
215
|
+
/**
|
|
216
|
+
* Compacts the stored conversation history on demand (US-002): the finished
|
|
217
|
+
* exploration is replaced by one summary plus the recent window, so the next
|
|
218
|
+
* turn starts small without waiting for the context limit. Refuses, in order:
|
|
219
|
+
* `AGENT_SESSION_CLOSED`, `AGENT_SESSION_BUSY` (a turn, a command, a configure
|
|
220
|
+
* or another compact is in flight), then `AGENT_SESSION_CAPABILITY_UNSUPPORTED`
|
|
221
|
+
* (the backend cannot). Holds the session's single slot while it runs, so
|
|
222
|
+
* `send()` refuses `AGENT_SESSION_BUSY`. The facade always provides this method.
|
|
223
|
+
*/
|
|
224
|
+
compact?(): Promise<CompactResult>;
|
|
196
225
|
/** Run through the session's Bash tool without a model turn. The facade always provides this method. */
|
|
197
226
|
runCommand?(command: string, onEvent: (event: SessionEvent) => void): Promise<CommandRunResult>;
|
|
227
|
+
/**
|
|
228
|
+
* Adds text to the running TURN's pending steers (US-001), injected as a user
|
|
229
|
+
* message at the turn's next safe boundary. Throws `AGENT_SESSION_CLOSED` on a
|
|
230
|
+
* closed session and `AGENT_SESSION_INVALID_OPTIONS` for empty or
|
|
231
|
+
* whitespace-only text; answers `unsupported` for a backend with no mid-turn
|
|
232
|
+
* input, and `no_turn` when no turn claim is active (a `runCommand` or
|
|
233
|
+
* `compact` in flight is not a turn). The facade always provides this method.
|
|
234
|
+
*/
|
|
235
|
+
steer?(text: string): import("./steer-channel.ts").SteerResult;
|
|
198
236
|
readonly id: string;
|
|
199
237
|
/** The backend that opened this session (`kind` and its capability summary). */
|
|
200
238
|
readonly backend: BackendInfo;
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createAgentSession: the conversational session facade (S3 spec 4, 5.1). The
|
|
3
|
+
* facade drives any SessionBackend: it resolves the shared options and the
|
|
4
|
+
* session root, opens the backend, and runs turns through the S1
|
|
5
|
+
* AgentSessionAdapter the backend returns. The native backend lives in
|
|
6
|
+
* native-backend.ts and the ACP backend (S4) satisfies the same seam.
|
|
7
|
+
* resumeAgentSession reopens a stored session (spec 4.2, 6.4).
|
|
8
|
+
*/
|
|
1
9
|
import type { AgentSession, CreateAgentSessionOptions } from "./agent-session-types.ts";
|
|
2
10
|
export declare function createAgentSession(input: CreateAgentSessionOptions): Promise<AgentSession>;
|
|
3
11
|
/**
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createAgentSession: the conversational session facade (S3 spec 4, 5.1). The
|
|
3
|
+
* facade drives any SessionBackend: it resolves the shared options and the
|
|
4
|
+
* session root, opens the backend, and runs turns through the S1
|
|
5
|
+
* AgentSessionAdapter the backend returns. The native backend lives in
|
|
6
|
+
* native-backend.ts and the ACP backend (S4) satisfies the same seam.
|
|
7
|
+
* resumeAgentSession reopens a stored session (spec 4.2, 6.4).
|
|
8
|
+
*/
|
|
9
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
1
10
|
import { claimCommand } from "./agent-session-command.js";
|
|
2
11
|
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
3
12
|
import { AgentSessionError } from "./agent-session-errors.js";
|
|
@@ -7,8 +16,43 @@ import { claimTurn } from "./agent-session-turn.js";
|
|
|
7
16
|
import { validateImageAttachments } from "./image-input.js";
|
|
8
17
|
import { createPendingAskTable } from "./pending-asks.js";
|
|
9
18
|
import { createSessionAskPort } from "./session-ask-port.js";
|
|
19
|
+
import { createSteerQueue } from "./steer-channel.js";
|
|
10
20
|
/** The handler's turn signal between turns: never aborts. */
|
|
11
21
|
const IDLE_SIGNAL = new AbortController().signal;
|
|
22
|
+
/**
|
|
23
|
+
* US-002: claim the session's single slot for one compaction.
|
|
24
|
+
*
|
|
25
|
+
* Same shape as `claimCommand` — the slot is taken synchronously, the work
|
|
26
|
+
* starts on a microtask, and the deadline timer is armed here — with one
|
|
27
|
+
* difference that matters: the signal handed to the backend fans in the
|
|
28
|
+
* session's own close signal as well as this call's deadline, so a `close()`
|
|
29
|
+
* stops a summarizer call instead of leaving it running past the session.
|
|
30
|
+
*
|
|
31
|
+
* The timer is armed BEFORE the work starts (not inside the microtask) because
|
|
32
|
+
* the deadline belongs to the claim: a compaction that has been claimed but not
|
|
33
|
+
* yet dispatched is still bounded by it.
|
|
34
|
+
*/
|
|
35
|
+
function claimCompact(options) {
|
|
36
|
+
const { ctx, closeSignal, run, hooks } = options;
|
|
37
|
+
const turnId = _agentSessionDeps.randomUUID();
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = _agentSessionDeps.setTimeout(() => controller.abort(new NaxError("compaction deadline", "AGENT_SESSION_TURN_TIMEOUT", { stage: "agent-session" })), ctx.turnTimeoutSeconds * 1000);
|
|
40
|
+
const signal = AbortSignal.any([controller.signal, closeSignal]);
|
|
41
|
+
const result = Promise.resolve()
|
|
42
|
+
.then(() => run({ signal }))
|
|
43
|
+
.finally(() => {
|
|
44
|
+
_agentSessionDeps.clearTimeout(timer);
|
|
45
|
+
hooks.onSettle();
|
|
46
|
+
});
|
|
47
|
+
const claim = {
|
|
48
|
+
kind: "compact",
|
|
49
|
+
turnId,
|
|
50
|
+
settled: result.then(() => { }, () => { }),
|
|
51
|
+
cancel: (reason) => controller.abort(reason),
|
|
52
|
+
};
|
|
53
|
+
hooks.onStart(claim);
|
|
54
|
+
return { claim, result };
|
|
55
|
+
}
|
|
12
56
|
/**
|
|
13
57
|
* The sandbox state as the embedder sees it: a frozen COPY, down to the
|
|
14
58
|
* `network` list an `available` state carries. Copying keeps the backend's own
|
|
@@ -78,21 +122,60 @@ class FacadeAgentSession {
|
|
|
78
122
|
capability: "images",
|
|
79
123
|
});
|
|
80
124
|
}
|
|
125
|
+
// US-001: this turn's pending steers. Built only for a backend that can take
|
|
126
|
+
// one mid-turn, so an ACP-backed session allocates nothing and its
|
|
127
|
+
// `sendTurn` options stay identical.
|
|
128
|
+
const steers = this.parts.opened.steerable === true ? createSteerQueue() : undefined;
|
|
81
129
|
const turn = claimTurn(this.parts.ctx, message, {
|
|
82
130
|
images,
|
|
131
|
+
...(steers !== undefined ? { steers } : {}),
|
|
83
132
|
onStart: (live) => {
|
|
84
133
|
this.parts.slot.turn = live;
|
|
85
134
|
},
|
|
86
135
|
onSettle: (turnId, status) => {
|
|
136
|
+
// US-001: before `turn_end` is emitted, so a steer still pending resolves
|
|
137
|
+
// `false` first and can never carry into the next `send()`.
|
|
138
|
+
steers?.close();
|
|
87
139
|
this.active = undefined;
|
|
88
140
|
this.parts.slot.turn = undefined;
|
|
89
141
|
if (status !== undefined)
|
|
90
142
|
this.last = { turnId, status };
|
|
91
143
|
},
|
|
92
144
|
});
|
|
93
|
-
this.active =
|
|
145
|
+
this.active = {
|
|
146
|
+
kind: "turn",
|
|
147
|
+
turnId: turn.turnId,
|
|
148
|
+
settled: turn.settled,
|
|
149
|
+
cancel: turn.cancel,
|
|
150
|
+
...(steers !== undefined ? { steers } : {}),
|
|
151
|
+
};
|
|
94
152
|
return turn.iterable;
|
|
95
153
|
}
|
|
154
|
+
/**
|
|
155
|
+
* US-001: adds text to the running turn's pending steers. The check order is
|
|
156
|
+
* the documented one — the session's own state first, then the text, then the
|
|
157
|
+
* backend's capability, then the claim — so a caller learns what it can fix
|
|
158
|
+
* (its text) before what it cannot (the backend, the claim).
|
|
159
|
+
*/
|
|
160
|
+
steer(text) {
|
|
161
|
+
if (this.closing !== undefined) {
|
|
162
|
+
throw new AgentSessionError(`Session "${this.id}" is closed`, "AGENT_SESSION_CLOSED", { sessionId: this.id });
|
|
163
|
+
}
|
|
164
|
+
if (text.trim() === "") {
|
|
165
|
+
throw new AgentSessionError(`Session "${this.id}" cannot steer empty text`, "AGENT_SESSION_INVALID_OPTIONS", {
|
|
166
|
+
sessionId: this.id,
|
|
167
|
+
path: "steer",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (this.parts.opened.steerable !== true)
|
|
171
|
+
return { status: "unsupported" };
|
|
172
|
+
const active = this.active;
|
|
173
|
+
const steers = active?.kind === "turn" ? active.steers : undefined;
|
|
174
|
+
if (steers === undefined)
|
|
175
|
+
return { status: "no_turn" };
|
|
176
|
+
const { steerId, applied } = steers.add(text);
|
|
177
|
+
return { status: "accepted", steerId, applied };
|
|
178
|
+
}
|
|
96
179
|
async configure(input) {
|
|
97
180
|
const refusal = this.configureRefusal(input);
|
|
98
181
|
if (refusal !== undefined)
|
|
@@ -123,6 +206,44 @@ class FacadeAgentSession {
|
|
|
123
206
|
refusal(code, context = {}) {
|
|
124
207
|
return new AgentSessionError(`Session "${this.id}" cannot be configured`, code, { sessionId: this.id, ...context });
|
|
125
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* US-002: compact the stored history now, holding the session's single slot
|
|
211
|
+
* for the whole call so a `send()` during it refuses BUSY rather than running
|
|
212
|
+
* a turn against a transcript the compaction is rewriting.
|
|
213
|
+
*
|
|
214
|
+
* The order of refusals is the documented one: the session's own state first
|
|
215
|
+
* (closed), then its claim (busy), then the backend's capability (last,
|
|
216
|
+
* because it is the one the caller can do nothing about). The signal the
|
|
217
|
+
* backend receives aborts on this call's own deadline — the session's
|
|
218
|
+
* `turnTimeoutSeconds`, the same budget a turn gets — and on `close()`.
|
|
219
|
+
*/
|
|
220
|
+
async compact() {
|
|
221
|
+
const run = this.parts.opened.compact;
|
|
222
|
+
const code = this.closing !== undefined
|
|
223
|
+
? "AGENT_SESSION_CLOSED"
|
|
224
|
+
: this.active !== undefined || this.configuring
|
|
225
|
+
? "AGENT_SESSION_BUSY"
|
|
226
|
+
: run === undefined
|
|
227
|
+
? "AGENT_SESSION_CAPABILITY_UNSUPPORTED"
|
|
228
|
+
: undefined;
|
|
229
|
+
if (code !== undefined || run === undefined) {
|
|
230
|
+
throw new AgentSessionError(`Session "${this.id}" cannot be compacted`, code ?? "AGENT_SESSION_CAPABILITY_UNSUPPORTED", { sessionId: this.id, capability: "compact" });
|
|
231
|
+
}
|
|
232
|
+
const claim = claimCompact({
|
|
233
|
+
ctx: this.parts.ctx,
|
|
234
|
+
closeSignal: this.parts.closeController.signal,
|
|
235
|
+
run: run.bind(this.parts.opened),
|
|
236
|
+
hooks: {
|
|
237
|
+
onStart: (claimed) => {
|
|
238
|
+
this.active = claimed;
|
|
239
|
+
},
|
|
240
|
+
onSettle: () => {
|
|
241
|
+
this.active = undefined;
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
return claim.result;
|
|
246
|
+
}
|
|
126
247
|
async runCommand(command, onEvent) {
|
|
127
248
|
const code = this.closing !== undefined
|
|
128
249
|
? "AGENT_SESSION_CLOSED"
|
|
@@ -150,7 +271,7 @@ class FacadeAgentSession {
|
|
|
150
271
|
},
|
|
151
272
|
},
|
|
152
273
|
});
|
|
153
|
-
this.active = claim;
|
|
274
|
+
this.active = { kind: "command", turnId: claim.turnId, settled: claim.settled, cancel: claim.cancel };
|
|
154
275
|
return claim.result;
|
|
155
276
|
}
|
|
156
277
|
answer(requestId, reply) {
|
|
@@ -193,28 +314,49 @@ async function sessionRoot(options) {
|
|
|
193
314
|
}
|
|
194
315
|
return { dir: workdir, cleanup: async () => { } };
|
|
195
316
|
}
|
|
317
|
+
/** Refuse cancellation with the session contract, not the caller's arbitrary abort reason. */
|
|
318
|
+
function checkOpenSignal(signal) {
|
|
319
|
+
if (signal?.aborted) {
|
|
320
|
+
throw new AgentSessionError("Agent session open was cancelled", "AGENT_SESSION_CLOSED");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** The caller owns only the open phase; never retain its signal for reconnects. */
|
|
324
|
+
function linkOpenSignal(signal, controller) {
|
|
325
|
+
checkOpenSignal(signal);
|
|
326
|
+
const abort = () => controller.abort();
|
|
327
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
328
|
+
return () => signal?.removeEventListener("abort", abort);
|
|
329
|
+
}
|
|
196
330
|
async function assemble(options, sessionId, root, opening) {
|
|
197
331
|
const raw = options.raw;
|
|
198
332
|
const table = createPendingAskTable(options.approvalTimeoutMs);
|
|
199
333
|
const slot = { turn: undefined };
|
|
200
334
|
const closeController = new AbortController();
|
|
201
335
|
const asks = createSessionAskPort({ table, emit: (body) => slot.turn?.emit(body), turn: () => slot.turn });
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
336
|
+
const unlink = linkOpenSignal(raw.openSignal, closeController);
|
|
337
|
+
let opened;
|
|
338
|
+
try {
|
|
339
|
+
opened = await raw.backend.open({
|
|
340
|
+
sessionId,
|
|
341
|
+
workdir: root.dir,
|
|
342
|
+
profile: raw.profile,
|
|
343
|
+
instructions: raw.instructions,
|
|
344
|
+
tools: options.tools,
|
|
345
|
+
transcriptStore: raw.transcriptStore,
|
|
346
|
+
resume: opening.doc === undefined ? undefined : { doc: opening.doc },
|
|
347
|
+
asks,
|
|
348
|
+
turnSignal: () => slot.turn?.signal ?? IDLE_SIGNAL,
|
|
349
|
+
currentTurnId: () => slot.turn?.turnId,
|
|
350
|
+
turnTimeoutSeconds: options.turnTimeoutSeconds,
|
|
351
|
+
metadata: options.metadata,
|
|
352
|
+
openSignal: closeController.signal,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
finally {
|
|
356
|
+
unlink();
|
|
357
|
+
}
|
|
217
358
|
try {
|
|
359
|
+
checkOpenSignal(closeController.signal);
|
|
218
360
|
const ctx = {
|
|
219
361
|
sessionId,
|
|
220
362
|
adapter: opened.adapter,
|
|
@@ -227,9 +369,8 @@ async function assemble(options, sessionId, root, opening) {
|
|
|
227
369
|
return new FacadeAgentSession({ ctx, table, slot, opened, closeController, cleanup: root.cleanup }, opening.lastTurn);
|
|
228
370
|
}
|
|
229
371
|
catch (err) {
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
// failure propagate untouched.
|
|
372
|
+
// Includes a backend that resolved despite cancellation. Close exactly once;
|
|
373
|
+
// open() removes the root and preserves the original failure.
|
|
233
374
|
await opened.close().catch(() => undefined);
|
|
234
375
|
throw err;
|
|
235
376
|
}
|
|
@@ -247,6 +388,7 @@ async function open(options, sessionId, opening) {
|
|
|
247
388
|
}
|
|
248
389
|
export async function createAgentSession(input) {
|
|
249
390
|
const options = resolveAgentSessionOptions(input);
|
|
391
|
+
checkOpenSignal(options.raw.openSignal);
|
|
250
392
|
const sessionId = options.raw.sessionId ?? _agentSessionDeps.randomUUID();
|
|
251
393
|
if ((await options.raw.transcriptStore.load(sessionId)) !== null) {
|
|
252
394
|
throw new AgentSessionError(`Session "${sessionId}" already exists in the transcript store; resume it instead`, "AGENT_SESSION_EXISTS", { sessionId });
|
|
@@ -274,6 +416,7 @@ async function endInterruptedTurn(session, store, sessionId, turnId) {
|
|
|
274
416
|
*/
|
|
275
417
|
export async function resumeAgentSession(sessionId, input) {
|
|
276
418
|
const options = resolveAgentSessionOptions(resumeInput(sessionId, input));
|
|
419
|
+
checkOpenSignal(options.raw.openSignal);
|
|
277
420
|
const store = options.raw.transcriptStore;
|
|
278
421
|
const doc = await loadResumable(store, sessionId);
|
|
279
422
|
checkBackendKind(doc, sessionId, options.raw.backend.kind);
|
|
@@ -151,6 +151,14 @@ async function openNative(resolved, ctx) {
|
|
|
151
151
|
}
|
|
152
152
|
},
|
|
153
153
|
info: { kind: NATIVE_BACKEND_KIND, capabilities: {}, sandbox },
|
|
154
|
+
// US-001: nax owns the conversation here, so a turn can take a steer at its
|
|
155
|
+
// next round boundary. The ACP backend declares nothing — its protocol has
|
|
156
|
+
// no mid-turn user input.
|
|
157
|
+
steerable: true,
|
|
158
|
+
// US-002: the history is nax's own transcript, so it can be compacted without
|
|
159
|
+
// a turn. The ACP backend implements neither this nor `steerable`: the agent
|
|
160
|
+
// on the other side of the protocol owns the conversation.
|
|
161
|
+
compact: (request) => adapter.compactHistory(handle, request),
|
|
154
162
|
turnOpts: () => ({
|
|
155
163
|
interactionHandler,
|
|
156
164
|
codingTools,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { TranscriptDoc, TranscriptStore } from "#src/native/session/transcript-types";
|
|
9
9
|
import type { SandboxState } from "#src/sandbox/types";
|
|
10
|
-
import type { AgentSessionProfile, ApprovalDecidedBy, CommandRunResult, EmbedderTool, SessionConfigureInput } from "./agent-session-types.ts";
|
|
10
|
+
import type { AgentSessionProfile, ApprovalDecidedBy, CommandRunResult, CompactResult, EmbedderTool, SessionConfigureInput } from "./agent-session-types.ts";
|
|
11
11
|
import type { AgentSessionAdapter, SendTurnOpts, SessionHandle } from "./session-types.ts";
|
|
12
12
|
export interface BackendInfo {
|
|
13
13
|
/** "native" or "acp:<agent>"; recorded in the transcript document. */
|
|
@@ -99,6 +99,24 @@ export interface OpenedBackend {
|
|
|
99
99
|
callId: string;
|
|
100
100
|
signal: AbortSignal;
|
|
101
101
|
}): Promise<CommandRunResult>;
|
|
102
|
+
/**
|
|
103
|
+
* US-002: compact the session's stored history now. Absent on a backend that
|
|
104
|
+
* has no history of its own to compact (ACP keeps the conversation), which is
|
|
105
|
+
* what `AgentSession.compact()` reports as
|
|
106
|
+
* `AGENT_SESSION_CAPABILITY_UNSUPPORTED`. The signal aborts on the session's
|
|
107
|
+
* turn deadline or on close; a summarizer failure rejects, and the stored
|
|
108
|
+
* history is then unchanged.
|
|
109
|
+
*/
|
|
110
|
+
compact?(request: {
|
|
111
|
+
signal: AbortSignal;
|
|
112
|
+
}): Promise<CompactResult>;
|
|
113
|
+
/**
|
|
114
|
+
* US-001: this backend can take a steer mid-turn, so the facade builds each
|
|
115
|
+
* turn a `SteerChannel` and `steer()` may answer `accepted`. Absent on a
|
|
116
|
+
* backend with no mid-turn user input (ACP): `steer()` then answers
|
|
117
|
+
* `unsupported` without ever looking at the claim.
|
|
118
|
+
*/
|
|
119
|
+
readonly steerable?: true;
|
|
102
120
|
readonly adapter: AgentSessionAdapter;
|
|
103
121
|
readonly handle: SessionHandle;
|
|
104
122
|
readonly info: BackendInfo;
|
|
@@ -288,6 +288,12 @@ export interface SendTurnOpts {
|
|
|
288
288
|
* nax sets none.
|
|
289
289
|
*/
|
|
290
290
|
onTurnEvent?: import("./turn-event.ts").TurnEventSink;
|
|
291
|
+
/**
|
|
292
|
+
* Native: the facade's pending steers for THIS turn (US-001), drained by the
|
|
293
|
+
* loop at a round boundary or at turn end. Set only when the backend declares
|
|
294
|
+
* `steerable`; ACP has no mid-turn user input and never sees it.
|
|
295
|
+
*/
|
|
296
|
+
steers?: import("./steer-channel.ts").SteerChannel;
|
|
291
297
|
}
|
|
292
298
|
/**
|
|
293
299
|
* A single mid-turn interactive Q&A exchange between the agent and a human
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `AgentSession.steer` answers (US-001).
|
|
3
|
+
*
|
|
4
|
+
* `accepted` is not "injected": the text is pending until the turn reaches a
|
|
5
|
+
* boundary. `applied` resolves `true` when the loop took the text, and `false`
|
|
6
|
+
* when the turn ended with it still pending — a cancelled, stopped, timed-out
|
|
7
|
+
* or failed turn, or a turn that finished before the steer arrived.
|
|
8
|
+
*/
|
|
9
|
+
export type SteerResult = {
|
|
10
|
+
readonly status: "accepted";
|
|
11
|
+
readonly steerId: string;
|
|
12
|
+
readonly applied: Promise<boolean>;
|
|
13
|
+
} | {
|
|
14
|
+
readonly status: "unsupported";
|
|
15
|
+
} | {
|
|
16
|
+
readonly status: "no_turn";
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* What a turn's `sendTurn` options carry (US-001). The loop reads it; nothing
|
|
20
|
+
* in the loop writes to it.
|
|
21
|
+
*/
|
|
22
|
+
export interface SteerChannel {
|
|
23
|
+
/** Removes every pending steer, in arrival order. `[]` once the channel closed. */
|
|
24
|
+
take(): readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/** The facade's side of a turn's channel. */
|
|
27
|
+
export interface SteerQueue extends SteerChannel {
|
|
28
|
+
/** Appends one text, in arrival order. */
|
|
29
|
+
add(text: string): {
|
|
30
|
+
readonly steerId: string;
|
|
31
|
+
readonly applied: Promise<boolean>;
|
|
32
|
+
};
|
|
33
|
+
/** Closes the channel: everything still pending resolves `false` and is discarded. */
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
export declare function createSteerQueue(): SteerQueue;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-turn steering (US-001). The facade owns each session's pending steers:
|
|
3
|
+
* `steer()` appends to the ACTIVE turn claim's queue, the native turn loop
|
|
4
|
+
* drains it through `take()` at a safe boundary, and the queue closes when the
|
|
5
|
+
* claim settles so a steer can never carry into the next `send()`.
|
|
6
|
+
*
|
|
7
|
+
* Ordering is arrival order and every pending text is handed over on one
|
|
8
|
+
* `take()`: the loop injects them as consecutive user messages, so splitting a
|
|
9
|
+
* drain across calls would interleave model output between them.
|
|
10
|
+
*
|
|
11
|
+
* The queue is deliberately transport-neutral: ACP has no mid-turn user input,
|
|
12
|
+
* so its backend never declares `steerable` and never receives a channel.
|
|
13
|
+
*/
|
|
14
|
+
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
15
|
+
export function createSteerQueue() {
|
|
16
|
+
let pending = [];
|
|
17
|
+
let closed = false;
|
|
18
|
+
/** Hand over everything pending, resolving each entry with `applied`. */
|
|
19
|
+
function drain(applied) {
|
|
20
|
+
const taken = pending;
|
|
21
|
+
pending = [];
|
|
22
|
+
for (const steer of taken)
|
|
23
|
+
steer.resolve(applied);
|
|
24
|
+
return taken.map((steer) => steer.text);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
add(text) {
|
|
28
|
+
const steerId = _agentSessionDeps.randomUUID();
|
|
29
|
+
const settled = Promise.withResolvers();
|
|
30
|
+
pending = [...pending, { text, resolve: settled.resolve, applied: settled.promise }];
|
|
31
|
+
return { steerId, applied: settled.promise };
|
|
32
|
+
},
|
|
33
|
+
take: () => (closed ? [] : drain(true)),
|
|
34
|
+
close: () => {
|
|
35
|
+
closed = true;
|
|
36
|
+
drain(false);
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.ts";
|
|
1
|
+
export { acquireSessionLock, LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.ts";
|
|
2
2
|
export { defaultSessionsDir, SESSION_ID_PATTERN, SESSION_META_MODES, type SessionMeta, type SessionMetaMode, } from "./session-meta.ts";
|
|
3
3
|
export { createSessionStorage, LIST_PAGE_SIZE, processAlive, type SessionListEntry, type SessionListPage, type SessionLockHolder, type SessionLockHolderState, type SessionStorage, type SessionStorageDeps, } from "./storage.ts";
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.js";
|
|
1
|
+
export { acquireSessionLock, LOCK_HEARTBEAT_MS, LOCK_STALE_MS } from "./session-lock.js";
|
|
2
2
|
export { defaultSessionsDir, SESSION_ID_PATTERN, SESSION_META_MODES, } from "./session-meta.js";
|
|
3
3
|
export { createSessionStorage, LIST_PAGE_SIZE, processAlive, } from "./storage.js";
|
|
@@ -20,8 +20,8 @@ declare const LockSchema: z.ZodUnion<readonly [z.ZodObject<{
|
|
|
20
20
|
}, z.core.$strip>, z.ZodObject<{
|
|
21
21
|
pid: z.ZodNumber;
|
|
22
22
|
startedAt: z.ZodString;
|
|
23
|
-
nonce: z.ZodUndefined
|
|
24
|
-
heartbeatAt: z.ZodUndefined
|
|
23
|
+
nonce: z.ZodOptional<z.ZodUndefined>;
|
|
24
|
+
heartbeatAt: z.ZodOptional<z.ZodUndefined>;
|
|
25
25
|
}, z.core.$strip>]>;
|
|
26
26
|
type Body = z.infer<typeof LockSchema>;
|
|
27
27
|
export declare const _lockDeps: {
|
|
@@ -17,8 +17,10 @@ const LockSchema = z.union([
|
|
|
17
17
|
z.object({
|
|
18
18
|
pid: z.number().int().positive(),
|
|
19
19
|
startedAt: z.string(),
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
// A legacy lock OMITS these keys. zod >=4.6 treats a bare `z.undefined()` key as
|
|
21
|
+
// required-present, so `.optional()` is what keeps an absent key parsing.
|
|
22
|
+
nonce: z.undefined().optional(),
|
|
23
|
+
heartbeatAt: z.undefined().optional(),
|
|
22
24
|
}),
|
|
23
25
|
]);
|
|
24
26
|
export const _lockDeps = { rename: renameSync, link: linkSync, remove: rmSync };
|
|
@@ -23,6 +23,14 @@ export interface AskSummary {
|
|
|
23
23
|
* a Write, a commit message, whatever a provider tool takes -- and an
|
|
24
24
|
* AskResolver is by definition an outbound channel to a human. A summary is
|
|
25
25
|
* what approval needs; the payload is what the audit sink already holds.
|
|
26
|
+
*
|
|
27
|
+
* Two kinds of declared field, and only these: the parts that say WHAT the
|
|
28
|
+
* call will do (command, argv, verb, paths), and the summary-only fields a tool
|
|
29
|
+
* adds for legibility (`ToolScope.summaryFields`) -- a memory tool's `slug`,
|
|
30
|
+
* so the person reads which note is about to be written instead of the same
|
|
31
|
+
* line the tool always rendered. Neither kind is grant-checked here, and
|
|
32
|
+
* neither is ever widened into the payload: a summary field renders only a
|
|
33
|
+
* string, number or boolean.
|
|
26
34
|
*/
|
|
27
35
|
export declare function askSummary(tool: string, scope: ToolScope, input: Record<string, unknown>): AskSummary;
|
|
28
36
|
/**
|