@nathapp/nax-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +22 -13
  3. package/dist/index.d.ts +13 -4
  4. package/dist/index.js +9 -2
  5. package/dist/infra/stderr-tail.d.ts +8 -0
  6. package/dist/infra/stderr-tail.js +48 -0
  7. package/dist/native/index.d.ts +1 -1
  8. package/dist/native/session/transcript-types.d.ts +16 -0
  9. package/dist/permissions/ask-chain.d.ts +1 -1
  10. package/dist/permissions/ask.d.ts +6 -0
  11. package/dist/permissions/ask.js +6 -0
  12. package/dist/permissions/index.d.ts +1 -1
  13. package/dist/permissions/index.js +1 -1
  14. package/dist/session/agent-session-errors.d.ts +1 -1
  15. package/dist/session/agent-session-options.d.ts +0 -4
  16. package/dist/session/agent-session-options.js +7 -48
  17. package/dist/session/agent-session-resume.d.ts +3 -1
  18. package/dist/session/agent-session-resume.js +10 -8
  19. package/dist/session/agent-session-turn.d.ts +3 -7
  20. package/dist/session/agent-session-turn.js +2 -4
  21. package/dist/session/agent-session-types.d.ts +22 -22
  22. package/dist/session/agent-session.js +51 -90
  23. package/dist/session/native-backend-options.d.ts +24 -0
  24. package/dist/session/native-backend-options.js +81 -0
  25. package/dist/session/native-backend.d.ts +4 -0
  26. package/dist/session/native-backend.js +106 -0
  27. package/dist/session/session-ask-link.d.ts +12 -5
  28. package/dist/session/session-ask-link.js +3 -2
  29. package/dist/session/session-ask-port.d.ts +12 -0
  30. package/dist/session/session-ask-port.js +64 -0
  31. package/dist/session/session-backend.d.ts +79 -0
  32. package/dist/session/session-backend.js +1 -0
  33. package/dist/session/session-interaction.d.ts +2 -2
  34. package/dist/session/session-interaction.js +3 -6
  35. package/dist/session/session-tool-support.d.ts +5 -5
  36. package/dist/session/session-tool-support.js +12 -9
  37. package/dist/session/session-types.d.ts +2 -0
  38. package/dist/session/turn-event.d.ts +5 -0
  39. package/dist/tools/ask-request.js +3 -1
  40. package/package.json +1 -1
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The backend seam (S4 spec §5.1). A `SessionBackend` opens an
3
+ * `OpenedBackend`: the S1 `AgentSessionAdapter` plus the per-turn options it
4
+ * contributes, the ask port it reaches the facade through, and the info the
5
+ * facade records. The native backend and the out-of-tree ACP backend both
6
+ * satisfy this contract; nothing here is backend-specific.
7
+ */
8
+ import type { TranscriptDoc, TranscriptStore } from "#src/native/session/transcript-types";
9
+ import type { AgentSessionProfile, ApprovalDecidedBy, EmbedderTool } from "./agent-session-types.ts";
10
+ import type { AgentSessionAdapter, SendTurnOpts, SessionHandle } from "./session-types.ts";
11
+ export interface BackendInfo {
12
+ /** "native" or "acp:<agent>"; recorded in the transcript document. */
13
+ readonly kind: string;
14
+ /** Read-only, JSON-safe capability summary. `{}` for native. */
15
+ readonly capabilities: Readonly<Record<string, unknown>>;
16
+ }
17
+ export interface ApprovalRequest {
18
+ readonly callId?: string;
19
+ readonly tool: string;
20
+ readonly summary: string;
21
+ readonly command?: string;
22
+ readonly reason: string;
23
+ /** Extra abort source, combined with the turn signal (for example a tool-level abort). */
24
+ readonly signal?: AbortSignal;
25
+ }
26
+ export interface SessionAskPort {
27
+ /** Throws NaxError AGENT_SESSION_TURN_FAILED (context.detail "no-turn") when no turn is running. */
28
+ requestApproval(req: ApprovalRequest): Promise<{
29
+ readonly decision: "allow" | "deny";
30
+ readonly decidedBy: ApprovalDecidedBy;
31
+ }>;
32
+ /** Emits approval_requested then approval_resolved (decidedBy "profile"). No-op when no turn is running. */
33
+ recordAutoDecision(req: Omit<ApprovalRequest, "command" | "signal">, decision: "allow" | "deny"): void;
34
+ /**
35
+ * The person's text, or null on deadline, cancel or no running turn. `opts.signal`
36
+ * is an extra abort source combined with the turn signal, as for approvals (for
37
+ * example a backend's per-request scope that ends before the turn does).
38
+ */
39
+ askQuestion(text: string, opts?: {
40
+ readonly signal?: AbortSignal;
41
+ }): Promise<string | null>;
42
+ /** An informational question event; answer() on its id returns "cancelled". No-op when no turn is running. */
43
+ noteQuestion(text: string): void;
44
+ }
45
+ /** What a backend contributes to every sendTurn. */
46
+ export type TurnContribution = Pick<SendTurnOpts, "interactionHandler"> & Partial<Pick<SendTurnOpts, "codingTools" | "loopHandlers" | "loopHandlerContext">>;
47
+ export interface BackendOpenContext {
48
+ readonly sessionId: string;
49
+ /** The facade's resolved root: the workdir, or a private scratch root for profile "none". */
50
+ readonly workdir: string;
51
+ readonly profile: AgentSessionProfile;
52
+ readonly instructions: string | undefined;
53
+ readonly tools: readonly EmbedderTool[];
54
+ readonly transcriptStore: TranscriptStore;
55
+ /** The stored document on resume (already presence-, schema- and kind-checked by the facade). */
56
+ readonly resume: {
57
+ readonly doc: TranscriptDoc;
58
+ } | undefined;
59
+ readonly asks: SessionAskPort;
60
+ /** Re-read at use time: the running turn's signal, or a never-aborting one between turns. */
61
+ readonly turnSignal: () => AbortSignal;
62
+ readonly currentTurnId: () => string | undefined;
63
+ readonly turnTimeoutSeconds: number;
64
+ readonly metadata: Readonly<Record<string, string>>;
65
+ /** Aborted when the session starts closing. */
66
+ readonly openSignal: AbortSignal;
67
+ }
68
+ export interface OpenedBackend {
69
+ readonly adapter: AgentSessionAdapter;
70
+ readonly handle: SessionHandle;
71
+ readonly info: BackendInfo;
72
+ turnOpts(): TurnContribution;
73
+ /** Releases backend resources after adapter.closeSession. Idempotent. */
74
+ close(): Promise<void>;
75
+ }
76
+ export interface SessionBackend {
77
+ readonly kind: string;
78
+ open(ctx: BackendOpenContext): Promise<OpenedBackend>;
79
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -2,14 +2,14 @@ import type { CodingTool } from "#src/tools/registry";
2
2
  import type { CodingToolRuntime } from "#src/tools/runtime";
3
3
  import type { EmbedderTool } from "./agent-session-types.ts";
4
4
  import type { InteractionHandler } from "./interaction-handler.ts";
5
- import { type SessionAskDeps } from "./session-ask-link.ts";
5
+ import type { SessionAskPort } from "./session-backend.ts";
6
6
  /** Byte cap on an embedder approval's default summary. */
7
7
  export declare const EMBEDDER_SUMMARY_BYTES = 1024;
8
8
  export interface SessionInteractionDeps {
9
9
  readonly sessionId: string;
10
10
  readonly runtime: CodingToolRuntime;
11
11
  readonly embedderTools: ReadonlyMap<string, EmbedderTool>;
12
- readonly asks: SessionAskDeps;
12
+ readonly asks: SessionAskPort;
13
13
  /** The running turn's signal (a never-aborting one between turns). */
14
14
  readonly turnSignal: () => AbortSignal;
15
15
  readonly setCurrentCallId: (callId: string | undefined) => void;
@@ -13,7 +13,6 @@ import { NaxError } from "#src/infra/nax-error";
13
13
  import { capStrings, redactSecrets } from "#src/internal/redact";
14
14
  import { askDenyReason } from "#src/tools/ask-request";
15
15
  import { cutToByteCap } from "#src/tools/truncate";
16
- import { askPerson } from "./session-ask-link.js";
17
16
  /** Byte cap on an embedder approval's default summary. */
18
17
  export const EMBEDDER_SUMMARY_BYTES = 1024;
19
18
  /**
@@ -108,7 +107,7 @@ async function runEmbedderTool(deps, tool, request) {
108
107
  reason: `"${tool.name}" asks before every run`,
109
108
  callId: toolCallId,
110
109
  };
111
- const outcome = await askPerson(deps.asks, ask, signal);
110
+ const outcome = await deps.asks.requestApproval({ ...ask, signal });
112
111
  if (outcome.decision !== "allow") {
113
112
  const reason = askDenyReason(outcome.decidedBy);
114
113
  return { answer: `Denied: ${reason}`, denied: { reason, breach: false } };
@@ -145,10 +144,8 @@ async function runCodingTool(deps, request) {
145
144
  }
146
145
  }
147
146
  async function answerQuestion(deps, text) {
148
- const { requestId, expiresAt, settled } = deps.asks.table.issue("question", deps.turnSignal());
149
- deps.asks.emit({ type: "question", requestId, text, expiresAt });
150
- const settlement = await settled;
151
- return settlement.by === "human" && "text" in settlement.reply ? { answer: settlement.reply.text } : null;
147
+ const answer = await deps.asks.askQuestion(text);
148
+ return answer === null ? null : { answer };
152
149
  }
153
150
  export function createSessionInteractionHandler(deps) {
154
151
  return {
@@ -11,11 +11,11 @@ export declare const SESSION_STAGE = "session";
11
11
  export declare function declaredToolsFor(profile: AgentSessionProfile, protectedPaths: ProtectedPathsPolicy): readonly CodingToolName[];
12
12
  export declare function grantsFor(declared: readonly CodingToolName[]): readonly ToolGrant[];
13
13
  /**
14
- * Under "gated", every Bash command is put to the person: an unconditional ask
15
- * rule on top of the unconditional grant (ask rules are evaluated after
16
- * grants). "raw" and "escalate" add none.
14
+ * Ask rules evaluate after grants. Under "gated" every Bash command is put to
15
+ * the person. Under the "ask" profile every mutating path tool is too, and the
16
+ * profile forces "gated", so Bash always asks there.
17
17
  */
18
- export declare function askRulesFor(declared: readonly CodingToolName[], bashApproval: BashApprovalMode): readonly ToolGrant[];
18
+ export declare function askRulesFor(declared: readonly CodingToolName[], bashApproval: BashApprovalMode, profile: AgentSessionProfile): readonly ToolGrant[];
19
19
  /**
20
20
  * The embedder default (spec 6.2). A session with its own credentials source
21
21
  * (memory or exec) has no credentials directory on disk; one that falls back
@@ -29,7 +29,7 @@ export interface SessionLauncherArgs {
29
29
  readonly bashApproval: BashApprovalMode;
30
30
  readonly allowUnsandboxed: boolean;
31
31
  }
32
- /** The sandbox floor (spec 6.3): "full" needs a usable sandbox unless gated + allowUnsandboxed. */
32
+ /** The sandbox floor (spec 6.3): "ask" and "full" need a usable sandbox unless gated + allowUnsandboxed. */
33
33
  export declare function resolveSessionLauncher(args: SessionLauncherArgs): Promise<CommandLauncher | undefined>;
34
34
  export interface SessionToolSupportArgs {
35
35
  readonly profile: AgentSessionProfile;
@@ -15,6 +15,7 @@ import { AgentSessionError } from "./agent-session-errors.js";
15
15
  export const SESSION_STAGE = "session";
16
16
  const READ_TOOLS = ["Read", "Glob", "Grep", "Git"];
17
17
  const WRITE_TOOLS = ["Write", "Edit", "Delete", "Bash"];
18
+ const ASK_PROFILE_TOOLS = ["Write", "Edit", "Delete", "GitCommit"];
18
19
  export function declaredToolsFor(profile, protectedPaths) {
19
20
  if (profile === "none")
20
21
  return [...UNIVERSAL_CODING_TOOLS];
@@ -28,12 +29,14 @@ export function grantsFor(declared) {
28
29
  return declared.map((tool) => ({ tool, patterns: ["*"] }));
29
30
  }
30
31
  /**
31
- * Under "gated", every Bash command is put to the person: an unconditional ask
32
- * rule on top of the unconditional grant (ask rules are evaluated after
33
- * grants). "raw" and "escalate" add none.
32
+ * Ask rules evaluate after grants. Under "gated" every Bash command is put to
33
+ * the person. Under the "ask" profile every mutating path tool is too, and the
34
+ * profile forces "gated", so Bash always asks there.
34
35
  */
35
- export function askRulesFor(declared, bashApproval) {
36
- return bashApproval === "gated" && declared.includes("Bash") ? [{ tool: "Bash", patterns: ["*"] }] : [];
36
+ export function askRulesFor(declared, bashApproval, profile) {
37
+ const bash = bashApproval === "gated" && declared.includes("Bash") ? ["Bash"] : [];
38
+ const mutating = profile === "ask" ? ASK_PROFILE_TOOLS.filter((tool) => declared.includes(tool)) : [];
39
+ return [...mutating, ...bash].map((tool) => ({ tool, patterns: ["*"] }));
37
40
  }
38
41
  /** The configured credentials directory; undefined when configureCredentials was never called. */
39
42
  function configuredCredentialDir() {
@@ -54,9 +57,9 @@ export function defaultProtectedPaths(ownCredentials) {
54
57
  const credentialDir = ownCredentials ? undefined : configuredCredentialDir();
55
58
  return credentialDir === undefined ? base : { ...base, credentialDir };
56
59
  }
57
- /** The sandbox floor (spec 6.3): "full" needs a usable sandbox unless gated + allowUnsandboxed. */
60
+ /** The sandbox floor (spec 6.3): "ask" and "full" need a usable sandbox unless gated + allowUnsandboxed. */
58
61
  export async function resolveSessionLauncher(args) {
59
- if (args.profile !== "full")
62
+ if (args.profile !== "full" && args.profile !== "ask")
60
63
  return undefined;
61
64
  const launcher = await resolveSessionSandbox({
62
65
  config: DEFAULT_SANDBOX_CONFIG,
@@ -70,12 +73,12 @@ export async function resolveSessionLauncher(args) {
70
73
  if (args.bashApproval === "gated" && args.allowUnsandboxed)
71
74
  return undefined;
72
75
  const reason = launcher.state.kind === "unavailable" ? launcher.state.reason : "the sandbox is disabled";
73
- throw new AgentSessionError(`Profile "full" needs a usable sandbox (${reason}). Pass bashApproval "gated" with allowUnsandboxed: true to run without one.`, "AGENT_SESSION_SANDBOX_UNAVAILABLE", { reason });
76
+ throw new AgentSessionError(`Profile "${args.profile}" needs a usable sandbox (${reason}). Pass bashApproval "gated" with allowUnsandboxed: true to run without one.`, "AGENT_SESSION_SANDBOX_UNAVAILABLE", { reason });
74
77
  }
75
78
  export function buildSessionToolSupport(args) {
76
79
  const declared = declaredToolsFor(args.profile, args.protectedPaths);
77
80
  const grants = grantsFor(declared);
78
- const askRules = askRulesFor(declared, args.bashApproval);
81
+ const askRules = askRulesFor(declared, args.bashApproval, args.profile);
79
82
  const support = buildCodingToolSupport({
80
83
  root: args.root,
81
84
  commandCwd: args.root,
@@ -260,6 +260,8 @@ export interface TurnResult {
260
260
  estimatedCostUsd: number;
261
261
  /** Exact cost reported by wire protocol (when available). */
262
262
  exactCostUsd?: number;
263
+ /** Absent means computed. */
264
+ costSource?: import("./agent-session-types.ts").CostSource;
263
265
  /** Number of session.prompt() calls made. */
264
266
  internalRoundTrips: number;
265
267
  /**
@@ -53,6 +53,11 @@ export type TurnEvent = {
53
53
  /** Absent when the call reported no cache data -- never coerced to 0. */
54
54
  readonly cacheWrite?: number;
55
55
  readonly costUsd: number;
56
+ /**
57
+ * Absent means computed from the catalog (the native backend). `unpriced`
58
+ * rows carry `costUsd: 0` and must not be summed as a real cost.
59
+ */
60
+ readonly costSource?: import("./agent-session-types.ts").CostSource;
56
61
  } | {
57
62
  readonly type: "compaction";
58
63
  readonly reason: "proactive" | "overflow";
@@ -6,7 +6,7 @@
6
6
  * `maskForPrompt` before they leave the process; a secret that cannot be
7
7
  * shown safely withholds the arguments entirely (`unshowable`, review #9).
8
8
  */
9
- import { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_TIMEOUT_REASON, ASK_UNSHOWABLE_REASON, maskForPrompt, } from "#src/permissions/index";
9
+ import { ASK_CANCELLED_REASON, ASK_DENIED_REASON, ASK_NO_CHANNEL_REASON, ASK_PROFILE_REASON, ASK_TIMEOUT_REASON, ASK_UNSHOWABLE_REASON, maskForPrompt, } from "#src/permissions/index";
10
10
  /** Ceiling for the one-line call description an AskResolver receives. */
11
11
  export const MAX_ASK_SUMMARY_CHARS = 200;
12
12
  /**
@@ -53,5 +53,7 @@ export function askDenyReason(decidedBy) {
53
53
  return ASK_CANCELLED_REASON;
54
54
  if (decidedBy === "unshowable")
55
55
  return ASK_UNSHOWABLE_REASON;
56
+ if (decidedBy === "profile")
57
+ return ASK_PROFILE_REASON;
56
58
  return ASK_NO_CHANNEL_REASON;
57
59
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "nax's native coding agent: session contract, native loop, tools, permissions, sandbox, command-safety.",
5
5
  "license": "MIT",
6
6
  "author": "William Khoo",