@nathapp/nax-agent 0.1.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.
- package/CHANGELOG.md +43 -0
- package/README.md +56 -2
- package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
- package/dist/coding-tools/coding-tool-sandbox.js +14 -3
- package/dist/coding-tools/coding-tool-support.d.ts +3 -0
- package/dist/coding-tools/coding-tool-support.js +2 -0
- package/dist/index.d.ts +21 -3
- package/dist/index.js +13 -3
- package/dist/infra/stderr-tail.d.ts +8 -0
- package/dist/infra/stderr-tail.js +48 -0
- package/dist/internal/redact.d.ts +23 -0
- package/dist/internal/redact.js +60 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +8 -0
- package/dist/native/adapter-deps.d.ts +3 -3
- package/dist/native/adapter-deps.js +3 -3
- package/dist/native/client.d.ts +7 -1
- package/dist/native/client.js +1 -1
- package/dist/native/credentials/change-guard.d.ts +8 -1
- package/dist/native/credentials/change-guard.js +1 -1
- package/dist/native/credentials/fingerprint.d.ts +4 -1
- package/dist/native/credentials/fingerprint.js +6 -3
- package/dist/native/credentials/session-source.d.ts +12 -0
- package/dist/native/credentials/session-source.js +33 -0
- package/dist/native/index.d.ts +3 -1
- package/dist/native/index.js +2 -1
- package/dist/native/session/loop-events/types.d.ts +1 -1
- package/dist/native/session/loop-handlers.d.ts +3 -0
- package/dist/native/session/loop-handlers.js +1 -1
- package/dist/native/session/memory-transcript-store.d.ts +12 -0
- package/dist/native/session/memory-transcript-store.js +36 -0
- package/dist/native/session/session.d.ts +138 -96
- package/dist/native/session/session.js +107 -127
- package/dist/native/session/transcript-identity.d.ts +39 -0
- package/dist/native/session/transcript-identity.js +81 -0
- package/dist/native/session/transcript-store.d.ts +22 -21
- package/dist/native/session/transcript-store.js +59 -68
- package/dist/native/session/transcript-types.d.ts +54 -0
- package/dist/native/session/transcript-types.js +7 -0
- package/dist/native/session/truncation-handler.d.ts +4 -3
- package/dist/native/session/truncation-handler.js +7 -10
- package/dist/native/session/turn-complete-step.d.ts +4 -1
- package/dist/native/session/turn-complete-step.js +11 -4
- package/dist/native/session/turn-event-emitter.d.ts +48 -0
- package/dist/native/session/turn-event-emitter.js +134 -0
- package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
- package/dist/native/session/turn-loop-round-trip.js +8 -4
- package/dist/native/session/turn-loop.js +27 -13
- package/dist/native/session/turn-result.d.ts +1 -1
- package/dist/native/session/turn-result.js +1 -1
- package/dist/native/session/turn-tool-batch.d.ts +3 -0
- package/dist/native/session/turn-tool-batch.js +14 -6
- package/dist/native/session/turn-types.d.ts +17 -1
- package/dist/native/session-adapter.d.ts +22 -4
- package/dist/native/session-adapter.js +79 -35
- package/dist/native/stream-complete.d.ts +17 -0
- package/dist/native/stream-complete.js +40 -0
- package/dist/permissions/approvals-link.js +5 -4
- package/dist/permissions/ask-chain.d.ts +1 -1
- package/dist/permissions/ask.d.ts +6 -0
- package/dist/permissions/ask.js +6 -0
- package/dist/permissions/index.d.ts +1 -1
- package/dist/permissions/index.js +1 -1
- package/dist/sandbox/policy-builder.d.ts +6 -1
- package/dist/sandbox/policy-builder.js +18 -14
- package/dist/session/agent-session-deps.d.ts +12 -0
- package/dist/session/agent-session-deps.js +28 -0
- package/dist/session/agent-session-errors.d.ts +11 -0
- package/dist/session/agent-session-errors.js +12 -0
- package/dist/session/agent-session-options.d.ts +12 -0
- package/dist/session/agent-session-options.js +96 -0
- package/dist/session/agent-session-resume.d.ts +14 -0
- package/dist/session/agent-session-resume.js +49 -0
- package/dist/session/agent-session-turn.d.ts +41 -0
- package/dist/session/agent-session-turn.js +177 -0
- package/dist/session/agent-session-types.d.ts +182 -0
- package/dist/session/agent-session-types.js +1 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +184 -0
- package/dist/session/native-backend-options.d.ts +24 -0
- package/dist/session/native-backend-options.js +81 -0
- package/dist/session/native-backend.d.ts +4 -0
- package/dist/session/native-backend.js +106 -0
- package/dist/session/pending-asks.d.ts +24 -0
- package/dist/session/pending-asks.js +77 -0
- package/dist/session/session-ask-link.d.ts +33 -0
- package/dist/session/session-ask-link.js +53 -0
- package/dist/session/session-ask-port.d.ts +12 -0
- package/dist/session/session-ask-port.js +64 -0
- package/dist/session/session-backend.d.ts +79 -0
- package/dist/session/session-backend.js +1 -0
- package/dist/session/session-event-channel.d.ts +17 -0
- package/dist/session/session-event-channel.js +132 -0
- package/dist/session/session-interaction.d.ts +20 -0
- package/dist/session/session-interaction.js +161 -0
- package/dist/session/session-tool-support.d.ts +48 -0
- package/dist/session/session-tool-support.js +104 -0
- package/dist/session/session-types.d.ts +33 -2
- package/dist/session/turn-event.d.ts +66 -0
- package/dist/session/turn-event.js +1 -0
- package/dist/tools/ask-request.js +3 -1
- package/dist/tools/bash-cwd.d.ts +3 -3
- package/dist/tools/credential-read-deny.d.ts +22 -0
- package/dist/tools/credential-read-deny.js +56 -0
- package/dist/tools/glob.js +9 -2
- package/dist/tools/grep.js +55 -26
- package/dist/tools/owned-paths.d.ts +43 -0
- package/dist/tools/owned-paths.js +17 -0
- package/dist/tools/package-managers.d.ts +3 -0
- package/dist/tools/package-managers.js +7 -3
- package/dist/tools/policy-bash-raw.d.ts +3 -0
- package/dist/tools/policy-bash-raw.js +49 -62
- package/dist/tools/policy-command-branch.d.ts +3 -0
- package/dist/tools/policy-command-branch.js +2 -1
- package/dist/tools/policy-paths-branch.d.ts +14 -8
- package/dist/tools/policy-paths-branch.js +20 -19
- package/dist/tools/policy.d.ts +10 -7
- package/dist/tools/policy.js +9 -7
- package/dist/tools/protected-paths.d.ts +6 -6
- package/dist/tools/read.js +38 -20
- package/dist/tools/registry.d.ts +3 -0
- package/dist/tools/run-command-exec.js +1 -0
- package/dist/tools/runtime.d.ts +3 -0
- package/dist/tools/runtime.js +1 -0
- package/dist/tools/scratchpad.js +2 -1
- package/dist/tools/types.d.ts +2 -2
- package/package.json +2 -2
- package/dist/tools/nax-owned-writes.d.ts +0 -135
- package/dist/tools/nax-owned-writes.js +0 -259
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types of the agent session facade (S3 spec section 4). The facade
|
|
3
|
+
* drives the S1 AgentSessionAdapter; nothing here is backend-specific, so the
|
|
4
|
+
* @nathapp/nax-agent-acp backend (S4) reuses these types unchanged.
|
|
5
|
+
*/
|
|
6
|
+
import type { CommandInterceptor } from "#src/command-interceptor/index";
|
|
7
|
+
import type { TokenUsage } from "#src/cost/standard-types";
|
|
8
|
+
import type { TranscriptStore } from "#src/native/session/transcript-types";
|
|
9
|
+
import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
|
|
10
|
+
import type { BackendInfo, SessionBackend } from "./session-backend.ts";
|
|
11
|
+
import type { JSONSchema } from "./tool-descriptor.ts";
|
|
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
|
+
export interface EmbedderToolContext {
|
|
18
|
+
readonly sessionId: string;
|
|
19
|
+
/** The provider's tool-call id: the same id as the `tool_call` / `tool_result` events. */
|
|
20
|
+
readonly toolCallId: string;
|
|
21
|
+
/** Aborts when the turn is cancelled, times out or the session closes. */
|
|
22
|
+
readonly signal: AbortSignal;
|
|
23
|
+
}
|
|
24
|
+
export interface EmbedderToolResult {
|
|
25
|
+
readonly content: string;
|
|
26
|
+
readonly isError?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** An in-process tool the embedder supplies (spec 4.3). */
|
|
29
|
+
export interface EmbedderTool {
|
|
30
|
+
/** The name the model sees. A letter, then letters, digits, `_` or `-`; at most 64 characters. */
|
|
31
|
+
readonly name: string;
|
|
32
|
+
readonly description: string;
|
|
33
|
+
readonly inputSchema: JSONSchema;
|
|
34
|
+
/** `always` asks the person through `answer()` before every run. */
|
|
35
|
+
readonly approval: "never" | "always";
|
|
36
|
+
/** The approval summary. Default: the redacted, byte-capped JSON of the input. */
|
|
37
|
+
describe?(input: unknown): string;
|
|
38
|
+
/** A throw, or `isError: true`, reaches the model as an error result. */
|
|
39
|
+
run(input: unknown, ctx: EmbedderToolContext): Promise<EmbedderToolResult>;
|
|
40
|
+
}
|
|
41
|
+
/** Host ports an embedder may supply (spec 6.2). */
|
|
42
|
+
export interface AgentSessionHostPorts {
|
|
43
|
+
/** Paths the sandbox and the read tools deny. Default: the configured credentials directory, if any. */
|
|
44
|
+
readonly protectedPaths?: ProtectedPathsPolicy;
|
|
45
|
+
readonly commandInterceptor?: CommandInterceptor;
|
|
46
|
+
}
|
|
47
|
+
export interface CreateAgentSessionOptions {
|
|
48
|
+
/** The driver: `nativeBackend({ model })`, or an ACP backend. Native-only options live on the backend. */
|
|
49
|
+
readonly backend: SessionBackend;
|
|
50
|
+
/** The store key. Generated when absent. Letters, digits, `.`, `_`, `-`; starts with a letter or digit; at most 128 characters. */
|
|
51
|
+
readonly sessionId?: string;
|
|
52
|
+
readonly profile: AgentSessionProfile;
|
|
53
|
+
/** An absolute directory. Required for `read` and `full`; `none` gets a private temporary root. */
|
|
54
|
+
readonly workdir?: string;
|
|
55
|
+
/** The embedder's system prompt. */
|
|
56
|
+
readonly instructions?: string;
|
|
57
|
+
readonly tools?: readonly EmbedderTool[];
|
|
58
|
+
/**
|
|
59
|
+
* Where the conversation lives. The session keeps its document on close, so
|
|
60
|
+
* `resumeAgentSession` can reopen it. A file store's failed-close prune counts
|
|
61
|
+
* every transcript in its directory, so do not share a file-store directory
|
|
62
|
+
* with sessions that are closed as failed.
|
|
63
|
+
*/
|
|
64
|
+
readonly transcriptStore: TranscriptStore;
|
|
65
|
+
/** How long an approval or question waits. Default 600000; 30000..3600000. */
|
|
66
|
+
readonly approvalTimeoutMs?: number;
|
|
67
|
+
/** Per-turn wall clock. Default 3600; 1..86400. */
|
|
68
|
+
readonly turnTimeoutSeconds?: number;
|
|
69
|
+
/** Copied onto every event. */
|
|
70
|
+
readonly metadata?: Readonly<Record<string, string>>;
|
|
71
|
+
}
|
|
72
|
+
export type TurnEndStatus = "completed" | "cancelled" | "timed_out" | "interrupted" | "errored";
|
|
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";
|
|
80
|
+
export interface SessionEventBase {
|
|
81
|
+
readonly sessionId: string;
|
|
82
|
+
readonly turnId: string;
|
|
83
|
+
/** ISO 8601. */
|
|
84
|
+
readonly at: string;
|
|
85
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
86
|
+
}
|
|
87
|
+
export type SessionEventBody = {
|
|
88
|
+
readonly type: "turn_start";
|
|
89
|
+
} | {
|
|
90
|
+
readonly type: "text_delta";
|
|
91
|
+
readonly round: number;
|
|
92
|
+
readonly text: string;
|
|
93
|
+
} | {
|
|
94
|
+
readonly type: "thinking_delta";
|
|
95
|
+
readonly round: number;
|
|
96
|
+
readonly text: string;
|
|
97
|
+
} | {
|
|
98
|
+
readonly type: "stream_reset";
|
|
99
|
+
readonly round: number;
|
|
100
|
+
readonly attempt: number;
|
|
101
|
+
} | {
|
|
102
|
+
readonly type: "tool_call";
|
|
103
|
+
readonly callId: string;
|
|
104
|
+
readonly name: string;
|
|
105
|
+
readonly input: unknown;
|
|
106
|
+
} | {
|
|
107
|
+
readonly type: "tool_result";
|
|
108
|
+
readonly callId: string;
|
|
109
|
+
readonly isError: boolean;
|
|
110
|
+
readonly preview: string;
|
|
111
|
+
} | {
|
|
112
|
+
readonly type: "approval_requested";
|
|
113
|
+
readonly requestId: string;
|
|
114
|
+
readonly callId?: string;
|
|
115
|
+
readonly tool: string;
|
|
116
|
+
readonly summary: string;
|
|
117
|
+
readonly command?: string;
|
|
118
|
+
readonly reason: string;
|
|
119
|
+
readonly expiresAt: string;
|
|
120
|
+
} | {
|
|
121
|
+
readonly type: "approval_resolved";
|
|
122
|
+
readonly requestId: string;
|
|
123
|
+
readonly decision: "allow" | "deny";
|
|
124
|
+
readonly decidedBy: ApprovalDecidedBy;
|
|
125
|
+
} | {
|
|
126
|
+
readonly type: "question";
|
|
127
|
+
readonly requestId: string;
|
|
128
|
+
readonly text: string;
|
|
129
|
+
readonly expiresAt: string;
|
|
130
|
+
} | {
|
|
131
|
+
readonly type: "usage";
|
|
132
|
+
readonly round: number;
|
|
133
|
+
readonly inputTokens: number;
|
|
134
|
+
readonly outputTokens: number;
|
|
135
|
+
readonly cacheRead?: number;
|
|
136
|
+
readonly cacheWrite?: number;
|
|
137
|
+
readonly costUsd: number;
|
|
138
|
+
readonly costSource?: CostSource;
|
|
139
|
+
} | {
|
|
140
|
+
readonly type: "compaction";
|
|
141
|
+
readonly reason: "proactive" | "overflow";
|
|
142
|
+
} | {
|
|
143
|
+
readonly type: "turn_end";
|
|
144
|
+
readonly status: TurnEndStatus;
|
|
145
|
+
/** The final round's text, as `TurnResult.output`. Empty when the turn did not complete. */
|
|
146
|
+
readonly output: string;
|
|
147
|
+
readonly usage: TokenUsage;
|
|
148
|
+
readonly costUsd: number;
|
|
149
|
+
readonly costSource?: CostSource;
|
|
150
|
+
readonly error?: {
|
|
151
|
+
readonly code: string;
|
|
152
|
+
readonly message: string;
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* One event of a `send()` (spec 4.4). Deltas are provisional: the transcript
|
|
157
|
+
* and `turn_end.output` are authoritative. `turn_end` is always last.
|
|
158
|
+
*/
|
|
159
|
+
export type SessionEvent = SessionEventBase & SessionEventBody;
|
|
160
|
+
export type AnswerReply = {
|
|
161
|
+
readonly decision: "allow" | "deny";
|
|
162
|
+
} | {
|
|
163
|
+
readonly text: string;
|
|
164
|
+
};
|
|
165
|
+
export type AnswerStatus = "accepted" | "expired" | "cancelled" | "unknown";
|
|
166
|
+
export interface AgentSession {
|
|
167
|
+
readonly id: string;
|
|
168
|
+
/** The backend that opened this session (`kind` and its capability summary). */
|
|
169
|
+
readonly backend: BackendInfo;
|
|
170
|
+
/** Set after each `turn_end`; on resume, `interrupted` when a dead process left a turn running. Undefined otherwise. */
|
|
171
|
+
readonly lastTurn: {
|
|
172
|
+
readonly turnId: string;
|
|
173
|
+
readonly status: TurnEndStatus;
|
|
174
|
+
} | undefined;
|
|
175
|
+
/** Claims the session's single turn slot synchronously; the turn starts on the first `next()`. */
|
|
176
|
+
send(message: string): AsyncIterable<SessionEvent>;
|
|
177
|
+
answer(requestId: string, reply: AnswerReply): AnswerStatus;
|
|
178
|
+
/** Aborts the running turn; a no-op when none runs. */
|
|
179
|
+
cancel(reason?: string): void;
|
|
180
|
+
/** Idempotent. Cancels a running turn, waits for it, keeps the document in the store. */
|
|
181
|
+
close(): Promise<void>;
|
|
182
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AgentSession, CreateAgentSessionOptions } from "./agent-session-types.ts";
|
|
2
|
+
export declare function createAgentSession(input: CreateAgentSessionOptions): Promise<AgentSession>;
|
|
3
|
+
/**
|
|
4
|
+
* Reopens a stored session (spec 4.2). `options` are a create's options; the
|
|
5
|
+
* session id is the argument. Instructions, tools and profile are not stored:
|
|
6
|
+
* pass them again.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resumeAgentSession(sessionId: string, input: CreateAgentSessionOptions): Promise<AgentSession>;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
2
|
+
import { AgentSessionError } from "./agent-session-errors.js";
|
|
3
|
+
import { resolveAgentSessionOptions } from "./agent-session-options.js";
|
|
4
|
+
import { checkBackendKind, interruptedTurnOf, loadResumable, resumeInput } from "./agent-session-resume.js";
|
|
5
|
+
import { claimTurn } from "./agent-session-turn.js";
|
|
6
|
+
import { createPendingAskTable } from "./pending-asks.js";
|
|
7
|
+
import { createSessionAskPort } from "./session-ask-port.js";
|
|
8
|
+
/** The handler's turn signal between turns: never aborts. */
|
|
9
|
+
const IDLE_SIGNAL = new AbortController().signal;
|
|
10
|
+
class FacadeAgentSession {
|
|
11
|
+
parts;
|
|
12
|
+
active;
|
|
13
|
+
last;
|
|
14
|
+
closing;
|
|
15
|
+
constructor(parts, last) {
|
|
16
|
+
this.parts = parts;
|
|
17
|
+
this.last = last;
|
|
18
|
+
}
|
|
19
|
+
get id() {
|
|
20
|
+
return this.parts.ctx.sessionId;
|
|
21
|
+
}
|
|
22
|
+
get backend() {
|
|
23
|
+
const info = this.parts.opened.info;
|
|
24
|
+
return Object.freeze({ kind: info.kind, capabilities: Object.freeze({ ...info.capabilities }) });
|
|
25
|
+
}
|
|
26
|
+
get lastTurn() {
|
|
27
|
+
return this.last;
|
|
28
|
+
}
|
|
29
|
+
send(message) {
|
|
30
|
+
if (this.closing !== undefined) {
|
|
31
|
+
throw new AgentSessionError(`Session "${this.id}" is closed`, "AGENT_SESSION_CLOSED", { sessionId: this.id });
|
|
32
|
+
}
|
|
33
|
+
if (this.active !== undefined) {
|
|
34
|
+
throw new AgentSessionError(`Session "${this.id}" already has a turn in flight`, "AGENT_SESSION_BUSY", {
|
|
35
|
+
sessionId: this.id,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const turn = claimTurn(this.parts.ctx, message, {
|
|
39
|
+
onStart: (live) => {
|
|
40
|
+
this.parts.slot.turn = live;
|
|
41
|
+
},
|
|
42
|
+
onSettle: (turnId, status) => {
|
|
43
|
+
this.active = undefined;
|
|
44
|
+
this.parts.slot.turn = undefined;
|
|
45
|
+
if (status !== undefined)
|
|
46
|
+
this.last = { turnId, status };
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
this.active = turn;
|
|
50
|
+
return turn.iterable;
|
|
51
|
+
}
|
|
52
|
+
answer(requestId, reply) {
|
|
53
|
+
return this.parts.table.answer(requestId, reply);
|
|
54
|
+
}
|
|
55
|
+
cancel(reason = "cancelled") {
|
|
56
|
+
this.active?.cancel(reason);
|
|
57
|
+
}
|
|
58
|
+
close() {
|
|
59
|
+
this.closing ??= this.shutdown();
|
|
60
|
+
return this.closing;
|
|
61
|
+
}
|
|
62
|
+
async shutdown() {
|
|
63
|
+
const active = this.active;
|
|
64
|
+
this.parts.closeController.abort();
|
|
65
|
+
this.parts.table.close();
|
|
66
|
+
active?.cancel("session closed");
|
|
67
|
+
await active?.settled;
|
|
68
|
+
try {
|
|
69
|
+
await this.parts.ctx.adapter.closeSession(this.parts.ctx.handle);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
try {
|
|
73
|
+
await this.parts.opened.close();
|
|
74
|
+
}
|
|
75
|
+
finally {
|
|
76
|
+
await this.parts.cleanup();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
async function sessionRoot(options) {
|
|
82
|
+
const workdir = options.raw.workdir;
|
|
83
|
+
if (workdir === undefined) {
|
|
84
|
+
const dir = await _agentSessionDeps.makeScratchRoot();
|
|
85
|
+
return { dir, cleanup: () => _agentSessionDeps.removeScratchRoot(dir) };
|
|
86
|
+
}
|
|
87
|
+
if (!(await _agentSessionDeps.isDirectory(workdir))) {
|
|
88
|
+
throw new AgentSessionError(`Invalid agent session options: workdir "${workdir}" is not a directory`, "AGENT_SESSION_INVALID_OPTIONS", { path: "workdir" });
|
|
89
|
+
}
|
|
90
|
+
return { dir: workdir, cleanup: async () => { } };
|
|
91
|
+
}
|
|
92
|
+
async function assemble(options, sessionId, root, opening) {
|
|
93
|
+
const raw = options.raw;
|
|
94
|
+
const table = createPendingAskTable(options.approvalTimeoutMs);
|
|
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({
|
|
99
|
+
sessionId,
|
|
100
|
+
workdir: root.dir,
|
|
101
|
+
profile: raw.profile,
|
|
102
|
+
instructions: raw.instructions,
|
|
103
|
+
tools: options.tools,
|
|
104
|
+
transcriptStore: raw.transcriptStore,
|
|
105
|
+
resume: opening.doc === undefined ? undefined : { doc: opening.doc },
|
|
106
|
+
asks,
|
|
107
|
+
turnSignal: () => slot.turn?.signal ?? IDLE_SIGNAL,
|
|
108
|
+
currentTurnId: () => slot.turn?.turnId,
|
|
109
|
+
turnTimeoutSeconds: options.turnTimeoutSeconds,
|
|
110
|
+
metadata: options.metadata,
|
|
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
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** Opens the backend session under a fresh root; a failure removes the root. */
|
|
134
|
+
async function open(options, sessionId, opening) {
|
|
135
|
+
const root = await sessionRoot(options);
|
|
136
|
+
try {
|
|
137
|
+
return await assemble(options, sessionId, root, opening);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
await root.cleanup();
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
export async function createAgentSession(input) {
|
|
145
|
+
const options = resolveAgentSessionOptions(input);
|
|
146
|
+
const sessionId = options.raw.sessionId ?? _agentSessionDeps.randomUUID();
|
|
147
|
+
if ((await options.raw.transcriptStore.load(sessionId)) !== null) {
|
|
148
|
+
throw new AgentSessionError(`Session "${sessionId}" already exists in the transcript store; resume it instead`, "AGENT_SESSION_EXISTS", { sessionId });
|
|
149
|
+
}
|
|
150
|
+
return open(options, sessionId, { doc: undefined, lastTurn: undefined });
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Spec 6.4: the turn a dead process left running is marked ended, so the next
|
|
154
|
+
* send starts clean. A failure closes the session and rethrows the store's
|
|
155
|
+
* error; a close failure would only mask it.
|
|
156
|
+
*/
|
|
157
|
+
async function endInterruptedTurn(session, store, sessionId, turnId) {
|
|
158
|
+
try {
|
|
159
|
+
await store.markTurn(sessionId, { turnId, state: "ended" });
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
await session.close().catch(() => undefined);
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Reopens a stored session (spec 4.2). `options` are a create's options; the
|
|
168
|
+
* session id is the argument. Instructions, tools and profile are not stored:
|
|
169
|
+
* pass them again.
|
|
170
|
+
*/
|
|
171
|
+
export async function resumeAgentSession(sessionId, input) {
|
|
172
|
+
const options = resolveAgentSessionOptions(resumeInput(sessionId, input));
|
|
173
|
+
const store = options.raw.transcriptStore;
|
|
174
|
+
const doc = await loadResumable(store, sessionId);
|
|
175
|
+
checkBackendKind(doc, sessionId, options.raw.backend.kind);
|
|
176
|
+
const interrupted = interruptedTurnOf(doc);
|
|
177
|
+
const session = await open(options, sessionId, {
|
|
178
|
+
doc,
|
|
179
|
+
lastTurn: interrupted === undefined ? undefined : { turnId: interrupted, status: "interrupted" },
|
|
180
|
+
});
|
|
181
|
+
if (interrupted !== undefined)
|
|
182
|
+
await endInterruptedTurn(session, store, sessionId, interrupted);
|
|
183
|
+
return session;
|
|
184
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { AnswerReply, AnswerStatus } from "./agent-session-types.ts";
|
|
2
|
+
export type PendingAskKind = "approval" | "question";
|
|
3
|
+
export type AskSettlement = {
|
|
4
|
+
readonly by: "human";
|
|
5
|
+
readonly reply: AnswerReply;
|
|
6
|
+
} | {
|
|
7
|
+
readonly by: "timeout";
|
|
8
|
+
} | {
|
|
9
|
+
readonly by: "cancelled";
|
|
10
|
+
};
|
|
11
|
+
export interface IssuedAsk {
|
|
12
|
+
readonly requestId: string;
|
|
13
|
+
readonly expiresAt: string;
|
|
14
|
+
readonly settled: Promise<AskSettlement>;
|
|
15
|
+
}
|
|
16
|
+
export interface PendingAskTable {
|
|
17
|
+
issue(kind: PendingAskKind, signal: AbortSignal | undefined): IssuedAsk;
|
|
18
|
+
answer(requestId: string, reply: AnswerReply): AnswerStatus;
|
|
19
|
+
/** Settles every pending request as cancelled. */
|
|
20
|
+
cancelAll(): void;
|
|
21
|
+
/** cancelAll; afterwards every answer() returns "unknown" and every issue() settles cancelled. */
|
|
22
|
+
close(): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function createPendingAskTable(timeoutMs: number): PendingAskTable;
|