@sema-agent/core 7.37.0 → 7.37.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 +35 -0
- package/dist/brain/anthropic.js +7 -14
- package/dist/brain/open-responses.js +6 -13
- package/dist/brain/openai.js +7 -14
- package/dist/brain/stream-shared.d.ts +21 -0
- package/dist/brain/stream-shared.js +13 -0
- package/dist/core/engine-notice.d.ts +9 -0
- package/dist/core/governance-codes.d.ts +1 -1
- package/dist/core/governance-codes.js +2 -0
- package/dist/core/runner/run-identity-wiring.d.ts +1 -1
- package/dist/core/runner/run-identity-wiring.js +2 -0
- package/dist/core/runner/tool-turn-repair-seat.d.ts +8 -0
- package/dist/core/runner/tool-turn-repair-seat.js +28 -0
- package/dist/core/session-reconcile.d.ts +35 -0
- package/dist/core/session-reconcile.js +31 -20
- package/dist/core/side-query.js +4 -2
- package/dist/engine/harness/agent-harness.d.ts +7 -0
- package/dist/engine/harness/agent-harness.js +14 -2
- package/dist/engine/llm/index.d.ts +1 -0
- package/dist/engine/llm/index.js +1 -0
- package/dist/engine/llm/tool-turns.d.ts +20 -0
- package/dist/engine/llm/tool-turns.js +91 -0
- package/dist/internal/llm.d.ts +2 -2
- package/dist/internal/llm.js +1 -1
- package/package.json +1 -1
- package/wire-changes/core@7.37.1.json +122 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
## 7.37.1 — 2026-10-01
|
|
2
|
+
|
|
3
|
+
**Hard-breaking changes:** none.
|
|
4
|
+
|
|
5
|
+
**Closed sets.** `ENGINE_NOTICE_CODES` / `EngineNoticeCode` (#1224): +`context.tool_turn_repaired` (audience `operator` in `NOTICE_AUDIENCE`); its `detail.repairs[].form` words are `trailing_text` / `late_result` / `missing_result` / `duplicate_result` (not a package export).
|
|
6
|
+
|
|
7
|
+
**Package exports:** +0 / −0.
|
|
8
|
+
|
|
9
|
+
### A session with a broken tool turn heals on its next request: every provider request holds the tool-turn law, once for the three lanes (#1224; behavior (the request view) + closed set +1 notice code; +0 exports) (@server @cli @client-core @test)
|
|
10
|
+
|
|
11
|
+
- **Before (v7.37.0).** Each brain replayed an assistant's blocks in stored order and merged only ADJACENT tool results into the message after its calls. A stored turn whose calls are followed by another block (the drop note of #1225: [thinking, text, toolCall×2, text]), a result recorded behind a later user or wake message, a call with no result, or two results for one call went to the provider as stored. The Anthropic Messages lane answered 400 `tool_use ids were found without tool_result blocks immediately after`, naming the same id on every later request, so the session could not continue; the two OpenAI-shaped lanes rejected the late / missing / doubled shapes the same way. The acquire reconcile writes the same late shape itself: it closes an orphan call at the transcript's TAIL, behind any later user message.
|
|
12
|
+
- **Now.** The harness's provider-boundary conversion applies one law (`src/engine/llm/tool-turns.ts`) to every request, whichever brain reads it: for every assistant message with calls, (i) the calls are a contiguous tail — every block that is not a call and stands after the first call (between two calls or after the last) moves ahead of the calls, order kept; blocks ahead of the first call, thinking first, keep their place; (ii) the messages right after it are exactly one result per call — a result recorded later moves into the batch (and is not sent again where it was), a second recorded result for the same call is folded into the first (no recorded result is withheld — a withheld result would also make the request lossy for a compaction fork), an interrupted close (the reconcile family's `details.errorKind`) stands in only for a call with no other result (beside a recorded result it is left out), a call with none is closed with the reconcile family's interrupted result (`interruptedResults`, the reconcile family's text and `errorKind` (without its tool-effect refinement — see below); one mint for both). A result answers the NEAREST EARLIER message that emitted its id, — the persisted reconcile's own positional reading, so the two agree on which occurrence a result answers: an earlier use of a reused id with no result before the next use is closed as interrupted, never handed a later use's success, and every result recorded after the last use is that use's. The public `runSideQuery` holds the same law on its caller-supplied history (a host can hand it a stored transcript; an entry's `details` is kept so an interrupted close is recognized; no notice — a side query has no run). The transcript is never rewritten; a legal request list is handed on as the same array. One notice per run per (form, calls): `context.tool_turn_repaired`, `detail: { repairs: [{ form, toolCallIds }], sessionId, runId }`. A result's tool name is NOT an attribution key: the owner is the nearest earlier site that emitted the id, the reconcile family's positional reading and nothing else (the review's two rounds found every name-based reading — the first tree's, and a bounded one — reaching back past a turn the model had already answered and moving its outcome, closing the later call as interrupted). An id reused by another tool before its result is recorded therefore reads positionally, as the reconcile reads it. The repair notice keys a call SET (ids sorted), so one batch read in two orders announces once. The synthesized close is the reconcile family's function with no tool-effect refinement (the view has no effect table), so a read-class tool's close carries the general wording where the persisted reconcile writes the read-class one; and the notice's sentence names a shape "a provider lane rejects" — the OpenAI lanes serialize an assistant's text and calls separately, so a trailing block is announced there but never reached their wire.
|
|
13
|
+
- **Across lanes.** A turn stored by one lane and replayed on another is repaired at the same place: an OpenAI-lane turn [unsigned thinking, text, toolCall×2, note] replayed on the Anthropic lane (the unsigned thinking is not replayed there) goes out as [text, note, tool_use×2].
|
|
14
|
+
- **Who is hurt.** A consumer with a closed table of notice codes (+1 row). A host `Brain` now receives the repaired order for a broken turn, not the stored one — on the run path and in `runSideQuery`.
|
|
15
|
+
- **Known limits.** Two calls sharing an id AND a tool name, the first one's result recorded after the second: it is read as the second's (folded with the second's own, when both were recorded), and the first is closed as interrupted (outcome unknown) — the view cannot tell the two uses apart without an occurrence identity, and it never states a success for the earlier one. Two calls with the SAME id inside one response (admission accepts it; the engine keys a call by its id throughout) go out as two calls with one folded result. The per-message aggregate tool-result budget is measured on the stored batches before the view is built: a batch completed by a moved or folded result can exceed it (the request's total size is unchanged, and the context guard measured it). The acquire reconcile's orphan rule is positional, not name-aware: with an id reused by a later turn it can persist an interrupted close for a call that has a result (the view voids that close; the persisted row is unchanged).
|
|
16
|
+
- **Compensation.** Add the notice row (`operator`). Nothing else moves: a transcript that already has the structure produces the same request as before.
|
|
17
|
+
- Pins: `test/backlog1224-tool-turn-invariants.test.ts` — the user's stored turn through a real Runner on each lane (two requests, both legal, one notice, the transcript unchanged); the sandwich [text, call, text, call] and the lane switch on the Anthropic lane; a late result (6 calls · 5 results · user · 1 result); the reconcile's tail close; a doubled result (folded, both texts sent); a late result crossing a later turn that reuses its id for another tool; a missing result (harness boundary over each real brain); a legal transcript (same array, no notice); the durable-park path (one call of a six-call batch parks, restart, the person types, deny on resume: every request legal, no repair — v7.36.0 the same); an id reused with the same tool, the result recorded after the second use (the first use closed as interrupted, never handed that success); `runSideQuery` over a stored broken turn on each lane; a result whose tool-supplied `details` throws on read (a recorded result, not a close; the view never throws); the law's attribution and mint cells; the seat's per-run dedup. Baseline (the boundary law taken out): 15 of the first 26 red (the sandwich and lane-switch cells, added later, are red under D1019). Mutations D1019 / D1021: `.gate-logs/criteria-7.37.1-mutations.md`.
|
|
18
|
+
|
|
19
|
+
### A turn's drop notes ride ahead of its tool calls (#1225; behavior (block order); +0 exports) (@server @cli @test)
|
|
20
|
+
|
|
21
|
+
- **Before (v7.37.0).** When a turn dropped a call (arguments cut mid-JSON, no tool name) or unparseable stream frames, the three brains pushed the `[note: …]` text block at the END of the final content, after the surviving tool calls — the persisted assistant read [thinking, text, toolCall…, note], the shape #1224 describes.
|
|
22
|
+
- **Now.** The notes are placed before the first tool call, in order (`placeTurnNotes`, one helper for the three finalizers); a turn with no call keeps them at the end. The note text is unchanged.
|
|
23
|
+
- **Who is hurt.** A reader of an assistant message's block order on a partial-batch turn (a renderer that showed the note after the calls now shows it before them).
|
|
24
|
+
- **Compensation.** None needed — the text is unchanged, and #1224 repairs turns stored before this release.
|
|
25
|
+
- **The silent cause.** One unparseable frame on an otherwise clean tool turn (every call complete, a normal stop) is a `toolUse` turn with no `errorMessage`: the note was its only trace, and it alone produced the rejected order.
|
|
26
|
+
- Pins: `test/backlog1224-note-placement.test.ts` — three lanes × three causes, an Anthropic request built from such a turn (the brain called directly) ends its assistant on `tool_use`, and the silent dropped-frame turn on each lane. v7.36.0 brains: 15 of 15 red. Mutation D1020.
|
|
27
|
+
|
|
28
|
+
### A dropped call's message names the output cap only when the cap cut it (#1227; behavior (message text); +0 exports) (@server @cli @test)
|
|
29
|
+
|
|
30
|
+
- **Before (v7.37.0).** The anthropic and openai lanes appended `; request output cap in effect: N tokens — a cut mid-arguments commonly means the cap was hit` to the `not valid JSON` message whenever a cap was on the wire — also when the stream ended without a stop frame after a few dozen tokens (the field reading: a torn stream, reported as a 64000-token cap), and on a normal `tool_use` / `tool_calls` stop.
|
|
31
|
+
- **Now.** One rule (`truncationCauseClause`, `src/brain/stream-shared.ts`): the cap is named on the lane's cap stop (`max_tokens` / `length`) or when the counted output reached the cap; a stream with no stop frame reads `; the stream ended before its stop frame — the connection closed mid-response`; any other stop adds nothing. The open-responses lane never carried the clause.
|
|
32
|
+
- **Who is hurt.** A reader matching the cap sentence on a message whose stream the cap did not cut.
|
|
33
|
+
- **Compensation.** None needed — the cap-stop message is byte-identical.
|
|
34
|
+
- Pins: `test/backlog1224-note-placement.test.ts` (7 cells; v7.36.0: the 4 normal-stop and torn cells red); `test/brain-unnamed-tool-use-disclosure.test.ts` — the cap-armed cell re-pinned on `finish_reason: "length"` (it asserted the clause on a `tool_calls` stop, the shape retired here). Mutation D1022.
|
|
35
|
+
|
|
1
36
|
## 7.37.0 — 2026-10-01
|
|
2
37
|
|
|
3
38
|
**Closed sets, one by one.**
|
package/dist/brain/anthropic.js
CHANGED
|
@@ -2,7 +2,7 @@ import {} from "../internal/llm.js";
|
|
|
2
2
|
import { BrainError } from "./errors.js";
|
|
3
3
|
import { startsWithInputTooLongAnchor } from "./input-too-long.js";
|
|
4
4
|
import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
|
|
5
|
-
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
|
|
5
|
+
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs, placeTurnNotes, truncationCauseClause } from "./stream-shared.js";
|
|
6
6
|
import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
|
|
7
7
|
import { emitBrainTelemetry, reportReasoningWireFacts } from "./status-sink.js";
|
|
8
8
|
import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
|
|
@@ -616,30 +616,23 @@ export function createAnthropicBrain(config = {}) {
|
|
|
616
616
|
const noUsableContent = toolCalls.length === 0 && !anyText;
|
|
617
617
|
const toolErrorParts = [];
|
|
618
618
|
if (malformed.length > 0) {
|
|
619
|
-
toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"${
|
|
619
|
+
toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, stop_reason="${stopReason ?? "?"}"${truncationCauseClause({ capStop: stopReason === "max_tokens", outputTokens: usageRaw.output_tokens, capTokens: sentMaxTokens, terminalFrameSeen: terminalFrameSeen() })}): ${malformed.join("; ")}`);
|
|
620
620
|
}
|
|
621
621
|
if (unnamed.length > 0) {
|
|
622
622
|
toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (stop_reason="${stopReason ?? "?"}"): ${unnamed.join("; ")}`);
|
|
623
623
|
}
|
|
624
624
|
const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
|
|
625
|
+
const notes = [];
|
|
625
626
|
if (malformed.length > 0) {
|
|
626
|
-
|
|
627
|
-
type: "text",
|
|
628
|
-
text: `\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
|
|
629
|
-
});
|
|
627
|
+
notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
|
|
630
628
|
}
|
|
631
629
|
if (unnamed.length > 0) {
|
|
632
|
-
|
|
633
|
-
type: "text",
|
|
634
|
-
text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
|
|
635
|
-
});
|
|
630
|
+
notes.push(`\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`);
|
|
636
631
|
}
|
|
637
632
|
if (malformedFrames > 0) {
|
|
638
|
-
|
|
639
|
-
type: "text",
|
|
640
|
-
text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
|
|
641
|
-
});
|
|
633
|
+
notes.push(`\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`);
|
|
642
634
|
}
|
|
635
|
+
placeTurnNotes(finalContent, notes);
|
|
643
636
|
const truncatedEmpty = stopReason === "max_tokens" && noUsableContent && !toolError;
|
|
644
637
|
const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
|
|
645
638
|
const staticReasoningCut = truncatedEmpty && !dynamicCut && reasoningSeen && options?.staticReasoningCutDowngrade === true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {} from "../internal/llm.js";
|
|
2
2
|
import { BrainError } from "./errors.js";
|
|
3
3
|
import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
|
|
4
|
-
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
|
|
4
|
+
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs, placeTurnNotes } from "./stream-shared.js";
|
|
5
5
|
import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
|
|
6
6
|
import { emitBrainTelemetry } from "./status-sink.js";
|
|
7
7
|
import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
|
|
@@ -673,24 +673,17 @@ export function createOpenResponsesBrain(config = {}) {
|
|
|
673
673
|
toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (status="${terminalLabel}"): ${unnamed.join("; ")}`);
|
|
674
674
|
}
|
|
675
675
|
const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
|
|
676
|
+
const notes = [];
|
|
676
677
|
if (malformed.length > 0) {
|
|
677
|
-
|
|
678
|
-
type: "text",
|
|
679
|
-
text: `\n[note: ${malformed.length} tool call(s) were truncated (status="${terminalLabel}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
|
|
680
|
-
});
|
|
678
|
+
notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (status="${terminalLabel}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
|
|
681
679
|
}
|
|
682
680
|
if (unnamed.length > 0) {
|
|
683
|
-
|
|
684
|
-
type: "text",
|
|
685
|
-
text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
|
|
686
|
-
});
|
|
681
|
+
notes.push(`\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`);
|
|
687
682
|
}
|
|
688
683
|
if (malformedFrames > 0) {
|
|
689
|
-
|
|
690
|
-
type: "text",
|
|
691
|
-
text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
|
|
692
|
-
});
|
|
684
|
+
notes.push(`\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`);
|
|
693
685
|
}
|
|
686
|
+
placeTurnNotes(finalContent, notes);
|
|
694
687
|
const truncated = incompleteReason === INCOMPLETE_MAX_OUTPUT_TOKENS;
|
|
695
688
|
const truncatedEmpty = truncated && noUsableContent && !toolError;
|
|
696
689
|
const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
|
package/dist/brain/openai.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {} from "../internal/llm.js";
|
|
2
2
|
import { repairTextToolCalls } from "./tool-call-repair.js";
|
|
3
3
|
import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
|
|
4
|
-
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs } from "./stream-shared.js";
|
|
4
|
+
import { STREAM_TORN_WARNING, createRepetitionPoll, noteResponseModel, parseStreamedToolArgs, placeTurnNotes, truncationCauseClause } from "./stream-shared.js";
|
|
5
5
|
import { admitToolCallId, providerResultIds, providerToolCallId } from "./tool-call-id.js";
|
|
6
6
|
import { emitBrainTelemetry } from "./status-sink.js";
|
|
7
7
|
import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
|
|
@@ -570,31 +570,24 @@ export function createOpenAIBrain(config = {}) {
|
|
|
570
570
|
}
|
|
571
571
|
const toolErrorParts = [];
|
|
572
572
|
if (malformed.length > 0) {
|
|
573
|
-
toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"${
|
|
573
|
+
toolErrorParts.push(`tool call argument(s) not valid JSON (likely truncated, finish_reason="${finishReason ?? "?"}"${truncationCauseClause({ capStop: finishReason === "length", outputTokens: usageRaw?.completion_tokens, capTokens: sentMaxTokens, terminalFrameSeen: terminalFrameSeen() })}): ${malformed.join("; ")}`);
|
|
574
574
|
}
|
|
575
575
|
if (unnamed.length > 0) {
|
|
576
576
|
toolErrorParts.push(`tool call(s) arrived with no tool name and cannot be executed (finish_reason="${finishReason ?? "?"}"): ${unnamed.join("; ")}`);
|
|
577
577
|
}
|
|
578
578
|
const toolError = toolErrorParts.length > 0 ? toolErrorParts.join(" | ") : undefined;
|
|
579
579
|
const noUsableContent = toolCalls.length === 0 && !accumText.trim();
|
|
580
|
+
const notes = [];
|
|
580
581
|
if (malformed.length > 0) {
|
|
581
|
-
|
|
582
|
-
type: "text",
|
|
583
|
-
text: `\n[note: ${malformed.length} tool call(s) were truncated (finish_reason="${finishReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
|
|
584
|
-
});
|
|
582
|
+
notes.push(`\n[note: ${malformed.length} tool call(s) were truncated (finish_reason="${finishReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`);
|
|
585
583
|
}
|
|
586
584
|
if (unnamed.length > 0) {
|
|
587
|
-
|
|
588
|
-
type: "text",
|
|
589
|
-
text: `\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`,
|
|
590
|
-
});
|
|
585
|
+
notes.push(`\n[note: ${unnamed.length} tool call(s) arrived with no tool name and were dropped — re-issue them next turn with an explicit tool name: ${unnamed.join("; ")}]`);
|
|
591
586
|
}
|
|
592
587
|
if (malformedFrames > 0) {
|
|
593
|
-
|
|
594
|
-
type: "text",
|
|
595
|
-
text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
|
|
596
|
-
});
|
|
588
|
+
notes.push(`\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`);
|
|
597
589
|
}
|
|
590
|
+
placeTurnNotes(finalContent, notes);
|
|
598
591
|
const truncatedEmpty = finishReason === "length" && noUsableContent && !toolError;
|
|
599
592
|
const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
|
|
600
593
|
const safetyCut = finishReason === "content_filter";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type RepetitionEvent } from "./repetition.js";
|
|
2
|
+
import type { AssistantMessage } from "../internal/llm.js";
|
|
2
3
|
/**
|
|
3
4
|
* design/157 A10 — stream-parser pieces that the anthropic and openai SSE lanes carried BYTE-IDENTICALLY
|
|
4
5
|
* (verified by diff at 1469481: a 16-line repetition-telemetry closure). Provider-agnostic policy, so it
|
|
@@ -28,6 +29,26 @@ import { type RepetitionEvent } from "./repetition.js";
|
|
|
28
29
|
* mid-stream promotion (the body threw) and by the parsers' own finalize (content arrived, no finish frame). */
|
|
29
30
|
export declare const STREAM_TORN_WARNING = "Connection closed mid-response. The response above may be incomplete.";
|
|
30
31
|
export declare function parseStreamedToolArgs(json: string): Record<string, unknown> | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Place a turn's engine notes in its final content: AHEAD of the first tool call, in order, or at the end of a turn
|
|
34
|
+
* that has none. Every dropped call (cut arguments, no tool name) and dropped stream frames are stated so the model
|
|
35
|
+
* re-issues them — also when text survived and no call did; a partial batch still executes its valid calls (a brain
|
|
36
|
+
* `error` is a terminal task failure, not a re-prompt). No block may follow the calls: the Anthropic Messages API
|
|
37
|
+
* rejects a `tool_use` followed by anything but another `tool_use`. One rule for the three lanes.
|
|
38
|
+
*/
|
|
39
|
+
export declare function placeTurnNotes(content: AssistantMessage["content"], notes: readonly string[]): void;
|
|
40
|
+
/**
|
|
41
|
+
* The cause clause of a dropped call's "not valid JSON" message. The output cap is named only when the stream says
|
|
42
|
+
* the cap cut it — the lane's own cap stop, or a counted output at or over the cap on the wire; a stream that ended
|
|
43
|
+
* without its stop frame names the transport instead; any other stop adds nothing (naming the cap there sent a reader
|
|
44
|
+
* after a budget that was never reached). One rule for the lanes that carry the clause.
|
|
45
|
+
*/
|
|
46
|
+
export declare function truncationCauseClause(facts: {
|
|
47
|
+
capStop: boolean;
|
|
48
|
+
outputTokens: number | undefined;
|
|
49
|
+
capTokens: number | undefined;
|
|
50
|
+
terminalFrameSeen: boolean;
|
|
51
|
+
}): string;
|
|
31
52
|
/** Repetition telemetry for ONE stream — the cut event plus the allowance-spared hits. */
|
|
32
53
|
export interface RepetitionPoll {
|
|
33
54
|
/** Inspect the accumulated text; returns whether it is degenerate (the caller trips its own cut). */
|
|
@@ -16,6 +16,19 @@ export function parseStreamedToolArgs(json) {
|
|
|
16
16
|
return undefined;
|
|
17
17
|
return parsed;
|
|
18
18
|
}
|
|
19
|
+
export function placeTurnNotes(content, notes) {
|
|
20
|
+
if (notes.length === 0)
|
|
21
|
+
return;
|
|
22
|
+
const at = content.findIndex((block) => block.type === "toolCall");
|
|
23
|
+
content.splice(at === -1 ? content.length : at, 0, ...notes.map((text) => ({ type: "text", text })));
|
|
24
|
+
}
|
|
25
|
+
export function truncationCauseClause(facts) {
|
|
26
|
+
const { capStop, outputTokens, capTokens, terminalFrameSeen } = facts;
|
|
27
|
+
if (capTokens !== undefined && (capStop || (outputTokens !== undefined && outputTokens >= capTokens))) {
|
|
28
|
+
return `; request output cap in effect: ${capTokens} tokens — a cut mid-arguments commonly means the cap was hit`;
|
|
29
|
+
}
|
|
30
|
+
return terminalFrameSeen ? "" : "; the stream ended before its stop frame — the connection closed mid-response";
|
|
31
|
+
}
|
|
19
32
|
export function createRepetitionPoll() {
|
|
20
33
|
let repCut;
|
|
21
34
|
const repSpared = new Map();
|
|
@@ -258,6 +258,15 @@ export interface EngineNotice {
|
|
|
258
258
|
* call fact. Audience `operator`; `detail: { toolCallId, rawLength, reason, sessionId, runId }` —
|
|
259
259
|
* `toolCallId` the engine id, `reason` `"charset"` (a control byte, a space, a non-ASCII unit or `~`) or
|
|
260
260
|
* `"length"` (admitted characters, over 128); never the raw bytes. Absent ⇒ no call of the run was folded.
|
|
261
|
+
* - `"context.tool_turn_repaired"` (#1224) — a provider request was built from a transcript that breaks the
|
|
262
|
+
* tool-turn law, and the request view restored it (`engine/llm/tool-turns.ts`; the transcript is never
|
|
263
|
+
* rewritten): a block after an assistant's tool calls moved ahead of them (`trailing_text`), a result recorded
|
|
264
|
+
* later moved up behind its call (`late_result`), a call with no result closed with the reconcile family's
|
|
265
|
+
* interrupted result (`missing_result`), a second result recorded for one call folded into the first
|
|
266
|
+
* (`duplicate_result`; an interrupted close beside a recorded result is left out, no recorded result is). Advisory:
|
|
267
|
+
* the run proceeds on the repaired request. Audience `operator`; `detail: { repairs: [{ form, toolCallIds }],
|
|
268
|
+
* sessionId, runId }`. ONCE PER RUN PER (form, calls) — the same broken turn is repaired on every request of
|
|
269
|
+
* the run and announced on the first. Absent ⇒ every request of the run already had the structure.
|
|
261
270
|
* - `"config.read_face_deployment_clamped"` (#237) — a deployment-wide `readFace: "open"` is not
|
|
262
271
|
* in force beside a read-only (verifier) mount: it clamps to "roots" without throwing
|
|
263
272
|
* (stricter-wins; the clamp verdict stands, only its occurrence was undisclosed). Announced
|
|
@@ -135,7 +135,7 @@ export type NoticeAudience = "user" | "operator";
|
|
|
135
135
|
* the union's catalog ({@link EngineNotice.code}). The groups below say only which family a row joins,
|
|
136
136
|
* so the catalog is not written a second time here and the two cannot drift.
|
|
137
137
|
*/
|
|
138
|
-
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "config.permission_mode_unavailable", "permission.ask_released_by_mode", "permission.read_deny_unapplied", "permission.rule_store_unavailable", "resume.legacy_mode_seed_migrated", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "classifier.unavailable", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "route.tool_call_id_folded", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
|
|
138
|
+
export declare const ENGINE_NOTICE_CODES: readonly ["config.autocompact_window_clamped", "config.env_timeout_discarded", "config.materialize_env_discarded", "config.models_swapped", "config.context_settings_swapped", "config.read_face_swapped", "config.read_face_deployment_clamped", "config.write_face_swapped", "config.write_face_deployment_clamped", "config.tool_model_gate_removed", "config.tool_model_gate_unknown_class", "config.tool_model_gate_env_invalid", "config.artifact_host_invalid", "config.task_root_not_canonical", "config.write_protection_target_view_absent", "config.write_protection_unresolved", "config.execution_env_capability_invalid", "config.tool_card_undeclared", "config.tool_face_undeclared", "config.tool_face_invalid", "config.forward_sink_failed", "config.durable_gate_unavailable", "config.peer_admission_out_of_range", "config.peer_lane_unmounted", "config.secret_env_scrubbed", "config.permission_mode_unavailable", "permission.ask_released_by_mode", "permission.read_deny_unapplied", "permission.rule_store_unavailable", "resume.legacy_mode_seed_migrated", "peer.inbound_disposition", "peer.held_settled", "peer.idle_subscription", "classifier.denial_limit", "classifier.fallback", "classifier.role_defaulted", "classifier.contract_broken", "classifier.unavailable", "checkpoint.execution_outcome_unrecorded", "delegation.transcript_integrity", "delegation.ask_unresolvable", "mcp.injection_dropped", "mcp.revocation_probe_failed", "mcp.server_redialed", "workflow.governance_key_stripped", "workflow.agent_option_ignored", "memory.session_polluted", "memory.harvest_quarantined", "memory.delegation_static_mark_waived", "memory.content_class_declared", "memory.hold_opened", "memory.hold_released", "memory.hold_disposed", "memory.consolidation_recommended", "memory.consolidation_committed", "memory.consolidation_conflict", "memory.consolidation_incomplete", "memory.consolidation_refused", "memory.consolidation_withheld", "memory.restore_withheld", "memory.project_scope_absent", "route.fallback_to_primary", "route.classifier_unroutable", "route.base_url_changed_key_unchanged", "route.response_model_mismatch", "route.tool_call_id_folded", "context.tool_turn_repaired", "task.user_steer_undrained", "task.user_followup_undrained", "steering.parked_input_blocked", "task.turn_interrupted", "task.halt_unconsumed", "task.late_approval", "task.interrupt_unconsumed", "approval.read_root_granted", "approval.read_root_grant_rejected", "memory.capture_opted_out", "memory.capture_optout_unpersisted", "skills.listing_truncated", "agents.listing_truncated", "tool_result.offload_put_failed"];
|
|
139
139
|
/** A code this engine mints (see {@link ENGINE_NOTICE_CODES}). NOT the type of
|
|
140
140
|
* `EngineNotice.code`, which stays `string` — a host forwarding its own notices through the same
|
|
141
141
|
* sink is a supported shape, and narrowing that field would break it. */
|
|
@@ -179,6 +179,7 @@ export const ENGINE_NOTICE_CODES = [
|
|
|
179
179
|
"route.base_url_changed_key_unchanged",
|
|
180
180
|
"route.response_model_mismatch",
|
|
181
181
|
"route.tool_call_id_folded",
|
|
182
|
+
"context.tool_turn_repaired",
|
|
182
183
|
"task.user_steer_undrained",
|
|
183
184
|
"task.user_followup_undrained",
|
|
184
185
|
"steering.parked_input_blocked",
|
|
@@ -271,6 +272,7 @@ const NOTICE_AUDIENCE_TABLE = {
|
|
|
271
272
|
"route.base_url_changed_key_unchanged": "operator",
|
|
272
273
|
"route.response_model_mismatch": "operator",
|
|
273
274
|
"route.tool_call_id_folded": "operator",
|
|
275
|
+
"context.tool_turn_repaired": "operator",
|
|
274
276
|
"skills.listing_truncated": "operator",
|
|
275
277
|
"agents.listing_truncated": "operator",
|
|
276
278
|
"tool_result.offload_put_failed": "operator",
|
|
@@ -12,7 +12,7 @@ export interface RunIdentityWiringInput {
|
|
|
12
12
|
* task_notification frames. */
|
|
13
13
|
queue: PushQueue<TaskEvent>;
|
|
14
14
|
/** borrowed-mutable — the leg's prepared seat: the harness sinks (`onUndrainedEngineNotes`, `engineInjectionsHeld`,
|
|
15
|
-
* `onUndrainedUserInputs`, `onEngineNoteConsumed`) are installed, `nextTurn` is appended to; the ids, the identity
|
|
15
|
+
* `onUndrainedUserInputs`, `onEngineNoteConsumed`, `onToolTurnRepaired`) are installed, `nextTurn` is appended to; the ids, the identity
|
|
16
16
|
* envelope, the hold refs, the memory session and the listing / roster faces are read. */
|
|
17
17
|
prepared: Prepared;
|
|
18
18
|
/** borrowed-readonly — the trusted run-scoped channel: `parentToolCallId`, `onNotifyInjectorReady`. */
|
|
@@ -3,6 +3,7 @@ import { createSafeNotifier, observeThenableRejection } from "../safe-notify.js"
|
|
|
3
3
|
import { recordFrameReceipt, toolResultReceiptKey } from "../frame-settlement.js";
|
|
4
4
|
import { discloseDroppedPending, renderTaskNotificationXml, taskNotificationDedupKey } from "../task-notification.js";
|
|
5
5
|
import { deliverDelegationLifecycle, deliverEngineNotice, undrainedUserInputNotices } from "../types.js";
|
|
6
|
+
import { createToolTurnRepairSeat } from "./tool-turn-repair-seat.js";
|
|
6
7
|
export function runIdentityWiring(input) {
|
|
7
8
|
const { spec, queue, prepared, internals, taskIdRef, onReady, manualCompactRef, notifyRef, captureOptOutRef, mcpRedialRef, injectTaskNotification, deliveredAtTurnOpen, peerInboundChainRef, notificationLane, pendingSessionNotifications, runner } = input;
|
|
8
9
|
notificationLane.harness = prepared.harness;
|
|
@@ -84,6 +85,7 @@ export function runIdentityWiring(input) {
|
|
|
84
85
|
if (key !== undefined)
|
|
85
86
|
recordFrameReceipt(seam, key, carrier.kind === "tool_result" ? carrier.content : undefined, entryId);
|
|
86
87
|
};
|
|
88
|
+
prepared.harness.onToolTurnRepaired = createToolTurnRepairSeat((notice) => runner.deps.onNotice?.(notice), { sessionId: prepared.sessionId, runId: prepared.runId });
|
|
87
89
|
if (notifyRef)
|
|
88
90
|
notifyRef.inject = injectTaskNotification;
|
|
89
91
|
if (captureOptOutRef && prepared.memoryEngineSession?.captureOptOut !== undefined) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { EngineNotice } from "../engine-notice.js";
|
|
2
|
+
import type { ToolTurnRepair } from "../../internal/llm.js";
|
|
3
|
+
/** The notice's attribution ids — the same pair, for the same reason, as the fold seat's. */
|
|
4
|
+
export interface ToolTurnRepairSeatIds {
|
|
5
|
+
sessionId: string;
|
|
6
|
+
runId: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function createToolTurnRepairSeat(onNotice: ((notice: EngineNotice) => void) | undefined, ids: ToolTurnRepairSeatIds): (repairs: readonly ToolTurnRepair[]) => void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { deliverEngineNotice } from "../engine-notice.js";
|
|
2
|
+
const FORM_SENTENCE = {
|
|
3
|
+
trailing_text: "a block after an assistant's tool calls was moved ahead of them",
|
|
4
|
+
late_result: "a result recorded later was moved up behind its call",
|
|
5
|
+
missing_result: "a call with no result was closed as interrupted (outcome unknown)",
|
|
6
|
+
duplicate_result: "a second result recorded for one call was folded into the first (an interrupted close beside a recorded result is left out)",
|
|
7
|
+
};
|
|
8
|
+
export function createToolTurnRepairSeat(onNotice, ids) {
|
|
9
|
+
const announced = new Set();
|
|
10
|
+
return (repairs) => {
|
|
11
|
+
const fresh = repairs.filter((repair) => {
|
|
12
|
+
const key = `${repair.form}\u0000${[...repair.toolCallIds].sort().join("\u0000")}`;
|
|
13
|
+
if (announced.has(key))
|
|
14
|
+
return false;
|
|
15
|
+
announced.add(key);
|
|
16
|
+
return true;
|
|
17
|
+
});
|
|
18
|
+
if (fresh.length === 0)
|
|
19
|
+
return;
|
|
20
|
+
deliverEngineNotice(onNotice, {
|
|
21
|
+
code: "context.tool_turn_repaired",
|
|
22
|
+
message: `The session transcript carries ${fresh.length === 1 ? "a tool turn" : `${fresh.length} tool turns`} in a shape a provider lane ` +
|
|
23
|
+
`rejects, so the request was repaired on its way out: ${[...new Set(fresh.map((r) => FORM_SENTENCE[r.form]))].join("; ")}. ` +
|
|
24
|
+
`The transcript is unchanged and every later request is repaired the same way; announced once per run.`,
|
|
25
|
+
detail: { repairs: fresh.map((r) => ({ form: r.form, toolCallIds: [...r.toolCallIds] })), sessionId: ids.sessionId, runId: ids.runId },
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentMessage } from "../internal/harness-types.js";
|
|
2
|
+
import type { ToolResultMessage } from "../internal/llm.js";
|
|
2
3
|
import type { Session } from "./session.js";
|
|
3
4
|
import type { ToolEffect } from "./types.js";
|
|
4
5
|
/**
|
|
@@ -116,6 +117,40 @@ export declare const INTERRUPTED_BY_USER_FOR_TOOL_USE_MARKER = "[Request interru
|
|
|
116
117
|
export declare function appendInterruptionMarker(session: Session, opts: {
|
|
117
118
|
toolUseInFlight: boolean;
|
|
118
119
|
}): Promise<string>;
|
|
120
|
+
/** One synthetic interrupted result: what a call with no recorded result is closed with. */
|
|
121
|
+
export interface InterruptedResult {
|
|
122
|
+
toolCallId: string;
|
|
123
|
+
toolName: string;
|
|
124
|
+
/** The model-facing text. */
|
|
125
|
+
text: string;
|
|
126
|
+
/** Which of the two honest states the call is closed in. */
|
|
127
|
+
errorKind: ReconciledErrorKind;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The ONE mint of interrupted-result content for a batch of calls that have no recorded result. Two readers: the
|
|
131
|
+
* persisted reconcile ({@link reconcileInterruptedSession}) and the request view's tool-turn law
|
|
132
|
+
* (`engine/llm/tool-turns.ts`, which closes a call on the wire only and never writes the transcript) — so a call
|
|
133
|
+
* closed either way reads the same to the model.
|
|
134
|
+
*
|
|
135
|
+
* `toolEffects` / `startedToolCallIds` as on {@link reconcileInterruptedSession}; absent ⇒ every call gets the
|
|
136
|
+
* conservative unknown-outcome wording. The first call of each class in the batch states it in full and the rest
|
|
137
|
+
* point back at it (scoped to this call, so a later batch always starts from the full wording again).
|
|
138
|
+
*/
|
|
139
|
+
export declare function interruptedResults(calls: readonly {
|
|
140
|
+
toolCallId: string;
|
|
141
|
+
toolName: string;
|
|
142
|
+
}[], toolEffects?: Map<string, ToolEffect>, startedToolCallIds?: ReadonlySet<string>): InterruptedResult[];
|
|
143
|
+
/** Is `message` an INTERRUPTED CLOSE — a result minted to stand in for one that was never recorded (this family's
|
|
144
|
+
* two-valued `details.errorKind`, written by the reconcile here and by the loop's turn-scoped interrupt)? A close
|
|
145
|
+
* says only "no result was recorded", so it is void beside a recorded result for the same call. Total: a tool's
|
|
146
|
+
* `details` is the tool's value, so an unreadable one reads as "not a close" (one guarded read per member). */
|
|
147
|
+
export declare function isInterruptedClose(message: {
|
|
148
|
+
details?: unknown;
|
|
149
|
+
}): boolean;
|
|
150
|
+
/** The `toolResult` message for one {@link InterruptedResult} — the persisted and the request-view closes are this one shape. */
|
|
151
|
+
export declare function interruptedResultMessage(result: InterruptedResult, timestamp: number): ToolResultMessage<{
|
|
152
|
+
errorKind: ReconciledErrorKind;
|
|
153
|
+
}>;
|
|
119
154
|
/**
|
|
120
155
|
* Reconcile a resumed session's active branch: close any orphan tool calls with a synthetic
|
|
121
156
|
* interrupted `toolResult` (never re-running the tool). Returns what was recovered.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readMember } from "./thrown-value.js";
|
|
1
2
|
const INTERRUPTED_UNKNOWN = "[INTERRUPTED] The previous run ended before this tool call's result was recorded. Its outcome " +
|
|
2
3
|
"is UNKNOWN — the action may have completed, partially completed, or never run. Do not assume " +
|
|
3
4
|
"success or failure. Before relying on it: if it is safe to read, query the current state to " +
|
|
@@ -96,30 +97,40 @@ export async function appendInterruptionMarker(session, opts) {
|
|
|
96
97
|
timestamp: Date.now(),
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
|
-
export
|
|
100
|
-
const { messages } = await session.buildContext();
|
|
101
|
-
const orphans = findOrphanToolCalls(messages, suspendedBatch).filter((o) => o.kind !== "result");
|
|
102
|
-
const recovered = [];
|
|
100
|
+
export function interruptedResults(calls, toolEffects, startedToolCallIds) {
|
|
103
101
|
const statedInFull = new Set();
|
|
104
|
-
|
|
105
|
-
const effect = toolEffects?.get(
|
|
102
|
+
return calls.map(({ toolCallId, toolName }) => {
|
|
103
|
+
const effect = toolEffects?.get(toolName) ?? "write";
|
|
106
104
|
const effectText = effect === "read" ? INTERRUPTED_SAFE : effect === "idempotent" ? INTERRUPTED_IDEMPOTENT : INTERRUPTED_UNKNOWN;
|
|
107
|
-
const neverStarted = startedToolCallIds !== undefined && !startedToolCallIds.has(
|
|
108
|
-
const fullText = neverStarted ? INTERRUPTED_NEVER_STARTED : effectText;
|
|
105
|
+
const neverStarted = startedToolCallIds !== undefined && !startedToolCallIds.has(toolCallId);
|
|
109
106
|
const cls = neverStarted ? "never_started" : effect === "read" ? "read" : effect === "idempotent" ? "idempotent" : "unknown";
|
|
110
|
-
const text = statedInFull.has(cls) ? REPEAT_TEXT[cls] :
|
|
107
|
+
const text = statedInFull.has(cls) ? REPEAT_TEXT[cls] : neverStarted ? INTERRUPTED_NEVER_STARTED : effectText;
|
|
111
108
|
statedInFull.add(cls);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
109
|
+
return { toolCallId, toolName, text, errorKind: neverStarted ? "interrupted_never_started" : "interrupted_outcome_unknown" };
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
export function isInterruptedClose(message) {
|
|
113
|
+
const kind = readMember(readMember(message, "details"), "errorKind");
|
|
114
|
+
return kind === "interrupted_never_started" || kind === "interrupted_outcome_unknown";
|
|
115
|
+
}
|
|
116
|
+
export function interruptedResultMessage(result, timestamp) {
|
|
117
|
+
return {
|
|
118
|
+
role: "toolResult",
|
|
119
|
+
toolCallId: result.toolCallId,
|
|
120
|
+
toolName: result.toolName,
|
|
121
|
+
content: [{ type: "text", text: result.text }],
|
|
122
|
+
details: { errorKind: result.errorKind },
|
|
123
|
+
isError: true,
|
|
124
|
+
timestamp,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export async function reconcileInterruptedSession(session, toolEffects, suspendedBatch, startedToolCallIds) {
|
|
128
|
+
const { messages } = await session.buildContext();
|
|
129
|
+
const orphans = findOrphanToolCalls(messages, suspendedBatch).filter((o) => o.kind !== "result");
|
|
130
|
+
const recovered = [];
|
|
131
|
+
for (const result of interruptedResults(orphans, toolEffects, startedToolCallIds)) {
|
|
132
|
+
const entryId = await session.appendMessage(interruptedResultMessage(result, Date.now()));
|
|
133
|
+
recovered.push({ toolCallId: result.toolCallId, toolName: result.toolName, entryId, text: result.text, errorKind: result.errorKind });
|
|
123
134
|
}
|
|
124
135
|
return { recovered };
|
|
125
136
|
}
|
package/dist/core/side-query.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ensureToolTurnInvariants } from "../internal/llm.js";
|
|
1
2
|
import { resolveTaskModel } from "./roles.js";
|
|
2
3
|
import { readDegradation } from "../brain/degrading.js";
|
|
3
4
|
import { cacheFamilyOf, uncachedInputTokensOf } from "./runner/usage-accounting.js";
|
|
@@ -33,14 +34,15 @@ export async function runSideQuery(spec, deps) {
|
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
if (m.role === "toolResult") {
|
|
36
|
-
|
|
37
|
+
const details = m.details;
|
|
38
|
+
return { role: "toolResult", toolCallId: m.toolCallId, toolName: m.toolName, content: m.content, isError: m.isError ?? false, timestamp, ...(details !== undefined ? { details } : {}) };
|
|
37
39
|
}
|
|
38
40
|
return { role: "user", content: m.content, timestamp };
|
|
39
41
|
};
|
|
40
42
|
const effectiveSystemPrompt = spec.systemPrompt ?? resolved.systemPrompt;
|
|
41
43
|
const context = {
|
|
42
44
|
...(effectiveSystemPrompt !== undefined ? { systemPrompt: effectiveSystemPrompt } : {}),
|
|
43
|
-
messages: spec.messages.map(normalize),
|
|
45
|
+
messages: ensureToolTurnInvariants(spec.messages.map(normalize)).messages,
|
|
44
46
|
...(spec.tools !== undefined && spec.tools.length > 0
|
|
45
47
|
? { tools: spec.tools.map((t) => ({ name: t.name, description: t.description, parameters: t.parameters })) }
|
|
46
48
|
: {}),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ActorAssertion, AssistantMessage, ImageContent, Model, UserMessage } from "../llm/index.js";
|
|
2
|
+
import { type ToolTurnRepair } from "../llm/index.js";
|
|
2
3
|
import type { HaltRemainingDirective } from "../loop/types.js";
|
|
3
4
|
import type { AgentMessage, AgentTool, LoopMalformedToolUseRecovery, LoopThinkingOnlyRecovery, LoopTruncatedOutputRecovery, QueueMode, ThinkingLevel } from "../loop/types.js";
|
|
4
5
|
import { type EngineSegment } from "../../core/untrusted-text.js";
|
|
@@ -189,6 +190,10 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
|
|
|
189
190
|
private thinkingLevel;
|
|
190
191
|
/** RB-30 terminal fix — runner-set sink for engine-note payloads left undrained at agent_end. */
|
|
191
192
|
onUndrainedEngineNotes?: (payloads: unknown[]) => void;
|
|
193
|
+
/** Runner-set sink for the provider boundary's tool-turn repairs ({@link ensureToolTurnInvariants}) — called
|
|
194
|
+
* with every request view that needed one (the transcript is never rewritten, so a broken turn is repaired, and
|
|
195
|
+
* reported, on each request; the sink owns its dedup). Advisory: a throwing sink never breaks the request. */
|
|
196
|
+
onToolTurnRepaired?: (repairs: readonly ToolTurnRepair[]) => void;
|
|
192
197
|
/** backlog #259 — USER-authored queue remnants at agent_end: steers/follow-ups whose receipts said
|
|
193
198
|
* "queued" but that no turn will ever drain (the run ended first). The engine-note sweep above
|
|
194
199
|
* hands ENGINE payloads back for redelivery; user inputs have no redelivery semantics (a steer
|
|
@@ -226,6 +231,8 @@ export declare class AgentHarness<TSkill extends Skill = Skill, TPromptTemplate
|
|
|
226
231
|
* gated: its splice opens a NEW run (idle-park redelivery), and a halt belongs to the run that
|
|
227
232
|
* minted it. */
|
|
228
233
|
engineInjectionsHeld?: () => boolean;
|
|
234
|
+
/** The provider boundary's tool-turn law: the repaired view goes to the brain, the repairs to the runner's sink. */
|
|
235
|
+
private holdToolTurnLaw;
|
|
229
236
|
/** RB-30 R2 belt-and-braces: runner-callable idempotent recovery for exit paths that reach
|
|
230
237
|
* neither agent_end nor abort() (a pre-prompt throw after prepare). Swept entries are removed,
|
|
231
238
|
* so a double call is a no-op. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createAssistantMessageEventStream, snapshotActorAssertion, stripEngineMetadata } from "../llm/index.js";
|
|
1
|
+
import { createAssistantMessageEventStream, ensureToolTurnInvariants, snapshotActorAssertion, stripEngineMetadata } from "../llm/index.js";
|
|
2
2
|
import { runAgentLoop } from "../loop/agent-loop.js";
|
|
3
3
|
import { resolveAgentCoreStreamFn } from "../loop/runtime-deps.js";
|
|
4
4
|
import { normalizeEngineSegments } from "../../core/untrusted-text.js";
|
|
@@ -211,10 +211,22 @@ export class AgentHarness {
|
|
|
211
211
|
modelRef;
|
|
212
212
|
thinkingLevel;
|
|
213
213
|
onUndrainedEngineNotes;
|
|
214
|
+
onToolTurnRepaired;
|
|
214
215
|
onUndrainedUserInputs;
|
|
215
216
|
onEngineNoteConsumed;
|
|
216
217
|
onFrameCommitted;
|
|
217
218
|
engineInjectionsHeld;
|
|
219
|
+
holdToolTurnLaw(messages) {
|
|
220
|
+
const view = ensureToolTurnInvariants(messages);
|
|
221
|
+
if (view.repairs.length > 0) {
|
|
222
|
+
try {
|
|
223
|
+
this.onToolTurnRepaired?.(view.repairs);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return view.messages;
|
|
229
|
+
}
|
|
218
230
|
recoverUndrainedEngineNotes() {
|
|
219
231
|
this.sweepUndrainedEngineNotes([this.drainedPendingInjection, this.nextTurnQueue, this.steerQueue, this.followUpQueue]);
|
|
220
232
|
}
|
|
@@ -620,7 +632,7 @@ export class AgentHarness {
|
|
|
620
632
|
...(this.streamingToolExecution === true && (this.getHandlers("tool_call")?.size ?? 0) === 0
|
|
621
633
|
? { streamingToolExecution: true }
|
|
622
634
|
: {}),
|
|
623
|
-
convertToLlm: (messages) => stripEngineMetadata(convertToLlm(messages)),
|
|
635
|
+
convertToLlm: (messages) => this.holdToolTurnLaw(stripEngineMetadata(convertToLlm(messages))),
|
|
624
636
|
shouldStopAfterTurn: () => this.consultStopLatch("post_turn"),
|
|
625
637
|
shouldHaltBeforeTurn: () => this.consultStopLatch("pre_turn"),
|
|
626
638
|
transformContext: async (messages, signal) => {
|
package/dist/engine/llm/index.js
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Message } from "./types.js";
|
|
2
|
+
/** What the law restored — the `forms` of a `context.tool_turn_repaired` notice. */
|
|
3
|
+
export declare const TOOL_TURN_REPAIR_FORMS: readonly ["trailing_text", "late_result", "missing_result", "duplicate_result"];
|
|
4
|
+
export type ToolTurnRepairForm = (typeof TOOL_TURN_REPAIR_FORMS)[number];
|
|
5
|
+
/** One restoration on one assistant message. */
|
|
6
|
+
export interface ToolTurnRepair {
|
|
7
|
+
readonly form: ToolTurnRepairForm;
|
|
8
|
+
/** The calls concerned, in call order — for `trailing_text` every call of the message; otherwise the calls whose
|
|
9
|
+
* result was moved up, synthesized, or folded with a second one. */
|
|
10
|
+
readonly toolCallIds: readonly string[];
|
|
11
|
+
}
|
|
12
|
+
export interface ToolTurnView {
|
|
13
|
+
/** The request view — the input array itself when {@link repairs} is empty. */
|
|
14
|
+
readonly messages: Message[];
|
|
15
|
+
readonly repairs: readonly ToolTurnRepair[];
|
|
16
|
+
}
|
|
17
|
+
/** The text standing between two results recorded for one call when the view folds them into one. */
|
|
18
|
+
export declare const SECOND_RESULT_SEPARATOR = "[another result was recorded for this same call:]";
|
|
19
|
+
/** Restore the tool-turn law on a provider-bound message list (see the module header). */
|
|
20
|
+
export declare function ensureToolTurnInvariants(messages: Message[]): ToolTurnView;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { interruptedResultMessage, interruptedResults, isInterruptedClose } from "../../core/session-reconcile.js";
|
|
2
|
+
export const TOOL_TURN_REPAIR_FORMS = ["trailing_text", "late_result", "missing_result", "duplicate_result"];
|
|
3
|
+
function callsOf(m) {
|
|
4
|
+
return m.role === "assistant" && Array.isArray(m.content) ? m.content.filter((c) => c?.type === "toolCall") : [];
|
|
5
|
+
}
|
|
6
|
+
export const SECOND_RESULT_SEPARATOR = "[another result was recorded for this same call:]";
|
|
7
|
+
export function ensureToolTurnInvariants(messages) {
|
|
8
|
+
const anchor = [];
|
|
9
|
+
messages.forEach((m, i) => anchor.push(m.role === "toolResult" && i > 0 ? anchor[i - 1] : i));
|
|
10
|
+
const sites = new Map();
|
|
11
|
+
const ownerOf = new Map();
|
|
12
|
+
const resultsOf = new Map();
|
|
13
|
+
messages.forEach((m, i) => {
|
|
14
|
+
if (m.role === "assistant") {
|
|
15
|
+
for (const call of callsOf(m)) {
|
|
16
|
+
const list = sites.get(call.id) ?? [];
|
|
17
|
+
if (list.at(-1) !== i)
|
|
18
|
+
list.push(i);
|
|
19
|
+
sites.set(call.id, list);
|
|
20
|
+
}
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (m.role !== "toolResult")
|
|
24
|
+
return;
|
|
25
|
+
const all = sites.get(m.toolCallId) ?? [];
|
|
26
|
+
if (all.length === 0)
|
|
27
|
+
return;
|
|
28
|
+
const owner = all.at(-1);
|
|
29
|
+
ownerOf.set(i, owner);
|
|
30
|
+
const batch = resultsOf.get(owner);
|
|
31
|
+
if (batch === undefined)
|
|
32
|
+
resultsOf.set(owner, [i]);
|
|
33
|
+
else
|
|
34
|
+
batch.push(i);
|
|
35
|
+
});
|
|
36
|
+
const repairs = [];
|
|
37
|
+
const out = [];
|
|
38
|
+
messages.forEach((m, i) => {
|
|
39
|
+
if (ownerOf.has(i))
|
|
40
|
+
return;
|
|
41
|
+
const calls = callsOf(m);
|
|
42
|
+
if (calls.length === 0) {
|
|
43
|
+
out.push(m);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const assistant = m;
|
|
47
|
+
const firstCall = assistant.content.findIndex((c) => c?.type === "toolCall");
|
|
48
|
+
const trails = assistant.content.slice(firstCall).some((c) => c?.type !== "toolCall");
|
|
49
|
+
out.push(trails ? { ...assistant, content: [...assistant.content.filter((c) => c?.type !== "toolCall"), ...calls] } : assistant);
|
|
50
|
+
if (trails)
|
|
51
|
+
repairs.push({ form: "trailing_text", toolCallIds: calls.map((c) => c.id) });
|
|
52
|
+
const byId = new Map();
|
|
53
|
+
for (const call of calls)
|
|
54
|
+
if (!byId.has(call.id))
|
|
55
|
+
byId.set(call.id, call);
|
|
56
|
+
const ids = [...byId.keys()];
|
|
57
|
+
const recorded = new Map();
|
|
58
|
+
for (const r of resultsOf.get(i) ?? []) {
|
|
59
|
+
const id = messages[r].toolCallId;
|
|
60
|
+
recorded.set(id, [...(recorded.get(id) ?? []), r]);
|
|
61
|
+
}
|
|
62
|
+
const sent = new Map();
|
|
63
|
+
const late = [];
|
|
64
|
+
const duplicate = [];
|
|
65
|
+
for (const [id, at] of recorded) {
|
|
66
|
+
const real = at.filter((r) => !isInterruptedClose(messages[r]));
|
|
67
|
+
const chosen = real.length > 0 ? real : at.slice(0, 1);
|
|
68
|
+
sent.set(id, chosen.map((r) => messages[r]).reduce(foldResults));
|
|
69
|
+
if (anchor[chosen[0]] !== i)
|
|
70
|
+
late.push(id);
|
|
71
|
+
if (at.length > 1)
|
|
72
|
+
duplicate.push(id);
|
|
73
|
+
}
|
|
74
|
+
out.push(...sent.values());
|
|
75
|
+
const missing = [...byId.values()].filter((c) => !sent.has(c.id));
|
|
76
|
+
for (const result of interruptedResults(missing.map((c) => ({ toolCallId: c.id, toolName: c.name })))) {
|
|
77
|
+
out.push(interruptedResultMessage(result, assistant.timestamp));
|
|
78
|
+
}
|
|
79
|
+
const inCallOrder = (set) => ids.filter((id) => set.includes(id));
|
|
80
|
+
if (late.length > 0)
|
|
81
|
+
repairs.push({ form: "late_result", toolCallIds: inCallOrder(late) });
|
|
82
|
+
if (missing.length > 0)
|
|
83
|
+
repairs.push({ form: "missing_result", toolCallIds: missing.map((c) => c.id) });
|
|
84
|
+
if (duplicate.length > 0)
|
|
85
|
+
repairs.push({ form: "duplicate_result", toolCallIds: inCallOrder(duplicate) });
|
|
86
|
+
});
|
|
87
|
+
return repairs.length === 0 ? { messages, repairs } : { messages: out, repairs };
|
|
88
|
+
}
|
|
89
|
+
function foldResults(first, second) {
|
|
90
|
+
return { ...first, content: [...first.content, { type: "text", text: SECOND_RESULT_SEPARATOR }, ...second.content], isError: first.isError && second.isError };
|
|
91
|
+
}
|
package/dist/internal/llm.d.ts
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* design/118 ①②: the layer is now first-party (`src/engine/llm/`, naturalized out of the vendored
|
|
5
5
|
* llm-core); the facade stays so consumers never notice which side a symbol lives on.
|
|
6
6
|
*/
|
|
7
|
-
export { createAssistantMessageEventStream, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
|
|
8
|
-
export type { ActorAssertion, AnthropicMessagesCompat, OpenAICompletionsCompat, OpenAIResponsesCompat, AssistantMessage, AssistantMessageDiagnostic, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, Model, ResilienceOptions, RouteAdjudication, RouteCredential, RouteCredentialSource, RoutePairingConfig, RoutePairingPosture, RouteRefusalCode, RouteRefusalDetail, SimpleStreamOptions, StallTimeouts, StopReason, StreamFn, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage, Usage, UserMessage, } from "../engine/llm/index.js";
|
|
7
|
+
export { createAssistantMessageEventStream, ensureToolTurnInvariants, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
|
|
8
|
+
export type { ActorAssertion, AnthropicMessagesCompat, OpenAICompletionsCompat, OpenAIResponsesCompat, AssistantMessage, AssistantMessageDiagnostic, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, Model, ResilienceOptions, RouteAdjudication, RouteCredential, RouteCredentialSource, RoutePairingConfig, RoutePairingPosture, RouteRefusalCode, RouteRefusalDetail, SimpleStreamOptions, StallTimeouts, StopReason, StreamFn, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage, ToolTurnRepair, Usage, UserMessage, } from "../engine/llm/index.js";
|
package/dist/internal/llm.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { createAssistantMessageEventStream, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
|
|
1
|
+
export { createAssistantMessageEventStream, ensureToolTurnInvariants, snapshotActorAssertion, stripEngineMetadata } from "../engine/llm/index.js";
|
package/package.json
CHANGED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repo": "core",
|
|
3
|
+
"version": "7.37.1",
|
|
4
|
+
"from": "979f761e382593d192c6c5b3170cb82cc676677c",
|
|
5
|
+
"to": "072c5d4502bddacb7b28e2e3f47653ad28c008a5",
|
|
6
|
+
"added": [],
|
|
7
|
+
"removed": [],
|
|
8
|
+
"changed": [],
|
|
9
|
+
"narrowed": [],
|
|
10
|
+
"closedSetMembers": [
|
|
11
|
+
{
|
|
12
|
+
"set": "ENGINE_NOTICE_CODES",
|
|
13
|
+
"at": [
|
|
14
|
+
"src/core/governance-codes.ts:344"
|
|
15
|
+
],
|
|
16
|
+
"newSet": false,
|
|
17
|
+
"added": [
|
|
18
|
+
"context.tool_turn_repaired"
|
|
19
|
+
],
|
|
20
|
+
"removed": [],
|
|
21
|
+
"breaking": false,
|
|
22
|
+
"criteriaCovered": true
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"set": "TOOL_TURN_REPAIR_FORMS",
|
|
26
|
+
"at": [
|
|
27
|
+
"src/engine/llm/tool-turns.ts:43"
|
|
28
|
+
],
|
|
29
|
+
"newSet": true,
|
|
30
|
+
"added": [
|
|
31
|
+
"duplicate_result",
|
|
32
|
+
"late_result",
|
|
33
|
+
"missing_result",
|
|
34
|
+
"trailing_text"
|
|
35
|
+
],
|
|
36
|
+
"removed": [],
|
|
37
|
+
"breaking": false,
|
|
38
|
+
"criteriaCovered": false
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"set": "ToolTurnRepairForm",
|
|
42
|
+
"at": [
|
|
43
|
+
"src/engine/llm/tool-turns.ts:44"
|
|
44
|
+
],
|
|
45
|
+
"newSet": true,
|
|
46
|
+
"added": [
|
|
47
|
+
"duplicate_result",
|
|
48
|
+
"late_result",
|
|
49
|
+
"missing_result",
|
|
50
|
+
"trailing_text"
|
|
51
|
+
],
|
|
52
|
+
"removed": [],
|
|
53
|
+
"breaking": false,
|
|
54
|
+
"criteriaCovered": false
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"closedSetsUnreadable": [],
|
|
58
|
+
"typeKeys": [],
|
|
59
|
+
"typeKeysUnreadableArms": [],
|
|
60
|
+
"typeKeysFlattenedCount": 219,
|
|
61
|
+
"declarationMoves": [],
|
|
62
|
+
"mintMoves": [],
|
|
63
|
+
"criteriaObserves": [
|
|
64
|
+
{
|
|
65
|
+
"kind": "engineNoticeCode",
|
|
66
|
+
"name": "context.tool_turn_repaired",
|
|
67
|
+
"assertions": [
|
|
68
|
+
"G1.b",
|
|
69
|
+
"G3.a",
|
|
70
|
+
"G3.b",
|
|
71
|
+
"G3.c"
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"kind": "typeMember",
|
|
76
|
+
"name": "AssistantMessage.content",
|
|
77
|
+
"assertions": [
|
|
78
|
+
"G1.a",
|
|
79
|
+
"G1.c",
|
|
80
|
+
"G2.a",
|
|
81
|
+
"G2.b"
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
"kind": "typeMember",
|
|
86
|
+
"name": "AssistantMessage.errorMessage",
|
|
87
|
+
"assertions": [
|
|
88
|
+
"G2.c",
|
|
89
|
+
"G4.a",
|
|
90
|
+
"G4.b"
|
|
91
|
+
]
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"kind": "typeMember",
|
|
95
|
+
"name": "AssistantMessage.stopReason",
|
|
96
|
+
"assertions": [
|
|
97
|
+
"G2.c"
|
|
98
|
+
]
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"kind": "typeMember",
|
|
102
|
+
"name": "EngineNotice.detail",
|
|
103
|
+
"assertions": [
|
|
104
|
+
"G1.b"
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
],
|
|
108
|
+
"consumersHint": {
|
|
109
|
+
"cli": [
|
|
110
|
+
"ENGINE_NOTICE_CODES"
|
|
111
|
+
],
|
|
112
|
+
"server": [
|
|
113
|
+
"ENGINE_NOTICE_CODES"
|
|
114
|
+
],
|
|
115
|
+
"sdk": [
|
|
116
|
+
"ENGINE_NOTICE_CODES"
|
|
117
|
+
],
|
|
118
|
+
"note": "scanned cli@6b6025f7 server@65b2df99 sdk@a90a03a — a name listed is MENTIONED there today, which is evidence of consumption, not proof of it"
|
|
119
|
+
},
|
|
120
|
+
"breakingCount": 0,
|
|
121
|
+
"breakingItems": []
|
|
122
|
+
}
|