@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.
@@ -75,6 +75,7 @@ export async function prepareChatTurn(deps, params) {
75
75
  runActivityContext,
76
76
  history,
77
77
  workingMemory: createWorkingMemory(pool),
78
+ ...(deps.logger ? { logger: deps.logger } : {}),
78
79
  });
79
80
  return { kind: "ok", threadType, messages, userMessage };
80
81
  }
@@ -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
- export type CortexChatPart = PresentationPart | PlanPart | RunCardPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepUsagePart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | PreviewPart | DataPreviewFailedPart;
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
  ]);
@@ -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
- if (reply.finishReason !== "tool-calls") {
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
- return ok(spawned.match((child) => ({ outcome: "started", threadId: child.threadId, title: child.title }), (fault) => {
171
- const outcome = toOutcome(fault);
172
- if (outcome.outcome === "failed") {
173
- logger.warn("the report session did not start", { parentThreadId, reason: fault.type, ...logger.errorFields(causeOf(fault)) });
174
- }
175
- return outcome;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",