@inflexa-ai/harness 0.22.1 → 0.23.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/dist/app/chat-turn.js +1 -0
- package/dist/app/message-assembly.d.ts +3 -0
- package/dist/app/message-assembly.js +17 -0
- package/dist/contracts/chat-parts.d.ts +17 -1
- package/dist/contracts/index.d.ts +1 -1
- package/dist/contracts/part-registry.js +1 -0
- package/dist/contracts/schemas/chat-parts.d.ts +9 -0
- package/dist/contracts/schemas/chat-parts.js +7 -0
- package/dist/loop/run-agent.js +40 -1
- package/dist/memory/conversation-display-storage.d.ts +2 -1
- package/dist/memory/conversation-display-storage.js +2 -1
- package/dist/memory/tool-call-integrity.d.ts +46 -0
- package/dist/memory/tool-call-integrity.js +92 -0
- package/dist/tools/start-report-session.js +17 -7
- package/package.json +1 -1
package/dist/app/chat-turn.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
* thread store.
|
|
36
36
|
*/
|
|
37
37
|
import type { ModelMessage } from "ai";
|
|
38
|
+
import type { Logger } from "../lib/logger.js";
|
|
38
39
|
import type { LoopMessage } from "../loop/types.js";
|
|
39
40
|
import type { ThreadHistory } from "../memory/thread-history.js";
|
|
40
41
|
import type { ThreadType } from "../memory/thread-store.js";
|
|
@@ -64,6 +65,8 @@ export interface AssembleMessagesArgs {
|
|
|
64
65
|
readonly workingMemory: WorkingMemoryStore;
|
|
65
66
|
/** History-window token budget. Defaults to {@link DEFAULT_HISTORY_TOKEN_BUDGET}. */
|
|
66
67
|
readonly tokenBudget?: number;
|
|
68
|
+
/** Diagnostic seam for the history repair record; omitted falls back to no-op. */
|
|
69
|
+
readonly logger?: Logger;
|
|
67
70
|
}
|
|
68
71
|
export interface AssembledMessages {
|
|
69
72
|
/** The full message array for `runAgent`. */
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
* tail injections (ephemeral, re-derived each turn) are never written to the
|
|
35
35
|
* thread store.
|
|
36
36
|
*/
|
|
37
|
+
import { createNoopLogger } from "../lib/console-logger.js";
|
|
37
38
|
import { unwrapOrThrow } from "../lib/result.js";
|
|
39
|
+
import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
|
|
38
40
|
import { normalizeUnicode, redactSecrets } from "../input-sanitization.js";
|
|
39
41
|
/**
|
|
40
42
|
* Token budget for the `loadRecent` history window. Sized to leave headroom
|
|
@@ -49,6 +51,21 @@ export const DEFAULT_HISTORY_TOKEN_BUDGET = 120_000;
|
|
|
49
51
|
export async function assembleMessages(args) {
|
|
50
52
|
const budget = args.tokenBudget ?? DEFAULT_HISTORY_TOKEN_BUDGET;
|
|
51
53
|
const history = unwrapOrThrow(await args.history.loadRecent(args.threadId, budget, { keepFirstTurn: args.threadType === "report" }));
|
|
54
|
+
// Read-side safety net for the wire contract: every tool call carries a
|
|
55
|
+
// result. The loop upholds it at every exit, but the store outlives any one
|
|
56
|
+
// writer — a row an older build wrote, or a hand-edited database, can carry
|
|
57
|
+
// an unanswered call, and the provider boundary then refuses the WHOLE
|
|
58
|
+
// transcript on every turn: the thread is wedged forever. Stripping the
|
|
59
|
+
// orphan here degrades one message instead. The warning is the only account
|
|
60
|
+
// a reader gets that the stored thread and the assembled one differ.
|
|
61
|
+
const repaired = stripUnansweredToolCalls(history);
|
|
62
|
+
if (repaired.length > 0) {
|
|
63
|
+
(args.logger ?? createNoopLogger()).named("assembly").warn("unanswered tool calls stripped from thread history", {
|
|
64
|
+
threadId: args.threadId,
|
|
65
|
+
toolCallIds: repaired.map((d) => d.toolCallId),
|
|
66
|
+
tools: repaired.map((d) => d.toolName),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
52
69
|
// Sanitization — applied once, here, to the new user input only.
|
|
53
70
|
const userMessage = {
|
|
54
71
|
role: "user",
|
|
@@ -359,4 +359,20 @@ export interface DataPreviewFailedPart {
|
|
|
359
359
|
/** Optional taxonomy of failure kinds. */
|
|
360
360
|
errorKind?: "render" | "submit" | "build" | "timeout" | "internal";
|
|
361
361
|
}
|
|
362
|
-
|
|
362
|
+
/**
|
|
363
|
+
* The record that a turn started a report session. It rides the chat stream
|
|
364
|
+
* live, and it persists into the turn at the position of the spawn.
|
|
365
|
+
*
|
|
366
|
+
* The part is a placement record and a freshness signal only. The thread
|
|
367
|
+
* store is the authority for the session: its existence, its title, and its
|
|
368
|
+
* archived state. When the thread of the part is archived or absent, a
|
|
369
|
+
* consumer renders nothing for it.
|
|
370
|
+
*/
|
|
371
|
+
export interface ReportSessionStartedPart {
|
|
372
|
+
type: "data-report-session-started";
|
|
373
|
+
/** The id of the report thread that the spawn made. */
|
|
374
|
+
threadId: string;
|
|
375
|
+
/** The id of the conversation thread that spawned it. */
|
|
376
|
+
parentThreadId: string;
|
|
377
|
+
}
|
|
378
|
+
export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepUsagePart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart | ReportSessionStartedPart;
|
|
@@ -2,7 +2,7 @@ export { ORGAN_SYSTEMS, OrganSystemSchema, ORGAN_SYSTEM_LABELS, organSystemLabel
|
|
|
2
2
|
export type { OrganSystem } from "./organ-system.js";
|
|
3
3
|
export { SEVERITIES, SeveritySchema } from "./severity.js";
|
|
4
4
|
export type { Severity } from "./severity.js";
|
|
5
|
-
export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepUsagePart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, CortexChatPart, } from "./chat-parts.js";
|
|
5
|
+
export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, DagStatePart, StepPhase, StepActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepUsagePart, StepBlockedPart, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, PreviewPart, DataPreviewFailedPart, ReportSessionStartedPart, CortexChatPart, } from "./chat-parts.js";
|
|
6
6
|
export type { EventSource, TextDeltaEvent, ToolStartedEvent, ToolFinishedEvent, FinishEvent, ChatErrorEvent, CortexChatEvent } from "./chat-events.js";
|
|
7
7
|
export type { TokenUsageRollup } from "./usage.js";
|
|
8
8
|
export { DATA_PROFILE_RUN_LITERAL } from "./data-profile.js";
|
|
@@ -18,6 +18,7 @@ export const PART_REGISTRY = {
|
|
|
18
18
|
"data-run-failed": { emitter: "workflow", consumer: "sidebar", transient: false, reconciling: false },
|
|
19
19
|
"data-report-preview": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
|
|
20
20
|
"data-report-preview-failed": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
|
|
21
|
+
"data-report-session-started": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
|
|
21
22
|
};
|
|
22
23
|
export function isTransient(type) {
|
|
23
24
|
return PART_REGISTRY[type].transient;
|
|
@@ -467,6 +467,11 @@ export declare const DataPreviewFailedPartSchema: z.ZodObject<{
|
|
|
467
467
|
internal: "internal";
|
|
468
468
|
}>>;
|
|
469
469
|
}, z.core.$strip>;
|
|
470
|
+
export declare const ReportSessionStartedPartSchema: z.ZodObject<{
|
|
471
|
+
type: z.ZodLiteral<"data-report-session-started">;
|
|
472
|
+
threadId: z.ZodString;
|
|
473
|
+
parentThreadId: z.ZodString;
|
|
474
|
+
}, z.core.$strip>;
|
|
470
475
|
export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
471
476
|
type: z.ZodLiteral<"data-presentation">;
|
|
472
477
|
id: z.ZodString;
|
|
@@ -763,4 +768,8 @@ export declare const CortexChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
|
|
|
763
768
|
build: "build";
|
|
764
769
|
internal: "internal";
|
|
765
770
|
}>>;
|
|
771
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
772
|
+
type: z.ZodLiteral<"data-report-session-started">;
|
|
773
|
+
threadId: z.ZodString;
|
|
774
|
+
parentThreadId: z.ZodString;
|
|
766
775
|
}, z.core.$strip>], "type">;
|
|
@@ -294,6 +294,12 @@ export const DataPreviewFailedPartSchema = z.object({
|
|
|
294
294
|
reason: z.string(),
|
|
295
295
|
errorKind: z.enum(["render", "submit", "build", "timeout", "internal"]).optional(),
|
|
296
296
|
});
|
|
297
|
+
// ── Report Session Started ─────────────────────────────────────────
|
|
298
|
+
export const ReportSessionStartedPartSchema = z.object({
|
|
299
|
+
type: z.literal("data-report-session-started"),
|
|
300
|
+
threadId: z.string(),
|
|
301
|
+
parentThreadId: z.string(),
|
|
302
|
+
});
|
|
297
303
|
// ── Union ───────────────────────────────────────────────────────────
|
|
298
304
|
export const CortexChatPartSchema = z.discriminatedUnion("type", [
|
|
299
305
|
PresentationPartSchema,
|
|
@@ -315,4 +321,5 @@ export const CortexChatPartSchema = z.discriminatedUnion("type", [
|
|
|
315
321
|
RunFailedPartSchema,
|
|
316
322
|
PreviewPartSchema,
|
|
317
323
|
DataPreviewFailedPartSchema,
|
|
324
|
+
ReportSessionStartedPartSchema,
|
|
318
325
|
]);
|
package/dist/loop/run-agent.js
CHANGED
|
@@ -5,6 +5,7 @@ import { stripNulCharacters } from "../input-sanitization.js";
|
|
|
5
5
|
import { createNoopLogger } from "../lib/console-logger.js";
|
|
6
6
|
import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
|
|
7
7
|
import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
|
|
8
|
+
import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
|
|
8
9
|
import { classifyProviderError } from "../providers/errors.js";
|
|
9
10
|
import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
|
|
10
11
|
import { resultStep } from "./run-step.js";
|
|
@@ -91,6 +92,28 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
91
92
|
const logFinish = (level, reason, cappedOut) => {
|
|
92
93
|
log[level]("run finished", { iterations, reason, cappedOut, truncationRecoveries, toolCalls: toolCallCount, toolErrors: toolErrorCount, usage });
|
|
93
94
|
};
|
|
95
|
+
/**
|
|
96
|
+
* Settle the transcript on the way out — called at every return. A reply can
|
|
97
|
+
* carry a complete tool call beside ANY finish reason, because the call
|
|
98
|
+
* streams before the stop reason arrives, and only the dispatching branches
|
|
99
|
+
* (`tool-calls`, a call-carrying `stop`, and `length`) run one. A call this
|
|
100
|
+
* run never dispatched is stripped
|
|
101
|
+
* rather than executed (a filtered or aborted reply must not act) or answered
|
|
102
|
+
* with an invented result (the model would read a fabrication as ground
|
|
103
|
+
* truth). Without the strip, the unanswered call violates the wire contract —
|
|
104
|
+
* every tool call carries a result — and the provider boundary then refuses
|
|
105
|
+
* the whole transcript on every later turn of the thread. Scoped past
|
|
106
|
+
* `initial`: the caller's prefix is not this run's to repair.
|
|
107
|
+
*/
|
|
108
|
+
const settleTranscript = () => {
|
|
109
|
+
const droppedCalls = stripUnansweredToolCalls(messages, initial.length);
|
|
110
|
+
if (droppedCalls.length === 0)
|
|
111
|
+
return;
|
|
112
|
+
log.warn("undispatched tool calls stripped at run exit", {
|
|
113
|
+
toolCallIds: droppedCalls.map((d) => d.toolCallId),
|
|
114
|
+
tools: droppedCalls.map((d) => d.toolName),
|
|
115
|
+
});
|
|
116
|
+
};
|
|
94
117
|
const usageRecorder = opts.usageRecorder ?? createNoopUsageRecorder();
|
|
95
118
|
/**
|
|
96
119
|
* Fold one completed call into both rollups and hand it to the recorder.
|
|
@@ -135,6 +158,7 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
135
158
|
// lands in a dispatch round — after the concurrent siblings in that same round
|
|
136
159
|
// have completed and been appended. Mirrors the clean-stop terminal path.
|
|
137
160
|
const stopOnDenial = async (i) => {
|
|
161
|
+
settleTranscript();
|
|
138
162
|
await emit({ type: "iteration", source, index: i, final: true });
|
|
139
163
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
|
|
140
164
|
logFinish("warn", "denied", false);
|
|
@@ -195,6 +219,7 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
195
219
|
return errored;
|
|
196
220
|
};
|
|
197
221
|
const stopOnResolved = async (i) => {
|
|
222
|
+
settleTranscript();
|
|
198
223
|
await emit({ type: "iteration", source, index: i, final: true });
|
|
199
224
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
|
|
200
225
|
return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
|
|
@@ -266,7 +291,19 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
266
291
|
return stopOnResolved(i);
|
|
267
292
|
continue;
|
|
268
293
|
}
|
|
269
|
-
|
|
294
|
+
// A local model behind an OpenAI-compatible server routinely labels a
|
|
295
|
+
// tool-calling reply `stop`. The calls are complete and the model waits
|
|
296
|
+
// on their results, thus the mislabeled round dispatches like a
|
|
297
|
+
// `tool-calls` one. Every other terminal keeps the strip: a filtered or
|
|
298
|
+
// aborted reply must not act.
|
|
299
|
+
const dispatchesRound = reply.finishReason === "tool-calls" || (reply.finishReason === "stop" && toolCalls.length > 0);
|
|
300
|
+
if (!dispatchesRound) {
|
|
301
|
+
settleTranscript();
|
|
302
|
+
// The settle can remove a call-only aborted partial whole; the
|
|
303
|
+
// interruption marker then rides the last assistant that survived,
|
|
304
|
+
// matching the no-output abort contract above.
|
|
305
|
+
if (reply.finishReason === "aborted")
|
|
306
|
+
markLastLoopAssistant(messages, initial.length);
|
|
270
307
|
await emit({ type: "iteration", source, index: i, final: true });
|
|
271
308
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
|
|
272
309
|
logFinish("info", reply.finishReason, false);
|
|
@@ -307,6 +344,7 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
307
344
|
// exhausted its iterations, while the reason carries the abort.
|
|
308
345
|
if (assistantHasContent(wrapUp.message))
|
|
309
346
|
messages.push(wrapUp.message);
|
|
347
|
+
settleTranscript();
|
|
310
348
|
markLastLoopAssistant(messages, initial.length);
|
|
311
349
|
await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
|
|
312
350
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
|
|
@@ -314,6 +352,7 @@ export async function runAgent(agent, initial, session, opts) {
|
|
|
314
352
|
return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries, ...finishUsage() } };
|
|
315
353
|
}
|
|
316
354
|
messages.push(wrapUp.message);
|
|
355
|
+
settleTranscript();
|
|
317
356
|
await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
|
|
318
357
|
recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
|
|
319
358
|
logFinish("warn", "max_iterations", true);
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* reconstruction.
|
|
16
16
|
*/
|
|
17
17
|
import { type DataUIPart, type UIMessage } from "ai";
|
|
18
|
-
import type { AskPart, FileReferencePart, PlanPart, PresentationPart, PreviewPart, DataPreviewFailedPart, RunCardPart } from "../contracts/chat-parts.js";
|
|
18
|
+
import type { AskPart, FileReferencePart, PlanPart, PresentationPart, PreviewPart, DataPreviewFailedPart, ReportSessionStartedPart, RunCardPart } from "../contracts/chat-parts.js";
|
|
19
19
|
import type { ToolCallOutcome } from "../contracts/chat-events.js";
|
|
20
20
|
import type { CortexMessage, CortexPart, ToolCallPart } from "../contracts/message.js";
|
|
21
21
|
export declare const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
|
|
@@ -40,6 +40,7 @@ export type ConversationUIData = {
|
|
|
40
40
|
ask: Payload<AskPart>;
|
|
41
41
|
"report-preview": Payload<PreviewPart>;
|
|
42
42
|
"report-preview-failed": Payload<DataPreviewFailedPart>;
|
|
43
|
+
"report-session-started": Payload<ReportSessionStartedPart>;
|
|
43
44
|
};
|
|
44
45
|
export interface ConversationDisplayMetadata {
|
|
45
46
|
interrupted?: boolean;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { validateUIMessages } from "ai";
|
|
18
18
|
import { z } from "zod";
|
|
19
|
-
import { AskPartSchema, DataPreviewFailedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, PreviewPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
|
|
19
|
+
import { AskPartSchema, DataPreviewFailedPartSchema, FileReferencePartSchema, PlanPartSchema, PresentationPartSchema, PreviewPartSchema, ReportSessionStartedPartSchema, RunCardPartSchema, } from "../contracts/schemas/chat-parts.js";
|
|
20
20
|
import { ToolCallOutcomeSchema } from "../contracts/schemas/chat-events.js";
|
|
21
21
|
export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
|
|
22
22
|
const DisplayEnvelopeHeaderSchema = z.object({
|
|
@@ -56,6 +56,7 @@ const dataSchemas = {
|
|
|
56
56
|
ask: AskPartSchema.omit({ type: true }).strict(),
|
|
57
57
|
"report-preview": PreviewPartSchema.omit({ type: true }).strict(),
|
|
58
58
|
"report-preview-failed": DataPreviewFailedPartSchema.omit({ type: true }).strict(),
|
|
59
|
+
"report-session-started": ReportSessionStartedPartSchema.omit({ type: true }).strict(),
|
|
59
60
|
};
|
|
60
61
|
export function envelopeDisplayMessages(messages) {
|
|
61
62
|
if (messages.length === 0)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript integrity for tool calls.
|
|
3
|
+
*
|
|
4
|
+
* The wire contract of every provider is that each tool call in an assistant
|
|
5
|
+
* message has a matching tool result. A transcript that violates it is refused
|
|
6
|
+
* WHOLE — the AI SDK throws at prompt conversion, and the Anthropic API answers
|
|
7
|
+
* 400 — so a single unanswered call makes a thread unable to take any further
|
|
8
|
+
* turn. An unanswered call is a reachable state, not a hypothetical: a reply
|
|
9
|
+
* can carry a complete tool call beside ANY finish reason, because the call
|
|
10
|
+
* streams before the stop reason arrives, and only a `tool-calls` (and, for its
|
|
11
|
+
* leading calls, a `length`) finish dispatches it.
|
|
12
|
+
*
|
|
13
|
+
* The repair is a STRIP, never an invented result. A call that was not
|
|
14
|
+
* dispatched did not happen, and a fabricated result would record an execution
|
|
15
|
+
* that did not occur — the model would read it as ground truth on the next
|
|
16
|
+
* turn. The prose of the message survives the strip; a message left with
|
|
17
|
+
* nothing the wire can render disappears entirely.
|
|
18
|
+
*/
|
|
19
|
+
import type { ModelMessage } from "ai";
|
|
20
|
+
/** One tool call removed by {@link stripUnansweredToolCalls}, identified for the caller's diagnostic record. */
|
|
21
|
+
export interface DroppedToolCall {
|
|
22
|
+
readonly toolCallId: string;
|
|
23
|
+
readonly toolName: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Remove, in place, every tool-call part at or past `fromIndex` that has no
|
|
27
|
+
* matching tool result anywhere in `messages`. Returns the removed calls in
|
|
28
|
+
* transcript order — empty on the healthy path — so the caller can log what
|
|
29
|
+
* a reader of the stored thread would otherwise never learn.
|
|
30
|
+
*
|
|
31
|
+
* Results are matched by id across the whole array, not by adjacency: the loop
|
|
32
|
+
* appends a round's results directly after its assistant message, but a
|
|
33
|
+
* deferred-image user message can ride between rounds, and the stored shape is
|
|
34
|
+
* not this function's to assume.
|
|
35
|
+
*
|
|
36
|
+
* A message that keeps a text or file part survives the strip — refusal prose
|
|
37
|
+
* is the one account the reader has of a filtered reply. A message left with
|
|
38
|
+
* no such part (a call-only message, or reasoning beside the removed call) is
|
|
39
|
+
* removed whole: reasoning without the output it preceded is not replayable,
|
|
40
|
+
* and an empty assistant message is itself a wire violation.
|
|
41
|
+
*
|
|
42
|
+
* `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
|
|
43
|
+
* `initial.length` so it never rewrites the caller's prefix; the assembly
|
|
44
|
+
* repair passes nothing and covers the whole stored window.
|
|
45
|
+
*/
|
|
46
|
+
export declare function stripUnansweredToolCalls(messages: ModelMessage[], fromIndex?: number): DroppedToolCall[];
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript integrity for tool calls.
|
|
3
|
+
*
|
|
4
|
+
* The wire contract of every provider is that each tool call in an assistant
|
|
5
|
+
* message has a matching tool result. A transcript that violates it is refused
|
|
6
|
+
* WHOLE — the AI SDK throws at prompt conversion, and the Anthropic API answers
|
|
7
|
+
* 400 — so a single unanswered call makes a thread unable to take any further
|
|
8
|
+
* turn. An unanswered call is a reachable state, not a hypothetical: a reply
|
|
9
|
+
* can carry a complete tool call beside ANY finish reason, because the call
|
|
10
|
+
* streams before the stop reason arrives, and only a `tool-calls` (and, for its
|
|
11
|
+
* leading calls, a `length`) finish dispatches it.
|
|
12
|
+
*
|
|
13
|
+
* The repair is a STRIP, never an invented result. A call that was not
|
|
14
|
+
* dispatched did not happen, and a fabricated result would record an execution
|
|
15
|
+
* that did not occur — the model would read it as ground truth on the next
|
|
16
|
+
* turn. The prose of the message survives the strip; a message left with
|
|
17
|
+
* nothing the wire can render disappears entirely.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Whether this tool call needs a client-supplied result. A provider-executed
|
|
21
|
+
* call is answered by the provider inside the same assistant message, so the
|
|
22
|
+
* wire contract makes no demand on the client for it — the AI SDK's own
|
|
23
|
+
* missing-result validation skips these, and so does the strip.
|
|
24
|
+
*/
|
|
25
|
+
function needsClientResult(part) {
|
|
26
|
+
return part.providerExecuted !== true;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Remove, in place, every tool-call part at or past `fromIndex` that has no
|
|
30
|
+
* matching tool result anywhere in `messages`. Returns the removed calls in
|
|
31
|
+
* transcript order — empty on the healthy path — so the caller can log what
|
|
32
|
+
* a reader of the stored thread would otherwise never learn.
|
|
33
|
+
*
|
|
34
|
+
* Results are matched by id across the whole array, not by adjacency: the loop
|
|
35
|
+
* appends a round's results directly after its assistant message, but a
|
|
36
|
+
* deferred-image user message can ride between rounds, and the stored shape is
|
|
37
|
+
* not this function's to assume.
|
|
38
|
+
*
|
|
39
|
+
* A message that keeps a text or file part survives the strip — refusal prose
|
|
40
|
+
* is the one account the reader has of a filtered reply. A message left with
|
|
41
|
+
* no such part (a call-only message, or reasoning beside the removed call) is
|
|
42
|
+
* removed whole: reasoning without the output it preceded is not replayable,
|
|
43
|
+
* and an empty assistant message is itself a wire violation.
|
|
44
|
+
*
|
|
45
|
+
* `fromIndex` bounds the REPAIR, not the result scan. The loop passes its
|
|
46
|
+
* `initial.length` so it never rewrites the caller's prefix; the assembly
|
|
47
|
+
* repair passes nothing and covers the whole stored window.
|
|
48
|
+
*/
|
|
49
|
+
export function stripUnansweredToolCalls(messages, fromIndex = 0) {
|
|
50
|
+
const resultIds = new Set();
|
|
51
|
+
for (const message of messages) {
|
|
52
|
+
if (message.role === "tool") {
|
|
53
|
+
for (const part of message.content) {
|
|
54
|
+
if (part.type === "tool-result")
|
|
55
|
+
resultIds.add(part.toolCallId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (message.role === "assistant" && typeof message.content !== "string") {
|
|
59
|
+
for (const part of message.content) {
|
|
60
|
+
if (part.type === "tool-result")
|
|
61
|
+
resultIds.add(part.toolCallId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const dropped = [];
|
|
66
|
+
// Backward, so a whole-message removal cannot shift an index this loop has
|
|
67
|
+
// yet to visit.
|
|
68
|
+
for (let idx = messages.length - 1; idx >= fromIndex; idx--) {
|
|
69
|
+
const message = messages[idx];
|
|
70
|
+
if (message.role !== "assistant" || typeof message.content === "string")
|
|
71
|
+
continue;
|
|
72
|
+
const removed = [];
|
|
73
|
+
for (const part of message.content) {
|
|
74
|
+
if (part.type === "tool-call" && needsClientResult(part) && !resultIds.has(part.toolCallId))
|
|
75
|
+
removed.push(part);
|
|
76
|
+
}
|
|
77
|
+
if (removed.length === 0)
|
|
78
|
+
continue;
|
|
79
|
+
// Prepended as a batch: the walk visits messages tail-first, but the
|
|
80
|
+
// caller's record reads in transcript order.
|
|
81
|
+
dropped.unshift(...removed.map((part) => ({ toolCallId: part.toolCallId, toolName: part.toolName })));
|
|
82
|
+
const removedParts = new Set(removed);
|
|
83
|
+
const kept = message.content.filter((part) => !removedParts.has(part));
|
|
84
|
+
if (kept.some((part) => part.type === "text" || part.type === "file" || part.type === "tool-call" || part.type === "tool-result")) {
|
|
85
|
+
messages[idx] = { ...message, content: kept };
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
messages.splice(idx, 1);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return dropped;
|
|
92
|
+
}
|
|
@@ -167,13 +167,23 @@ export function createStartReportSessionTool(deps) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
const spawned = await spawn.spawnReportSession(parentThreadId, brief);
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
170
|
+
if (spawned.isOk()) {
|
|
171
|
+
const child = spawned.value;
|
|
172
|
+
// The part rides the started arm only. The existing-session arm
|
|
173
|
+
// names a session that an earlier turn announced, thus a second
|
|
174
|
+
// part would put a second entry into the transcript.
|
|
175
|
+
await ctx.emit({
|
|
176
|
+
type: "data-report-session-started",
|
|
177
|
+
data: { threadId: child.threadId, parentThreadId },
|
|
178
|
+
});
|
|
179
|
+
return ok({ outcome: "started", threadId: child.threadId, title: child.title });
|
|
180
|
+
}
|
|
181
|
+
const fault = spawned.error;
|
|
182
|
+
const outcome = toOutcome(fault);
|
|
183
|
+
if (outcome.outcome === "failed") {
|
|
184
|
+
logger.warn("the report session did not start", { parentThreadId, reason: fault.type, ...logger.errorFields(causeOf(fault)) });
|
|
185
|
+
}
|
|
186
|
+
return ok(outcome);
|
|
177
187
|
},
|
|
178
188
|
});
|
|
179
189
|
}
|
package/package.json
CHANGED