@inflexa-ai/harness 0.48.2 → 0.48.3

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.
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import type { AuthContext } from "../auth/types.js";
17
17
  import type { TokenUsageRollup } from "../contracts/usage.js";
18
- import type { ChatFrame } from "../contracts/chat-events.js";
18
+ import type { ChatErrorEvent, ChatFrame, EventSource, FinishEvent } from "../contracts/chat-events.js";
19
19
  import type { AgentDefinition } from "../loop/types.js";
20
20
  import type { ThreadType } from "../memory/thread-store.js";
21
21
  import type { PromptCachePolicy } from "../providers/types.js";
@@ -110,5 +110,7 @@ export declare const DEFAULT_ASK_WAIT_SECONDS: number;
110
110
  * finds the workflow of a recovery by its registered name.
111
111
  */
112
112
  export declare function registerChatTurns(deps: ChatTurnWorkflowDeps): ChatTurns;
113
+ /** The last frame of a turn that ran: `error` for a failed turn, else `finish` with the stop reason of the turn. */
114
+ export declare function endFrameOf(ran: ChatTurnRan, rootSource: EventSource): ChatErrorEvent | FinishEvent;
113
115
  /** How a turn that ran ended, as a host reads it. */
114
116
  export declare function chatTurnEndOf(ran: ChatTurnRan): ChatTurnEnd;
@@ -277,9 +277,7 @@ async function runTurnWorkflow(deps, input, signal) {
277
277
  };
278
278
  const ran = await runOpenTurn(deps, { analysisId: input.analysisId, threadId: input.threadId, userInput: input.userText }, { kind: "ok", threadType: input.threadType, history: view, userMessage: opening.userMessage, contextRecords: opening.contextRecords }, agent.value, { session, author: input.author, startedAtMs: input.startedAtMs, promptCache: input.promptCache }, transport);
279
279
  const { outcome } = ran;
280
- await writer.write(outcome.status === "failed"
281
- ? { type: "error", message: outcome.reason, reason: outcome.code, source: rootSource }
282
- : { type: "finish", source: rootSource, turnUsage: ran.turnUsage });
280
+ await writer.write(endFrameOf(ran, rootSource));
283
281
  await writer.settle();
284
282
  await closeDurableStream(CHAT_STREAM_KEY, outcome.status === "done" ? "stop" : outcome.status === "aborted" ? "aborted" : "error");
285
283
  // A sub-agent that a stop canceled in its pause leaves its row, thus the end of the turn clears each row of the turn.
@@ -287,6 +285,23 @@ async function runTurnWorkflow(deps, input, signal) {
287
285
  await DBOS.runStep(async () => unwrapOrThrow(await releaseThreadOfTurn(deps.pool, input.threadId, input.turnId)), { name: "chat-turn.release" });
288
286
  return chatTurnEndOf(ran);
289
287
  }
288
+ /** The last frame of a turn that ran: `error` for a failed turn, else `finish` with the stop reason of the turn. */
289
+ export function endFrameOf(ran, rootSource) {
290
+ const { outcome } = ran;
291
+ switch (outcome.status) {
292
+ case "failed":
293
+ return { type: "error", message: outcome.reason, reason: outcome.code, source: rootSource };
294
+ case "done":
295
+ return { type: "finish", source: rootSource, finishReason: outcome.finish.reason, turnUsage: ran.turnUsage };
296
+ // An abort that cut the run before its end leaves no finish of the loop.
297
+ case "aborted":
298
+ return { type: "finish", source: rootSource, finishReason: "aborted", turnUsage: ran.turnUsage };
299
+ default: {
300
+ const exhaustive = outcome;
301
+ throw new Error(`unhandled turn outcome: ${JSON.stringify(exhaustive)}`);
302
+ }
303
+ }
304
+ }
290
305
  /** How a turn that ran ended, as a host reads it. */
291
306
  export function chatTurnEndOf(ran) {
292
307
  const { outcome } = ran;
@@ -218,7 +218,7 @@ export async function runOpenTurn(deps, turn, prepared, agent, params, transport
218
218
  await flush({
219
219
  status: outcome.status,
220
220
  reason: outcome.status === "failed" ? outcome.reason : undefined,
221
- note: outcome.status === "failed" ? conversationRecordTurn(failureNote(outcome.reason)) : outcome.status === "aborted" ? interruptionNote() : undefined,
221
+ note: closingNoteOf(outcome, recorder),
222
222
  turnUsage,
223
223
  turnDurationMs: durationMs,
224
224
  });
@@ -292,6 +292,32 @@ function failureReasonOf(err) {
292
292
  return findProviderError(err) === undefined ? "The turn stopped on an internal error." : "The model request failed.";
293
293
  }
294
294
  }
295
+ /** The note that closes a turn: a failed, an aborted, or a declined turn has one, and a turn that the model answered has none. */
296
+ function closingNoteOf(outcome, recorder) {
297
+ switch (outcome.status) {
298
+ case "failed":
299
+ return conversationRecordTurn(failureNote(outcome.reason));
300
+ case "aborted":
301
+ return interruptionNote();
302
+ case "done":
303
+ return outcome.finish.reason === "content-filter" ? declineNote(recorder) : undefined;
304
+ default: {
305
+ const exhaustive = outcome;
306
+ throw new Error(`unhandled turn outcome: ${JSON.stringify(exhaustive)}`);
307
+ }
308
+ }
309
+ }
310
+ /**
311
+ * The note of a turn that the content filter stopped. A declined request often gives no message, and a round
312
+ * with no message gives no row. The note is the row that holds the display of such a round, thus a reload
313
+ * shows the refusal. It also tells the next turn that this request has no reply.
314
+ */
315
+ function declineNote(recorder) {
316
+ return {
317
+ modelMessages: [syntheticUserMessage("[The model provider declined the request above, thus it has no reply.]")],
318
+ displayMessages: recorder.takeRound([]),
319
+ };
320
+ }
295
321
  /**
296
322
  * The note of an aborted turn. Without it, the next turn reads the request of the aborted turn as open, and
297
323
  * its reply also answers that request. The transcript shows the interruption from the turn record, thus the
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { toChatFrame } from "../contracts/chat-frame.js";
9
- import { chatTurnEndOf } from "./chat-turn-workflow.js";
9
+ import { chatTurnEndOf, endFrameOf } from "./chat-turn-workflow.js";
10
10
  import { createConversationDisplayRecorder } from "../memory/conversation-display-recorder.js";
11
11
  import { prepareChatTurn, runOpenTurn, userTextOf, } from "./chat-turn.js";
12
12
  /** Prepare a turn and resolve its agent. A refusal comes back before any row of the turn exists. */
@@ -112,11 +112,8 @@ export function inProcessChatTurns(deps) {
112
112
  promptCache: request.promptCache,
113
113
  })
114
114
  .then((ran) => {
115
- const turnEnd = chatTurnEndOf(ran);
116
- log.push(turnEnd.status === "failed"
117
- ? { type: "error", message: turnEnd.reason ?? "The turn failed.", reason: turnEnd.code, source: rootSource }
118
- : { type: "finish", source: rootSource, turnUsage: turnEnd.turnUsage });
119
- return turnEnd;
115
+ log.push(endFrameOf(ran, rootSource));
116
+ return chatTurnEndOf(ran);
120
117
  })
121
118
  .catch(() => null)
122
119
  .finally(() => log.close());
@@ -162,6 +162,11 @@ export type ToolFinishedEvent = {
162
162
  export type FinishEvent = {
163
163
  type: "finish";
164
164
  source: EventSource;
165
+ /**
166
+ * Why the turn stopped: the finish reason of the last reply, for example `stop` or `content-filter`, or `aborted`,
167
+ * `max_iterations`, or `denied`. A word outside these stays as the provider sent it.
168
+ */
169
+ finishReason: string;
165
170
  /** What this loop's own LLM calls used. */
166
171
  usage?: TokenUsageRollup;
167
172
  /**
@@ -9,6 +9,7 @@
9
9
  * data-plan-approval (the record of an approved plan)
10
10
  * Compaction: data-compaction (a compaction of the conversation thread)
11
11
  * Model retry: data-model-retry (a retry of a model request inside the retry envelope of the provider)
12
+ * Model refusal: data-model-refusal (a model request that the provider declined)
12
13
  * Run lifecycle: run-started, dag-state, run-completed, run-failed, run-synthesis,
13
14
  * synthesis-progress
14
15
  * Per-step live: step-activity, step-file-tree
@@ -498,6 +499,23 @@ export type ModelRetryPart = {
498
499
  /** The HTTP status of the failed attempt. Absent for a failure with no status, for example a lost connection. */
499
500
  status?: number;
500
501
  };
502
+ /**
503
+ * A model request that the provider declined. `fallback`: another model answered the request, and `model` names it.
504
+ * `stopped`: no model answered, thus the reply of the request ended with the content filter.
505
+ */
506
+ export type ModelRefusal = {
507
+ /** The id of the model response. */
508
+ id: string;
509
+ outcome: "fallback";
510
+ model: string;
511
+ } | {
512
+ /** The id of the model response. */
513
+ id: string;
514
+ outcome: "stopped";
515
+ };
516
+ export type ModelRefusalPart = {
517
+ type: "data-model-refusal";
518
+ } & ModelRefusal;
501
519
  /** The kind of a durable work, and of the work that a pause belongs to. A sub-agent belongs to its turn, and a sandbox step to its run. */
502
520
  export type WorkKind = "turn" | "run" | "profile";
503
521
  /**
@@ -521,7 +539,7 @@ export type PausePart = {
521
539
  retryAt?: string;
522
540
  };
523
541
  /** The parts whose contracts this version of the package declares — the arms the part registry knows. */
524
- export type KnownChatPart = PresentationPart | PlanPart | RunCardPart | PlanApprovalPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | ActivityEntryPart | SubAgentActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepUsagePart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | ChildSessionStartedPart | ReportRenderedPart | CompactionPart | PausePart | ModelRetryPart;
542
+ export type KnownChatPart = PresentationPart | PlanPart | RunCardPart | PlanApprovalPart | FileReferencePart | AskPart | RunStartedPart | DagStatePart | StepActivityPart | ActivityEntryPart | SubAgentActivityPart | StepFileTreePart | StepOutputPart | StepSummaryPart | StepUsagePart | StepBlockedPart | RunSynthesisPart | SynthesisProgressPart | RunCompletedPart | RunFailedPart | ChildSessionStartedPart | ReportRenderedPart | CompactionPart | PausePart | ModelRetryPart | ModelRefusalPart;
525
543
  /**
526
544
  * A part of a known contract, or a part whose type this build does not know. An emitter newer than this
527
545
  * build can lead the registry, and such a part already rides through the check boundary and the live view
@@ -9,6 +9,7 @@
9
9
  * data-plan-approval (the record of an approved plan)
10
10
  * Compaction: data-compaction (a compaction of the conversation thread)
11
11
  * Model retry: data-model-retry (a retry of a model request inside the retry envelope of the provider)
12
+ * Model refusal: data-model-refusal (a model request that the provider declined)
12
13
  * Run lifecycle: run-started, dag-state, run-completed, run-failed, run-synthesis,
13
14
  * synthesis-progress
14
15
  * Per-step live: step-activity, step-file-tree
@@ -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, PlanApprovalPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, PlanGraphStep, DagStatePart, StepPhase, StepActivityPart, ActivityEntryPart, SubAgentActivityEntry, SubAgentActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepUsagePart, StepBlockedPart, FindingReference, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, ChildSessionStartedPart, ReportRenderedPart, CompactionPart, PauseCause, WorkKind, PausePart, ModelRetryPart, ChatPart, KnownChatPart, } from "./chat-parts.js";
5
+ export type { PlanStep, PresentationContent, PresentationPart, PlanPart, RunCardPart, PlanApprovalPart, FileReferenceEntry, FileReferencePart, AskPart, RunStartedPart, StepStatus, DagStepState, PlanGraphStep, DagStatePart, StepPhase, StepActivityPart, ActivityEntryPart, SubAgentActivityEntry, SubAgentActivityPart, FileTreeEntry, StepFileTreePart, StepOutputFile, StepOutputPart, StepSummaryPart, StepUsagePart, StepBlockedPart, FindingReference, SynthesizedFinding, BiologicalTheme, RunSynthesisPart, SynthesisPhase, SynthesisProgressPart, RunCompletedFinding, RunCompletedPart, RunFailedPart, ChildSessionStartedPart, ReportRenderedPart, CompactionPart, PauseCause, WorkKind, PausePart, ModelRetryPart, ModelRefusal, ModelRefusalPart, ChatPart, KnownChatPart, } from "./chat-parts.js";
6
6
  export type { EventSource, TextDeltaEvent, IterationEvent, ToolStartedEvent, ToolFinishedEvent, ToolCallLeaf, ToolCallValue, FinishEvent, ChatErrorEvent, MessageStartEvent, ChatEvent, ChatFrame, ChatPartFrame, } from "./chat-events.js";
7
7
  export type { TokenUsageRollup } from "./usage.js";
8
8
  export { DATA_PROFILE_RUN_LITERAL } from "./data-profile.js";
@@ -24,6 +24,7 @@ export const PART_REGISTRY = {
24
24
  "data-compaction": { emitter: "conversation", consumer: "conversation", transient: true, reconciling: true },
25
25
  "data-pause": { emitter: "workflow", consumer: "conversation", transient: false, reconciling: true },
26
26
  "data-model-retry": { emitter: "conversation", consumer: "conversation", transient: true, reconciling: true },
27
+ "data-model-refusal": { emitter: "conversation", consumer: "conversation", transient: false, reconciling: false },
27
28
  };
28
29
  export function isTransient(type) {
29
30
  return PART_REGISTRY[type].transient;
@@ -259,6 +259,7 @@ export declare const FinishEventSchema: z.ZodObject<{
259
259
  callPath: z.ZodArray<z.ZodString>;
260
260
  invocationId: z.ZodOptional<z.ZodString>;
261
261
  }, z.core.$strip>;
262
+ finishReason: z.ZodString;
262
263
  usage: z.ZodOptional<z.ZodObject<{
263
264
  inputTokens: z.ZodOptional<z.ZodNumber>;
264
265
  outputTokens: z.ZodOptional<z.ZodNumber>;
@@ -484,6 +485,7 @@ export declare const ChatEventSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
484
485
  callPath: z.ZodArray<z.ZodString>;
485
486
  invocationId: z.ZodOptional<z.ZodString>;
486
487
  }, z.core.$strip>;
488
+ finishReason: z.ZodString;
487
489
  usage: z.ZodOptional<z.ZodObject<{
488
490
  inputTokens: z.ZodOptional<z.ZodNumber>;
489
491
  outputTokens: z.ZodOptional<z.ZodNumber>;
@@ -70,6 +70,7 @@ export const ToolFinishedEventSchema = z.object({
70
70
  export const FinishEventSchema = z.object({
71
71
  type: z.literal("finish"),
72
72
  source: EventSourceSchema,
73
+ finishReason: z.string(),
73
74
  usage: TokenUsageRollupSchema.optional(),
74
75
  turnUsage: TokenUsageRollupSchema.optional(),
75
76
  });
@@ -625,6 +625,25 @@ export declare const PausePartSchema: z.ZodObject<{
625
625
  attempt: z.ZodOptional<z.ZodNumber>;
626
626
  retryAt: z.ZodOptional<z.ZodString>;
627
627
  }, z.core.$strip>;
628
+ /** Mirrors `ModelRefusalPart` without its `type`: the stored payload of the part. */
629
+ export declare const ModelRefusalPayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
630
+ id: z.ZodString;
631
+ outcome: z.ZodLiteral<"fallback">;
632
+ model: z.ZodString;
633
+ }, z.core.$strip>, z.ZodObject<{
634
+ id: z.ZodString;
635
+ outcome: z.ZodLiteral<"stopped">;
636
+ }, z.core.$strip>], "outcome">;
637
+ export declare const ModelRefusalPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
638
+ id: z.ZodString;
639
+ outcome: z.ZodLiteral<"fallback">;
640
+ model: z.ZodString;
641
+ type: z.ZodLiteral<"data-model-refusal">;
642
+ }, z.core.$strip>, z.ZodObject<{
643
+ id: z.ZodString;
644
+ outcome: z.ZodLiteral<"stopped">;
645
+ type: z.ZodLiteral<"data-model-refusal">;
646
+ }, z.core.$strip>], "outcome">;
628
647
  export declare const ModelRetryPartSchema: z.ZodObject<{
629
648
  type: z.ZodLiteral<"data-model-retry">;
630
649
  id: z.ZodString;
@@ -1090,4 +1109,13 @@ export declare const ChatPartSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
1090
1109
  maxRetries: z.ZodNumber;
1091
1110
  retryAt: z.ZodString;
1092
1111
  status: z.ZodOptional<z.ZodNumber>;
1093
- }, z.core.$strip>], "type">;
1112
+ }, z.core.$strip>, z.ZodDiscriminatedUnion<[z.ZodObject<{
1113
+ id: z.ZodString;
1114
+ outcome: z.ZodLiteral<"fallback">;
1115
+ model: z.ZodString;
1116
+ type: z.ZodLiteral<"data-model-refusal">;
1117
+ }, z.core.$strip>, z.ZodObject<{
1118
+ id: z.ZodString;
1119
+ outcome: z.ZodLiteral<"stopped">;
1120
+ type: z.ZodLiteral<"data-model-refusal">;
1121
+ }, z.core.$strip>], "outcome">], "type">;
@@ -375,6 +375,15 @@ export const PausePartSchema = z.object({
375
375
  attempt: z.number().int().nonnegative().optional(),
376
376
  retryAt: z.string().optional(),
377
377
  });
378
+ // ── Model refusal ───────────────────────────────────────────────────
379
+ const ModelRefusalFallbackShape = { id: z.string(), outcome: z.literal("fallback"), model: z.string() };
380
+ const ModelRefusalStoppedShape = { id: z.string(), outcome: z.literal("stopped") };
381
+ /** Mirrors `ModelRefusalPart` without its `type`: the stored payload of the part. */
382
+ export const ModelRefusalPayloadSchema = z.discriminatedUnion("outcome", [z.object(ModelRefusalFallbackShape), z.object(ModelRefusalStoppedShape)]);
383
+ export const ModelRefusalPartSchema = z.discriminatedUnion("outcome", [
384
+ z.object({ type: z.literal("data-model-refusal"), ...ModelRefusalFallbackShape }),
385
+ z.object({ type: z.literal("data-model-refusal"), ...ModelRefusalStoppedShape }),
386
+ ]);
378
387
  // ── Model retry ─────────────────────────────────────────────────────
379
388
  export const ModelRetryPartSchema = z.object({
380
389
  type: z.literal("data-model-retry"),
@@ -428,4 +437,5 @@ export const ChatPartSchema = z.discriminatedUnion("type", [
428
437
  CompactionPartSchema,
429
438
  PausePartSchema,
430
439
  ModelRetryPartSchema,
440
+ ModelRefusalPartSchema,
431
441
  ]);
package/dist/index.d.ts CHANGED
@@ -108,7 +108,7 @@ export type { SubAgentActivityTracker } from "./contracts/subagent-activity.js";
108
108
  export type { TextPart, ToolCallPart, MessagePart, ChatMessage } from "./contracts/message.js";
109
109
  export type { TurnTerminal, ApplyFrameResult, ChatPartCheck } from "./contracts/chat-frame.js";
110
110
  export type { TokenUsageRollup } from "./contracts/usage.js";
111
- export type { ChatPart, PresentationPart, PresentationContent, PlanPart, RunCardPart, PlanApprovalPart, FileReferencePart, FileReferenceEntry, RunStartedPart, DagStatePart, DagStepState, PlanGraphStep, StepStatus, StepActivityPart, ActivityEntryPart, SubAgentActivityEntry, SubAgentActivityPart, StepPhase, StepFileTreePart, FileTreeEntry, StepOutputPart, StepOutputFile, StepSummaryPart, StepUsagePart, StepBlockedPart, RunSynthesisPart, FindingReference, SynthesizedFinding, BiologicalTheme, SynthesisProgressPart, SynthesisPhase, RunCompletedPart, RunCompletedFinding, RunFailedPart, CompactionPart, PauseCause, WorkKind, PausePart, ModelRetryPart, AskPart, ChildSessionStartedPart, ReportRenderedPart, } from "./contracts/chat-parts.js";
111
+ export type { ChatPart, PresentationPart, PresentationContent, PlanPart, RunCardPart, PlanApprovalPart, FileReferencePart, FileReferenceEntry, RunStartedPart, DagStatePart, DagStepState, PlanGraphStep, StepStatus, StepActivityPart, ActivityEntryPart, SubAgentActivityEntry, SubAgentActivityPart, StepPhase, StepFileTreePart, FileTreeEntry, StepOutputPart, StepOutputFile, StepSummaryPart, StepUsagePart, StepBlockedPart, RunSynthesisPart, FindingReference, SynthesizedFinding, BiologicalTheme, SynthesisProgressPart, SynthesisPhase, RunCompletedPart, RunCompletedFinding, RunFailedPart, CompactionPart, PauseCause, WorkKind, PausePart, ModelRetryPart, ModelRefusal, ModelRefusalPart, AskPart, ChildSessionStartedPart, ReportRenderedPart, } from "./contracts/chat-parts.js";
112
112
  export { PART_REGISTRY, isTransient, isReconciling, isSidebarPart } from "./contracts/part-registry.js";
113
113
  export type { ChatPartType, PartDescriptor, PartEmitter, PartConsumer } from "./contracts/part-registry.js";
114
114
  export { parseStructureUrl, alphafoldEntryUrl, alphafoldPredictionUrl } from "./contracts/structure-source.js";
@@ -156,7 +156,7 @@ export function framesOfChunk(chunk, rootSource) {
156
156
  case "error":
157
157
  return [{ type: "error", message: chunk.errorText, source: rootSource }];
158
158
  case "abort":
159
- return [{ type: "finish", source: rootSource }];
159
+ return [{ type: "finish", source: rootSource, finishReason: "aborted" }];
160
160
  default: {
161
161
  const frame = harnessFrameOf(chunk);
162
162
  return frame === undefined ? [] : [frame];
@@ -654,6 +654,9 @@ async function runSeries(agent, opts, calls, log, target, spec) {
654
654
  target.steps.push(loopStep);
655
655
  if (loopStep.calls.length > 0)
656
656
  log.debug("iteration", { iteration: seriesSteps.length - 1, tools: loopStep.calls.map((call) => call.toolName) });
657
+ const refusal = refusalOf(step, loopStep);
658
+ if (refusal !== undefined)
659
+ await opts.emit({ type: "data-model-refusal", source, data: refusal });
657
660
  await opts.emit({ type: "iteration", source, index: seriesSteps.length - 1, final: !continuesAfter(loopStep) || stops() });
658
661
  },
659
662
  });
@@ -749,6 +752,19 @@ function loopStepOf(step, deniedByTheUser) {
749
752
  });
750
753
  return { finishReason: step.finishReason, rawFinishReason: step.rawFinishReason, calls };
751
754
  }
755
+ /**
756
+ * The refusal of one step, when the provider declined its request. A reply that ends with the content filter
757
+ * stopped, also after a fallback hop, because a chain whose last model declined reports that hop too. A hop that
758
+ * names no model gives the served model of the response, as the usage record does.
759
+ */
760
+ function refusalOf(step, loopStep) {
761
+ if (loopStep.finishReason === "content-filter")
762
+ return { id: step.response.id, outcome: "stopped" };
763
+ const servedBy = fallbackModelOf(step.providerMetadata);
764
+ if (servedBy === undefined)
765
+ return undefined;
766
+ return { id: step.response.id, outcome: "fallback", model: servedBy === "" ? step.response.modelId : servedBy };
767
+ }
752
768
  /**
753
769
  * The messages of one step as the transcript records them. The request after
754
770
  * them sends them as they are recorded, thus the record is the prefix of the
@@ -15,7 +15,7 @@
15
15
  * reconstruction.
16
16
  */
17
17
  import { type DataUIPart, type UIMessage } from "ai";
18
- import type { AskPart, ChildSessionStartedPart, CompactionPart, FileReferencePart, PlanApprovalPart, PlanPart, PresentationPart, ReportRenderedPart, RunCardPart, SubAgentActivityEntry, SubAgentActivityPart } from "../contracts/chat-parts.js";
18
+ import type { AskPart, ChildSessionStartedPart, CompactionPart, FileReferencePart, ModelRefusal, ModelRefusalPart, PlanApprovalPart, PlanPart, PresentationPart, ReportRenderedPart, RunCardPart, SubAgentActivityEntry, SubAgentActivityPart } from "../contracts/chat-parts.js";
19
19
  import type { ToolCallOutcome } from "../contracts/chat-events.js";
20
20
  import type { ChatMessage, MessagePart, ToolCallPart } from "../contracts/message.js";
21
21
  import type { Logger } from "../lib/logger.js";
@@ -43,6 +43,7 @@ export type ConversationUIData = {
43
43
  "child-session-started": Payload<ChildSessionStartedPart>;
44
44
  "report-rendered": Payload<ReportRenderedPart>;
45
45
  compaction: Payload<CompactionPart>;
46
+ "model-refusal": ModelRefusal;
46
47
  "subagent-activity": Omit<Payload<SubAgentActivityPart>, "entries"> & {
47
48
  entries: (Omit<SubAgentActivityEntry, "outcome"> & {
48
49
  outcome: ToolCallOutcome;
@@ -65,7 +66,7 @@ export declare function parseStoredDisplayEnvelope(value: unknown, identity: str
65
66
  export type StoredSourcePart = Extract<Exclude<MessagePart, {
66
67
  type: "text";
67
68
  }>, {
68
- type: ToolCallPart["type"] | PresentationPart["type"] | PlanPart["type"] | RunCardPart["type"] | PlanApprovalPart["type"] | FileReferencePart["type"] | AskPart["type"] | ChildSessionStartedPart["type"] | ReportRenderedPart["type"] | CompactionPart["type"] | SubAgentActivityPart["type"];
69
+ type: ToolCallPart["type"] | PresentationPart["type"] | PlanPart["type"] | RunCardPart["type"] | PlanApprovalPart["type"] | FileReferencePart["type"] | AskPart["type"] | ChildSessionStartedPart["type"] | ReportRenderedPart["type"] | CompactionPart["type"] | ModelRefusalPart["type"] | SubAgentActivityPart["type"];
69
70
  }>;
70
71
  /** Whether `part` is an arm the stored vocabulary can hold; anything else cannot become a stored card. */
71
72
  export declare function isStoredSourcePart(part: Exclude<MessagePart, {
@@ -19,7 +19,7 @@ import { textOf } from "../lib/unknown-text.js";
19
19
  import { isDataUIPart, validateUIMessages } from "ai";
20
20
  import { z } from "zod";
21
21
  import { parseUnknownRecord } from "../lib/unknown-containers.js";
22
- import { AskPartSchema, ChildSessionStartedPartSchema, CompactionPartSchema, FileReferencePartSchema, PlanApprovalPartSchema, PlanPartSchema, PresentationPartSchema, ReportRenderedPartSchema, RunCardPartSchema, SubAgentActivityEntrySchema, SubAgentActivityPartSchema, } from "../contracts/schemas/chat-parts.js";
22
+ import { AskPartSchema, ChildSessionStartedPartSchema, CompactionPartSchema, FileReferencePartSchema, ModelRefusalPayloadSchema, PlanApprovalPartSchema, PlanPartSchema, PresentationPartSchema, ReportRenderedPartSchema, RunCardPartSchema, SubAgentActivityEntrySchema, SubAgentActivityPartSchema, } from "../contracts/schemas/chat-parts.js";
23
23
  import { ToolCallOutcomeSchema, ToolCallValueSchema } from "../contracts/schemas/chat-events.js";
24
24
  export const SUPPORTED_DISPLAY_SCHEMA_VERSION = 1;
25
25
  const DisplayEnvelopeHeaderSchema = z.object({
@@ -77,6 +77,7 @@ const dataSchemas = {
77
77
  "child-session-started": ChildSessionStartedPartSchema.omit({ type: true }),
78
78
  "report-rendered": ReportRenderedPartSchema.omit({ type: true }),
79
79
  compaction: CompactionPartSchema.omit({ type: true }),
80
+ "model-refusal": ModelRefusalPayloadSchema,
80
81
  "subagent-activity": SubAgentActivityPartSchema.omit({ type: true }).extend({
81
82
  entries: z.array(SubAgentActivityEntrySchema.extend({ outcome: ToolCallOutcomeSchema })),
82
83
  }),
@@ -182,6 +183,7 @@ const STORED_SOURCE_TYPES = [
182
183
  "data-child-session-started",
183
184
  "data-report-rendered",
184
185
  "data-compaction",
186
+ "data-model-refusal",
185
187
  "data-subagent-activity",
186
188
  ];
187
189
  const STORED_SOURCE_TYPE_SET = new Set(STORED_SOURCE_TYPES);
@@ -255,6 +257,10 @@ export function conversationDisplayPart(part) {
255
257
  const { type: _type, ...data } = part;
256
258
  return { type: "data-compaction", id, data };
257
259
  }
260
+ case "data-model-refusal": {
261
+ const { type: _type, ...data } = part;
262
+ return { type: "data-model-refusal", id, data };
263
+ }
258
264
  case "data-subagent-activity": {
259
265
  const { type: _type, ...payload } = part;
260
266
  return { type: "data-subagent-activity", id, data: { ...payload, entries: part.entries.map(closedEntry) } };
@@ -315,6 +321,9 @@ export function conversationUIToChatMessages(messages) {
315
321
  case "data-compaction":
316
322
  parts.push({ type: "data-compaction", ...part.data });
317
323
  break;
324
+ case "data-model-refusal":
325
+ parts.push({ type: "data-model-refusal", ...part.data });
326
+ break;
318
327
  case "data-subagent-activity":
319
328
  parts.push({ type: "data-subagent-activity", ...part.data });
320
329
  break;
@@ -118,6 +118,12 @@ export type AiSdkProviderConfig = {
118
118
  * Needs the `thinking-binding-controls-2026-08-01` beta header.
119
119
  */
120
120
  readonly thinkingBinding?: "drop_block" | "error" | "off";
121
+ /**
122
+ * `default`: when a safety classifier declines a request, the API sends it again to the model that it
123
+ * recommends for the category of the refusal, inside the same call. When the field is absent, a declined
124
+ * request ends with the content filter. A model with no fallback target keeps its refusal.
125
+ */
126
+ readonly fallbacks?: "default";
121
127
  } | {
122
128
  readonly kind: "openai";
123
129
  readonly baseURL?: string;
@@ -252,22 +258,19 @@ export declare function sessionKeyDigestOf(session: Pick<AgentSession, "scope" |
252
258
  * A model can refuse a request by category — a biology prompt is one such category —
253
259
  * and the API then re-serves it on another model. It reports the hop TWICE: as a
254
260
  * `fallback` content block inside the assistant turn, and on
255
- * `providerMetadata.anthropic.iterations`. `@ai-sdk/anthropic` keeps only the second,
256
- * because the AI SDK has no part for a model hop, and it drops the block from the
257
- * content it returns (`anthropic-messages-language-model`, the `content_block_start`
258
- * case). Thus the turn the harness holds is not the turn the API produced, and every
259
- * signed thinking block of that turn is rejected from the next request on.
261
+ * `providerMetadata.anthropic.iterations`. `@ai-sdk/anthropic` keeps the block as a
262
+ * custom part and sends it back on the next request, but it keeps each thinking block
263
+ * before it too. The API accepts no thinking block from before the hop, and a turn
264
+ * whose thinking blocks changed earns `thinking ... blocks in the latest assistant
265
+ * message cannot be modified`, which is a 400 that no retry can pass.
260
266
  *
261
- * The API holds the whole turn against the signature of each of its thinking blocks, so
262
- * echoing that turn earns `thinking ... blocks in the latest assistant message cannot be
263
- * modified`, which is a 400 that no retry can pass. A thinking block is optional on the
264
- * way back, thus the loop records the reply of a hop with no thinking block, and the next
265
- * request continues. The cost is the reasoning of this one turn and the cache write that
266
- * its new prefix pays for.
267
+ * A thinking block is optional on the way back, thus the loop records the reply of a
268
+ * hop with no thinking block, and the next request continues. The cost is the
269
+ * reasoning of this one turn and the cache write that its new prefix pays for.
267
270
  *
268
- * The response metadata of the package carries `message_start.message.model`, and that
269
- * field keeps the REQUESTED model across a hop. The iteration names the model that
270
- * answered, thus it wins for the usage record.
271
+ * The response metadata of the package carries `message_start.message.model`, and
272
+ * after a hop in the middle of a reply that field keeps the REQUESTED model. The
273
+ * iteration names the model that answered, thus it wins for the usage record.
271
274
  */
272
275
  export declare function fallbackModelOf(metadata: ProviderMetadata | undefined): string | undefined;
273
276
  /**
@@ -108,22 +108,19 @@ function thinkingWithBinding(reasoning, mode) {
108
108
  * A model can refuse a request by category — a biology prompt is one such category —
109
109
  * and the API then re-serves it on another model. It reports the hop TWICE: as a
110
110
  * `fallback` content block inside the assistant turn, and on
111
- * `providerMetadata.anthropic.iterations`. `@ai-sdk/anthropic` keeps only the second,
112
- * because the AI SDK has no part for a model hop, and it drops the block from the
113
- * content it returns (`anthropic-messages-language-model`, the `content_block_start`
114
- * case). Thus the turn the harness holds is not the turn the API produced, and every
115
- * signed thinking block of that turn is rejected from the next request on.
111
+ * `providerMetadata.anthropic.iterations`. `@ai-sdk/anthropic` keeps the block as a
112
+ * custom part and sends it back on the next request, but it keeps each thinking block
113
+ * before it too. The API accepts no thinking block from before the hop, and a turn
114
+ * whose thinking blocks changed earns `thinking ... blocks in the latest assistant
115
+ * message cannot be modified`, which is a 400 that no retry can pass.
116
116
  *
117
- * The API holds the whole turn against the signature of each of its thinking blocks, so
118
- * echoing that turn earns `thinking ... blocks in the latest assistant message cannot be
119
- * modified`, which is a 400 that no retry can pass. A thinking block is optional on the
120
- * way back, thus the loop records the reply of a hop with no thinking block, and the next
121
- * request continues. The cost is the reasoning of this one turn and the cache write that
122
- * its new prefix pays for.
117
+ * A thinking block is optional on the way back, thus the loop records the reply of a
118
+ * hop with no thinking block, and the next request continues. The cost is the
119
+ * reasoning of this one turn and the cache write that its new prefix pays for.
123
120
  *
124
- * The response metadata of the package carries `message_start.message.model`, and that
125
- * field keeps the REQUESTED model across a hop. The iteration names the model that
126
- * answered, thus it wins for the usage record.
121
+ * The response metadata of the package carries `message_start.message.model`, and
122
+ * after a hop in the middle of a reply that field keeps the REQUESTED model. The
123
+ * iteration names the model that answered, thus it wins for the usage record.
127
124
  */
128
125
  export function fallbackModelOf(metadata) {
129
126
  const entries = metadata?.["anthropic"]?.["iterations"];
@@ -717,12 +714,13 @@ export function createConfiguredAiSdkProvider(deps) {
717
714
  maxRetries: config.maxRetries,
718
715
  requestTimeoutMs,
719
716
  reasoning: config.reasoning,
720
- // The package sends the key as `metadata.user_id`, and the binding as
721
- // `thinking.block_binding`.
717
+ // The package sends the key as `metadata.user_id`, the binding as
718
+ // `thinking.block_binding`, and the default fallback with its beta header.
722
719
  providerOptionsFor: ({ session, reasoning }) => ({
723
720
  anthropic: {
724
721
  metadata: { userId: sessionKeyDigestOf(session) },
725
722
  thinking: binding === undefined ? undefined : thinkingWithBinding(reasoning, binding),
723
+ fallbacks: config.fallbacks,
726
724
  },
727
725
  }),
728
726
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.48.2",
3
+ "version": "0.48.3",
4
4
  "description": "Host-agnostic bioinformatics agent harness — an agent loop on the AI SDK, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",