@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.
Files changed (129) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +56 -2
  3. package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
  4. package/dist/coding-tools/coding-tool-sandbox.js +14 -3
  5. package/dist/coding-tools/coding-tool-support.d.ts +3 -0
  6. package/dist/coding-tools/coding-tool-support.js +2 -0
  7. package/dist/index.d.ts +21 -3
  8. package/dist/index.js +13 -3
  9. package/dist/infra/stderr-tail.d.ts +8 -0
  10. package/dist/infra/stderr-tail.js +48 -0
  11. package/dist/internal/redact.d.ts +23 -0
  12. package/dist/internal/redact.js +60 -0
  13. package/dist/internal.d.ts +7 -0
  14. package/dist/internal.js +8 -0
  15. package/dist/native/adapter-deps.d.ts +3 -3
  16. package/dist/native/adapter-deps.js +3 -3
  17. package/dist/native/client.d.ts +7 -1
  18. package/dist/native/client.js +1 -1
  19. package/dist/native/credentials/change-guard.d.ts +8 -1
  20. package/dist/native/credentials/change-guard.js +1 -1
  21. package/dist/native/credentials/fingerprint.d.ts +4 -1
  22. package/dist/native/credentials/fingerprint.js +6 -3
  23. package/dist/native/credentials/session-source.d.ts +12 -0
  24. package/dist/native/credentials/session-source.js +33 -0
  25. package/dist/native/index.d.ts +3 -1
  26. package/dist/native/index.js +2 -1
  27. package/dist/native/session/loop-events/types.d.ts +1 -1
  28. package/dist/native/session/loop-handlers.d.ts +3 -0
  29. package/dist/native/session/loop-handlers.js +1 -1
  30. package/dist/native/session/memory-transcript-store.d.ts +12 -0
  31. package/dist/native/session/memory-transcript-store.js +36 -0
  32. package/dist/native/session/session.d.ts +138 -96
  33. package/dist/native/session/session.js +107 -127
  34. package/dist/native/session/transcript-identity.d.ts +39 -0
  35. package/dist/native/session/transcript-identity.js +81 -0
  36. package/dist/native/session/transcript-store.d.ts +22 -21
  37. package/dist/native/session/transcript-store.js +59 -68
  38. package/dist/native/session/transcript-types.d.ts +54 -0
  39. package/dist/native/session/transcript-types.js +7 -0
  40. package/dist/native/session/truncation-handler.d.ts +4 -3
  41. package/dist/native/session/truncation-handler.js +7 -10
  42. package/dist/native/session/turn-complete-step.d.ts +4 -1
  43. package/dist/native/session/turn-complete-step.js +11 -4
  44. package/dist/native/session/turn-event-emitter.d.ts +48 -0
  45. package/dist/native/session/turn-event-emitter.js +134 -0
  46. package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
  47. package/dist/native/session/turn-loop-round-trip.js +8 -4
  48. package/dist/native/session/turn-loop.js +27 -13
  49. package/dist/native/session/turn-result.d.ts +1 -1
  50. package/dist/native/session/turn-result.js +1 -1
  51. package/dist/native/session/turn-tool-batch.d.ts +3 -0
  52. package/dist/native/session/turn-tool-batch.js +14 -6
  53. package/dist/native/session/turn-types.d.ts +17 -1
  54. package/dist/native/session-adapter.d.ts +22 -4
  55. package/dist/native/session-adapter.js +79 -35
  56. package/dist/native/stream-complete.d.ts +17 -0
  57. package/dist/native/stream-complete.js +40 -0
  58. package/dist/permissions/approvals-link.js +5 -4
  59. package/dist/permissions/ask-chain.d.ts +1 -1
  60. package/dist/permissions/ask.d.ts +6 -0
  61. package/dist/permissions/ask.js +6 -0
  62. package/dist/permissions/index.d.ts +1 -1
  63. package/dist/permissions/index.js +1 -1
  64. package/dist/sandbox/policy-builder.d.ts +6 -1
  65. package/dist/sandbox/policy-builder.js +18 -14
  66. package/dist/session/agent-session-deps.d.ts +12 -0
  67. package/dist/session/agent-session-deps.js +28 -0
  68. package/dist/session/agent-session-errors.d.ts +11 -0
  69. package/dist/session/agent-session-errors.js +12 -0
  70. package/dist/session/agent-session-options.d.ts +12 -0
  71. package/dist/session/agent-session-options.js +96 -0
  72. package/dist/session/agent-session-resume.d.ts +14 -0
  73. package/dist/session/agent-session-resume.js +49 -0
  74. package/dist/session/agent-session-turn.d.ts +41 -0
  75. package/dist/session/agent-session-turn.js +177 -0
  76. package/dist/session/agent-session-types.d.ts +182 -0
  77. package/dist/session/agent-session-types.js +1 -0
  78. package/dist/session/agent-session.d.ts +8 -0
  79. package/dist/session/agent-session.js +184 -0
  80. package/dist/session/native-backend-options.d.ts +24 -0
  81. package/dist/session/native-backend-options.js +81 -0
  82. package/dist/session/native-backend.d.ts +4 -0
  83. package/dist/session/native-backend.js +106 -0
  84. package/dist/session/pending-asks.d.ts +24 -0
  85. package/dist/session/pending-asks.js +77 -0
  86. package/dist/session/session-ask-link.d.ts +33 -0
  87. package/dist/session/session-ask-link.js +53 -0
  88. package/dist/session/session-ask-port.d.ts +12 -0
  89. package/dist/session/session-ask-port.js +64 -0
  90. package/dist/session/session-backend.d.ts +79 -0
  91. package/dist/session/session-backend.js +1 -0
  92. package/dist/session/session-event-channel.d.ts +17 -0
  93. package/dist/session/session-event-channel.js +132 -0
  94. package/dist/session/session-interaction.d.ts +20 -0
  95. package/dist/session/session-interaction.js +161 -0
  96. package/dist/session/session-tool-support.d.ts +48 -0
  97. package/dist/session/session-tool-support.js +104 -0
  98. package/dist/session/session-types.d.ts +33 -2
  99. package/dist/session/turn-event.d.ts +66 -0
  100. package/dist/session/turn-event.js +1 -0
  101. package/dist/tools/ask-request.js +3 -1
  102. package/dist/tools/bash-cwd.d.ts +3 -3
  103. package/dist/tools/credential-read-deny.d.ts +22 -0
  104. package/dist/tools/credential-read-deny.js +56 -0
  105. package/dist/tools/glob.js +9 -2
  106. package/dist/tools/grep.js +55 -26
  107. package/dist/tools/owned-paths.d.ts +43 -0
  108. package/dist/tools/owned-paths.js +17 -0
  109. package/dist/tools/package-managers.d.ts +3 -0
  110. package/dist/tools/package-managers.js +7 -3
  111. package/dist/tools/policy-bash-raw.d.ts +3 -0
  112. package/dist/tools/policy-bash-raw.js +49 -62
  113. package/dist/tools/policy-command-branch.d.ts +3 -0
  114. package/dist/tools/policy-command-branch.js +2 -1
  115. package/dist/tools/policy-paths-branch.d.ts +14 -8
  116. package/dist/tools/policy-paths-branch.js +20 -19
  117. package/dist/tools/policy.d.ts +10 -7
  118. package/dist/tools/policy.js +9 -7
  119. package/dist/tools/protected-paths.d.ts +6 -6
  120. package/dist/tools/read.js +38 -20
  121. package/dist/tools/registry.d.ts +3 -0
  122. package/dist/tools/run-command-exec.js +1 -0
  123. package/dist/tools/runtime.d.ts +3 -0
  124. package/dist/tools/runtime.js +1 -0
  125. package/dist/tools/scratchpad.js +2 -1
  126. package/dist/tools/types.d.ts +2 -2
  127. package/package.json +2 -2
  128. package/dist/tools/nax-owned-writes.d.ts +0 -135
  129. package/dist/tools/nax-owned-writes.js +0 -259
@@ -10,7 +10,6 @@ import { isAbsolute, join, resolve } from "node:path";
10
10
  import { NaxError } from "#src/infra/index";
11
11
  import { realOrRaw } from "#src/internal/realpath";
12
12
  import { SANDBOX_GLOB_CHARS } from "../config/schemas-sandbox.js";
13
- import { NAX_ALWAYS_DENIED_ENTRIES, NAX_SCRATCHPAD_ENTRY, naxWriteOptIns, QUEUE_CONTROL_FILES, } from "../tools/nax-owned-writes.js";
14
13
  import { BUILTIN_CACHE_WRITE_ROOTS, BUILTIN_CREDENTIAL_READ_DENIES, MACOS_CACHE_WRITE_ROOT, SRT_MACOS_TMPDIR, SRT_MACOS_TMPDIR_DENIES, } from "./defaults.js";
15
14
  import { WORKTREE_COMMON_WRITE_DIRS, WORKTREE_CONFIG_FILE } from "./git-guards.js";
16
15
  function expandHome(p, home) {
@@ -62,13 +61,15 @@ function worktreeGitWriteRoots(git) {
62
61
  return [git.gitDir, ...WORKTREE_COMMON_WRITE_DIRS.map((dir) => join(git.commonDir, dir))];
63
62
  }
64
63
  /**
65
- * nax#2260: every top-level `.nax/` entry, whole, plus the ones nax reads even
66
- * when absent -- except the scratchpad and entries opted in via allowWrite.
67
- * srt applies deny over allow and on Linux turns every literal deny into a
68
- * bind mount, so whole entries (one `features` deny, not one per PRD) keep
69
- * both the protection and the per-command cost bounded.
64
+ * nax#2260: every top-level project-state entry, whole, plus the ones the host
65
+ * loads even when absent (`ownedPaths.deniedEntries`) -- except the scratchpad
66
+ * and entries opted in via allowWrite. The ENTRIES come from the owned-paths
67
+ * port; the DIRECTORY comes from `projectStateDir`, and its absence denies
68
+ * nothing. srt applies deny over allow and on Linux turns every literal deny
69
+ * into a bind mount, so whole entries (one `features` deny, not one per PRD)
70
+ * keep both the protection and the per-command cost bounded.
70
71
  *
71
- * Only `<root>/.nax` is covered. A monorepo package's own `.nax/`
72
+ * Only `<root>/<projectStateDir>` is covered. A monorepo package's own `.nax/`
72
73
  * (`packages/<pkg>/.nax/`) is deliberately excluded: what lives there --
73
74
  * cache/, scratchpad/, status.json, the features' acceptance tests and run
74
75
  * artifacts -- is gitignored and rebuilt or regenerated, so deleting it is
@@ -77,11 +78,14 @@ function worktreeGitWriteRoots(git) {
77
78
  * into prompts; covering it needs package discovery, because a `.nax/` at any
78
79
  * depth also matches test-fixture projects (`test/fixtures/<p>/.nax/`).
79
80
  */
80
- function naxDenies(root, entries, allowWrite) {
81
- const optIns = naxWriteOptIns(root, allowWrite);
82
- return [...new Set([...entries, ...NAX_ALWAYS_DENIED_ENTRIES])]
83
- .filter((name) => name !== NAX_SCRATCHPAD_ENTRY && !optIns.has(name))
84
- .map((name) => join(root, ".nax", name));
81
+ function projectStateDenies(input) {
82
+ const { root, projectStateDir, ownedPaths } = input;
83
+ if (projectStateDir === undefined)
84
+ return [];
85
+ const optIns = ownedPaths.writeOptIns(root, input.config.filesystem.allowWrite);
86
+ return [...new Set([...input.naxEntries, ...ownedPaths.deniedEntries])]
87
+ .filter((name) => name !== ownedPaths.scratchpadEntry && !optIns.has(name))
88
+ .map((name) => join(root, projectStateDir, name));
85
89
  }
86
90
  export function buildSandboxPolicy(input) {
87
91
  const { root, home, config } = input;
@@ -131,8 +135,8 @@ export function buildSandboxPolicy(input) {
131
135
  }),
132
136
  ]);
133
137
  const denyWrite = literal([
134
- ...naxDenies(root, input.naxEntries, config.filesystem.allowWrite),
135
- ...[...QUEUE_CONTROL_FILES].map((name) => join(root, name)),
138
+ ...projectStateDenies(input),
139
+ ...input.ownedPaths.rootWriteDenies.map((name) => join(root, name)),
136
140
  ...gitDenies(root, input.git),
137
141
  ...input.gitGuardFiles,
138
142
  ...(input.approvalsFile !== undefined ? [input.approvalsFile] : []),
@@ -0,0 +1,12 @@
1
+ /** Undelivered control events a turn may queue before it is cancelled as stalled (spec 4.4). */
2
+ export declare const MAX_UNDELIVERED_CONTROL_EVENTS = 1000;
3
+ export declare const _agentSessionDeps: {
4
+ now: () => number;
5
+ setTimeout: (fn: () => void, ms: number) => unknown;
6
+ clearTimeout: (handle: unknown) => void;
7
+ randomUUID: () => string;
8
+ isDirectory: (path: string) => Promise<boolean>;
9
+ makeScratchRoot: () => Promise<string>;
10
+ removeScratchRoot: (dir: string) => Promise<void>;
11
+ controlEventCap: number;
12
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Injectable clock, timers, ids and filesystem for the agent session facade.
3
+ * Tests replace members (paired with withDepsRestore) to fire deadlines
4
+ * without sleeping and to lower the control-event cap. Exported on ./internal.
5
+ */
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdtemp, rm, stat } from "node:fs/promises";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ /** Undelivered control events a turn may queue before it is cancelled as stalled (spec 4.4). */
11
+ export const MAX_UNDELIVERED_CONTROL_EVENTS = 1000;
12
+ export const _agentSessionDeps = {
13
+ now: () => Date.now(),
14
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
15
+ clearTimeout: (handle) => clearTimeout(handle),
16
+ randomUUID: () => randomUUID(),
17
+ isDirectory: async (path) => {
18
+ try {
19
+ return (await stat(path)).isDirectory();
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ },
25
+ makeScratchRoot: () => mkdtemp(join(tmpdir(), "nax-agent-session-")),
26
+ removeScratchRoot: (dir) => rm(dir, { recursive: true, force: true }),
27
+ controlEventCap: MAX_UNDELIVERED_CONTROL_EVENTS,
28
+ };
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The agent session facade's public error (S3 spec section 7). Every code is
3
+ * namespaced AGENT_SESSION_* so it cannot collide with nax's SESSION_* codes
4
+ * or registerCodingTool's TOOL_NAME_RESERVED.
5
+ */
6
+ import { NaxError } from "#src/infra/nax-error";
7
+ export type AgentSessionErrorCode = "AGENT_SESSION_INVALID_OPTIONS" | "AGENT_SESSION_EXISTS" | "AGENT_SESSION_BUSY" | "AGENT_SESSION_CLOSED" | "AGENT_SESSION_INVALID_ANSWER" | "AGENT_SESSION_NOT_FOUND" | "AGENT_SESSION_SCHEMA_UNSUPPORTED" | "AGENT_SESSION_MODEL_MISMATCH" | "AGENT_SESSION_SANDBOX_UNAVAILABLE" | "AGENT_SESSION_TOOL_NAME_RESERVED" | "AGENT_SESSION_BACKEND_UNAVAILABLE" | "AGENT_SESSION_AUTH_REQUIRED" | "AGENT_SESSION_CAPABILITY_UNSUPPORTED" | "AGENT_SESSION_BACKEND_MISMATCH";
8
+ export declare class AgentSessionError extends NaxError {
9
+ readonly code: AgentSessionErrorCode;
10
+ constructor(message: string, code: AgentSessionErrorCode, context?: Record<string, unknown>);
11
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The agent session facade's public error (S3 spec section 7). Every code is
3
+ * namespaced AGENT_SESSION_* so it cannot collide with nax's SESSION_* codes
4
+ * or registerCodingTool's TOOL_NAME_RESERVED.
5
+ */
6
+ import { NaxError } from "#src/infra/nax-error";
7
+ export class AgentSessionError extends NaxError {
8
+ constructor(message, code, context = {}) {
9
+ super(message, code, { stage: "agent-session", ...context });
10
+ this.name = "AgentSessionError";
11
+ }
12
+ }
@@ -0,0 +1,12 @@
1
+ import type { CreateAgentSessionOptions, EmbedderTool } from "./agent-session-types.ts";
2
+ export declare const DEFAULT_APPROVAL_TIMEOUT_MS = 600000;
3
+ export declare const DEFAULT_TURN_TIMEOUT_SECONDS = 3600;
4
+ export interface ResolvedAgentSessionOptions {
5
+ /** The caller's object, unchanged (tool objects keep their `this`). */
6
+ readonly raw: CreateAgentSessionOptions;
7
+ readonly approvalTimeoutMs: number;
8
+ readonly turnTimeoutSeconds: number;
9
+ readonly metadata: Readonly<Record<string, string>>;
10
+ readonly tools: readonly EmbedderTool[];
11
+ }
12
+ export declare function resolveAgentSessionOptions(input: unknown): ResolvedAgentSessionOptions;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Validates createAgentSession's shared options (spec 4.1, 7): the backend
3
+ * plus the options every backend reads. Native-only options live on
4
+ * `nativeBackend` (S4). zod checks the shape; the result keeps the caller's
5
+ * own objects (zod returns copies, which would detach a class-based tool's
6
+ * methods from `this`).
7
+ */
8
+ import { isAbsolute } from "node:path";
9
+ import { z } from "zod";
10
+ import { ASK_HUMAN_TOOL_NAME } from "#src/native/session/ask-human";
11
+ import { RESERVED_TOOL_NAMES } from "#src/tools/registry";
12
+ import { AgentSessionError } from "./agent-session-errors.js";
13
+ export const DEFAULT_APPROVAL_TIMEOUT_MS = 600_000;
14
+ export const DEFAULT_TURN_TIMEOUT_SECONDS = 3600;
15
+ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
16
+ const TOOL_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
17
+ const STORE_METHODS = ["load", "save", "retainFailed", "delete", "markTurn"];
18
+ function isRecord(value) {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ }
21
+ function hasMethods(names) {
22
+ return (value) => isRecord(value) && names.every((name) => typeof value[name] === "function");
23
+ }
24
+ const isFunction = (value) => typeof value === "function";
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.custom((value) => isRecord(value) && typeof value.kind === "string" && value.kind.length > 0 && typeof value.open === "function", "must be a SessionBackend (for example nativeBackend({ model }))"),
35
+ sessionId: z
36
+ .string()
37
+ .regex(SESSION_ID, "must be 1-128 letters, digits, '.', '_' or '-', starting with a letter or digit")
38
+ .optional(),
39
+ profile: z.enum(["none", "read", "ask", "full"]),
40
+ workdir: z
41
+ .string()
42
+ .refine((dir) => isAbsolute(dir), "must be an absolute path")
43
+ .optional(),
44
+ instructions: z.string().optional(),
45
+ tools: z.array(EmbedderToolSchema).optional(),
46
+ transcriptStore: z.custom(hasMethods(STORE_METHODS), `must implement ${STORE_METHODS.join(", ")}`),
47
+ approvalTimeoutMs: z.number().int().min(30_000).max(3_600_000).optional(),
48
+ metadata: z.record(z.string(), z.string()).optional(),
49
+ turnTimeoutSeconds: z.number().int().min(1).max(86_400).optional(),
50
+ });
51
+ function invalid(message, context = {}) {
52
+ return new AgentSessionError(`Invalid agent session options: ${message}`, "AGENT_SESSION_INVALID_OPTIONS", context);
53
+ }
54
+ function checkShape(input) {
55
+ if (isRecord(input) && "mcpServers" in input) {
56
+ throw invalid("mcpServers is reserved for a later release (an MCP client for embedder tools)", {
57
+ path: "mcpServers",
58
+ });
59
+ }
60
+ const parsed = OptionsSchema.safeParse(input);
61
+ if (parsed.success)
62
+ return input;
63
+ const issue = parsed.error.issues[0];
64
+ const path = issue === undefined || issue.path.length === 0 ? "options" : issue.path.join(".");
65
+ throw invalid(`${path}: ${issue?.message ?? "invalid"}`, { path });
66
+ }
67
+ function checkProfileRules(options) {
68
+ if (options.profile !== "none" && options.workdir === undefined) {
69
+ throw invalid(`workdir is required for profile "${options.profile}"`, { path: "workdir" });
70
+ }
71
+ }
72
+ function checkToolNames(tools) {
73
+ const reserved = new Set([...RESERVED_TOOL_NAMES, ASK_HUMAN_TOOL_NAME]);
74
+ const seen = new Set();
75
+ for (const tool of tools) {
76
+ if (reserved.has(tool.name)) {
77
+ throw new AgentSessionError(`Embedder tool name "${tool.name}" is reserved for a built-in tool`, "AGENT_SESSION_TOOL_NAME_RESERVED", { tool: tool.name });
78
+ }
79
+ if (seen.has(tool.name))
80
+ throw invalid(`tools: duplicate tool name "${tool.name}"`, { path: "tools" });
81
+ seen.add(tool.name);
82
+ }
83
+ }
84
+ export function resolveAgentSessionOptions(input) {
85
+ const options = checkShape(input);
86
+ checkProfileRules(options);
87
+ const tools = options.tools ?? [];
88
+ checkToolNames(tools);
89
+ return {
90
+ raw: options,
91
+ approvalTimeoutMs: options.approvalTimeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS,
92
+ turnTimeoutSeconds: options.turnTimeoutSeconds ?? DEFAULT_TURN_TIMEOUT_SECONDS,
93
+ metadata: options.metadata ?? {},
94
+ tools,
95
+ };
96
+ }
@@ -0,0 +1,14 @@
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): Promise<TranscriptDoc>;
11
+ /** The stored document's backend (absent means native) must be the resuming backend's (S4 spec 5.5). */
12
+ export declare function checkBackendKind(doc: TranscriptDoc, sessionId: string, kind: string): void;
13
+ /** The turn a dead process left running (spec 6.4), or undefined. */
14
+ export declare function interruptedTurnOf(doc: TranscriptDoc): string | undefined;
@@ -0,0 +1,49 @@
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
+ * backend that wrote it is the one resuming (S4 spec 5.5). A backend's own
6
+ * model rule (an effort suffix is not a different model) runs in its `open`.
7
+ */
8
+ import { NaxError } from "#src/infra/nax-error";
9
+ import { AgentSessionError } from "./agent-session-errors.js";
10
+ /**
11
+ * The options to validate for a resume: the argument is the session id. A
12
+ * different `options.sessionId` is a caller bug, refused before the store is
13
+ * read. Non-object options pass through for the option validator to reject.
14
+ */
15
+ export function resumeInput(sessionId, options) {
16
+ if (typeof options !== "object" || options === null)
17
+ return options;
18
+ if (options.sessionId !== undefined && options.sessionId !== sessionId) {
19
+ throw new AgentSessionError(`Invalid agent session options: sessionId "${options.sessionId}" is not the session being resumed ("${sessionId}")`, "AGENT_SESSION_INVALID_OPTIONS", { path: "sessionId" });
20
+ }
21
+ return { ...options, sessionId };
22
+ }
23
+ /** The stored document a resume starts from. Throws when there is none or this session may not read it. */
24
+ export async function loadResumable(store, sessionId) {
25
+ const doc = await store.load(sessionId);
26
+ if (doc === null) {
27
+ throw new AgentSessionError(`Session "${sessionId}" has no document in the transcript store`, "AGENT_SESSION_NOT_FOUND", { sessionId });
28
+ }
29
+ const version = doc.schemaVersion;
30
+ if (version !== undefined && version !== 1) {
31
+ throw new AgentSessionError(`Session "${sessionId}" has transcript schemaVersion ${String(version)}; this build reads 1`, "AGENT_SESSION_SCHEMA_UNSUPPORTED", { sessionId });
32
+ }
33
+ if (!Array.isArray(doc.messages)) {
34
+ throw new NaxError(`transcript for session "${sessionId}" is unreadable: messages is not an array`, "TRANSCRIPT_CORRUPT", { stage: "agent-session", sessionId });
35
+ }
36
+ return doc;
37
+ }
38
+ /** The stored document's backend (absent means native) must be the resuming backend's (S4 spec 5.5). */
39
+ export function checkBackendKind(doc, sessionId, kind) {
40
+ const stored = doc.backend ?? "native";
41
+ if (stored !== kind) {
42
+ throw new AgentSessionError(`Session "${sessionId}" was written by backend "${stored}"; it cannot be resumed with "${kind}"`, "AGENT_SESSION_BACKEND_MISMATCH", { sessionId, stored, kind });
43
+ }
44
+ }
45
+ /** The turn a dead process left running (spec 6.4), or undefined. */
46
+ export function interruptedTurnOf(doc) {
47
+ const turn = doc.turn;
48
+ return turn?.state === "running" && typeof turn.turnId === "string" ? turn.turnId : undefined;
49
+ }
@@ -0,0 +1,41 @@
1
+ import type { TranscriptStore } from "#src/native/session/transcript-types";
2
+ import type { SessionEvent, SessionEventBody, TurnEndStatus } from "./agent-session-types.ts";
3
+ import type { TurnContribution } from "./session-backend.ts";
4
+ import { type AgentSessionAdapter, type SessionHandle, type TurnResult } from "./session-types.ts";
5
+ /** The ask_human budget per turn: nax's agent.maxInteractionTurns default. */
6
+ export declare const ASK_HUMAN_BUDGET = 10;
7
+ export interface TurnRunContext {
8
+ readonly sessionId: string;
9
+ readonly adapter: AgentSessionAdapter;
10
+ readonly handle: SessionHandle;
11
+ readonly store: TranscriptStore;
12
+ /** The backend's per-turn contribution, read at each sendTurn. */
13
+ readonly turnOpts: () => TurnContribution;
14
+ readonly turnTimeoutSeconds: number;
15
+ readonly metadata: Readonly<Record<string, string>>;
16
+ }
17
+ /** The running turn, as the ask link and the interaction handler see it. */
18
+ export interface LiveTurn {
19
+ readonly turnId: string;
20
+ readonly signal: AbortSignal;
21
+ emit(body: SessionEventBody): void;
22
+ }
23
+ export interface ClaimedTurn {
24
+ readonly turnId: string;
25
+ readonly iterable: AsyncIterable<SessionEvent>;
26
+ /** Resolves once the turn has ended, or at once for a claim voided before it started. */
27
+ readonly settled: Promise<void>;
28
+ cancel(reason: string): void;
29
+ }
30
+ export interface ClaimTurnHooks {
31
+ readonly onStart: (live: LiveTurn) => void;
32
+ /** Runs before turn_end is emitted. `status` is undefined for a voided claim. */
33
+ readonly onSettle: (turnId: string, status: TurnEndStatus | undefined) => void;
34
+ }
35
+ type TurnEndBody = Extract<SessionEventBody, {
36
+ readonly type: "turn_end";
37
+ }>;
38
+ /** Maps a returned TurnResult to turn_end. Exported for its unit test. */
39
+ export declare function turnEndFromResult(result: TurnResult): TurnEndBody;
40
+ export declare function claimTurn(ctx: TurnRunContext, message: string, hooks: ClaimTurnHooks): ClaimedTurn;
41
+ export {};
@@ -0,0 +1,177 @@
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
+ ...(result.costSource !== undefined ? { costSource: result.costSource } : {}),
46
+ };
47
+ if (result.timedOut === true)
48
+ return { ...base, status: "timed_out" };
49
+ const halt = haltOf(result);
50
+ return halt === undefined ? { ...base, status: "completed" } : { ...base, status: "errored", error: halt };
51
+ }
52
+ function errorOf(err) {
53
+ const message = err instanceof Error ? err.message : String(err);
54
+ if (err instanceof SessionTurnError && err.adapterFailure !== undefined)
55
+ return failure(err.adapterFailure.outcome, message);
56
+ if (err instanceof NaxError)
57
+ return failure(err.code, message);
58
+ return failure("AGENT_SESSION_TURN_FAILED", message);
59
+ }
60
+ function spendOf(err) {
61
+ const recorded = readNativeTurnFailureUsage(err);
62
+ if (recorded !== undefined)
63
+ return { usage: recorded.tokenUsage, costUsd: recorded.costUsd };
64
+ if (err instanceof SessionTurnError)
65
+ return { usage: err.tokenUsage ?? ZERO_USAGE, costUsd: err.estimatedCostUsd ?? 0 };
66
+ return { usage: ZERO_USAGE, costUsd: 0 };
67
+ }
68
+ function fromError(err, signal) {
69
+ const base = { type: "turn_end", output: "", ...spendOf(err) };
70
+ const code = abortCode(signal);
71
+ if (code === STALLED) {
72
+ const message = `More than ${_agentSessionDeps.controlEventCap} control events went undelivered; the turn was cancelled.`;
73
+ return { ...base, status: "errored", error: failure(STALLED, message) };
74
+ }
75
+ if (code === TIMED_OUT)
76
+ return { ...base, status: "timed_out" };
77
+ if (signal.aborted)
78
+ return { ...base, status: "cancelled" };
79
+ return { ...base, status: "errored", error: errorOf(err) };
80
+ }
81
+ function sendTurn(ctx, live, message) {
82
+ return ctx.adapter.sendTurn(ctx.handle, message, {
83
+ ...ctx.turnOpts(),
84
+ maxInteractions: ASK_HUMAN_BUDGET,
85
+ turnId: live.turnId,
86
+ signal: live.signal,
87
+ onTurnEvent: (event) => live.emit(event),
88
+ });
89
+ }
90
+ /** Writes the turn marker; a failure is returned, not thrown. */
91
+ async function markTurn(ctx, turnId, state) {
92
+ try {
93
+ await ctx.store.markTurn(ctx.sessionId, { turnId, state });
94
+ return undefined;
95
+ }
96
+ catch (error) {
97
+ return { error };
98
+ }
99
+ }
100
+ async function runTurn(ctx, live, message) {
101
+ live.emit({ type: "turn_start" });
102
+ const started = await markTurn(ctx, live.turnId, "running");
103
+ if (started !== undefined)
104
+ return fromError(started.error, live.signal);
105
+ let end;
106
+ try {
107
+ end = turnEndFromResult(await sendTurn(ctx, live, message));
108
+ }
109
+ catch (err) {
110
+ end = fromError(err, live.signal);
111
+ }
112
+ const ended = await markTurn(ctx, live.turnId, "ended");
113
+ // A failed end marker fails the turn (spec 5.5) but keeps its output and spend.
114
+ return ended === undefined ? end : { ...end, status: "errored", error: errorOf(ended.error) };
115
+ }
116
+ function singleUse(iterator, sessionId) {
117
+ let taken = false;
118
+ return {
119
+ [Symbol.asyncIterator]() {
120
+ if (taken) {
121
+ throw new AgentSessionError("send() returns a single-use iterable; iterate it once", "AGENT_SESSION_BUSY", {
122
+ sessionId,
123
+ });
124
+ }
125
+ taken = true;
126
+ return iterator;
127
+ },
128
+ };
129
+ }
130
+ export function claimTurn(ctx, message, hooks) {
131
+ const turnId = _agentSessionDeps.randomUUID();
132
+ const controller = new AbortController();
133
+ let state = "claimed";
134
+ let resolveSettled = () => { };
135
+ const settled = new Promise((resolve) => {
136
+ resolveSettled = resolve;
137
+ });
138
+ const channel = createSessionEventChannel({
139
+ controlCap: _agentSessionDeps.controlEventCap,
140
+ onFirstPull: () => start(),
141
+ onReturn: () => cancel("iterator closed"),
142
+ onStall: () => controller.abort(abortReason(STALLED, "consumer stalled")),
143
+ });
144
+ const emit = (body) => {
145
+ const at = new Date(_agentSessionDeps.now()).toISOString();
146
+ channel.push({ sessionId: ctx.sessionId, turnId, at, metadata: ctx.metadata, ...body });
147
+ };
148
+ const live = { turnId, signal: controller.signal, emit };
149
+ function settle(end) {
150
+ state = "settled";
151
+ hooks.onSettle(turnId, end?.status);
152
+ if (end !== undefined)
153
+ emit(end);
154
+ channel.end();
155
+ resolveSettled();
156
+ }
157
+ function start() {
158
+ if (state !== "claimed")
159
+ return;
160
+ state = "running";
161
+ const timer = _agentSessionDeps.setTimeout(() => controller.abort(abortReason(TIMED_OUT, "turn deadline")), ctx.turnTimeoutSeconds * 1000);
162
+ hooks.onStart(live);
163
+ void runTurn(ctx, live, message)
164
+ .catch((err) => fromError(err, controller.signal))
165
+ .then((end) => {
166
+ _agentSessionDeps.clearTimeout(timer);
167
+ settle(end);
168
+ });
169
+ }
170
+ function cancel(reason) {
171
+ if (state === "claimed")
172
+ settle(undefined);
173
+ else if (state === "running")
174
+ controller.abort(abortReason(CANCELLED, reason));
175
+ }
176
+ return { turnId, iterable: singleUse(channel.iterator, ctx.sessionId), settled, cancel };
177
+ }