@nathapp/nax-agent 0.1.0 → 0.2.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 +22 -0
- package/README.md +47 -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 +10 -1
- package/dist/index.js +4 -1
- 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 +38 -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/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 +16 -0
- package/dist/session/agent-session-options.js +137 -0
- package/dist/session/agent-session-resume.d.ts +12 -0
- package/dist/session/agent-session-resume.js +47 -0
- package/dist/session/agent-session-turn.d.ts +45 -0
- package/dist/session/agent-session-turn.js +179 -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 +223 -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 +26 -0
- package/dist/session/session-ask-link.js +52 -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 +164 -0
- package/dist/session/session-tool-support.d.ts +48 -0
- package/dist/session/session-tool-support.js +101 -0
- package/dist/session/session-types.d.ts +31 -2
- package/dist/session/turn-event.d.ts +61 -0
- package/dist/session/turn-event.js +1 -0
- 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,16 @@
|
|
|
1
|
+
import type { BashApprovalMode } from "#src/config/bash-approval";
|
|
2
|
+
import type { CreateAgentSessionOptions, EmbedderTool } from "./agent-session-types.ts";
|
|
3
|
+
export declare const DEFAULT_APPROVAL_TIMEOUT_MS = 600000;
|
|
4
|
+
export declare const DEFAULT_TURN_TIMEOUT_SECONDS = 3600;
|
|
5
|
+
export interface ResolvedAgentSessionOptions {
|
|
6
|
+
/** The caller's object, unchanged (tool objects keep their `this`). */
|
|
7
|
+
readonly raw: CreateAgentSessionOptions;
|
|
8
|
+
readonly provider: string;
|
|
9
|
+
readonly approvalTimeoutMs: number;
|
|
10
|
+
readonly turnTimeoutSeconds: number;
|
|
11
|
+
readonly bashApproval: BashApprovalMode;
|
|
12
|
+
readonly allowUnsandboxed: boolean;
|
|
13
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
14
|
+
readonly tools: readonly EmbedderTool[];
|
|
15
|
+
}
|
|
16
|
+
export declare function resolveAgentSessionOptions(input: unknown): ResolvedAgentSessionOptions;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates createAgentSession's options (spec 4.1, 7). zod checks the shape;
|
|
3
|
+
* the result keeps the caller's own objects (zod returns copies, which would
|
|
4
|
+
* detach a class-based tool's methods from `this`).
|
|
5
|
+
*/
|
|
6
|
+
import { isAbsolute } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { parseNativeModel } from "#src/native/models";
|
|
9
|
+
import { ASK_HUMAN_TOOL_NAME } from "#src/native/session/ask-human";
|
|
10
|
+
import { RESERVED_TOOL_NAMES } from "#src/tools/registry";
|
|
11
|
+
import { AgentSessionError } from "./agent-session-errors.js";
|
|
12
|
+
export const DEFAULT_APPROVAL_TIMEOUT_MS = 600_000;
|
|
13
|
+
export const DEFAULT_TURN_TIMEOUT_SECONDS = 3600;
|
|
14
|
+
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
15
|
+
const TOOL_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
16
|
+
const STORE_METHODS = ["load", "save", "retainFailed", "delete", "markTurn"];
|
|
17
|
+
function isRecord(value) {
|
|
18
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
function hasMethods(names) {
|
|
21
|
+
return (value) => isRecord(value) && names.every((name) => typeof value[name] === "function");
|
|
22
|
+
}
|
|
23
|
+
const isFunction = (value) => typeof value === "function";
|
|
24
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
25
|
+
const EmbedderToolSchema = z.object({
|
|
26
|
+
name: z.string().regex(TOOL_NAME, "must be a letter, then letters, digits, _ or -, at most 64 characters"),
|
|
27
|
+
description: z.string().min(1),
|
|
28
|
+
inputSchema: z.custom(isRecord, "must be a JSON Schema object"),
|
|
29
|
+
approval: z.enum(["never", "always"]),
|
|
30
|
+
describe: z.custom(isFunction, "must be a function").optional(),
|
|
31
|
+
run: z.custom(isFunction, "must be a function"),
|
|
32
|
+
});
|
|
33
|
+
const OptionsSchema = z.strictObject({
|
|
34
|
+
backend: z.literal("native"),
|
|
35
|
+
sessionId: z
|
|
36
|
+
.string()
|
|
37
|
+
.regex(SESSION_ID, "must be 1-128 letters, digits, '.', '_' or '-', starting with a letter or digit")
|
|
38
|
+
.optional(),
|
|
39
|
+
model: z.string().min(1),
|
|
40
|
+
profile: z.enum(["none", "read", "full"]),
|
|
41
|
+
workdir: z
|
|
42
|
+
.string()
|
|
43
|
+
.refine((dir) => isAbsolute(dir), "must be an absolute path")
|
|
44
|
+
.optional(),
|
|
45
|
+
instructions: z.string().optional(),
|
|
46
|
+
tools: z.array(EmbedderToolSchema).optional(),
|
|
47
|
+
transcriptStore: z.custom(hasMethods(STORE_METHODS), `must implement ${STORE_METHODS.join(", ")}`),
|
|
48
|
+
approvalTimeoutMs: z.number().int().min(30_000).max(3_600_000).optional(),
|
|
49
|
+
bashApproval: z.enum(["raw", "gated", "escalate"]).optional(),
|
|
50
|
+
allowUnsandboxed: z.boolean().optional(),
|
|
51
|
+
credentials: z
|
|
52
|
+
.custom((value) => isRecord(value) && (value.kind === "memory" || value.kind === "exec"), "must be a memory or exec source")
|
|
53
|
+
.optional(),
|
|
54
|
+
catalogOverrides: z.array(z.custom(isRecord, "must be a catalog override object")).optional(),
|
|
55
|
+
loopHandlers: z.array(z.custom(isRecord, "must be a loop handler entry")).optional(),
|
|
56
|
+
hostPorts: z
|
|
57
|
+
.strictObject({
|
|
58
|
+
protectedPaths: z
|
|
59
|
+
.custom((value) => isRecord(value) && isStringArray(value.gitExcludePathspecs) && isStringArray(value.gitIgnorePatterns), "must be a protected-paths policy with gitExcludePathspecs and gitIgnorePatterns string arrays")
|
|
60
|
+
.optional(),
|
|
61
|
+
commandInterceptor: z.custom(hasMethods(["intercept"]), "must implement intercept").optional(),
|
|
62
|
+
})
|
|
63
|
+
.optional(),
|
|
64
|
+
metadata: z.record(z.string(), z.string()).optional(),
|
|
65
|
+
turnTimeoutSeconds: z.number().int().min(1).max(86_400).optional(),
|
|
66
|
+
});
|
|
67
|
+
function invalid(message, context = {}) {
|
|
68
|
+
return new AgentSessionError(`Invalid agent session options: ${message}`, "AGENT_SESSION_INVALID_OPTIONS", context);
|
|
69
|
+
}
|
|
70
|
+
function checkShape(input) {
|
|
71
|
+
if (isRecord(input) && "mcpServers" in input) {
|
|
72
|
+
throw invalid("mcpServers is reserved for a later release (an MCP client for embedder tools)", {
|
|
73
|
+
path: "mcpServers",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (isRecord(input) && isRecord(input.hostPorts) && "runDeclaredCommand" in input.hostPorts) {
|
|
77
|
+
throw invalid("hostPorts.runDeclaredCommand is deferred to a later release", {
|
|
78
|
+
path: "hostPorts.runDeclaredCommand",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const parsed = OptionsSchema.safeParse(input);
|
|
82
|
+
if (parsed.success)
|
|
83
|
+
return input;
|
|
84
|
+
const issue = parsed.error.issues[0];
|
|
85
|
+
const path = issue === undefined || issue.path.length === 0 ? "options" : issue.path.join(".");
|
|
86
|
+
throw invalid(`${path}: ${issue?.message ?? "invalid"}`, { path });
|
|
87
|
+
}
|
|
88
|
+
function checkProfileRules(options) {
|
|
89
|
+
if (options.profile !== "none" && options.workdir === undefined) {
|
|
90
|
+
throw invalid(`workdir is required for profile "${options.profile}"`, { path: "workdir" });
|
|
91
|
+
}
|
|
92
|
+
if (options.profile !== "full" && options.bashApproval !== undefined) {
|
|
93
|
+
throw invalid('bashApproval applies to profile "full" only', { path: "bashApproval" });
|
|
94
|
+
}
|
|
95
|
+
if (options.profile !== "full" && options.allowUnsandboxed !== undefined) {
|
|
96
|
+
throw invalid('allowUnsandboxed applies to profile "full" only', { path: "allowUnsandboxed" });
|
|
97
|
+
}
|
|
98
|
+
if (options.allowUnsandboxed === true && (options.bashApproval ?? "gated") !== "gated") {
|
|
99
|
+
throw invalid('allowUnsandboxed requires bashApproval "gated"', { path: "allowUnsandboxed" });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function checkToolNames(tools) {
|
|
103
|
+
const reserved = new Set([...RESERVED_TOOL_NAMES, ASK_HUMAN_TOOL_NAME]);
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
for (const tool of tools) {
|
|
106
|
+
if (reserved.has(tool.name)) {
|
|
107
|
+
throw new AgentSessionError(`Embedder tool name "${tool.name}" is reserved for a built-in tool`, "AGENT_SESSION_TOOL_NAME_RESERVED", { tool: tool.name });
|
|
108
|
+
}
|
|
109
|
+
if (seen.has(tool.name))
|
|
110
|
+
throw invalid(`tools: duplicate tool name "${tool.name}"`, { path: "tools" });
|
|
111
|
+
seen.add(tool.name);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function providerOf(model) {
|
|
115
|
+
try {
|
|
116
|
+
return parseNativeModel(model).provider;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
throw invalid(`model: "${model}" is not "provider/model[effort]"`, { path: "model" });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function resolveAgentSessionOptions(input) {
|
|
123
|
+
const options = checkShape(input);
|
|
124
|
+
checkProfileRules(options);
|
|
125
|
+
const tools = options.tools ?? [];
|
|
126
|
+
checkToolNames(tools);
|
|
127
|
+
return {
|
|
128
|
+
raw: options,
|
|
129
|
+
provider: providerOf(options.model),
|
|
130
|
+
approvalTimeoutMs: options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
|
|
131
|
+
turnTimeoutSeconds: options.turnTimeoutSeconds ?? DEFAULT_TURN_TIMEOUT_SECONDS,
|
|
132
|
+
bashApproval: options.bashApproval ?? "gated",
|
|
133
|
+
allowUnsandboxed: options.allowUnsandboxed === true,
|
|
134
|
+
metadata: options.metadata ?? {},
|
|
135
|
+
tools,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TranscriptDoc, TranscriptStore } from "#src/native/session/transcript-types";
|
|
2
|
+
import type { CreateAgentSessionOptions } from "./agent-session-types.ts";
|
|
3
|
+
/**
|
|
4
|
+
* The options to validate for a resume: the argument is the session id. A
|
|
5
|
+
* different `options.sessionId` is a caller bug, refused before the store is
|
|
6
|
+
* read. Non-object options pass through for the option validator to reject.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resumeInput(sessionId: string, options: CreateAgentSessionOptions): unknown;
|
|
9
|
+
/** The stored document a resume starts from. Throws when there is none or this session may not read it. */
|
|
10
|
+
export declare function loadResumable(store: TranscriptStore, sessionId: string, model: string): Promise<TranscriptDoc>;
|
|
11
|
+
/** The turn a dead process left running (spec 6.4), or undefined. */
|
|
12
|
+
export declare function interruptedTurnOf(doc: TranscriptDoc): string | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* resumeAgentSession's checks on the stored document (S3 spec 4.2, 6.4). They
|
|
3
|
+
* run before any backend opens: the id comes from the argument, the document
|
|
4
|
+
* exists, this build reads its schema, its messages are an array, and the
|
|
5
|
+
* model that wrote it is the one resuming. The model rule is the loop's own
|
|
6
|
+
* (transcriptModelIdentity): an effort suffix is not a different model.
|
|
7
|
+
*/
|
|
8
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
9
|
+
import { transcriptModelIdentity } from "#src/native/session/transcript-identity";
|
|
10
|
+
import { AgentSessionError } from "./agent-session-errors.js";
|
|
11
|
+
/**
|
|
12
|
+
* The options to validate for a resume: the argument is the session id. A
|
|
13
|
+
* different `options.sessionId` is a caller bug, refused before the store is
|
|
14
|
+
* read. Non-object options pass through for the option validator to reject.
|
|
15
|
+
*/
|
|
16
|
+
export function resumeInput(sessionId, options) {
|
|
17
|
+
if (typeof options !== "object" || options === null)
|
|
18
|
+
return options;
|
|
19
|
+
if (options.sessionId !== undefined && options.sessionId !== sessionId) {
|
|
20
|
+
throw new AgentSessionError(`Invalid agent session options: sessionId "${options.sessionId}" is not the session being resumed ("${sessionId}")`, "AGENT_SESSION_INVALID_OPTIONS", { path: "sessionId" });
|
|
21
|
+
}
|
|
22
|
+
return { ...options, sessionId };
|
|
23
|
+
}
|
|
24
|
+
/** The stored document a resume starts from. Throws when there is none or this session may not read it. */
|
|
25
|
+
export async function loadResumable(store, sessionId, model) {
|
|
26
|
+
const doc = await store.load(sessionId);
|
|
27
|
+
if (doc === null) {
|
|
28
|
+
throw new AgentSessionError(`Session "${sessionId}" has no document in the transcript store`, "AGENT_SESSION_NOT_FOUND", { sessionId });
|
|
29
|
+
}
|
|
30
|
+
const version = doc.schemaVersion;
|
|
31
|
+
if (version !== undefined && version !== 1) {
|
|
32
|
+
throw new AgentSessionError(`Session "${sessionId}" has transcript schemaVersion ${String(version)}; this build reads 1`, "AGENT_SESSION_SCHEMA_UNSUPPORTED", { sessionId });
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(doc.messages)) {
|
|
35
|
+
throw new NaxError(`transcript for session "${sessionId}" is unreadable: messages is not an array`, "TRANSCRIPT_CORRUPT", { stage: "agent-session", sessionId });
|
|
36
|
+
}
|
|
37
|
+
const resuming = transcriptModelIdentity(model);
|
|
38
|
+
if (doc.model !== undefined && doc.model !== resuming) {
|
|
39
|
+
throw new AgentSessionError(`Session "${sessionId}" was written by model "${doc.model}"; resume it with that model, not "${resuming}"`, "AGENT_SESSION_MODEL_MISMATCH", { sessionId });
|
|
40
|
+
}
|
|
41
|
+
return doc;
|
|
42
|
+
}
|
|
43
|
+
/** The turn a dead process left running (spec 6.4), or undefined. */
|
|
44
|
+
export function interruptedTurnOf(doc) {
|
|
45
|
+
const turn = doc.turn;
|
|
46
|
+
return turn?.state === "running" && typeof turn.turnId === "string" ? turn.turnId : undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { LoopHandlerContext, LoopHandlerSet } from "#src/native/session/loop-events/types";
|
|
2
|
+
import type { TranscriptStore } from "#src/native/session/transcript-types";
|
|
3
|
+
import type { CodingTool } from "#src/tools/registry";
|
|
4
|
+
import type { SessionEvent, SessionEventBody, TurnEndStatus } from "./agent-session-types.ts";
|
|
5
|
+
import type { InteractionHandler } from "./interaction-handler.ts";
|
|
6
|
+
import { type AgentSessionAdapter, type SessionHandle, type TurnResult } from "./session-types.ts";
|
|
7
|
+
/** The ask_human budget per turn: nax's agent.maxInteractionTurns default. */
|
|
8
|
+
export declare const ASK_HUMAN_BUDGET = 10;
|
|
9
|
+
export interface TurnRunContext {
|
|
10
|
+
readonly sessionId: string;
|
|
11
|
+
readonly adapter: AgentSessionAdapter;
|
|
12
|
+
readonly handle: SessionHandle;
|
|
13
|
+
readonly store: TranscriptStore;
|
|
14
|
+
readonly codingTools: readonly CodingTool[];
|
|
15
|
+
readonly interactionHandler: InteractionHandler;
|
|
16
|
+
readonly loopHandlers: LoopHandlerSet | undefined;
|
|
17
|
+
readonly loopHandlerContext: LoopHandlerContext;
|
|
18
|
+
readonly turnTimeoutSeconds: number;
|
|
19
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
20
|
+
}
|
|
21
|
+
/** The running turn, as the ask link and the interaction handler see it. */
|
|
22
|
+
export interface LiveTurn {
|
|
23
|
+
readonly turnId: string;
|
|
24
|
+
readonly signal: AbortSignal;
|
|
25
|
+
emit(body: SessionEventBody): void;
|
|
26
|
+
}
|
|
27
|
+
export interface ClaimedTurn {
|
|
28
|
+
readonly turnId: string;
|
|
29
|
+
readonly iterable: AsyncIterable<SessionEvent>;
|
|
30
|
+
/** Resolves once the turn has ended, or at once for a claim voided before it started. */
|
|
31
|
+
readonly settled: Promise<void>;
|
|
32
|
+
cancel(reason: string): void;
|
|
33
|
+
}
|
|
34
|
+
export interface ClaimTurnHooks {
|
|
35
|
+
readonly onStart: (live: LiveTurn) => void;
|
|
36
|
+
/** Runs before turn_end is emitted. `status` is undefined for a voided claim. */
|
|
37
|
+
readonly onSettle: (turnId: string, status: TurnEndStatus | undefined) => void;
|
|
38
|
+
}
|
|
39
|
+
type TurnEndBody = Extract<SessionEventBody, {
|
|
40
|
+
readonly type: "turn_end";
|
|
41
|
+
}>;
|
|
42
|
+
/** Maps a returned TurnResult to turn_end. Exported for its unit test. */
|
|
43
|
+
export declare function turnEndFromResult(result: TurnResult): TurnEndBody;
|
|
44
|
+
export declare function claimTurn(ctx: TurnRunContext, message: string, hooks: ClaimTurnHooks): ClaimedTurn;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { NaxError } from "#src/infra/nax-error";
|
|
2
|
+
import { redactSecrets } from "#src/internal/redact";
|
|
3
|
+
import { readNativeTurnFailureUsage } from "#src/native/session/turn-types";
|
|
4
|
+
import { _agentSessionDeps } from "./agent-session-deps.js";
|
|
5
|
+
import { AgentSessionError } from "./agent-session-errors.js";
|
|
6
|
+
import { createSessionEventChannel } from "./session-event-channel.js";
|
|
7
|
+
import { SessionTurnError } from "./session-types.js";
|
|
8
|
+
/** The ask_human budget per turn: nax's agent.maxInteractionTurns default. */
|
|
9
|
+
export const ASK_HUMAN_BUDGET = 10;
|
|
10
|
+
/** Codes of the turn signal's abort reasons. AbortSignal keeps the first reason, so the first cause wins. */
|
|
11
|
+
const CANCELLED = "AGENT_SESSION_CANCELLED";
|
|
12
|
+
const TIMED_OUT = "AGENT_SESSION_TURN_TIMEOUT";
|
|
13
|
+
const STALLED = "AGENT_SESSION_CONSUMER_STALLED";
|
|
14
|
+
const ZERO_USAGE = { inputTokens: 0, outputTokens: 0 };
|
|
15
|
+
function abortReason(code, message) {
|
|
16
|
+
return new NaxError(message, code, { stage: "agent-session" });
|
|
17
|
+
}
|
|
18
|
+
function abortCode(signal) {
|
|
19
|
+
return signal.aborted && signal.reason instanceof NaxError ? signal.reason.code : undefined;
|
|
20
|
+
}
|
|
21
|
+
function failure(code, message) {
|
|
22
|
+
return { code, message: redactSecrets(message) };
|
|
23
|
+
}
|
|
24
|
+
function haltOf(result) {
|
|
25
|
+
if (result.spinStopped === true) {
|
|
26
|
+
return failure("AGENT_SESSION_SPIN_STOPPED", "The turn was stopped: the agent repeated calls without progress.");
|
|
27
|
+
}
|
|
28
|
+
if (result.invalidCallBudgetExceeded === true) {
|
|
29
|
+
const call = result.invalidToolCall;
|
|
30
|
+
const detail = call === undefined ? "" : ` (${call.tool}.${call.property}: expected ${call.expected}, got ${call.actual})`;
|
|
31
|
+
return failure("AGENT_SESSION_INVALID_TOOL_CALLS", `The turn was stopped after repeated invalid tool calls${detail}.`);
|
|
32
|
+
}
|
|
33
|
+
if (result.turnIncomplete === true) {
|
|
34
|
+
return failure("AGENT_SESSION_TURN_INCOMPLETE", "The turn ended with tool calls still pending.");
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
/** Maps a returned TurnResult to turn_end. Exported for its unit test. */
|
|
39
|
+
export function turnEndFromResult(result) {
|
|
40
|
+
const base = {
|
|
41
|
+
type: "turn_end",
|
|
42
|
+
output: result.output,
|
|
43
|
+
usage: result.tokenUsage,
|
|
44
|
+
costUsd: result.exactCostUsd ?? result.estimatedCostUsd,
|
|
45
|
+
};
|
|
46
|
+
if (result.timedOut === true)
|
|
47
|
+
return { ...base, status: "timed_out" };
|
|
48
|
+
const halt = haltOf(result);
|
|
49
|
+
return halt === undefined ? { ...base, status: "completed" } : { ...base, status: "errored", error: halt };
|
|
50
|
+
}
|
|
51
|
+
function errorOf(err) {
|
|
52
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
53
|
+
if (err instanceof SessionTurnError && err.adapterFailure !== undefined)
|
|
54
|
+
return failure(err.adapterFailure.outcome, message);
|
|
55
|
+
if (err instanceof NaxError)
|
|
56
|
+
return failure(err.code, message);
|
|
57
|
+
return failure("AGENT_SESSION_TURN_FAILED", message);
|
|
58
|
+
}
|
|
59
|
+
function spendOf(err) {
|
|
60
|
+
const recorded = readNativeTurnFailureUsage(err);
|
|
61
|
+
if (recorded !== undefined)
|
|
62
|
+
return { usage: recorded.tokenUsage, costUsd: recorded.costUsd };
|
|
63
|
+
if (err instanceof SessionTurnError)
|
|
64
|
+
return { usage: err.tokenUsage ?? ZERO_USAGE, costUsd: err.estimatedCostUsd ?? 0 };
|
|
65
|
+
return { usage: ZERO_USAGE, costUsd: 0 };
|
|
66
|
+
}
|
|
67
|
+
function fromError(err, signal) {
|
|
68
|
+
const base = { type: "turn_end", output: "", ...spendOf(err) };
|
|
69
|
+
const code = abortCode(signal);
|
|
70
|
+
if (code === STALLED) {
|
|
71
|
+
const message = `More than ${_agentSessionDeps.controlEventCap} control events went undelivered; the turn was cancelled.`;
|
|
72
|
+
return { ...base, status: "errored", error: failure(STALLED, message) };
|
|
73
|
+
}
|
|
74
|
+
if (code === TIMED_OUT)
|
|
75
|
+
return { ...base, status: "timed_out" };
|
|
76
|
+
if (signal.aborted)
|
|
77
|
+
return { ...base, status: "cancelled" };
|
|
78
|
+
return { ...base, status: "errored", error: errorOf(err) };
|
|
79
|
+
}
|
|
80
|
+
function sendTurn(ctx, live, message) {
|
|
81
|
+
return ctx.adapter.sendTurn(ctx.handle, message, {
|
|
82
|
+
interactionHandler: ctx.interactionHandler,
|
|
83
|
+
codingTools: ctx.codingTools,
|
|
84
|
+
maxInteractions: ASK_HUMAN_BUDGET,
|
|
85
|
+
turnId: live.turnId,
|
|
86
|
+
signal: live.signal,
|
|
87
|
+
onTurnEvent: (event) => live.emit(event),
|
|
88
|
+
loopHandlerContext: ctx.loopHandlerContext,
|
|
89
|
+
...(ctx.loopHandlers !== undefined ? { loopHandlers: ctx.loopHandlers } : {}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/** Writes the turn marker; a failure is returned, not thrown. */
|
|
93
|
+
async function markTurn(ctx, turnId, state) {
|
|
94
|
+
try {
|
|
95
|
+
await ctx.store.markTurn(ctx.sessionId, { turnId, state });
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
return { error };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function runTurn(ctx, live, message) {
|
|
103
|
+
live.emit({ type: "turn_start" });
|
|
104
|
+
const started = await markTurn(ctx, live.turnId, "running");
|
|
105
|
+
if (started !== undefined)
|
|
106
|
+
return fromError(started.error, live.signal);
|
|
107
|
+
let end;
|
|
108
|
+
try {
|
|
109
|
+
end = turnEndFromResult(await sendTurn(ctx, live, message));
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
end = fromError(err, live.signal);
|
|
113
|
+
}
|
|
114
|
+
const ended = await markTurn(ctx, live.turnId, "ended");
|
|
115
|
+
// A failed end marker fails the turn (spec 5.5) but keeps its output and spend.
|
|
116
|
+
return ended === undefined ? end : { ...end, status: "errored", error: errorOf(ended.error) };
|
|
117
|
+
}
|
|
118
|
+
function singleUse(iterator, sessionId) {
|
|
119
|
+
let taken = false;
|
|
120
|
+
return {
|
|
121
|
+
[Symbol.asyncIterator]() {
|
|
122
|
+
if (taken) {
|
|
123
|
+
throw new AgentSessionError("send() returns a single-use iterable; iterate it once", "AGENT_SESSION_BUSY", {
|
|
124
|
+
sessionId,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
taken = true;
|
|
128
|
+
return iterator;
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
export function claimTurn(ctx, message, hooks) {
|
|
133
|
+
const turnId = _agentSessionDeps.randomUUID();
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
let state = "claimed";
|
|
136
|
+
let resolveSettled = () => { };
|
|
137
|
+
const settled = new Promise((resolve) => {
|
|
138
|
+
resolveSettled = resolve;
|
|
139
|
+
});
|
|
140
|
+
const channel = createSessionEventChannel({
|
|
141
|
+
controlCap: _agentSessionDeps.controlEventCap,
|
|
142
|
+
onFirstPull: () => start(),
|
|
143
|
+
onReturn: () => cancel("iterator closed"),
|
|
144
|
+
onStall: () => controller.abort(abortReason(STALLED, "consumer stalled")),
|
|
145
|
+
});
|
|
146
|
+
const emit = (body) => {
|
|
147
|
+
const at = new Date(_agentSessionDeps.now()).toISOString();
|
|
148
|
+
channel.push({ sessionId: ctx.sessionId, turnId, at, metadata: ctx.metadata, ...body });
|
|
149
|
+
};
|
|
150
|
+
const live = { turnId, signal: controller.signal, emit };
|
|
151
|
+
function settle(end) {
|
|
152
|
+
state = "settled";
|
|
153
|
+
hooks.onSettle(turnId, end?.status);
|
|
154
|
+
if (end !== undefined)
|
|
155
|
+
emit(end);
|
|
156
|
+
channel.end();
|
|
157
|
+
resolveSettled();
|
|
158
|
+
}
|
|
159
|
+
function start() {
|
|
160
|
+
if (state !== "claimed")
|
|
161
|
+
return;
|
|
162
|
+
state = "running";
|
|
163
|
+
const timer = _agentSessionDeps.setTimeout(() => controller.abort(abortReason(TIMED_OUT, "turn deadline")), ctx.turnTimeoutSeconds * 1000);
|
|
164
|
+
hooks.onStart(live);
|
|
165
|
+
void runTurn(ctx, live, message)
|
|
166
|
+
.catch((err) => fromError(err, controller.signal))
|
|
167
|
+
.then((end) => {
|
|
168
|
+
_agentSessionDeps.clearTimeout(timer);
|
|
169
|
+
settle(end);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function cancel(reason) {
|
|
173
|
+
if (state === "claimed")
|
|
174
|
+
settle(undefined);
|
|
175
|
+
else if (state === "running")
|
|
176
|
+
controller.abort(abortReason(CANCELLED, reason));
|
|
177
|
+
}
|
|
178
|
+
return { turnId, iterable: singleUse(channel.iterator, ctx.sessionId), settled, cancel };
|
|
179
|
+
}
|
|
@@ -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
|
+
* acpx backend (S4) reuses these types unchanged.
|
|
5
|
+
*/
|
|
6
|
+
import type { CommandInterceptor } from "#src/command-interceptor/index";
|
|
7
|
+
import type { BashApprovalMode } from "#src/config/bash-approval";
|
|
8
|
+
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
|
+
import type { TranscriptStore } from "#src/native/session/transcript-types";
|
|
13
|
+
import type { ProtectedPathsPolicy } from "#src/tools/protected-paths";
|
|
14
|
+
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";
|
|
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
|
+
readonly backend: "native";
|
|
49
|
+
/** The store key. Generated when absent. Letters, digits, `.`, `_`, `-`; starts with a letter or digit; at most 128 characters. */
|
|
50
|
+
readonly sessionId?: string;
|
|
51
|
+
/** `"provider/model"`, optionally with an `[effort]` suffix. */
|
|
52
|
+
readonly model: string;
|
|
53
|
+
readonly profile: AgentSessionProfile;
|
|
54
|
+
/** An absolute directory. Required for `read` and `full`; `none` gets a private temporary root. */
|
|
55
|
+
readonly workdir?: string;
|
|
56
|
+
/** The embedder's system prompt. */
|
|
57
|
+
readonly instructions?: string;
|
|
58
|
+
readonly tools?: readonly EmbedderTool[];
|
|
59
|
+
/**
|
|
60
|
+
* Where the conversation lives. The session keeps its document on close, so
|
|
61
|
+
* `resumeAgentSession` can reopen it. A file store's failed-close prune counts
|
|
62
|
+
* every transcript in its directory, so do not share a file-store directory
|
|
63
|
+
* with sessions that are closed as failed.
|
|
64
|
+
*/
|
|
65
|
+
readonly transcriptStore: TranscriptStore;
|
|
66
|
+
/** How long an approval or question waits. Default 600000; 30000..3600000. */
|
|
67
|
+
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
|
+
/** Per-turn wall clock. Default 3600; 1..86400. */
|
|
80
|
+
readonly turnTimeoutSeconds?: number;
|
|
81
|
+
}
|
|
82
|
+
export type TurnEndStatus = "completed" | "cancelled" | "timed_out" | "interrupted" | "errored";
|
|
83
|
+
export type ApprovalDecidedBy = "human" | "timeout" | "cancelled" | "unavailable" | "unshowable";
|
|
84
|
+
export interface SessionEventBase {
|
|
85
|
+
readonly sessionId: string;
|
|
86
|
+
readonly turnId: string;
|
|
87
|
+
/** ISO 8601. */
|
|
88
|
+
readonly at: string;
|
|
89
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
90
|
+
}
|
|
91
|
+
export type SessionEventBody = {
|
|
92
|
+
readonly type: "turn_start";
|
|
93
|
+
} | {
|
|
94
|
+
readonly type: "text_delta";
|
|
95
|
+
readonly round: number;
|
|
96
|
+
readonly text: string;
|
|
97
|
+
} | {
|
|
98
|
+
readonly type: "thinking_delta";
|
|
99
|
+
readonly round: number;
|
|
100
|
+
readonly text: string;
|
|
101
|
+
} | {
|
|
102
|
+
readonly type: "stream_reset";
|
|
103
|
+
readonly round: number;
|
|
104
|
+
readonly attempt: number;
|
|
105
|
+
} | {
|
|
106
|
+
readonly type: "tool_call";
|
|
107
|
+
readonly callId: string;
|
|
108
|
+
readonly name: string;
|
|
109
|
+
readonly input: unknown;
|
|
110
|
+
} | {
|
|
111
|
+
readonly type: "tool_result";
|
|
112
|
+
readonly callId: string;
|
|
113
|
+
readonly isError: boolean;
|
|
114
|
+
readonly preview: string;
|
|
115
|
+
} | {
|
|
116
|
+
readonly type: "approval_requested";
|
|
117
|
+
readonly requestId: string;
|
|
118
|
+
readonly callId?: string;
|
|
119
|
+
readonly tool: string;
|
|
120
|
+
readonly summary: string;
|
|
121
|
+
readonly command?: string;
|
|
122
|
+
readonly reason: string;
|
|
123
|
+
readonly expiresAt: string;
|
|
124
|
+
} | {
|
|
125
|
+
readonly type: "approval_resolved";
|
|
126
|
+
readonly requestId: string;
|
|
127
|
+
readonly decision: "allow" | "deny";
|
|
128
|
+
readonly decidedBy: ApprovalDecidedBy;
|
|
129
|
+
} | {
|
|
130
|
+
readonly type: "question";
|
|
131
|
+
readonly requestId: string;
|
|
132
|
+
readonly text: string;
|
|
133
|
+
readonly expiresAt: string;
|
|
134
|
+
} | {
|
|
135
|
+
readonly type: "usage";
|
|
136
|
+
readonly round: number;
|
|
137
|
+
readonly inputTokens: number;
|
|
138
|
+
readonly outputTokens: number;
|
|
139
|
+
readonly cacheRead?: number;
|
|
140
|
+
readonly cacheWrite?: number;
|
|
141
|
+
readonly costUsd: number;
|
|
142
|
+
} | {
|
|
143
|
+
readonly type: "compaction";
|
|
144
|
+
readonly reason: "proactive" | "overflow";
|
|
145
|
+
} | {
|
|
146
|
+
readonly type: "turn_end";
|
|
147
|
+
readonly status: TurnEndStatus;
|
|
148
|
+
/** The final round's text, as `TurnResult.output`. Empty when the turn did not complete. */
|
|
149
|
+
readonly output: string;
|
|
150
|
+
readonly usage: TokenUsage;
|
|
151
|
+
readonly costUsd: number;
|
|
152
|
+
readonly error?: {
|
|
153
|
+
readonly code: string;
|
|
154
|
+
readonly message: string;
|
|
155
|
+
};
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* One event of a `send()` (spec 4.4). Deltas are provisional: the transcript
|
|
159
|
+
* and `turn_end.output` are authoritative. `turn_end` is always last.
|
|
160
|
+
*/
|
|
161
|
+
export type SessionEvent = SessionEventBase & SessionEventBody;
|
|
162
|
+
export type AnswerReply = {
|
|
163
|
+
readonly decision: "allow" | "deny";
|
|
164
|
+
} | {
|
|
165
|
+
readonly text: string;
|
|
166
|
+
};
|
|
167
|
+
export type AnswerStatus = "accepted" | "expired" | "cancelled" | "unknown";
|
|
168
|
+
export interface AgentSession {
|
|
169
|
+
readonly id: string;
|
|
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>;
|