@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.
Files changed (113) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +47 -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 +10 -1
  8. package/dist/index.js +4 -1
  9. package/dist/internal/redact.d.ts +23 -0
  10. package/dist/internal/redact.js +60 -0
  11. package/dist/internal.d.ts +7 -0
  12. package/dist/internal.js +8 -0
  13. package/dist/native/adapter-deps.d.ts +3 -3
  14. package/dist/native/adapter-deps.js +3 -3
  15. package/dist/native/client.d.ts +7 -1
  16. package/dist/native/client.js +1 -1
  17. package/dist/native/credentials/change-guard.d.ts +8 -1
  18. package/dist/native/credentials/change-guard.js +1 -1
  19. package/dist/native/credentials/fingerprint.d.ts +4 -1
  20. package/dist/native/credentials/fingerprint.js +6 -3
  21. package/dist/native/credentials/session-source.d.ts +12 -0
  22. package/dist/native/credentials/session-source.js +33 -0
  23. package/dist/native/index.d.ts +3 -1
  24. package/dist/native/index.js +2 -1
  25. package/dist/native/session/loop-events/types.d.ts +1 -1
  26. package/dist/native/session/loop-handlers.d.ts +3 -0
  27. package/dist/native/session/loop-handlers.js +1 -1
  28. package/dist/native/session/memory-transcript-store.d.ts +12 -0
  29. package/dist/native/session/memory-transcript-store.js +36 -0
  30. package/dist/native/session/session.d.ts +138 -96
  31. package/dist/native/session/session.js +107 -127
  32. package/dist/native/session/transcript-identity.d.ts +39 -0
  33. package/dist/native/session/transcript-identity.js +81 -0
  34. package/dist/native/session/transcript-store.d.ts +22 -21
  35. package/dist/native/session/transcript-store.js +59 -68
  36. package/dist/native/session/transcript-types.d.ts +38 -0
  37. package/dist/native/session/transcript-types.js +7 -0
  38. package/dist/native/session/truncation-handler.d.ts +4 -3
  39. package/dist/native/session/truncation-handler.js +7 -10
  40. package/dist/native/session/turn-complete-step.d.ts +4 -1
  41. package/dist/native/session/turn-complete-step.js +11 -4
  42. package/dist/native/session/turn-event-emitter.d.ts +48 -0
  43. package/dist/native/session/turn-event-emitter.js +134 -0
  44. package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
  45. package/dist/native/session/turn-loop-round-trip.js +8 -4
  46. package/dist/native/session/turn-loop.js +27 -13
  47. package/dist/native/session/turn-result.d.ts +1 -1
  48. package/dist/native/session/turn-result.js +1 -1
  49. package/dist/native/session/turn-tool-batch.d.ts +3 -0
  50. package/dist/native/session/turn-tool-batch.js +14 -6
  51. package/dist/native/session/turn-types.d.ts +17 -1
  52. package/dist/native/session-adapter.d.ts +22 -4
  53. package/dist/native/session-adapter.js +79 -35
  54. package/dist/native/stream-complete.d.ts +17 -0
  55. package/dist/native/stream-complete.js +40 -0
  56. package/dist/permissions/approvals-link.js +5 -4
  57. package/dist/sandbox/policy-builder.d.ts +6 -1
  58. package/dist/sandbox/policy-builder.js +18 -14
  59. package/dist/session/agent-session-deps.d.ts +12 -0
  60. package/dist/session/agent-session-deps.js +28 -0
  61. package/dist/session/agent-session-errors.d.ts +11 -0
  62. package/dist/session/agent-session-errors.js +12 -0
  63. package/dist/session/agent-session-options.d.ts +16 -0
  64. package/dist/session/agent-session-options.js +137 -0
  65. package/dist/session/agent-session-resume.d.ts +12 -0
  66. package/dist/session/agent-session-resume.js +47 -0
  67. package/dist/session/agent-session-turn.d.ts +45 -0
  68. package/dist/session/agent-session-turn.js +179 -0
  69. package/dist/session/agent-session-types.d.ts +182 -0
  70. package/dist/session/agent-session-types.js +1 -0
  71. package/dist/session/agent-session.d.ts +8 -0
  72. package/dist/session/agent-session.js +223 -0
  73. package/dist/session/pending-asks.d.ts +24 -0
  74. package/dist/session/pending-asks.js +77 -0
  75. package/dist/session/session-ask-link.d.ts +26 -0
  76. package/dist/session/session-ask-link.js +52 -0
  77. package/dist/session/session-event-channel.d.ts +17 -0
  78. package/dist/session/session-event-channel.js +132 -0
  79. package/dist/session/session-interaction.d.ts +20 -0
  80. package/dist/session/session-interaction.js +164 -0
  81. package/dist/session/session-tool-support.d.ts +48 -0
  82. package/dist/session/session-tool-support.js +101 -0
  83. package/dist/session/session-types.d.ts +31 -2
  84. package/dist/session/turn-event.d.ts +61 -0
  85. package/dist/session/turn-event.js +1 -0
  86. package/dist/tools/bash-cwd.d.ts +3 -3
  87. package/dist/tools/credential-read-deny.d.ts +22 -0
  88. package/dist/tools/credential-read-deny.js +56 -0
  89. package/dist/tools/glob.js +9 -2
  90. package/dist/tools/grep.js +55 -26
  91. package/dist/tools/owned-paths.d.ts +43 -0
  92. package/dist/tools/owned-paths.js +17 -0
  93. package/dist/tools/package-managers.d.ts +3 -0
  94. package/dist/tools/package-managers.js +7 -3
  95. package/dist/tools/policy-bash-raw.d.ts +3 -0
  96. package/dist/tools/policy-bash-raw.js +49 -62
  97. package/dist/tools/policy-command-branch.d.ts +3 -0
  98. package/dist/tools/policy-command-branch.js +2 -1
  99. package/dist/tools/policy-paths-branch.d.ts +14 -8
  100. package/dist/tools/policy-paths-branch.js +20 -19
  101. package/dist/tools/policy.d.ts +10 -7
  102. package/dist/tools/policy.js +9 -7
  103. package/dist/tools/protected-paths.d.ts +6 -6
  104. package/dist/tools/read.js +38 -20
  105. package/dist/tools/registry.d.ts +3 -0
  106. package/dist/tools/run-command-exec.js +1 -0
  107. package/dist/tools/runtime.d.ts +3 -0
  108. package/dist/tools/runtime.js +1 -0
  109. package/dist/tools/scratchpad.js +2 -1
  110. package/dist/tools/types.d.ts +2 -2
  111. package/package.json +2 -2
  112. package/dist/tools/nax-owned-writes.d.ts +0 -135
  113. package/dist/tools/nax-owned-writes.js +0 -259
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The transcript port (S3 spec 5.5). The native loop loads the whole history
3
+ * through it at turn start and saves a fresh snapshot at turn end, so a store
4
+ * is a document store, not a log. nax uses the file store
5
+ * (`createFileTranscriptStore`); an embedder may inject its own.
6
+ */
7
+ import type { ConversationMessage } from "@nathapp/nax-ai";
8
+ /** Facade-owned turn bookkeeping. The loop never writes it; `markTurn` does. */
9
+ export interface TurnMarker {
10
+ readonly turnId: string;
11
+ readonly state: "running" | "ended";
12
+ }
13
+ export interface TranscriptDoc {
14
+ /** Absent means 1. nax-agent does not write it in S3-1. */
15
+ readonly schemaVersion?: 1;
16
+ readonly owner?: string;
17
+ readonly model?: string;
18
+ readonly savedAt: string;
19
+ readonly messages: readonly ConversationMessage[];
20
+ /**
21
+ * Owned by `markTurn`. The loop never writes it, and its saves replace the
22
+ * whole document, so a loop save drops it; the caller re-marks after the turn
23
+ * (S3 spec 5.5 write order: markTurn(running), turn, markTurn(ended)).
24
+ */
25
+ readonly turn?: TurnMarker;
26
+ }
27
+ export interface TranscriptStore {
28
+ /** `null` when the session has no document yet. */
29
+ load(sessionId: string): Promise<TranscriptDoc | null>;
30
+ /** Replace the document. The loop calls it at turn end and after a caught turn error. */
31
+ save(sessionId: string, doc: TranscriptDoc): Promise<void>;
32
+ /** Move the live document out of `load`'s reach, keeping it for a human. Missing is not an error. */
33
+ retainFailed(sessionId: string): Promise<void>;
34
+ /** Missing is not an error. */
35
+ delete(sessionId: string): Promise<void>;
36
+ /** Read-merge `turn` into the document, creating an empty one when none exists. */
37
+ markTurn(sessionId: string, marker: TurnMarker): Promise<void>;
38
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * The transcript port (S3 spec 5.5). The native loop loads the whole history
3
+ * through it at turn start and saves a fresh snapshot at turn end, so a store
4
+ * is a document store, not a log. nax uses the file store
5
+ * (`createFileTranscriptStore`); an embedder may inject its own.
6
+ */
7
+ export {};
@@ -21,19 +21,20 @@
21
21
  * the session is known to own.
22
22
  */
23
23
  import type { HandlerOf } from "./loop-events/types.ts";
24
+ import type { NativeSessionState } from "./session.ts";
24
25
  /**
25
26
  * Where this session's spills belong, or `undefined` when the session is not
26
27
  * known to the runtime at all — in which case nothing is spilled and the marker
27
28
  * names no path, rather than naming a file written somewhere unreachable.
28
29
  */
29
- export declare function spillRootFor(sessionId: string): string | undefined;
30
+ export declare function spillRootFor(state: NativeSessionState, sessionId: string): string | undefined;
30
31
  /**
31
32
  * Shape one tool result for the model: within every cap it is returned
32
33
  * untouched (and nothing is spilled); over a cap it is cut, the untruncated
33
34
  * body is written under the scratchpad, and the content ends with the marker
34
35
  * naming the spill path and both byte counts.
35
36
  */
36
- export declare function truncateNativeToolResult(sessionId: string, body: string, opts: {
37
+ export declare function truncateNativeToolResult(state: NativeSessionState, sessionId: string, body: string, opts: {
37
38
  readonly toolName?: string;
38
39
  readonly callId?: string;
39
40
  readonly reserveBytes?: number;
@@ -44,4 +45,4 @@ export declare function truncateNativeToolResult(sessionId: string, body: string
44
45
  * riding on the payload reserves its own bytes out of the result's budget, so
45
46
  * the ceiling holds after `withNudge` prepends it.
46
47
  */
47
- export declare function createTruncationHandler(sessionName: string): HandlerOf<"after_tool">;
48
+ export declare function createTruncationHandler(state: NativeSessionState, sessionName: string): HandlerOf<"after_tool">;
@@ -22,14 +22,13 @@
22
22
  */
23
23
  import { applyModelTruncationPolicy, MODEL_MAX_BYTES } from "#src/tools/index";
24
24
  import { nudgeOverheadBytes } from "./nudge.js";
25
- import { nativeSessionScratchpadRoots, nativeTranscriptDirs } from "./session.js";
26
25
  /**
27
26
  * Where this session's spills belong, or `undefined` when the session is not
28
27
  * known to the runtime at all — in which case nothing is spilled and the marker
29
28
  * names no path, rather than naming a file written somewhere unreachable.
30
29
  */
31
- export function spillRootFor(sessionId) {
32
- return nativeSessionScratchpadRoots.get(sessionId) ?? nativeTranscriptDirs.get(sessionId);
30
+ export function spillRootFor(state, sessionId) {
31
+ return state.scratchpadRoots.get(sessionId) ?? state.transcriptDirs.get(sessionId);
33
32
  }
34
33
  /**
35
34
  * Shape one tool result for the model: within every cap it is returned
@@ -37,8 +36,8 @@ export function spillRootFor(sessionId) {
37
36
  * body is written under the scratchpad, and the content ends with the marker
38
37
  * naming the spill path and both byte counts.
39
38
  */
40
- export async function truncateNativeToolResult(sessionId, body, opts) {
41
- const root = spillRootFor(sessionId);
39
+ export async function truncateNativeToolResult(state, sessionId, body, opts) {
40
+ const root = spillRootFor(state, sessionId);
42
41
  // `reserveBytes` is what the CALLER will add to this result after the policy
43
42
  // has run -- today only the spin-breaker nudge, which the turn loop prepends.
44
43
  // Anything appended or prepended downstream has to be charged against the
@@ -57,9 +56,7 @@ export async function truncateNativeToolResult(sessionId, body, opts) {
57
56
  ...(root !== undefined
58
57
  ? {
59
58
  root,
60
- spillPathStyle: nativeSessionScratchpadRoots.has(sessionId)
61
- ? "root-relative"
62
- : "absolute",
59
+ spillPathStyle: state.scratchpadRoots.has(sessionId) ? "root-relative" : "absolute",
63
60
  }
64
61
  : {}),
65
62
  maxBytes: Math.max(0, MODEL_MAX_BYTES - reserved),
@@ -71,9 +68,9 @@ export async function truncateNativeToolResult(sessionId, body, opts) {
71
68
  * riding on the payload reserves its own bytes out of the result's budget, so
72
69
  * the ceiling holds after `withNudge` prepends it.
73
70
  */
74
- export function createTruncationHandler(sessionName) {
71
+ export function createTruncationHandler(state, sessionName) {
75
72
  return async (payload) => {
76
- const shaped = await truncateNativeToolResult(sessionName, payload.content, {
73
+ const shaped = await truncateNativeToolResult(state, sessionName, payload.content, {
77
74
  toolName: payload.toolName,
78
75
  callId: payload.callId,
79
76
  ...(payload.nudgeText !== undefined ? { reserveBytes: nudgeOverheadBytes(payload.nudgeText) } : {}),
@@ -9,7 +9,7 @@
9
9
  * attaches `transform_context` and `before_request` here: both fire per request
10
10
  * attempt from the private `request()` wrapper below (spec 6.5, 6.6) —
11
11
  * before_request shapes the per-call options, transform_context shapes the WIRE
12
- * COPY only, and the array the caller holds (which saveTranscript persists) is
12
+ * COPY only, and the array the caller holds (which the turn-end transcript save persists) is
13
13
  * returned untouched. An honoured rewrite is reported as `honoured`, with the
14
14
  * boundary fact that produced it as `boundary`, so the caller can clear the
15
15
  * cache anchor exactly when spec 3.6 says to — a prefix-stable honour leaves
@@ -21,6 +21,7 @@
21
21
  import type { TranscriptMessage as NativeTranscriptMessage } from "./compaction.ts";
22
22
  import type { LoopEventRegistry } from "./loop-events/index.ts";
23
23
  import type { TurnAccumulator } from "./turn-accumulator.ts";
24
+ import type { TurnEventEmitter } from "./turn-event-emitter.ts";
24
25
  import type { NativeTurnResponse, TurnDeps } from "./turn-types.ts";
25
26
  export interface CompleteStepResult {
26
27
  readonly res: NativeTurnResponse;
@@ -73,5 +74,7 @@ export interface CompleteStepArgs {
73
74
  readonly model?: string;
74
75
  readonly deps: TurnDeps;
75
76
  readonly signal?: AbortSignal;
77
+ /** S3-3: the turn's event emitter. Emits stream_reset and the overflow compaction; hands the adapter a per-attempt delta sink. */
78
+ readonly turnEvents: TurnEventEmitter;
76
79
  }
77
80
  export declare function completeWithRecovery(args: CompleteStepArgs): Promise<CompleteStepResult>;
@@ -9,7 +9,7 @@
9
9
  * attaches `transform_context` and `before_request` here: both fire per request
10
10
  * attempt from the private `request()` wrapper below (spec 6.5, 6.6) —
11
11
  * before_request shapes the per-call options, transform_context shapes the WIRE
12
- * COPY only, and the array the caller holds (which saveTranscript persists) is
12
+ * COPY only, and the array the caller holds (which the turn-end transcript save persists) is
13
13
  * returned untouched. An honoured rewrite is reported as `honoured`, with the
14
14
  * boundary fact that produced it as `boundary`, so the caller can clear the
15
15
  * cache anchor exactly when spec 3.6 says to — a prefix-stable honour leaves
@@ -33,7 +33,7 @@ function isContextOverflow(err) {
33
33
  return protocolError?.kind === "context-overflow";
34
34
  }
35
35
  export async function completeWithRecovery(args) {
36
- const { tools, usage, summarizeFailed, sessionName, lastUsage, anchorIndex, loopEvents, roundTrip, model, deps, signal, } = args;
36
+ const { tools, usage, summarizeFailed, sessionName, lastUsage, anchorIndex, loopEvents, roundTrip, model, deps, signal, turnEvents, } = args;
37
37
  let messages = args.messages;
38
38
  let compacted = false;
39
39
  let honoured = false;
@@ -47,6 +47,12 @@ export async function completeWithRecovery(args) {
47
47
  let attempt = 0;
48
48
  const request = async (msgs) => {
49
49
  attempt += 1;
50
+ // Spec 5.3: every request attempt after the first voids the round's
51
+ // deltas so far. nax-ai retries only before the first event, so any
52
+ // re-issue with deltas already shown comes from here (transport retry or
53
+ // the overflow retry).
54
+ if (attempt > 1)
55
+ turnEvents.emit({ type: "stream_reset", round: roundTrip, attempt });
50
56
  const patch = await loopEvents.dispatch("before_request", {
51
57
  ...(model !== undefined ? { model } : {}),
52
58
  roundTrip,
@@ -56,7 +62,7 @@ export async function completeWithRecovery(args) {
56
62
  const options = patch.options === undefined ? baseOptions : { ...baseOptions, ...patch.options };
57
63
  // transform_context fires here too, per attempt (spec 6.6). The patch
58
64
  // shapes ONLY the wire copy handed to deps.complete below: `msgs` — the
59
- // caller's array, the one saveTranscript persists — is returned untouched,
65
+ // caller's array, the one the turn-end transcript save persists — is returned untouched,
60
66
  // so the transcript stays the true record. `boundary` is this step's
61
67
  // overflow fact (`compacted`): true only on the post-compaction retry,
62
68
  // where a prefix rewrite is free. A model change never reaches this step:
@@ -79,7 +85,7 @@ export async function completeWithRecovery(args) {
79
85
  // successful attempt is the last one to reach this line — `honoured`
80
86
  // describes the wire the provider actually answered (spec 3.6).
81
87
  honoured = wire.honoured;
82
- return deps.complete(wire.messages, tools, options);
88
+ return deps.complete(wire.messages, tools, options, turnEvents.deltaSink(roundTrip));
83
89
  };
84
90
  try {
85
91
  res = await request(messages);
@@ -150,6 +156,7 @@ export async function completeWithRecovery(args) {
150
156
  });
151
157
  messages = [...step.messages];
152
158
  compacted = true;
159
+ turnEvents.emit({ type: "compaction", reason: "overflow" });
153
160
  // Retried once. A second overflow propagates: compacting further would be
154
161
  // guessing, and the failure now carries a correct diagnosis.
155
162
  res = await request(messages);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The one caller of an embedder's `onTurnEvent` sink (S3-3).
3
+ *
4
+ * Built once per turn by `runNativeTurn`. Every method is a no-op without a
5
+ * sink, so nax (which sets none) does no redaction or encoding work. The
6
+ * emitter owns every branch the event sites would otherwise need, which keeps
7
+ * `runToolBatch` and `sendTurn` free of new branches (complexity ratchet).
8
+ *
9
+ * - A sink that throws, or returns a promise that rejects, is contained and
10
+ * logged once per turn: an embedder's bug must not end a turn, a tool call
11
+ * or a Node process (an unhandled rejection does, by default).
12
+ * - Tool input is redacted with the logger's redactor before it leaves; the
13
+ * result preview first cuts to `REDACTION_SCAN_BYTES` to bound the scan,
14
+ * redacts, then cuts to `TOOL_RESULT_PREVIEW_BYTES` on a codepoint boundary.
15
+ * The final cut follows redaction so it cannot split a secret into a prefix
16
+ * the patterns no longer match.
17
+ * - `toolResult` reports only a call `toolCall` reported (counted per id), and
18
+ * `flushUnanswered` answers what is left when a turn throws, so a consumer
19
+ * sees each `tool_call` answered by exactly one `tool_result`.
20
+ * - Redaction is best-effort (see `TurnEvent`'s doc) and runs over a bounded
21
+ * prefix: a result can be up to 2 MB when no truncation handler ran.
22
+ */
23
+ import type { ToolCall } from "@nathapp/nax-ai";
24
+ import type { TokenUsage } from "#src/cost/standard-types";
25
+ import type { TurnEvent, TurnEventSink } from "#src/session/turn-event";
26
+ import type { ToolResultMessage } from "./tool-result.ts";
27
+ /** Byte cap on `tool_result.preview`. A preview is for display; the transcript holds the full result. */
28
+ export declare const TOOL_RESULT_PREVIEW_BYTES = 4096;
29
+ /** Byte cap on `tool_call.input` as JSON; a larger input becomes `{ truncated: true, preview }`. */
30
+ export declare const TOOL_CALL_INPUT_BYTES = 8192;
31
+ /** One streamed delta, before the loop stamps its round. */
32
+ export interface StreamDelta {
33
+ readonly type: "text_delta" | "thinking_delta";
34
+ readonly text: string;
35
+ }
36
+ export type StreamDeltaSink = (delta: StreamDelta) => void;
37
+ export interface TurnEventEmitter {
38
+ emit(event: TurnEvent): void;
39
+ /** A delta sink stamped with `round`; undefined without a sink, so the adapter's tap stays a pass-through. */
40
+ deltaSink(round: number): StreamDeltaSink | undefined;
41
+ /** `recordedInput` is a `before_tool` rewrite when there is one; otherwise the model's `call.input` is reported. */
42
+ toolCall(call: ToolCall, recordedInput: unknown): void;
43
+ toolResult(result: ToolResultMessage): void;
44
+ /** Answers every outstanding `tool_call` with an error result. Called from the turn's catch block. */
45
+ flushUnanswered(): void;
46
+ }
47
+ export declare function usageEvent(round: number, usage: TokenUsage, costUsd: number): TurnEvent;
48
+ export declare function createTurnEventEmitter(sink: TurnEventSink | undefined): TurnEventEmitter;
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The one caller of an embedder's `onTurnEvent` sink (S3-3).
3
+ *
4
+ * Built once per turn by `runNativeTurn`. Every method is a no-op without a
5
+ * sink, so nax (which sets none) does no redaction or encoding work. The
6
+ * emitter owns every branch the event sites would otherwise need, which keeps
7
+ * `runToolBatch` and `sendTurn` free of new branches (complexity ratchet).
8
+ *
9
+ * - A sink that throws, or returns a promise that rejects, is contained and
10
+ * logged once per turn: an embedder's bug must not end a turn, a tool call
11
+ * or a Node process (an unhandled rejection does, by default).
12
+ * - Tool input is redacted with the logger's redactor before it leaves; the
13
+ * result preview first cuts to `REDACTION_SCAN_BYTES` to bound the scan,
14
+ * redacts, then cuts to `TOOL_RESULT_PREVIEW_BYTES` on a codepoint boundary.
15
+ * The final cut follows redaction so it cannot split a secret into a prefix
16
+ * the patterns no longer match.
17
+ * - `toolResult` reports only a call `toolCall` reported (counted per id), and
18
+ * `flushUnanswered` answers what is left when a turn throws, so a consumer
19
+ * sees each `tool_call` answered by exactly one `tool_result`.
20
+ * - Redaction is best-effort (see `TurnEvent`'s doc) and runs over a bounded
21
+ * prefix: a result can be up to 2 MB when no truncation handler ran.
22
+ */
23
+ import { errorMessage } from "#src/infra/errors";
24
+ import { getSafeLogger } from "#src/infra/index";
25
+ import { capStrings, redactSecrets } from "#src/internal/redact";
26
+ import { isThenable } from "#src/internal/thenable";
27
+ import { cutToByteCap } from "#src/tools/truncate";
28
+ import { cacheUsageFields } from "./turn-types.js";
29
+ /** Byte cap on `tool_result.preview`. A preview is for display; the transcript holds the full result. */
30
+ export const TOOL_RESULT_PREVIEW_BYTES = 4096;
31
+ /** Byte cap on `tool_call.input` as JSON; a larger input becomes `{ truncated: true, preview }`. */
32
+ export const TOOL_CALL_INPUT_BYTES = 8192;
33
+ /** Redaction scans at most this many bytes of a result, and of each string in a tool input; the preview keeps far fewer. */
34
+ const REDACTION_SCAN_BYTES = TOOL_RESULT_PREVIEW_BYTES * 16;
35
+ const UNANSWERED_PREVIEW = "Not answered: the turn ended.";
36
+ export function usageEvent(round, usage, costUsd) {
37
+ return {
38
+ type: "usage",
39
+ round,
40
+ inputTokens: usage.inputTokens,
41
+ outputTokens: usage.outputTokens,
42
+ ...cacheUsageFields(usage),
43
+ costUsd,
44
+ };
45
+ }
46
+ function cappedInput(input) {
47
+ const redacted = redactSecrets(capStrings(input, REDACTION_SCAN_BYTES));
48
+ let json;
49
+ try {
50
+ json = JSON.stringify(redacted) ?? "null";
51
+ }
52
+ catch {
53
+ return { truncated: true, preview: "[input not serializable]" };
54
+ }
55
+ if (Buffer.byteLength(json, "utf8") <= TOOL_CALL_INPUT_BYTES)
56
+ return redacted;
57
+ return { truncated: true, preview: cutToByteCap(json, TOOL_CALL_INPUT_BYTES) };
58
+ }
59
+ function previewOf(content) {
60
+ return cutToByteCap(redactSecrets(cutToByteCap(content, REDACTION_SCAN_BYTES)), TOOL_RESULT_PREVIEW_BYTES);
61
+ }
62
+ const NOOP_EMITTER = {
63
+ emit: () => { },
64
+ deltaSink: () => undefined,
65
+ toolCall: () => { },
66
+ toolResult: () => { },
67
+ flushUnanswered: () => { },
68
+ };
69
+ export function createTurnEventEmitter(sink) {
70
+ if (sink === undefined)
71
+ return NOOP_EMITTER;
72
+ let warned = false;
73
+ /** Outstanding tool calls per id: a provider can repeat an id within a batch. */
74
+ const outstanding = new Map();
75
+ const contain = (event, err) => {
76
+ if (warned)
77
+ return;
78
+ warned = true;
79
+ try {
80
+ getSafeLogger()?.warn("native-turn-events", "onTurnEvent sink failed; events are still delivered", {
81
+ eventType: event.type,
82
+ error: errorMessage(err),
83
+ });
84
+ }
85
+ catch {
86
+ // The containment path itself must never throw or reject.
87
+ }
88
+ };
89
+ const emit = (event) => {
90
+ try {
91
+ const returned = sink(event);
92
+ if (isThenable(returned))
93
+ returned.then(undefined, (err) => contain(event, err));
94
+ }
95
+ catch (err) {
96
+ contain(event, err);
97
+ }
98
+ };
99
+ return {
100
+ emit,
101
+ deltaSink: (round) => (delta) => emit({ ...delta, round }),
102
+ toolCall(call, recordedInput) {
103
+ // Capped BEFORE the count moves: a throwing input (a hostile getter or
104
+ // proxy) must not leave an outstanding call the matching toolResult then
105
+ // answers with no preceding tool_call.
106
+ const input = cappedInput(recordedInput ?? call.input);
107
+ outstanding.set(call.id, (outstanding.get(call.id) ?? 0) + 1);
108
+ emit({ type: "tool_call", callId: call.id, name: call.name, input });
109
+ },
110
+ toolResult(result) {
111
+ const count = outstanding.get(result.toolCallId) ?? 0;
112
+ if (count === 0)
113
+ return;
114
+ if (count === 1)
115
+ outstanding.delete(result.toolCallId);
116
+ else
117
+ outstanding.set(result.toolCallId, count - 1);
118
+ emit({
119
+ type: "tool_result",
120
+ callId: result.toolCallId,
121
+ isError: result.isError === true,
122
+ preview: previewOf(result.content),
123
+ });
124
+ },
125
+ flushUnanswered() {
126
+ for (const [callId, count] of outstanding) {
127
+ for (let i = 0; i < count; i += 1) {
128
+ emit({ type: "tool_result", callId, isError: true, preview: UNANSWERED_PREVIEW });
129
+ }
130
+ }
131
+ outstanding.clear();
132
+ },
133
+ };
134
+ }
@@ -38,6 +38,7 @@ import { type TranscriptMessage as NativeTranscriptMessage } from "./compaction.
38
38
  import type { InvalidCallBudget } from "./handle-invalid-tool-call.ts";
39
39
  import type { LoopEventRegistry } from "./loop-events/index.ts";
40
40
  import type { createTurnAccumulator } from "./turn-accumulator.ts";
41
+ import { type TurnEventEmitter } from "./turn-event-emitter.ts";
41
42
  import type { TurnDeps } from "./turn-types.ts";
42
43
  /**
43
44
  * The per-turn bound on `before_turn_end`'s followUp channel (spec 6.4): the
@@ -71,7 +72,7 @@ export interface TurnRoundParams {
71
72
  handle: SessionHandle;
72
73
  opts: SendTurnOpts;
73
74
  deps: TurnDeps;
74
- /** `transcriptIdentity.model` — carried into every `nativeSessionLastUsage.set` this turn writes. */
75
+ /** `transcriptIdentity.model` — carried into every `sessionState.lastUsage.set` this turn writes. */
75
76
  transcriptModel: string | undefined;
76
77
  tools: ToolDefinition[];
77
78
  codingToolNames: ReadonlySet<string>;
@@ -86,6 +87,8 @@ export interface TurnRoundParams {
86
87
  codingToolsCalled: string[];
87
88
  /** Mutated in place via `.add()` — the caller owns the turn-lifetime accumulator. */
88
89
  usage: ReturnType<typeof createTurnAccumulator>;
90
+ /** S3-3: the turn's one event emitter (a no-op without an `onTurnEvent` sink). */
91
+ turnEvents: TurnEventEmitter;
89
92
  }
90
93
  /**
91
94
  * Deliberately unbounded by COUNT of varied calls. A coding agent working a
@@ -103,7 +106,7 @@ export type TurnEndOutcome = {
103
106
  };
104
107
  /**
105
108
  * P3 `before_turn_end` (spec 6.4): fires at every turn ENDING, before the
106
- * final saveTranscript. `stopped` is dispatcher-computed: the three endings
109
+ * final turn-end transcript save. `stopped` is dispatcher-computed: the three endings
107
110
  * that are stops, not completions — the spin breaker (nax#2120), the
108
111
  * invalid-call budget (nax#2047), and the deadline — close the followUp
109
112
  * channel entirely, because resurrecting a turn a breaker just killed would
@@ -34,10 +34,10 @@
34
34
  import { inputClassTokens } from "#src/cost/core/index";
35
35
  import { getSafeLogger } from "#src/infra/index";
36
36
  import { estimateContextTokens, shouldCompact, } from "./compaction.js";
37
- import { nativeSessionLastUsage } from "./session.js";
38
37
  import { usageBeat } from "./turn-accumulator.js";
39
38
  import { runProactiveCompaction } from "./turn-compaction-step.js";
40
39
  import { completeWithRecovery } from "./turn-complete-step.js";
40
+ import { usageEvent } from "./turn-event-emitter.js";
41
41
  import { runToolBatch } from "./turn-tool-batch.js";
42
42
  /**
43
43
  * The per-turn bound on `before_turn_end`'s followUp channel (spec 6.4): the
@@ -92,6 +92,7 @@ async function maybeCompact(state, params) {
92
92
  // The anchor described the pre-compaction array; it is meaningless now.
93
93
  state.lastUsage = undefined;
94
94
  state.anchorIndex = undefined;
95
+ params.turnEvents.emit({ type: "compaction", reason: "proactive" });
95
96
  }
96
97
  return { summarizeFailed: step.summarizeFailed };
97
98
  }
@@ -121,6 +122,7 @@ async function runModelRoundTrip(state, params, summarizeFailed) {
121
122
  roundTrip: state.roundTrips + 1,
122
123
  ...(handle.modelDef?.model !== undefined ? { model: handle.modelDef.model } : {}),
123
124
  deps,
125
+ turnEvents: params.turnEvents,
124
126
  ...(opts.signal !== undefined ? { signal: opts.signal } : {}),
125
127
  });
126
128
  const res = step.res;
@@ -146,13 +148,14 @@ async function runModelRoundTrip(state, params, summarizeFailed) {
146
148
  const promptTokens = inputClassTokens(res.usage);
147
149
  state.lastUsage = { promptTokens };
148
150
  state.anchorIndex = state.messages.length - 1;
149
- nativeSessionLastUsage.set(handle.id, {
151
+ deps.sessionState.lastUsage.set(handle.id, {
150
152
  promptTokens,
151
153
  anchorIndex: state.anchorIndex,
152
154
  ...(params.transcriptModel !== undefined ? { model: params.transcriptModel } : {}),
153
155
  });
154
156
  // 1-based; `roundTrips` is incremented above, before this beat fires.
155
157
  deps.onActivity?.(usageBeat(res.usage, res.costUsd, state.roundTrips));
158
+ params.turnEvents.emit(usageEvent(state.roundTrips, res.usage, res.costUsd));
156
159
  if (res.text.length > 0)
157
160
  deps.onActivity?.({ kind: "message", bytes: res.text.length });
158
161
  if (res.thinking !== undefined && res.thinking.length > 0) {
@@ -219,6 +222,7 @@ async function dispatchToolBatch(state, params, toolCalls) {
219
222
  maxInteractions,
220
223
  spinWarned: spinFlags.warned,
221
224
  interactionsSoFar: params.interactions.length,
225
+ turnEvents: params.turnEvents,
222
226
  });
223
227
  state.messages = [...batch.messages];
224
228
  params.interactions.push(...batch.interactions);
@@ -229,7 +233,7 @@ async function dispatchToolBatch(state, params, toolCalls) {
229
233
  return { shouldBreak: true };
230
234
  // US-002: a cancelled turn signal during the batch answers every
231
235
  // outstanding call synthetically and stops the loop. The throw routes
232
- // through the existing catch block — its best-effort saveTranscript
236
+ // through the existing catch block — its best-effort transcript save
233
237
  // persists the synthetic results before the throw propagates, keeping one
234
238
  // result per assistant id in the saved transcript (AC2 / AC3). AC4 carries
235
239
  // the abort reason verbatim; AC6 — a no-reason abort — produces a
@@ -267,7 +271,7 @@ export async function runRoundTripLoop(state, params) {
267
271
  }
268
272
  /**
269
273
  * P3 `before_turn_end` (spec 6.4): fires at every turn ENDING, before the
270
- * final saveTranscript. `stopped` is dispatcher-computed: the three endings
274
+ * final turn-end transcript save. `stopped` is dispatcher-computed: the three endings
271
275
  * that are stops, not completions — the spin breaker (nax#2120), the
272
276
  * invalid-call budget (nax#2047), and the deadline — close the followUp
273
277
  * channel entirely, because resurrecting a turn a breaker just killed would
@@ -24,11 +24,12 @@ import { createInvalidCallBudget } from "./handle-invalid-tool-call.js";
24
24
  import { applyHistoryPatch } from "./loop-events/cache-boundary.js";
25
25
  import { createLoopEventRegistry } from "./loop-events/index.js";
26
26
  import { registerBuiltinLoopHandlers } from "./loop-handlers.js";
27
- import { nativeSessionTranscriptOwners, nativeTranscriptDirs, sessionAnchorFor } from "./session.js";
27
+ import { sessionAnchorFor, sessionTranscriptFor } from "./session.js";
28
28
  import { codingToolsToDefinitions, toToolDefinitions } from "./tool-mapping.js";
29
- import { loadTranscript, saveTranscript, transcriptModelIdentity, } from "./transcript-store.js";
29
+ import { historyFromTranscript, transcriptDocFor, transcriptModelIdentity, } from "./transcript-identity.js";
30
30
  import { createTurnAccumulator } from "./turn-accumulator.js";
31
31
  import { dispatchTurnEndOnError } from "./turn-end-event.js";
32
+ import { createTurnEventEmitter } from "./turn-event-emitter.js";
32
33
  import { runRoundTripLoop, runTurnEndPhase } from "./turn-loop-round-trip.js";
33
34
  import { buildTurnResult, logTurnTailWarnings } from "./turn-result.js";
34
35
  import { recordNativeTurnFailureUsage } from "./turn-types.js";
@@ -49,21 +50,25 @@ function beforeTurnSeed(patch, prompt) {
49
50
  return [{ role: "user", content: prompt }];
50
51
  }
51
52
  export async function runNativeTurn(handle, prompt, opts, deps) {
52
- const dir = nativeTranscriptDirs.get(handle.id);
53
- if (dir === undefined) {
54
- throw new NaxError(`no transcript directory for session "${handle.id}"`, "NATIVE_TRANSCRIPT_DIR_MISSING", {
53
+ const transcript = sessionTranscriptFor(deps.sessionState, handle.id);
54
+ if (transcript === undefined) {
55
+ throw new NaxError(`no transcript store for session "${handle.id}"`, "NATIVE_TRANSCRIPT_DIR_MISSING", {
55
56
  stage: "native-session",
56
57
  });
57
58
  }
59
+ const { store } = transcript;
58
60
  // nax#1877: an owner mismatch reads as a new conversation, so an abandoned
59
61
  // invocation's history cannot ride along on the first request of this one.
60
- // nax#2150 (P3 spec 8.3): so does a recorded different model — the store
61
- // owns that guarantee, whatever the session layer above decided.
62
+ // nax#2150 (P3 spec 8.3): so does a recorded different model — the loop
63
+ // applies that guarantee to every store's document (historyFromTranscript),
64
+ // whatever the session layer above decided.
62
65
  const transcriptIdentity = {
63
- owner: nativeSessionTranscriptOwners.get(handle.id),
66
+ owner: deps.sessionState.transcriptOwners.get(handle.id),
64
67
  model: transcriptModelIdentity(handle.modelDef?.model),
65
68
  };
66
- let messages = [...(await loadTranscript(dir, handle.id, transcriptIdentity))];
69
+ let messages = [
70
+ ...historyFromTranscript(await store.load(handle.id), transcriptIdentity, handle.id),
71
+ ];
67
72
  // nax#2151: the invalid-call repair and the spin breaker are `before_tool`
68
73
  // registrations rather than inline branches. Absent a caller-supplied
69
74
  // registry the loop owns one, so both built-ins run exactly as they did
@@ -75,6 +80,7 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
75
80
  const loopEvents = deps.loopEvents ?? createLoopEventRegistry();
76
81
  registerBuiltinLoopHandlers(loopEvents, {
77
82
  sessionName: handle.id,
83
+ sessionState: deps.sessionState,
78
84
  budget: invalidCallBudget,
79
85
  ...(deps.spinBreaker !== undefined ? { spinBreaker: deps.spinBreaker } : {}),
80
86
  ...(deps.loopHandlers !== undefined ? { loopHandlers: deps.loopHandlers } : {}),
@@ -84,7 +90,7 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
84
90
  spinFlags.warned = true;
85
91
  },
86
92
  });
87
- const anchor = sessionAnchorFor(handle.id, transcriptIdentity.model);
93
+ const anchor = sessionAnchorFor(deps.sessionState, handle.id, transcriptIdentity.model);
88
94
  const lastUsage = anchor?.promptTokens !== undefined ? { promptTokens: anchor.promptTokens } : undefined;
89
95
  const anchorIndex = anchor?.anchorIndex;
90
96
  // P3 `before_turn` (spec 6.1): fires ONCE, after the transcript loads and
@@ -101,7 +107,7 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
101
107
  boundary: false,
102
108
  });
103
109
  // An honoured history patch (reachable only at an undefined anchor today)
104
- // rewrites the IN-MEMORY array, and the saveTranscript at the turn's end
110
+ // rewrites the IN-MEMORY array, and the turn-end transcript save
105
111
  // persists it — before_turn is the conversation-rewriting event, unlike
106
112
  // transform_context's wire-copy-only ruling (spec 6.6).
107
113
  messages = [
@@ -148,6 +154,7 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
148
154
  interactions,
149
155
  codingToolsCalled,
150
156
  usage,
157
+ turnEvents: createTurnEventEmitter(deps.onTurnEvent),
151
158
  };
152
159
  let state = {
153
160
  messages,
@@ -183,6 +190,9 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
183
190
  }
184
191
  }
185
192
  catch (err) {
193
+ // S3-3: a throw can leave a reported tool_call unanswered; answer it so the
194
+ // sink's one-result-per-call rule holds on every ending.
195
+ params.turnEvents.flushUnanswered();
186
196
  // Review #20: the event fires on EVERY ending, so a throwing turn
187
197
  // dispatches it before the transcript save — a handler sees the failure
188
198
  // ending with `ended: "aborted"` (signal fired) or `"errored"`, and its
@@ -205,7 +215,11 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
205
215
  // failure fails the turn, because continuing on unstored history is silent
206
216
  // degradation. Here a failure is already in flight, and masking it with a
207
217
  // write error would lose the cause.
208
- await saveTranscript(dir, handle.id, state.messages, transcriptIdentity).catch((saveErr) => {
218
+ // Deferred into a promise so a store whose `save` throws synchronously is
219
+ // caught here too, rather than escaping and replacing the turn's error.
220
+ await Promise.resolve()
221
+ .then(() => store.save(handle.id, transcriptDocFor(state.messages, transcriptIdentity)))
222
+ .catch((saveErr) => {
209
223
  getSafeLogger()?.warn("native-adapter", "could not persist the transcript of a failed turn", {
210
224
  sessionName: handle.id,
211
225
  error: saveErr instanceof Error ? saveErr.message : String(saveErr),
@@ -233,7 +247,7 @@ export async function runNativeTurn(handle, prompt, opts, deps) {
233
247
  // Persisted before returning, and a write failure fails the turn: continuing
234
248
  // on a history that could not be stored is the silent degradation #1794
235
249
  // removed from the pipeline (ADR-028 s4).
236
- await saveTranscript(dir, handle.id, state.messages, transcriptIdentity);
250
+ await store.save(handle.id, transcriptDocFor(state.messages, transcriptIdentity));
237
251
  return buildTurnResult({
238
252
  output: state.output,
239
253
  usage,
@@ -2,7 +2,7 @@
2
2
  * The turn's tail: the warnings emitted once the loop has exited, and the
3
3
  * `TurnResult` assembled from what the loop accumulated.
4
4
  *
5
- * Extracted from `turn-loop.ts`, which keeps the `saveTranscript` call that
5
+ * Extracted from `turn-loop.ts`, which keeps the turn-end transcript save that
6
6
  * sits between the two: a write failure there fails the turn, unlike the
7
7
  * best-effort save in the catch, and that distinction belongs with the control
8
8
  * flow rather than in a result builder.
@@ -2,7 +2,7 @@
2
2
  * The turn's tail: the warnings emitted once the loop has exited, and the
3
3
  * `TurnResult` assembled from what the loop accumulated.
4
4
  *
5
- * Extracted from `turn-loop.ts`, which keeps the `saveTranscript` call that
5
+ * Extracted from `turn-loop.ts`, which keeps the turn-end transcript save that
6
6
  * sits between the two: a write failure there fails the turn, unlike the
7
7
  * best-effort save in the catch, and that distinction belongs with the control
8
8
  * flow rather than in a result builder.
@@ -22,6 +22,7 @@ import type { InteractionExchange, SendTurnOpts } from "#src/session/session-typ
22
22
  import type { TranscriptMessage as NativeTranscriptMessage } from "./compaction.ts";
23
23
  import { type InvalidCallBudget } from "./handle-invalid-tool-call.ts";
24
24
  import type { LoopEventRegistry } from "./loop-events/index.ts";
25
+ import type { TurnEventEmitter } from "./turn-event-emitter.ts";
25
26
  import type { TurnDeps } from "./turn-types.ts";
26
27
  export interface ToolBatchResult {
27
28
  readonly messages: readonly NativeTranscriptMessage[];
@@ -57,5 +58,7 @@ export interface ToolBatchArgs {
57
58
  readonly spinWarned: boolean;
58
59
  /** Exchanges already recorded this turn, so the ask_human budget is turn-lifetime, not per-batch. */
59
60
  readonly interactionsSoFar: number;
61
+ /** S3-3: the turn's event emitter; reports each answered call (no-op without a sink). */
62
+ readonly turnEvents: TurnEventEmitter;
60
63
  }
61
64
  export declare function runToolBatch(args: ToolBatchArgs): Promise<ToolBatchResult>;