@nathapp/nax-agent 0.87.0 → 0.87.1

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 CHANGED
@@ -4,6 +4,10 @@ All notable changes to `@nathapp/nax-agent` are recorded here. The format follow
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). While the version is `0.x`, a minor
5
5
  release may change the public API.
6
6
 
7
+ ## [0.87.1] - 2026-10-12
8
+
9
+ - No changes in this package. Released in lockstep with the other nax packages.
10
+
7
11
  ## [0.87.0] - 2026-10-11
8
12
 
9
13
  - No changes in this package. Released in lockstep with the other nax packages.
@@ -14,12 +14,24 @@ import type { BashApprovalMode } from "#src/config/bash-approval";
14
14
  import type { QualityCommandSpec } from "#src/internal/command-spec/index";
15
15
  import type { AskResolver } from "#src/permissions/index";
16
16
  import type { CommandLauncher } from "#src/sandbox/index";
17
- import { type CodingTool, type CodingToolName, type CodingToolRuntime, type DeclaredCommandRunner, type ProtectedPathsPolicy, type ToolAuditSink, type ToolGrant, type ToolPatternNarrowing } from "#src/tools/index";
17
+ import { type CodingTool, type CodingToolName, type CodingToolRuntime, type DeclaredCommandRunner, type ProtectedPathsPolicy, type ToolAuditSink, type ToolGrant, type ToolPatternNarrowing, type ToolSurfaceDraft, type ToolSurfaceRecord } from "#src/tools/index";
18
18
  import type { OwnedPathsPolicy } from "#src/tools/owned-paths";
19
19
  export interface CodingToolSupport {
20
20
  readonly runtime: CodingToolRuntime;
21
21
  readonly tools: readonly CodingTool[];
22
22
  readonly auditSink: ToolAuditSink;
23
+ /** The materialized declaration this support resolved from (nax#2444 D2) — set on every resolve. */
24
+ readonly declaredTools?: readonly CodingToolName[];
25
+ /** The session's MCP tool surface (nax#2410 D4), stamped into the audit file at flush. */
26
+ readonly toolSurface?: ToolSurfaceRecord;
27
+ /**
28
+ * The policy-filtered provider-tool delivery for an ACP session (nax#2444 D4),
29
+ * attached by the nax resolver; the ACP adapter adapts `tools` to EmbedderTools.
30
+ */
31
+ readonly acpDelivery?: {
32
+ readonly tools: readonly CodingTool[];
33
+ readonly surface: ToolSurfaceDraft;
34
+ };
23
35
  }
24
36
  export declare function buildCodingToolSupport(args: {
25
37
  root?: string;
@@ -76,6 +88,8 @@ export declare function buildCodingToolSupport(args: {
76
88
  /** `quality.shell` — the shell the Bash tool spawns. Defaults to /bin/sh. */
77
89
  shell?: string;
78
90
  auditDir?: string;
91
+ /** Stamped into the audit file at flush (nax#2410 D4); absent ⇒ no surface block. */
92
+ toolSurface?: ToolSurfaceRecord;
79
93
  sessionName?: string;
80
94
  header?: import("../tools/tool-audit.ts").ToolAuditHeader;
81
95
  callId?: string;
@@ -71,13 +71,7 @@ export function buildCodingToolSupport(args) {
71
71
  // P4/S5: under `raw`, an UNAVAILABLE launcher refuses every Bash call.
72
72
  // US-002: an AVAILABLE one marks the screen sandbox-wrapped, so a PRD it only reads is allowed.
73
73
  const rawScreenOptions = rawScreenOptionsFor(args.launcher);
74
- const sink = args.auditDir !== undefined
75
- ? createToolAuditSink({
76
- dir: args.auditDir,
77
- sessionName: args.sessionName ?? "unattached",
78
- ...(args.header ? { header: args.header } : {}),
79
- })
80
- : createNoOpToolAuditSink();
74
+ const sink = supportSink(args);
81
75
  const runtime = createCodingToolRuntime({
82
76
  policy: compileToolPolicy(effectiveGrants, args.root, {
83
77
  bashApproval,
@@ -144,7 +138,24 @@ export function buildCodingToolSupport(args) {
144
138
  }
145
139
  if (tools.length === 0)
146
140
  return undefined;
147
- return { runtime, tools, auditSink: sink };
141
+ return {
142
+ runtime,
143
+ tools,
144
+ auditSink: sink,
145
+ declaredTools: args.declared,
146
+ ...(args.toolSurface !== undefined ? { toolSurface: args.toolSurface } : {}),
147
+ };
148
+ }
149
+ /** The audit sink a support resolution records through; no dir ⇒ a non-recording sink. */
150
+ function supportSink(args) {
151
+ if (args.auditDir === undefined)
152
+ return createNoOpToolAuditSink();
153
+ return createToolAuditSink({
154
+ dir: args.auditDir,
155
+ sessionName: args.sessionName ?? "unattached",
156
+ ...(args.header !== undefined ? { header: args.header } : {}),
157
+ ...(args.toolSurface !== undefined ? { toolSurface: args.toolSurface } : {}),
158
+ });
148
159
  }
149
160
  /**
150
161
  * Ledger session name.
package/dist/index.d.ts CHANGED
@@ -42,5 +42,5 @@ export { attachTurnSpend, type FailedTurnSpend, readTurnSpend } from "#src/sessi
42
42
  export type { JSONSchema, ToolDescriptor, } from "#src/session/tool-descriptor";
43
43
  export { createTurnDeadline, type TurnDeadline, } from "#src/session/turn-deadline";
44
44
  export type { TurnEvent, TurnEventSink } from "#src/session/turn-event";
45
- export { adaptProviderTool, advertisedSchemaBytes, applyModelTruncationPolicy, BASH_TIMEOUT_MS, BASH_TOOL_NAME, buildCommitArgvs, buildGitArgv, buildGrepArgv, type CodingTool, type CodingToolName, type CodingToolOutcome, type CodingToolRuntime, classifyExec, compileToolPolicy, createBashTool, createCodingToolRuntime, createNoOpToolAuditSink, createRunCommandTool, createToolAuditSink, cutBufferToByteCap, DEFAULT_BASH_SHELL, DEFAULT_LOG_FORMAT, DEFAULT_LOG_MAX_COUNT, DEFAULT_TOOL_MAX_BYTES, DEFAULT_TOOL_MAX_FILE_BYTES, type DeclaredCommandRequest, type DeclaredCommandResult, type DeclaredCommandRunner, deleteTool, EXEC_TOOL_NAME, type ExecTarget, editTool, expandMcpRuleGrants, expandProviderGrants, flushOpenToolAuditSinks, GIT_ESCAPE_FLAGS, GIT_READ_VERBS, getCodingTool, gitCommitTool, gitExcludePathspecsOf, gitIgnorePatternsOf, gitTool, globTool, grepTool, isKnownManager, listCodingTools, MAX_PROVIDER_DESCRIPTION_BYTES, MAX_PROVIDER_SCHEMA_BYTES, MCP_RULE_TOOL, MODEL_MAX_BYTES, MODEL_MAX_LINE_CHARS, MODEL_MAX_LINES, type ModelTruncationOptions, mcpRuleAdmits, type NormalizeInput, type NormalizeResult, namespacedToolName, narrowGrants, normalizeExec, normalizeManagerBinary, type PolicyVerdict, PROVIDER_ID_RE, type ProtectedPathsPolicy, type ProviderGrantEntry, type ProviderKind, type ProviderTool, partitionMcpRules, partitionNaxOwnedPaths, providerAttachesTo, READ_CEILING, RESERVED_TOOL_NAMES, type ReadFileSliceOptions, type ReadFileSliceResult, type RegisteredSink, type ResolvedProviderTools, readFileSlice, readTool, registerBuiltinCodingTools, registerBuiltinTool, registerCodingTool, registerToolAuditSink, requestCapabilityTool, resolveProviderTools, resolveWithin, SCRATCHPAD_DIR, SPILL_DIR, type SpillRequest, sanitizeProviderTools, scratchpadListTool, scratchpadReadTool, scratchpadWriteTool, spillRelativePath, substituteCommand, substituteCommandSpec, type ToolAuditHeader, type ToolAuditSink, type ToolCallContext, type ToolCallRecord, type ToolGrant, type ToolPatternNarrowing, type ToolPolicy, type ToolPolicyOptions, type ToolProvider, type ToolResult, type ToolRunContext, type ToolScope, type TruncateForModelOptions, type TruncationDirection, type TruncationResult, truncateForModel, truncationDirectionFor, unregisterToolAuditSink, validateProviderId, writeSpill, writeTool, } from "#src/tools/index";
45
+ export { adaptProviderTool, advertisedSchemaBytes, applyModelTruncationPolicy, BASH_TIMEOUT_MS, BASH_TOOL_NAME, buildCommitArgvs, buildGitArgv, buildGrepArgv, type CodingTool, type CodingToolName, type CodingToolOutcome, type CodingToolRuntime, classifyExec, compileToolPolicy, createBashTool, createCodingToolRuntime, createNoOpToolAuditSink, createRunCommandTool, createToolAuditSink, cutBufferToByteCap, DEFAULT_BASH_SHELL, DEFAULT_LOG_FORMAT, DEFAULT_LOG_MAX_COUNT, DEFAULT_TOOL_MAX_BYTES, DEFAULT_TOOL_MAX_FILE_BYTES, type DeclaredCommandRequest, type DeclaredCommandResult, type DeclaredCommandRunner, deleteTool, EXEC_TOOL_NAME, type ExecTarget, editTool, expandMcpRuleGrants, expandProviderGrants, flushOpenToolAuditSinks, GIT_ESCAPE_FLAGS, GIT_READ_VERBS, getCodingTool, gitCommitTool, gitExcludePathspecsOf, gitIgnorePatternsOf, gitTool, globTool, grepTool, isKnownManager, listCodingTools, MAX_PROVIDER_DESCRIPTION_BYTES, MAX_PROVIDER_SCHEMA_BYTES, MCP_RULE_TOOL, MODEL_MAX_BYTES, MODEL_MAX_LINE_CHARS, MODEL_MAX_LINES, type ModelTruncationOptions, mcpRuleAdmits, type NormalizeInput, type NormalizeResult, namespacedToolName, narrowGrants, normalizeExec, normalizeManagerBinary, type PolicyVerdict, PROVIDER_ID_RE, type ProtectedPathsPolicy, type ProviderGrantEntry, type ProviderKind, type ProviderTool, partitionMcpRules, partitionNaxOwnedPaths, providerAttachesTo, READ_CEILING, RESERVED_TOOL_NAMES, type ReadFileSliceOptions, type ReadFileSliceResult, type RegisteredSink, type ResolvedProviderTools, readFileSlice, readTool, registerBuiltinCodingTools, registerBuiltinTool, registerCodingTool, registerToolAuditSink, requestCapabilityTool, resolveProviderTools, resolveWithin, SCRATCHPAD_DIR, SPILL_DIR, type SpillRequest, sanitizeProviderTools, scratchpadListTool, scratchpadReadTool, scratchpadWriteTool, spillRelativePath, substituteCommand, substituteCommandSpec, type ToolAuditHeader, type ToolAuditSink, type ToolCallContext, type ToolCallRecord, type ToolGrant, type ToolPatternNarrowing, type ToolPolicy, type ToolPolicyOptions, type ToolProvider, type ToolResult, type ToolRunContext, type ToolScope, type ToolSurfaceDraft, type ToolSurfaceRecord, type TruncateForModelOptions, type TruncationDirection, type TruncationResult, truncateForModel, truncationDirectionFor, unregisterToolAuditSink, validateProviderId, writeSpill, writeTool, } from "#src/tools/index";
46
46
  export { EMPTY_OWNED_PATHS_POLICY, type OwnedBashCandidate, type OwnedPathsPolicy, } from "#src/tools/owned-paths";
@@ -69,6 +69,7 @@ export { _repositoryInstructionDeps } from "#src/native/session/repository-instr
69
69
  export * from "#src/native/session/session";
70
70
  export * as sessionModule from "#src/native/session/session";
71
71
  export * from "#src/native/session/tool-mapping";
72
+ export { _transcriptArchiveDeps } from "#src/native/session/transcript-archive";
72
73
  export * from "#src/native/session/transcript-identity";
73
74
  export * from "#src/native/session/transcript-store";
74
75
  export * as transcriptStoreModule from "#src/native/session/transcript-store";
package/dist/internal.js CHANGED
@@ -69,6 +69,7 @@ export { _repositoryInstructionDeps } from "#src/native/session/repository-instr
69
69
  export * from "#src/native/session/session";
70
70
  export * as sessionModule from "#src/native/session/session";
71
71
  export * from "#src/native/session/tool-mapping";
72
+ export { _transcriptArchiveDeps } from "#src/native/session/transcript-archive";
72
73
  export * from "#src/native/session/transcript-identity";
73
74
  export * from "#src/native/session/transcript-store";
74
75
  export * as transcriptStoreModule from "#src/native/session/transcript-store";
@@ -11,6 +11,7 @@ import type { AgentStreamEvent } from "#src/session/agent-stream-events";
11
11
  import type { OpenSessionOpts, SessionHandle } from "#src/session/session-types";
12
12
  import type { ResolvedCompaction } from "./compaction.ts";
13
13
  import type { RepositoryInstructions } from "./repository-instructions.ts";
14
+ import { type TranscriptArchive } from "./transcript-archive.ts";
14
15
  import type { TranscriptStore } from "./transcript-types.ts";
15
16
  import type { TurnRetryConfig } from "./turn-retry.ts";
16
17
  /** The runtime hooks handed to openSession. */
@@ -22,6 +23,8 @@ export interface NativeSessionStreamHooks {
22
23
  export interface SessionTranscript {
23
24
  readonly store: TranscriptStore;
24
25
  readonly retainOnClose: boolean;
26
+ /** When set, close writes a redacted copy here before deleting or retaining the live document (nax#2453). */
27
+ readonly transcriptArchive?: TranscriptArchive;
25
28
  }
26
29
  /**
27
30
  * The per-owner state behind the native session lifecycle.
@@ -13,6 +13,7 @@ import { nativeSessionId } from "../session-affinity.js";
13
13
  import { instructionFileNameFor } from "./instruction-file-name.js";
14
14
  import { openRepositoryInstructions } from "./instruction-session.js";
15
15
  import { instructionTranscriptStore } from "./instruction-transcript.js";
16
+ import { archiveTranscript } from "./transcript-archive.js";
16
17
  import { createFileTranscriptStore } from "./transcript-store.js";
17
18
  export function createNativeSessionState() {
18
19
  return {
@@ -106,6 +107,7 @@ export async function openNativeSession(state, name, opts) {
106
107
  state.transcripts.set(name, {
107
108
  store,
108
109
  retainOnClose: opts.retainOnClose === true,
110
+ transcriptArchive: opts.transcriptArchive,
109
111
  });
110
112
  state.timeouts.set(name, opts.timeoutSeconds);
111
113
  state.scratchpadRoots.set(name, opts.workdir);
@@ -123,6 +125,7 @@ export async function openNativeSession(state, name, opts) {
123
125
  state.transcripts.set(name, {
124
126
  store: instructionTranscriptStore(store, instructions),
125
127
  retainOnClose: opts.retainOnClose === true,
128
+ transcriptArchive: opts.transcriptArchive,
126
129
  });
127
130
  // `resume` is SessionManager's "this name already has a descriptor in this
128
131
  // process" signal, and it had no consumer on this transport (nax#1877) — a
@@ -199,6 +202,10 @@ export async function closeNativeSession(state, handle, failed) {
199
202
  const treatAsFailed = failed ?? state.failed.has(handle.id);
200
203
  try {
201
204
  if (transcript !== undefined && !transcript.retainOnClose) {
205
+ // Archive first (nax#2453): best-effort, never throws, so the branch below is unchanged.
206
+ if (transcript.transcriptArchive !== undefined) {
207
+ await archiveTranscript(transcript.store, handle.id, transcript.transcriptArchive);
208
+ }
202
209
  // Retain for a human, out of reach of the next session of this name.
203
210
  if (treatAsFailed)
204
211
  await transcript.store.retainFailed(handle.id);
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Audit archive for native session transcripts (nax#2453).
3
+ *
4
+ * `closeNativeSession` deletes a clean session's transcript and renames a failed
5
+ * one, so a passing session, which is where most tokens go, left no turn-level
6
+ * record. When the caller opts in, this writes a redacted copy into an archive
7
+ * directory first. The archive file name contains `.transcript.` and ends in
8
+ * `.json`, so `pruneRetainedTranscripts` already counts it; it never sits in the
9
+ * live `sessions/` directory, so it can never be loaded as a resumption source
10
+ * (nax#1877).
11
+ */
12
+ import type { TranscriptStore } from "./transcript-types.ts";
13
+ /** Injectable clock for the archive stamp, so tests order archives without sleeping. */
14
+ export declare const _transcriptArchiveDeps: {
15
+ now: () => Date;
16
+ };
17
+ /** Where, and how many, archived transcripts are kept (the `OpenSessionOpts.transcriptArchive` shape). */
18
+ export interface TranscriptArchive {
19
+ readonly dir: string;
20
+ readonly maxRetained: number;
21
+ }
22
+ /**
23
+ * Write a redacted copy of the session's transcript into `archive.dir`, then
24
+ * prune the directory to `archive.maxRetained`. Nothing to archive (no saved
25
+ * document) is a quiet return. Every error is logged and swallowed: the archive
26
+ * is supplementary and must never change how a session closes.
27
+ */
28
+ export declare function archiveTranscript(store: TranscriptStore, sessionId: string, archive: TranscriptArchive): Promise<void>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Audit archive for native session transcripts (nax#2453).
3
+ *
4
+ * `closeNativeSession` deletes a clean session's transcript and renames a failed
5
+ * one, so a passing session, which is where most tokens go, left no turn-level
6
+ * record. When the caller opts in, this writes a redacted copy into an archive
7
+ * directory first. The archive file name contains `.transcript.` and ends in
8
+ * `.json`, so `pruneRetainedTranscripts` already counts it; it never sits in the
9
+ * live `sessions/` directory, so it can never be loaded as a resumption source
10
+ * (nax#1877).
11
+ */
12
+ import { mkdir } from "node:fs/promises";
13
+ import { join } from "node:path";
14
+ import { getLogger } from "#src/infra/index";
15
+ import { writeFileAtomic } from "#src/internal/atomic-write";
16
+ import { redactSecrets } from "#src/internal/redact";
17
+ import { pruneRetainedTranscripts } from "./transcript-store.js";
18
+ /** Injectable clock for the archive stamp, so tests order archives without sleeping. */
19
+ export const _transcriptArchiveDeps = { now: () => new Date() };
20
+ /**
21
+ * Write a redacted copy of the session's transcript into `archive.dir`, then
22
+ * prune the directory to `archive.maxRetained`. Nothing to archive (no saved
23
+ * document) is a quiet return. Every error is logged and swallowed: the archive
24
+ * is supplementary and must never change how a session closes.
25
+ */
26
+ export async function archiveTranscript(store, sessionId, archive) {
27
+ try {
28
+ const doc = await store.load(sessionId);
29
+ if (doc === null)
30
+ return;
31
+ const stamp = _transcriptArchiveDeps.now().toISOString().replace(/[:.]/g, "-");
32
+ await mkdir(archive.dir, { recursive: true });
33
+ await writeFileAtomic(join(archive.dir, `${sessionId}.transcript.${stamp}.json`), JSON.stringify(redactSecrets(doc), null, 2));
34
+ await pruneRetainedTranscripts(archive.dir, archive.maxRetained);
35
+ }
36
+ catch (err) {
37
+ getLogger().warn("native-session", "Transcript archive failed; close continues unchanged", {
38
+ sessionId,
39
+ dir: archive.dir,
40
+ error: err instanceof Error ? err.message : String(err),
41
+ });
42
+ }
43
+ }
@@ -146,6 +146,17 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
146
146
  readonly dir: string;
147
147
  readonly header: import("#src/tools/tool-audit").ToolAuditHeader;
148
148
  };
149
+ /**
150
+ * ACP delivery (nax#2444, D3): provider tools already policy-filtered at the
151
+ * hop (deny/ask-ruled tools withheld), adapted by the ACP backend only when
152
+ * its registry entry declares a pre-approval channel. Native ignores it —
153
+ * provider tools ride `codingTools` on the turn.
154
+ */
155
+ providerTools?: readonly import("#src/tools/registry").CodingTool[];
156
+ /** ACP: the op's materialized declaration, which narrows the session profile (nax#2444, D2). */
157
+ declaredTools?: readonly string[];
158
+ /** ACP: the transport-less surface draft; the adapter stamps transport + profile (nax#2410, D4). */
159
+ toolSurface?: import("#src/tools/tool-audit").ToolSurfaceDraft;
149
160
  /**
150
161
  * Native: identity that owns this session's transcript — the op invocation's
151
162
  * `scopeId ?? callId`. Stable across the retries and hops of one invocation,
@@ -169,6 +180,14 @@ export interface OpenSessionOpts extends TrackedSpawnDeadlineOptions {
169
180
  * that close failed.
170
181
  */
171
182
  retainOnClose?: boolean;
183
+ /**
184
+ * Native: when set, closeNativeSession writes a redacted copy of the transcript
185
+ * here before deleting or retaining it. ACP ignores it.
186
+ */
187
+ transcriptArchive?: {
188
+ readonly dir: string;
189
+ readonly maxRetained: number;
190
+ };
172
191
  /**
173
192
  * Native: the session's system prompt, sent as the request's top-level
174
193
  * `system` on every round trip (not on the compaction summary). The S3
@@ -28,7 +28,7 @@ export type { CodingToolOutcome, CodingToolRuntime, ToolCallContext } from "./ru
28
28
  export { _codingToolDeps, _resetBuiltinsForTest, createCodingToolRuntime, DEFAULT_TOOL_MAX_BYTES, DEFAULT_TOOL_MAX_FILE_BYTES, registerBuiltinCodingTools, } from "./runtime.ts";
29
29
  export { SCRATCHPAD_DIR, scratchpadListTool, scratchpadReadTool, scratchpadWriteTool } from "./scratchpad.ts";
30
30
  export { _spillDeps, applyModelTruncationPolicy, type ModelTruncationOptions, SPILL_DIR, type SpillRequest, spillRelativePath, writeSpill, } from "./spill.ts";
31
- export type { RegisteredSink, ToolAuditHeader, ToolAuditSink, ToolCallRecord } from "./tool-audit.ts";
31
+ export type { RegisteredSink, ToolAuditHeader, ToolAuditSink, ToolCallRecord, ToolSurfaceDraft, ToolSurfaceRecord, } from "./tool-audit.ts";
32
32
  export { createNoOpToolAuditSink, createToolAuditSink, flushOpenToolAuditSinks, registerToolAuditSink, unregisterToolAuditSink, } from "./tool-audit.ts";
33
33
  export { cutBufferToByteCap, MODEL_MAX_BYTES, MODEL_MAX_LINE_CHARS, MODEL_MAX_LINES, READ_CEILING, type TruncateForModelOptions, type TruncationDirection, type TruncationResult, truncateForModel, truncationDirectionFor, } from "./truncate.ts";
34
34
  export type { CodingToolName, PolicyVerdict, ToolGrant, ToolPolicy, ToolScope } from "./types.ts";
@@ -9,6 +9,24 @@ export interface ResolvedProviderTools {
9
9
  providerId: string;
10
10
  reason: string;
11
11
  }[];
12
+ /**
13
+ * Providers whose configured `stages` exclude this hop's stage, with the
14
+ * stages they were configured for (#2410 gap 2: a misconfigured `stages`
15
+ * must be visible, not silent). The scope-none arm contributes the
16
+ * whole-layer `provider-scope:none` row (permission profile `safe`, or a
17
+ * `rules` scope that resolved no providers).
18
+ */
19
+ readonly skipped: readonly {
20
+ providerId: string;
21
+ reason: "stage-mismatch" | "provider-scope:none";
22
+ stages: readonly string[];
23
+ }[];
24
+ /** Sanitised tools a scoped stage's `admits` rejected (#2410 gap 1). */
25
+ readonly withheld: readonly {
26
+ providerId: string;
27
+ localName: string;
28
+ reason: "not-admitted";
29
+ }[];
12
30
  /**
13
31
  * Advertised tool name -> owning provider id, carried explicitly so the
14
32
  * ledger never has to parse the `<id>__<local>` name apart (a naming
@@ -2,9 +2,13 @@
2
2
  * Per-hop resolution of provider tools.
3
3
  *
4
4
  * A provider whose `tools()` rejects is DROPPED, not fatal: a broken provider
5
- * must never wedge a run. The failure is logged by the caller, which owns the
6
- * logger; this module stays pure so it can be tested without one.
5
+ * must never wedge a run. The failure reason is carried in `failures` and
6
+ * logged by the caller, which owns the logger. Stage mismatches and
7
+ * `admits`-rejected tools are carried in `skipped`/`withheld` (#2410) and the
8
+ * stage skip is logged once here at debug — the null-safe logger keeps the
9
+ * module testable without one.
7
10
  */
11
+ import { getSafeLogger } from "#src/infra/index";
8
12
  import { adaptProviderTool } from "./provider-adapt.js";
9
13
  import { expandProviderGrants } from "./provider-grants.js";
10
14
  import { sanitizeProviderTools } from "./provider-sanitize.js";
@@ -13,16 +17,23 @@ export async function resolveProviderTools(providers, stage, workdir, options) {
13
17
  const tools = [];
14
18
  const entries = [];
15
19
  const failures = [];
20
+ const skipped = [];
21
+ const withheld = [];
16
22
  const providerIdByTool = new Map();
17
23
  for (const provider of providers) {
18
- if (!providerAttachesTo(provider, stage))
24
+ if (!providerAttachesTo(provider, stage)) {
25
+ skipped.push({ providerId: provider.id, reason: "stage-mismatch", stages: provider.stages });
26
+ getSafeLogger()?.debug("mcp", `[provider] ${provider.id} not attached`, { stage, stages: provider.stages });
19
27
  continue;
28
+ }
20
29
  try {
21
30
  const sanitized = sanitizeProviderTools(provider.kind, await provider.tools(workdir));
22
31
  const localNames = [];
23
32
  for (const tool of sanitized) {
24
- if (options?.admits !== undefined && !options.admits(provider.id, tool.localName))
33
+ if (options?.admits !== undefined && !options.admits(provider.id, tool.localName)) {
34
+ withheld.push({ providerId: provider.id, localName: tool.localName, reason: "not-admitted" });
25
35
  continue;
36
+ }
26
37
  const adapted = adaptProviderTool(provider.id, tool);
27
38
  tools.push(adapted);
28
39
  providerIdByTool.set(adapted.name, provider.id);
@@ -34,7 +45,7 @@ export async function resolveProviderTools(providers, stage, workdir, options) {
34
45
  failures.push({ providerId: provider.id, reason: String(error) });
35
46
  }
36
47
  }
37
- return { tools, grants: expandProviderGrants(entries), failures, providerIdByTool, entries };
48
+ return { tools, grants: expandProviderGrants(entries), failures, skipped, withheld, providerIdByTool, entries };
38
49
  }
39
50
  /**
40
51
  * Bytes a tool list costs the prompt, paid on EVERY hop whether or not any
@@ -164,8 +164,50 @@ export interface ToolAuditHeader {
164
164
  readonly storyId?: string;
165
165
  readonly sessionRole?: string;
166
166
  }
167
+ /**
168
+ * Which MCP provider tools a session was OFFERED — the delivery-side twin of
169
+ * the per-call `calls` rows (#2410 gap 1). Present in the file body as
170
+ * `toolSurface` whenever a session's hop resolved provider tools, so
171
+ * "the model ignored the tool" can be told apart from "the tool was never
172
+ * advertised".
173
+ */
174
+ export interface ToolSurfaceDraft {
175
+ /** The pipeline stage the session was resolved for. */
176
+ readonly stage: string;
177
+ /** The op's materialized declaration (what the profile/delivery decision saw). */
178
+ readonly declaredTools: readonly string[];
179
+ /** Provider tools actually offered: (providerId, local names) with content. */
180
+ readonly advertised: readonly {
181
+ providerId: string;
182
+ names: readonly string[];
183
+ }[];
184
+ /** Provider tools resolved but not offered, with why. */
185
+ readonly withheld: readonly {
186
+ providerId: string;
187
+ name?: string;
188
+ reason: string;
189
+ }[];
190
+ /** Providers skipped whole, with why. */
191
+ readonly skippedProviders: readonly {
192
+ id: string;
193
+ reason: string;
194
+ stages?: readonly string[];
195
+ }[];
196
+ /** Providers whose `tools()` rejected (same rows as `failures`). */
197
+ readonly failures: readonly {
198
+ providerId: string;
199
+ reason: string;
200
+ }[];
201
+ }
202
+ /** A draft stamped with the transport (and, on ACP, the narrowed profile). */
203
+ export interface ToolSurfaceRecord extends ToolSurfaceDraft {
204
+ readonly transport: "native" | "acp";
205
+ readonly profile?: "full" | "read";
206
+ }
167
207
  export declare function createToolAuditSink(opts: {
168
208
  dir: string;
169
209
  sessionName: string;
170
210
  header?: ToolAuditHeader;
211
+ /** The session's MCP tool surface (#2410): present ⇒ the file is written even with zero calls. */
212
+ toolSurface?: ToolSurfaceRecord;
171
213
  }): RegisteredSink;
@@ -129,7 +129,10 @@ export function createToolAuditSink(opts) {
129
129
  // may still run afterwards must not write a second, non-partial file.
130
130
  let partialFlushed = false;
131
131
  const write = async (partial) => {
132
- if (calls.length === 0)
132
+ // Zero calls normally means "nothing to audit" — but a surface with no
133
+ // calls is exactly the never-called session #2410 exists to expose, so it
134
+ // must still produce its file.
135
+ if (calls.length === 0 && opts.toolSurface === undefined)
133
136
  return;
134
137
  await mkdir(opts.dir, { recursive: true });
135
138
  const body = JSON.stringify({
@@ -139,6 +142,7 @@ export function createToolAuditSink(opts) {
139
142
  // Additive and optional: absent on a normal flush, `true` only when the
140
143
  // shutdown path persisted a buffer its hop never closed.
141
144
  ...(partial ? { partial: true } : {}),
145
+ ...(opts.toolSurface !== undefined ? { toolSurface: opts.toolSurface } : {}),
142
146
  calls: redactRowStrings(calls),
143
147
  }, null, 2);
144
148
  const prefix = runId !== undefined ? `${runId}-` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax-agent",
3
- "version": "0.87.0",
3
+ "version": "0.87.1",
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",
@@ -47,7 +47,7 @@
47
47
  "dependencies": {
48
48
  "@anthropic-ai/sandbox-runtime": "0.0.77",
49
49
  "@modelcontextprotocol/sdk": "^1.32.1",
50
- "@nathapp/nax-ai": "0.87.0",
50
+ "@nathapp/nax-ai": "0.87.1",
51
51
  "zod": "^4.3.6"
52
52
  },
53
53
  "publishConfig": {