@nathapp/nax-agent 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +47 -2
- package/dist/coding-tools/coding-tool-sandbox.d.ts +3 -0
- package/dist/coding-tools/coding-tool-sandbox.js +14 -3
- package/dist/coding-tools/coding-tool-support.d.ts +3 -0
- package/dist/coding-tools/coding-tool-support.js +2 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +4 -1
- package/dist/internal/redact.d.ts +23 -0
- package/dist/internal/redact.js +60 -0
- package/dist/internal.d.ts +7 -0
- package/dist/internal.js +8 -0
- package/dist/native/adapter-deps.d.ts +3 -3
- package/dist/native/adapter-deps.js +3 -3
- package/dist/native/client.d.ts +7 -1
- package/dist/native/client.js +1 -1
- package/dist/native/credentials/change-guard.d.ts +8 -1
- package/dist/native/credentials/change-guard.js +1 -1
- package/dist/native/credentials/fingerprint.d.ts +4 -1
- package/dist/native/credentials/fingerprint.js +6 -3
- package/dist/native/credentials/session-source.d.ts +12 -0
- package/dist/native/credentials/session-source.js +33 -0
- package/dist/native/index.d.ts +3 -1
- package/dist/native/index.js +2 -1
- package/dist/native/session/loop-events/types.d.ts +1 -1
- package/dist/native/session/loop-handlers.d.ts +3 -0
- package/dist/native/session/loop-handlers.js +1 -1
- package/dist/native/session/memory-transcript-store.d.ts +12 -0
- package/dist/native/session/memory-transcript-store.js +36 -0
- package/dist/native/session/session.d.ts +138 -96
- package/dist/native/session/session.js +107 -127
- package/dist/native/session/transcript-identity.d.ts +39 -0
- package/dist/native/session/transcript-identity.js +81 -0
- package/dist/native/session/transcript-store.d.ts +22 -21
- package/dist/native/session/transcript-store.js +59 -68
- package/dist/native/session/transcript-types.d.ts +38 -0
- package/dist/native/session/transcript-types.js +7 -0
- package/dist/native/session/truncation-handler.d.ts +4 -3
- package/dist/native/session/truncation-handler.js +7 -10
- package/dist/native/session/turn-complete-step.d.ts +4 -1
- package/dist/native/session/turn-complete-step.js +11 -4
- package/dist/native/session/turn-event-emitter.d.ts +48 -0
- package/dist/native/session/turn-event-emitter.js +134 -0
- package/dist/native/session/turn-loop-round-trip.d.ts +5 -2
- package/dist/native/session/turn-loop-round-trip.js +8 -4
- package/dist/native/session/turn-loop.js +27 -13
- package/dist/native/session/turn-result.d.ts +1 -1
- package/dist/native/session/turn-result.js +1 -1
- package/dist/native/session/turn-tool-batch.d.ts +3 -0
- package/dist/native/session/turn-tool-batch.js +14 -6
- package/dist/native/session/turn-types.d.ts +17 -1
- package/dist/native/session-adapter.d.ts +22 -4
- package/dist/native/session-adapter.js +79 -35
- package/dist/native/stream-complete.d.ts +17 -0
- package/dist/native/stream-complete.js +40 -0
- package/dist/permissions/approvals-link.js +5 -4
- package/dist/sandbox/policy-builder.d.ts +6 -1
- package/dist/sandbox/policy-builder.js +18 -14
- package/dist/session/agent-session-deps.d.ts +12 -0
- package/dist/session/agent-session-deps.js +28 -0
- package/dist/session/agent-session-errors.d.ts +11 -0
- package/dist/session/agent-session-errors.js +12 -0
- package/dist/session/agent-session-options.d.ts +16 -0
- package/dist/session/agent-session-options.js +137 -0
- package/dist/session/agent-session-resume.d.ts +12 -0
- package/dist/session/agent-session-resume.js +47 -0
- package/dist/session/agent-session-turn.d.ts +45 -0
- package/dist/session/agent-session-turn.js +179 -0
- package/dist/session/agent-session-types.d.ts +182 -0
- package/dist/session/agent-session-types.js +1 -0
- package/dist/session/agent-session.d.ts +8 -0
- package/dist/session/agent-session.js +223 -0
- package/dist/session/pending-asks.d.ts +24 -0
- package/dist/session/pending-asks.js +77 -0
- package/dist/session/session-ask-link.d.ts +26 -0
- package/dist/session/session-ask-link.js +52 -0
- package/dist/session/session-event-channel.d.ts +17 -0
- package/dist/session/session-event-channel.js +132 -0
- package/dist/session/session-interaction.d.ts +20 -0
- package/dist/session/session-interaction.js +164 -0
- package/dist/session/session-tool-support.d.ts +48 -0
- package/dist/session/session-tool-support.js +101 -0
- package/dist/session/session-types.d.ts +31 -2
- package/dist/session/turn-event.d.ts +61 -0
- package/dist/session/turn-event.js +1 -0
- package/dist/tools/bash-cwd.d.ts +3 -3
- package/dist/tools/credential-read-deny.d.ts +22 -0
- package/dist/tools/credential-read-deny.js +56 -0
- package/dist/tools/glob.js +9 -2
- package/dist/tools/grep.js +55 -26
- package/dist/tools/owned-paths.d.ts +43 -0
- package/dist/tools/owned-paths.js +17 -0
- package/dist/tools/package-managers.d.ts +3 -0
- package/dist/tools/package-managers.js +7 -3
- package/dist/tools/policy-bash-raw.d.ts +3 -0
- package/dist/tools/policy-bash-raw.js +49 -62
- package/dist/tools/policy-command-branch.d.ts +3 -0
- package/dist/tools/policy-command-branch.js +2 -1
- package/dist/tools/policy-paths-branch.d.ts +14 -8
- package/dist/tools/policy-paths-branch.js +20 -19
- package/dist/tools/policy.d.ts +10 -7
- package/dist/tools/policy.js +9 -7
- package/dist/tools/protected-paths.d.ts +6 -6
- package/dist/tools/read.js +38 -20
- package/dist/tools/registry.d.ts +3 -0
- package/dist/tools/run-command-exec.js +1 -0
- package/dist/tools/runtime.d.ts +3 -0
- package/dist/tools/runtime.js +1 -0
- package/dist/tools/scratchpad.js +2 -1
- package/dist/tools/types.d.ts +2 -2
- package/package.json +2 -2
- package/dist/tools/nax-owned-writes.d.ts +0 -135
- package/dist/tools/nax-owned-writes.js +0 -259
|
@@ -0,0 +1,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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
27
|
+
import { sessionAnchorFor, sessionTranscriptFor } from "./session.js";
|
|
28
28
|
import { codingToolsToDefinitions, toToolDefinitions } from "./tool-mapping.js";
|
|
29
|
-
import {
|
|
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
|
|
53
|
-
if (
|
|
54
|
-
throw new NaxError(`no transcript
|
|
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
|
|
61
|
-
//
|
|
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:
|
|
66
|
+
owner: deps.sessionState.transcriptOwners.get(handle.id),
|
|
64
67
|
model: transcriptModelIdentity(handle.modelDef?.model),
|
|
65
68
|
};
|
|
66
|
-
let messages = [
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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>;
|