@nathapp/nax-agent 0.2.0 → 0.3.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +22 -13
  3. package/dist/index.d.ts +13 -4
  4. package/dist/index.js +9 -2
  5. package/dist/infra/stderr-tail.d.ts +8 -0
  6. package/dist/infra/stderr-tail.js +48 -0
  7. package/dist/native/index.d.ts +1 -1
  8. package/dist/native/session/transcript-types.d.ts +16 -0
  9. package/dist/permissions/ask-chain.d.ts +1 -1
  10. package/dist/permissions/ask.d.ts +6 -0
  11. package/dist/permissions/ask.js +6 -0
  12. package/dist/permissions/index.d.ts +1 -1
  13. package/dist/permissions/index.js +1 -1
  14. package/dist/session/agent-session-errors.d.ts +1 -1
  15. package/dist/session/agent-session-options.d.ts +0 -4
  16. package/dist/session/agent-session-options.js +7 -48
  17. package/dist/session/agent-session-resume.d.ts +3 -1
  18. package/dist/session/agent-session-resume.js +10 -8
  19. package/dist/session/agent-session-turn.d.ts +3 -7
  20. package/dist/session/agent-session-turn.js +2 -4
  21. package/dist/session/agent-session-types.d.ts +22 -22
  22. package/dist/session/agent-session.js +51 -90
  23. package/dist/session/native-backend-options.d.ts +24 -0
  24. package/dist/session/native-backend-options.js +81 -0
  25. package/dist/session/native-backend.d.ts +4 -0
  26. package/dist/session/native-backend.js +106 -0
  27. package/dist/session/session-ask-link.d.ts +12 -5
  28. package/dist/session/session-ask-link.js +3 -2
  29. package/dist/session/session-ask-port.d.ts +12 -0
  30. package/dist/session/session-ask-port.js +64 -0
  31. package/dist/session/session-backend.d.ts +79 -0
  32. package/dist/session/session-backend.js +1 -0
  33. package/dist/session/session-interaction.d.ts +2 -2
  34. package/dist/session/session-interaction.js +3 -6
  35. package/dist/session/session-tool-support.d.ts +5 -5
  36. package/dist/session/session-tool-support.js +12 -9
  37. package/dist/session/session-types.d.ts +2 -0
  38. package/dist/session/turn-event.d.ts +5 -0
  39. package/dist/tools/ask-request.js +3 -1
  40. package/package.json +1 -1
@@ -1,19 +1,19 @@
1
1
  /**
2
2
  * Public types of the agent session facade (S3 spec section 4). The facade
3
3
  * drives the S1 AgentSessionAdapter; nothing here is backend-specific, so the
4
- * acpx backend (S4) reuses these types unchanged.
4
+ * @nathapp/nax-agent-acp backend (S4) reuses these types unchanged.
5
5
  */
6
6
  import type { CommandInterceptor } from "#src/command-interceptor/index";
7
- import type { BashApprovalMode } from "#src/config/bash-approval";
8
7
  import type { TokenUsage } from "#src/cost/standard-types";
9
- import type { NativeCatalogOverrides } from "#src/native/client";
10
- import type { CredentialSource } from "#src/native/credentials/session-source";
11
- import type { LoopHandlerSet } from "#src/native/session/loop-events/types";
12
8
  import type { TranscriptStore } from "#src/native/session/transcript-types";
13
9
  import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
10
+ import type { BackendInfo, SessionBackend } from "./session-backend.ts";
14
11
  import type { JSONSchema } from "./tool-descriptor.ts";
15
- /** What the session's agent may touch. Capability statements, not tool lists (spec 4.5). */
16
- export type AgentSessionProfile = "none" | "read" | "full";
12
+ /**
13
+ * What the session's agent may touch. Capability statements, not tool lists
14
+ * (spec 4.5). `ask`: every mutating action is approved through `answer()`.
15
+ */
16
+ export type AgentSessionProfile = "none" | "read" | "ask" | "full";
17
17
  export interface EmbedderToolContext {
18
18
  readonly sessionId: string;
19
19
  /** The provider's tool-call id: the same id as the `tool_call` / `tool_result` events. */
@@ -45,11 +45,10 @@ export interface AgentSessionHostPorts {
45
45
  readonly commandInterceptor?: CommandInterceptor;
46
46
  }
47
47
  export interface CreateAgentSessionOptions {
48
- readonly backend: "native";
48
+ /** The driver: `nativeBackend({ model })`, or an ACP backend. Native-only options live on the backend. */
49
+ readonly backend: SessionBackend;
49
50
  /** The store key. Generated when absent. Letters, digits, `.`, `_`, `-`; starts with a letter or digit; at most 128 characters. */
50
51
  readonly sessionId?: string;
51
- /** `"provider/model"`, optionally with an `[effort]` suffix. */
52
- readonly model: string;
53
52
  readonly profile: AgentSessionProfile;
54
53
  /** An absolute directory. Required for `read` and `full`; `none` gets a private temporary root. */
55
54
  readonly workdir?: string;
@@ -65,22 +64,19 @@ export interface CreateAgentSessionOptions {
65
64
  readonly transcriptStore: TranscriptStore;
66
65
  /** How long an approval or question waits. Default 600000; 30000..3600000. */
67
66
  readonly approvalTimeoutMs?: number;
68
- /** `full` only. Default `"gated"`. */
69
- readonly bashApproval?: BashApprovalMode;
70
- /** `full` only, and only with `bashApproval: "gated"`: run without a sandbox when none is usable. */
71
- readonly allowUnsandboxed?: boolean;
72
- /** Falls back to the `configureCredentials` slot when absent. */
73
- readonly credentials?: CredentialSource;
74
- readonly catalogOverrides?: NativeCatalogOverrides;
75
- readonly loopHandlers?: LoopHandlerSet;
76
- readonly hostPorts?: AgentSessionHostPorts;
77
- /** Copied onto every event. */
78
- readonly metadata?: Readonly<Record<string, string>>;
79
67
  /** Per-turn wall clock. Default 3600; 1..86400. */
80
68
  readonly turnTimeoutSeconds?: number;
69
+ /** Copied onto every event. */
70
+ readonly metadata?: Readonly<Record<string, string>>;
81
71
  }
82
72
  export type TurnEndStatus = "completed" | "cancelled" | "timed_out" | "interrupted" | "errored";
83
- export type ApprovalDecidedBy = "human" | "timeout" | "cancelled" | "unavailable" | "unshowable";
73
+ export type ApprovalDecidedBy = "human" | "timeout" | "cancelled" | "unavailable" | "unshowable" | "profile";
74
+ /**
75
+ * How a turn's `costUsd` was obtained (S4 spec 5.5). Absent means `computed`
76
+ * from the catalog (the native backend). `unpriced` rows carry `costUsd: 0`
77
+ * and must not be summed as a real cost.
78
+ */
79
+ export type CostSource = "computed" | "reported" | "unpriced";
84
80
  export interface SessionEventBase {
85
81
  readonly sessionId: string;
86
82
  readonly turnId: string;
@@ -139,6 +135,7 @@ export type SessionEventBody = {
139
135
  readonly cacheRead?: number;
140
136
  readonly cacheWrite?: number;
141
137
  readonly costUsd: number;
138
+ readonly costSource?: CostSource;
142
139
  } | {
143
140
  readonly type: "compaction";
144
141
  readonly reason: "proactive" | "overflow";
@@ -149,6 +146,7 @@ export type SessionEventBody = {
149
146
  readonly output: string;
150
147
  readonly usage: TokenUsage;
151
148
  readonly costUsd: number;
149
+ readonly costSource?: CostSource;
152
150
  readonly error?: {
153
151
  readonly code: string;
154
152
  readonly message: string;
@@ -167,6 +165,8 @@ export type AnswerReply = {
167
165
  export type AnswerStatus = "accepted" | "expired" | "cancelled" | "unknown";
168
166
  export interface AgentSession {
169
167
  readonly id: string;
168
+ /** The backend that opened this session (`kind` and its capability summary). */
169
+ readonly backend: BackendInfo;
170
170
  /** Set after each `turn_end`; on resume, `interrupted` when a dead process left a turn running. Undefined otherwise. */
171
171
  readonly lastTurn: {
172
172
  readonly turnId: string;
@@ -1,32 +1,13 @@
1
- /**
2
- * createAgentSession: the conversational session facade (S3 spec 4, 5.1).
3
- * One NativeSessionAdapter per session. The facade fills the S1
4
- * OpenSessionOpts itself and talks to the backend through the S1 contract
5
- * only (openSession, sendTurn, closeSession), so the acpx backend (S4) slots
6
- * in behind the same API. resumeAgentSession reopens a stored session
7
- * (spec 4.2, 6.4).
8
- */
9
- import { DEFAULT_SPIN_BREAKER_SETTINGS } from "#src/infra/spin-breaker/index";
10
- import { NATIVE_AGENT } from "#src/native/models";
11
- import { NativeSessionAdapter } from "#src/native/session-adapter";
12
1
  import { _agentSessionDeps } from "./agent-session-deps.js";
13
2
  import { AgentSessionError } from "./agent-session-errors.js";
14
3
  import { resolveAgentSessionOptions } from "./agent-session-options.js";
15
- import { interruptedTurnOf, loadResumable, resumeInput } from "./agent-session-resume.js";
4
+ import { checkBackendKind, interruptedTurnOf, loadResumable, resumeInput } from "./agent-session-resume.js";
16
5
  import { claimTurn } from "./agent-session-turn.js";
17
6
  import { createPendingAskTable } from "./pending-asks.js";
18
- import { createSessionAskLink, createSessionAskResolver } from "./session-ask-link.js";
19
- import { createSessionInteractionHandler, embedderToolDescriptor } from "./session-interaction.js";
20
- import { buildSessionToolSupport, defaultProtectedPaths, resolveSessionLauncher } from "./session-tool-support.js";
7
+ import { createSessionAskPort } from "./session-ask-port.js";
21
8
  /** The handler's turn signal between turns: never aborts. */
22
9
  const IDLE_SIGNAL = new AbortController().signal;
23
- /**
24
- * nax's `agent.native.transportRetry` default. The loop retries a transport,
25
- * overloaded or rate-limit fault that arrives after events have streamed, and
26
- * emits `stream_reset` so the consumer voids that round's deltas (spec 5.3).
27
- */
28
- const SESSION_TRANSPORT_RETRY = { maxAttempts: 3, baseDelayMs: 2000 };
29
- class NativeAgentSession {
10
+ class FacadeAgentSession {
30
11
  parts;
31
12
  active;
32
13
  last;
@@ -38,6 +19,10 @@ class NativeAgentSession {
38
19
  get id() {
39
20
  return this.parts.ctx.sessionId;
40
21
  }
22
+ get backend() {
23
+ const info = this.parts.opened.info;
24
+ return Object.freeze({ kind: info.kind, capabilities: Object.freeze({ ...info.capabilities }) });
25
+ }
41
26
  get lastTurn() {
42
27
  return this.last;
43
28
  }
@@ -76,6 +61,7 @@ class NativeAgentSession {
76
61
  }
77
62
  async shutdown() {
78
63
  const active = this.active;
64
+ this.parts.closeController.abort();
79
65
  this.parts.table.close();
80
66
  active?.cancel("session closed");
81
67
  await active?.settled;
@@ -83,7 +69,12 @@ class NativeAgentSession {
83
69
  await this.parts.ctx.adapter.closeSession(this.parts.ctx.handle);
84
70
  }
85
71
  finally {
86
- await this.parts.cleanup();
72
+ try {
73
+ await this.parts.opened.close();
74
+ }
75
+ finally {
76
+ await this.parts.cleanup();
77
+ }
87
78
  }
88
79
  }
89
80
  }
@@ -98,78 +89,46 @@ async function sessionRoot(options) {
98
89
  }
99
90
  return { dir: workdir, cleanup: async () => { } };
100
91
  }
101
- function createAdapter(options) {
102
- const overrides = options.raw.catalogOverrides ?? [];
103
- const credentials = options.raw.credentials;
104
- // A session with its own catalog or credentials owns its client; the rest share the process memo.
105
- const owns = overrides.length > 0 || credentials !== undefined;
106
- return new NativeSessionAdapter(overrides, {
107
- ...(credentials !== undefined ? { credentials } : {}),
108
- ...(owns ? { ownClient: true } : {}),
109
- });
110
- }
111
92
  async function assemble(options, sessionId, root, opening) {
112
93
  const raw = options.raw;
113
- // A supplied policy is merged over the default, so enabling GitCommit never drops the credential read-deny.
114
- const protectedPaths = { ...defaultProtectedPaths(raw.credentials !== undefined), ...raw.hostPorts?.protectedPaths };
115
- const launcher = await resolveSessionLauncher({
116
- profile: raw.profile,
117
- root: root.dir,
118
- protectedPaths,
119
- bashApproval: options.bashApproval,
120
- allowUnsandboxed: options.allowUnsandboxed,
121
- });
122
94
  const table = createPendingAskTable(options.approvalTimeoutMs);
123
- const slot = { turn: undefined, callId: undefined };
124
- const asks = { table, emit: (body) => slot.turn?.emit(body), currentCallId: () => slot.callId };
125
- const { support, grants } = buildSessionToolSupport({
126
- profile: raw.profile,
127
- root: root.dir,
128
- sessionName: sessionId,
129
- protectedPaths,
130
- bashApproval: options.bashApproval,
131
- launcher,
132
- askResolver: createSessionAskResolver(createSessionAskLink(asks)),
133
- interceptor: raw.hostPorts?.commandInterceptor,
134
- });
135
- const interactionHandler = createSessionInteractionHandler({
95
+ const slot = { turn: undefined };
96
+ const closeController = new AbortController();
97
+ const asks = createSessionAskPort({ table, emit: (body) => slot.turn?.emit(body), turn: () => slot.turn });
98
+ const opened = await raw.backend.open({
136
99
  sessionId,
137
- runtime: support.runtime,
138
- embedderTools: new Map(options.tools.map((tool) => [tool.name, tool])),
139
- asks,
140
- turnSignal: () => slot.turn?.signal ?? IDLE_SIGNAL,
141
- setCurrentCallId: (callId) => {
142
- slot.callId = callId;
143
- },
144
- });
145
- const adapter = createAdapter(options);
146
- const handle = await adapter.openSession(sessionId, {
147
- agentName: NATIVE_AGENT,
148
100
  workdir: root.dir,
149
- resolvedPermissions: { mode: "default", toolGrants: grants, bashApproval: options.bashApproval },
150
- modelDef: { provider: options.provider, model: raw.model },
151
- timeoutSeconds: options.turnTimeoutSeconds,
101
+ profile: raw.profile,
102
+ instructions: raw.instructions,
103
+ tools: options.tools,
152
104
  transcriptStore: raw.transcriptStore,
153
- retainOnClose: true,
154
- resume: opening.resume,
155
- spinBreaker: DEFAULT_SPIN_BREAKER_SETTINGS,
156
- transportRetry: SESSION_TRANSPORT_RETRY,
157
- // An empty instructions is no system prompt: `system: ""` on the wire invites provider quirks.
158
- ...(raw.instructions !== undefined && raw.instructions !== "" ? { systemPrompt: raw.instructions } : {}),
159
- });
160
- const ctx = {
161
- sessionId,
162
- adapter,
163
- handle,
164
- store: raw.transcriptStore,
165
- codingTools: [...support.tools, ...options.tools.map(embedderToolDescriptor)],
166
- interactionHandler,
167
- loopHandlers: raw.loopHandlers,
168
- loopHandlerContext: { sessionName: sessionId, workdir: root.dir, model: raw.model, provider: options.provider },
105
+ resume: opening.doc === undefined ? undefined : { doc: opening.doc },
106
+ asks,
107
+ turnSignal: () => slot.turn?.signal ?? IDLE_SIGNAL,
108
+ currentTurnId: () => slot.turn?.turnId,
169
109
  turnTimeoutSeconds: options.turnTimeoutSeconds,
170
110
  metadata: options.metadata,
171
- };
172
- return new NativeAgentSession({ ctx, table, slot, cleanup: root.cleanup }, opening.lastTurn);
111
+ openSignal: closeController.signal,
112
+ });
113
+ try {
114
+ const ctx = {
115
+ sessionId,
116
+ adapter: opened.adapter,
117
+ handle: opened.handle,
118
+ store: raw.transcriptStore,
119
+ turnOpts: () => opened.turnOpts(),
120
+ turnTimeoutSeconds: options.turnTimeoutSeconds,
121
+ metadata: options.metadata,
122
+ };
123
+ return new FacadeAgentSession({ ctx, table, slot, opened, closeController, cleanup: root.cleanup }, opening.lastTurn);
124
+ }
125
+ catch (err) {
126
+ // No step after backend.open throws today; a future backend's must not leak
127
+ // the opened resources. open() still removes the root and lets open()'s own
128
+ // failure propagate untouched.
129
+ await opened.close().catch(() => undefined);
130
+ throw err;
131
+ }
173
132
  }
174
133
  /** Opens the backend session under a fresh root; a failure removes the root. */
175
134
  async function open(options, sessionId, opening) {
@@ -188,7 +147,7 @@ export async function createAgentSession(input) {
188
147
  if ((await options.raw.transcriptStore.load(sessionId)) !== null) {
189
148
  throw new AgentSessionError(`Session "${sessionId}" already exists in the transcript store; resume it instead`, "AGENT_SESSION_EXISTS", { sessionId });
190
149
  }
191
- return open(options, sessionId, { resume: false, lastTurn: undefined });
150
+ return open(options, sessionId, { doc: undefined, lastTurn: undefined });
192
151
  }
193
152
  /**
194
153
  * Spec 6.4: the turn a dead process left running is marked ended, so the next
@@ -212,9 +171,11 @@ async function endInterruptedTurn(session, store, sessionId, turnId) {
212
171
  export async function resumeAgentSession(sessionId, input) {
213
172
  const options = resolveAgentSessionOptions(resumeInput(sessionId, input));
214
173
  const store = options.raw.transcriptStore;
215
- const interrupted = interruptedTurnOf(await loadResumable(store, sessionId, options.raw.model));
174
+ const doc = await loadResumable(store, sessionId);
175
+ checkBackendKind(doc, sessionId, options.raw.backend.kind);
176
+ const interrupted = interruptedTurnOf(doc);
216
177
  const session = await open(options, sessionId, {
217
- resume: true,
178
+ doc,
218
179
  lastTurn: interrupted === undefined ? undefined : { turnId: interrupted, status: "interrupted" },
219
180
  });
220
181
  if (interrupted !== undefined)
@@ -0,0 +1,24 @@
1
+ import type { BashApprovalMode } from "#src/config/bash-approval";
2
+ import type { NativeCatalogOverrides } from "#src/native/client";
3
+ import type { CredentialSource } from "#src/native/credentials/session-source";
4
+ import type { LoopHandlerSet } from "#src/native/session/loop-events/types";
5
+ import type { AgentSessionHostPorts, AgentSessionProfile } from "./agent-session-types.ts";
6
+ export interface NativeBackendOptions {
7
+ readonly model: string;
8
+ readonly credentials?: CredentialSource;
9
+ readonly catalogOverrides?: NativeCatalogOverrides;
10
+ readonly loopHandlers?: LoopHandlerSet;
11
+ readonly hostPorts?: AgentSessionHostPorts;
12
+ readonly bashApproval?: BashApprovalMode;
13
+ readonly allowUnsandboxed?: boolean;
14
+ }
15
+ export interface ResolvedNativeOptions {
16
+ /** The caller's object, unchanged. */
17
+ readonly raw: NativeBackendOptions;
18
+ readonly provider: string;
19
+ }
20
+ export declare function parseNativeBackendOptions(input: unknown): ResolvedNativeOptions;
21
+ export declare function nativeProfileRules(profile: AgentSessionProfile, raw: NativeBackendOptions): {
22
+ readonly bashApproval: BashApprovalMode;
23
+ readonly allowUnsandboxed: boolean;
24
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * nativeBackend's options (S4 spec 5.2): the native half of the facade's
3
+ * createAgentSession options, validated here rather than by the facade. zod
4
+ * checks the shape; the caller's own object is kept as `raw` (zod returns
5
+ * copies, which would break identity for handlers the caller owns).
6
+ */
7
+ import { z } from "zod";
8
+ import { parseNativeModel } from "#src/native/models";
9
+ import { AgentSessionError } from "./agent-session-errors.js";
10
+ function isRecord(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value);
12
+ }
13
+ function hasMethods(names) {
14
+ return (value) => isRecord(value) && names.every((name) => typeof value[name] === "function");
15
+ }
16
+ const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
17
+ const OptionsSchema = z.strictObject({
18
+ model: z.string().min(1),
19
+ credentials: z
20
+ .custom((value) => isRecord(value) && (value.kind === "memory" || value.kind === "exec"), "must be a memory or exec source")
21
+ .optional(),
22
+ catalogOverrides: z.array(z.custom(isRecord, "must be a catalog override object")).optional(),
23
+ loopHandlers: z.array(z.custom(isRecord, "must be a loop handler entry")).optional(),
24
+ hostPorts: z
25
+ .strictObject({
26
+ protectedPaths: z
27
+ .custom((value) => isRecord(value) && isStringArray(value.gitExcludePathspecs) && isStringArray(value.gitIgnorePatterns), "must be a protected-paths policy with gitExcludePathspecs and gitIgnorePatterns string arrays")
28
+ .optional(),
29
+ commandInterceptor: z.custom(hasMethods(["intercept"]), "must implement intercept").optional(),
30
+ })
31
+ .optional(),
32
+ bashApproval: z.enum(["raw", "gated", "escalate"]).optional(),
33
+ allowUnsandboxed: z.boolean().optional(),
34
+ });
35
+ function invalid(message, context = {}) {
36
+ return new AgentSessionError(`Invalid agent session options: ${message}`, "AGENT_SESSION_INVALID_OPTIONS", context);
37
+ }
38
+ function checkShape(input) {
39
+ if (isRecord(input) && isRecord(input.hostPorts) && "runDeclaredCommand" in input.hostPorts) {
40
+ throw invalid("hostPorts.runDeclaredCommand is deferred to a later release", {
41
+ path: "backend.hostPorts.runDeclaredCommand",
42
+ });
43
+ }
44
+ const parsed = OptionsSchema.safeParse(input);
45
+ if (parsed.success)
46
+ return input;
47
+ const issue = parsed.error.issues[0];
48
+ const path = issue === undefined || issue.path.length === 0 ? "backend" : `backend.${issue.path.join(".")}`;
49
+ throw invalid(`${path}: ${issue?.message ?? "invalid"}`, { path });
50
+ }
51
+ function providerOf(model) {
52
+ try {
53
+ return parseNativeModel(model).provider;
54
+ }
55
+ catch {
56
+ throw invalid(`model: "${model}" is not "provider/model[effort]"`, { path: "backend.model" });
57
+ }
58
+ }
59
+ export function parseNativeBackendOptions(input) {
60
+ const options = checkShape(input);
61
+ return { raw: options, provider: providerOf(options.model) };
62
+ }
63
+ export function nativeProfileRules(profile, raw) {
64
+ const tools = profile === "full" || profile === "ask";
65
+ if (!tools && raw.bashApproval !== undefined) {
66
+ throw invalid('bashApproval applies to profiles "ask" and "full" only', { path: "backend.bashApproval" });
67
+ }
68
+ if (!tools && raw.allowUnsandboxed !== undefined) {
69
+ throw invalid('allowUnsandboxed applies to profiles "ask" and "full" only', { path: "backend.allowUnsandboxed" });
70
+ }
71
+ const bashApproval = raw.bashApproval ?? "gated";
72
+ if (profile === "ask" && bashApproval !== "gated") {
73
+ throw invalid('profile "ask" requires bashApproval "gated" (other modes run Bash without asking)', {
74
+ path: "backend.bashApproval",
75
+ });
76
+ }
77
+ if (raw.allowUnsandboxed === true && bashApproval !== "gated") {
78
+ throw invalid('allowUnsandboxed requires bashApproval "gated"', { path: "backend.allowUnsandboxed" });
79
+ }
80
+ return { bashApproval, allowUnsandboxed: raw.allowUnsandboxed === true };
81
+ }
@@ -0,0 +1,4 @@
1
+ import { type NativeBackendOptions } from "./native-backend-options.ts";
2
+ import type { SessionBackend } from "./session-backend.ts";
3
+ export declare const NATIVE_BACKEND_KIND = "native";
4
+ export declare function nativeBackend(options: NativeBackendOptions): SessionBackend;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The native backend (S4 spec 5.2): the S3 facade's native half behind the
3
+ * SessionBackend seam. Sandbox floor, profile tools, the interaction handler
4
+ * with built-in and embedder tools, the NativeSessionAdapter, loop handlers
5
+ * and the resume model check.
6
+ */
7
+ import { DEFAULT_SPIN_BREAKER_SETTINGS } from "#src/infra/spin-breaker/index";
8
+ import { NATIVE_AGENT } from "#src/native/models";
9
+ import { transcriptModelIdentity } from "#src/native/session/transcript-identity";
10
+ import { NativeSessionAdapter } from "#src/native/session-adapter";
11
+ import { AgentSessionError } from "./agent-session-errors.js";
12
+ import { nativeProfileRules, parseNativeBackendOptions, } from "./native-backend-options.js";
13
+ import { createSessionAskLink, createSessionAskResolver } from "./session-ask-link.js";
14
+ import { createSessionInteractionHandler, embedderToolDescriptor } from "./session-interaction.js";
15
+ import { buildSessionToolSupport, defaultProtectedPaths, resolveSessionLauncher } from "./session-tool-support.js";
16
+ export const NATIVE_BACKEND_KIND = "native";
17
+ /** nax's agent.native.transportRetry default (S3 spec 5.3). */
18
+ const SESSION_TRANSPORT_RETRY = { maxAttempts: 3, baseDelayMs: 2000 };
19
+ function createAdapter(raw) {
20
+ const overrides = raw.catalogOverrides ?? [];
21
+ const owns = overrides.length > 0 || raw.credentials !== undefined;
22
+ return new NativeSessionAdapter(overrides, {
23
+ ...(raw.credentials !== undefined ? { credentials: raw.credentials } : {}),
24
+ ...(owns ? { ownClient: true } : {}),
25
+ });
26
+ }
27
+ function checkResumeModel(doc, sessionId, model) {
28
+ const resuming = transcriptModelIdentity(model);
29
+ if (doc.model !== undefined && doc.model !== resuming) {
30
+ throw new AgentSessionError(`Session "${sessionId}" was written by model "${doc.model}"; resume it with that model, not "${resuming}"`, "AGENT_SESSION_MODEL_MISMATCH", { sessionId });
31
+ }
32
+ }
33
+ async function openNative(resolved, ctx) {
34
+ const raw = resolved.raw;
35
+ if (ctx.resume !== undefined)
36
+ checkResumeModel(ctx.resume.doc, ctx.sessionId, raw.model);
37
+ const { bashApproval, allowUnsandboxed } = nativeProfileRules(ctx.profile, raw);
38
+ const protectedPaths = { ...defaultProtectedPaths(raw.credentials !== undefined), ...raw.hostPorts?.protectedPaths };
39
+ const launcher = await resolveSessionLauncher({
40
+ profile: ctx.profile,
41
+ root: ctx.workdir,
42
+ protectedPaths,
43
+ bashApproval,
44
+ allowUnsandboxed,
45
+ });
46
+ let callId;
47
+ const { support, grants } = buildSessionToolSupport({
48
+ profile: ctx.profile,
49
+ root: ctx.workdir,
50
+ sessionName: ctx.sessionId,
51
+ protectedPaths,
52
+ bashApproval,
53
+ launcher,
54
+ askResolver: createSessionAskResolver(createSessionAskLink({ port: ctx.asks, currentCallId: () => callId })),
55
+ interceptor: raw.hostPorts?.commandInterceptor,
56
+ });
57
+ const interactionHandler = createSessionInteractionHandler({
58
+ sessionId: ctx.sessionId,
59
+ runtime: support.runtime,
60
+ embedderTools: new Map(ctx.tools.map((tool) => [tool.name, tool])),
61
+ asks: ctx.asks,
62
+ turnSignal: ctx.turnSignal,
63
+ setCurrentCallId: (id) => {
64
+ callId = id;
65
+ },
66
+ });
67
+ const adapter = createAdapter(raw);
68
+ const handle = await adapter.openSession(ctx.sessionId, {
69
+ agentName: NATIVE_AGENT,
70
+ workdir: ctx.workdir,
71
+ resolvedPermissions: { mode: "default", toolGrants: grants, bashApproval },
72
+ modelDef: { provider: resolved.provider, model: raw.model },
73
+ timeoutSeconds: ctx.turnTimeoutSeconds,
74
+ transcriptStore: ctx.transcriptStore,
75
+ retainOnClose: true,
76
+ resume: ctx.resume !== undefined,
77
+ spinBreaker: DEFAULT_SPIN_BREAKER_SETTINGS,
78
+ transportRetry: SESSION_TRANSPORT_RETRY,
79
+ ...(ctx.instructions !== undefined && ctx.instructions !== "" ? { systemPrompt: ctx.instructions } : {}),
80
+ });
81
+ const codingTools = [...support.tools, ...ctx.tools.map(embedderToolDescriptor)];
82
+ const loopHandlerContext = {
83
+ sessionName: ctx.sessionId,
84
+ workdir: ctx.workdir,
85
+ model: raw.model,
86
+ provider: resolved.provider,
87
+ };
88
+ return {
89
+ adapter,
90
+ handle,
91
+ info: { kind: NATIVE_BACKEND_KIND, capabilities: {} },
92
+ turnOpts: () => ({
93
+ interactionHandler,
94
+ codingTools,
95
+ loopHandlerContext,
96
+ ...(raw.loopHandlers !== undefined ? { loopHandlers: raw.loopHandlers } : {}),
97
+ }),
98
+ // Native owns no per-session resource beyond the adapter, which
99
+ // closeSession (the facade's step before this) already releases.
100
+ close: async () => { },
101
+ };
102
+ }
103
+ export function nativeBackend(options) {
104
+ const resolved = parseNativeBackendOptions(options);
105
+ return { kind: NATIVE_BACKEND_KIND, open: (ctx) => openNative(resolved, ctx) };
106
+ }
@@ -4,15 +4,14 @@
4
4
  * whose approval is "always". Asks are serial in practice: the tool batch
5
5
  * runs one call at a time.
6
6
  */
7
- import { type AskLink, type AskLinkOutcome, type AskResolver } from "#src/permissions/index";
7
+ import { type AskLink, type AskResolver } from "#src/permissions/index";
8
8
  import type { SessionEventBody } from "./agent-session-types.ts";
9
9
  import type { PendingAskTable } from "./pending-asks.ts";
10
+ import type { SessionAskPort } from "./session-backend.ts";
10
11
  export interface SessionAskDeps {
11
12
  readonly table: PendingAskTable;
12
13
  /** Emits on the running turn's event stream. */
13
14
  readonly emit: (body: SessionEventBody) => void;
14
- /** The tool call being answered right now, if any. */
15
- readonly currentCallId: () => string | undefined;
16
15
  }
17
16
  export interface ApprovalAsk {
18
17
  readonly tool: string;
@@ -21,6 +20,14 @@ export interface ApprovalAsk {
21
20
  readonly reason: string;
22
21
  readonly callId?: string;
23
22
  }
24
- export declare function askPerson(deps: SessionAskDeps, ask: ApprovalAsk, signal: AbortSignal | undefined): Promise<AskLinkOutcome>;
25
- export declare function createSessionAskLink(deps: SessionAskDeps): AskLink;
23
+ export declare function askPerson(deps: SessionAskDeps, ask: ApprovalAsk, signal: AbortSignal | undefined): Promise<{
24
+ readonly decision: "allow" | "deny";
25
+ readonly decidedBy: "human" | "timeout" | "cancelled";
26
+ }>;
27
+ export interface SessionAskLinkDeps {
28
+ readonly port: SessionAskPort;
29
+ /** The tool call being answered right now, if any. */
30
+ readonly currentCallId: () => string | undefined;
31
+ }
32
+ export declare function createSessionAskLink(deps: SessionAskLinkDeps): AskLink;
26
33
  export declare function createSessionAskResolver(link: AskLink): AskResolver;
@@ -36,13 +36,14 @@ export function createSessionAskLink(deps) {
36
36
  if (masked !== undefined && !masked.ok)
37
37
  return Promise.resolve(UNSHOWABLE);
38
38
  const callId = deps.currentCallId();
39
- return askPerson(deps, {
39
+ return deps.port.requestApproval({
40
40
  tool: req.tool,
41
41
  summary: req.summary,
42
42
  ...(masked !== undefined ? { command: masked.masked } : {}),
43
43
  reason: req.reason ?? `matched ${req.rule}`,
44
44
  ...(callId !== undefined ? { callId } : {}),
45
- }, control?.signal);
45
+ ...(control?.signal !== undefined ? { signal: control.signal } : {}),
46
+ });
46
47
  },
47
48
  };
48
49
  }
@@ -0,0 +1,12 @@
1
+ import type { SessionEventBody } from "./agent-session-types.ts";
2
+ import type { PendingAskTable } from "./pending-asks.ts";
3
+ import type { SessionAskPort } from "./session-backend.ts";
4
+ export interface SessionAskPortDeps {
5
+ readonly table: PendingAskTable;
6
+ readonly emit: (body: SessionEventBody) => void;
7
+ readonly turn: () => {
8
+ readonly turnId: string;
9
+ readonly signal: AbortSignal;
10
+ } | undefined;
11
+ }
12
+ export declare function createSessionAskPort(deps: SessionAskPortDeps): SessionAskPort;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The session's ask port (S4 spec 5.1): approvals and questions any backend
3
+ * raises, on top of the pending-ask table. A request needs a running turn: its
4
+ * events go on that turn's stream and its turn signal settles it. Profile
5
+ * auto-decisions and informational questions are emitted, never queued.
6
+ */
7
+ import { NaxError } from "#src/infra/nax-error";
8
+ import { _agentSessionDeps } from "./agent-session-deps.js";
9
+ import { askPerson } from "./session-ask-link.js";
10
+ const ALREADY_ABORTED = AbortSignal.abort();
11
+ function noTurn() {
12
+ return new NaxError("No turn is running; an approval needs a running turn", "AGENT_SESSION_TURN_FAILED", {
13
+ stage: "agent-session",
14
+ detail: "no-turn",
15
+ });
16
+ }
17
+ export function createSessionAskPort(deps) {
18
+ const askDeps = { table: deps.table, emit: deps.emit };
19
+ const now = () => new Date(_agentSessionDeps.now()).toISOString();
20
+ return {
21
+ async requestApproval(req) {
22
+ const turn = deps.turn();
23
+ if (turn === undefined)
24
+ throw noTurn();
25
+ const signal = req.signal === undefined ? turn.signal : AbortSignal.any([turn.signal, req.signal]);
26
+ const { signal: _ignored, ...ask } = req;
27
+ return askPerson(askDeps, ask, signal);
28
+ },
29
+ // Informational only. The requestId is emitted but never put in the pending
30
+ // table, so session.answer() on it is not answerable, and expiresAt is the
31
+ // emit time (the auto-decision is already resolved), not a real deadline.
32
+ recordAutoDecision(req, decision) {
33
+ if (deps.turn() === undefined)
34
+ return;
35
+ const requestId = _agentSessionDeps.randomUUID();
36
+ deps.emit({
37
+ type: "approval_requested",
38
+ requestId,
39
+ ...(req.callId !== undefined ? { callId: req.callId } : {}),
40
+ tool: req.tool,
41
+ summary: req.summary,
42
+ reason: req.reason,
43
+ expiresAt: now(),
44
+ });
45
+ deps.emit({ type: "approval_resolved", requestId, decision, decidedBy: "profile" });
46
+ },
47
+ async askQuestion(text, opts) {
48
+ const turn = deps.turn();
49
+ if (turn === undefined)
50
+ return null;
51
+ const signal = opts?.signal === undefined ? turn.signal : AbortSignal.any([turn.signal, opts.signal]);
52
+ const { requestId, expiresAt, settled } = deps.table.issue("question", signal);
53
+ deps.emit({ type: "question", requestId, text, expiresAt });
54
+ const settlement = await settled;
55
+ return settlement.by === "human" && "text" in settlement.reply ? settlement.reply.text : null;
56
+ },
57
+ noteQuestion(text) {
58
+ if (deps.turn() === undefined)
59
+ return;
60
+ const { requestId } = deps.table.issue("question", ALREADY_ABORTED);
61
+ deps.emit({ type: "question", requestId, text, expiresAt: now() });
62
+ },
63
+ };
64
+ }