Files
orca/src/shared/agent-session-failure.ts
T
Brennan Benson 498818fc63 Native chat: a message stopped before the agent got it reaches the agent with your next send (#27623)
* Carry a person's stopped messages to the agent with their next send

A message a person sent and then stopped before the agent started on it was kept
on screen as "Stopped manually" but never reached the agent. The delivery loop
now derives the stopped messages the agent is still owed, names them on the next
ordinary send's handover row, and each agent receives them in that same write as
framed context (Claude and Pi: framed text and images in the prompt; ACP: leading
blocks; Codex: turn/start additionalContext, or framed input items for images
and steers). Codex withdrawals that may have reached the agent are marked so they
are never carried.

* Let a queued card's hand-off carry stopped history; test the exactly-once outcomes

A card's hand-off records no sender, so carrier eligibility also accepts a
queued card whose body names no other agent. Tests cover every row of the
exactly-once table against the real host, each agent's rendering, the journal
fold, and that nothing a client parses changes.

* Keep a send that carried stopped history out of fingerprint reconciliation

After a host crash, restart reconciliation matches an unsettled send to the
agent's transcript by fingerprint. A carrier's transcript copy leads with its
stopped history, so it could never match and would read as never delivered,
which would also release its history again. Like a send with an attachment,
it now stays unconfirmed.

* Owe a stopped message only on proof it never reached the agent; carry Codex history as input

- Codex: every stopped message rides as leading framed input on turn/start and
  turn/steer (an older Codex silently ignores additionalContext and would read as
  delivered); add a Codex fit check against its 1,048,576-character input limit.
- Flip the marker: a withdrawal is owed only with `neverReachedAgent`, written where
  Orca proves it (host-held Stop, ACP held steer, Claude queue clear, a start the
  Stop ended). Old journals and Codex withdrawals read as not owed.
- A carrier releases its history only for Orca's own pre-write refusals or a marked
  withdrawal; provider refusals and not-signed-in are doubt.
- If the history does not fit, try once without its images, then send alone.
- Re-read the turn scope after selection; owed excludes typed slash commands;
  dropped-history fallbacks log through the host logger.

* Owe stopped messages only where Orca held them; list only the history actually written

- Claude's queue clear (lifecycle and interrupt sweep) and a start ended by
  Stop write plain `cancelled` again, as on main: Claude may already hold a
  swept send, and the child-exit site proved nothing for leftover rows. The
  proof sites are the host-held Stop withdrawal and ACP held steers.
- An adapter that leaves the history out after it fit calls the host's new
  `historyLeftOut` before writing; the host rewrites the pending row without
  `carries`, so those messages stay owed.
- History is chosen oldest first: each message with its images if it fits,
  else without them, else skipped this time without holding back later ones.
- Session History titles and previews skip `<stopped-message>` blocks through
  the existing hidden-block list.

* Build the swept-send test's marked fact with the shared builder

* Keep a person's own words when a rewind rebuilds their message

A rewind rebuilt each kept message from the provider's copy. Codex's copy of a
send that carried stopped history starts with the framed history, so that
bubble showed it. Keep Orca's body for a person's own message, as the live
fold already does; other senders keep the provider's body with their sender.

* Require the logger on the handover context

* Write a plain Claude send as soon as before when no stopped history rides

Wrapping the content build in an async function added microtask ticks before
every Claude write, even with no history, which a turn-timing test caught.
Return the same promise when nothing rides.

* Keep stopped messages and their carriers' coverage through a rewind

A rewind rebuilt history from the provider's items, which hold none of the
host's send facts, so a stopped message the agent never received lost its
bubble and its submission, and a surviving send that carried it lost its
coverage. The rebuild now restates those sends with the submission and
dispatch rows that first recorded them, in both the epoch and the post-clear
paths. A carrier the rewind removed is simply absent, so what it carried is
owed again with the next send.

* Test that a rewind keeps stopped messages owed or covered

Drives the real host, store and journal through a Codex-shaped rewind:
before and after the carrier, after a /clear, across a host restart, for a
stopped message nothing carried, and for a rewind that crashed after its
history committed. Each checks what the desktop and the phone draw and what
the next send carries.

* Owe stopped history again when a carrier provably never reached the agent

A Pi prompt refused for sign-in after its retries, and an ACP prompt line the
encoder refused before writing a byte, now settle with neverReachedAgent. The
owed derivation treats any carrier rejection carrying that proof as refused
before write, so its history rides with the next send instead of being lost.
Pi checks sign-in before it records a prompt from 0.84.0 (Orca's floor).

* Share the Pi adapter test harness with other Pi tests

Moves the scripted connection and setup out of the adapter test, unchanged, so
a stopped-history test can drive the real adapter without growing that file.

* Prepare each send once with the stopped history that fits, in one batch frame

Stopped messages now reach the agent in one frame ahead of the next send: a
header saying they are earlier, stopped history, one tagged element each with
its images numbered in the order sent, then a 'User message:' label. Only a
'<' that would open or close the frame's own tags is escaped.

Each adapter prepares the send once (prepareDispatch): images are read once,
each message's cost is weighed once against that agent's real limits, and the
result is the exact payload plus the ordered ids inside it. The host lists
those ids on the pending row and dispatch writes that same payload, so what a
row says it carried is what the agent got. A message that does not fit goes
without its images, then shortened with a marker; only when not even that fits
does it wait, and the frame says how many were left out. A plain send with
nothing owed is prepared and written exactly as before.

ACP measures every prompt's whole line, text included, and refuses a text-only
message too long for one line before writing it. Titles, previews and copied
first prompts hide the whole frame and its label, however long.

* Keep a stopped or restarted queue paused through a rewind after a /clear

After a /clear the rewind rebuilds in place, so a restated accepted carrier
reads as a turn after the person's Stop (or a restart's pause) and released
the cards it held. Restate those pauses above it, as a new epoch does.

* Restate a rewind's stopped messages in the order they were sent

Owed history follows the restated rows' order, so write them in their old
send order within each anchor rather than the order they were refused.

* Fit the rewind tests to the prepared-dispatch frame

* Cap carried history at 64k characters and degrade only the oldest stopped message

* Owe stopped history again when ACP or Claude refused its carrier before writing it

* Strip a carried-history frame before capping a Grok preview

* Leave a title that opens like a frame but never closes it unchanged

* Drop the image labels Codex writes for carried history images from titles and copied prompts

* Read a stopped message's images one by one so a refused image leaves the readable ones riding

* Settle a Pi prompt still waiting out a sign-in refusal as never reaching Pi when the session ends

* Name the journal rewind module after the rows it builds

* Type Pi's rejection helper with the facts that can reject a message

* Hide everything ahead of a carried-history frame within a bounded lookahead, and cap carried images at 20

* Fit Pi's stopped history beside the line it is written, and settle an encoder-refused prompt as never reaching Pi

Preparation measured the prompt without the streamingBehavior field PiRpcTurns adds, so a nearly full
carrier could exceed the line limit; the encoder then refused it and the write settled as unknown,
covering the carried history forever. Both now measure the frame from the same builder, and an
encoder refusal settles as writeFailed with neverReachedAgent, leaving the history owed.

* Record stopped messages appended to the agent's own log in the journal

A host-only mark on an id no item takes, written before the append and
cleared by a later proven refusal, so an older host reads past it and
clients never see it.

* Derive owed stopped messages from append records as well as ACP and Pi carriers

* Append owed stopped messages to the agent's own log at the next handover

The record is written first and covers them; only a proven refusal owes
them again. A failed or unknown append is logged and never holds the
send. Agents that cannot append keep carrying them in the send.

* Write Claude's stopped messages as shouldQuery:false frames that run no turn

Each message is its content and then Claude's own block-form
"[Request interrupted by user]". The append settles when Claude names
every frame in a result, and its zero-turn commands are claimed by uuid
so they open no turn, show no Working and write no row. The prepared
carrier send and the startup-failure never-reached mark are gone.

* Inject Codex's stopped messages with its own turn_aborted record while idle

One thread/inject_items holds each message and the default-mode
user-role marker. Only "method not found" proves a refusal; any other
error may have recorded them, so they are never injected again. The
prepared carrier send is gone.

* Carry stopped messages for ACP and Pi as plain text, whole messages only

Each rides as its words and Claude's interruption line ahead of the
send's own words, oldest first, while it fits the agent's own limits;
the first that does not fit waits with the rest. The frame, numbering,
labels, shortening and history caps are gone.

* Drop the stopped-history frame stripping from Session History scanners

Nothing writes the frame any more. Claude's and Codex's own interruption
records were already never read as a title or first prompt.

* Keep stopped messages and their append records through a Codex rewind

Only stopped sends are restated, each with the record that covers it, so
a kept message is never injected twice. Carrier coverage on rewind is
gone: Claude and Codex no longer carry, and ACP and Pi never rewind.

* Keep an append record through a Codex rewind only where Codex keeps its pair

A turn can come between a Stop and the send that appends the stopped message (another
agent's mail, a slash command, a busy Codex). A rewind to before that turn kept the
bubble and its append record, while Codex dropped the injected pair, so the message
read as covered when the agent no longer had it.

The reducer now keeps each record's row position. A rewind restates a record only when
that row precedes the rewound turn, and writes it back after the row it followed, so a
later rewind compares the same way; the same-epoch rebuild after a /clear takes back
every record after the floor first. A stopped send is restated with its submission and
rejection rows only: the queued withdrawal never wrote a handover row.

* Check, bound and settle each stopped-message append once

Each session now gives one answer for how its agent takes stopped messages (append,
carry or none), instead of a per-session check beside a router that always offered
the append.

Right before writing, both agents check again that the child, connection and fence are
the same and that no turn runs, by the agent's account or Orca's journal; a failed
check is refused, so the message stays owed. Reading a stopped message's images has its
own 2 s limit and the write and its settlement another 2 s, so a send or Stop queued
behind a stalled append waits about 4 s at most; work abandoned on a timeout never
writes later. A message with nothing left to read is logged as such, not as unknown.

Claude: the append keeps only a uuid observer. Its own lifecycle, echo and result
settle it and stay out of turn tracking, even late; its running, init and idle frames
are left alone. It holds the child from rest only while unsettled, and a child that
ends settles it from what the writes prove, so frames it never took are refused.

Codex: every inject error is unknown. A Codex without the method answers an error Orca
cannot tell from a failed write, so it is never asked again, as before appends existed.
Remote image URLs are left out: Codex refuses them before recording anything.

* Give ACP's carried stopped messages a text block of their own

The carried words were folded into the send's first text block. With an image-only
send its own images came before the interrupted text and read as part of the stopped
request. The prompt is now the carried images, one text block with the carried words,
then the send's own blocks unchanged. Pi keeps text and images apart, so it cannot
interleave them, and stays as it was.

* Hide Codex's own <turn_aborted> record in Session History

Codex writes a user-role <turn_aborted> message for its own Stop. After a first message
holding only an image, that marker became the session's title, its copyable first
prompt and a preview row, in natively interrupted sessions as in appended ones. It is
now hidden like the other harness blocks in titles and previews, and never seeds the
first prompt.

* Give carried stopped-message preparation one time limit, so a stalled image never holds the send or Stop

* Read Codex's stopped images within Orca's attachment limit, not Claude's

* Append only while the agent is idle by its own account, and record nothing while it is busy

* Find a Codex rewind's cut in one place for kept rows and kept append records

* Drop the rewind send-order sort, share the interruption marker, and return untouched files to main

* Test the append end to end through the router, and a late append echo with the next send's words

* Leave out a stopped image whose read stalls, and never read that file again while it is stuck

* Inject Codex's stopped images only into a Codex that resizes injected images, read from its handshake

* Skip and log a kept append record no rebuilt row precedes, instead of anchoring it at the end

* Restate a rewound stopped send's rejection through the one dispatch-row builder

* Keep a stopped image unread until every abandoned read of it settles, not just the first

* Read stopped-history transcripts through the one projection every client now shares

Main removed the rejectedInPlace option: the desktop and the phone now draw a recorded-then-rejected
send the same way, so the tests read one transcript instead of comparing two.

* Deliver stopped messages with the next send: Codex by injection, every other agent inline

Claude no longer writes stopped messages as separate shouldQuery:false appends with
their own frame-claiming, idle gate and settlement. Like ACP agents and Pi, it now gets
them at the front of the person's next words-only send, followed by the
"[Request interrupted by user]" record. Codex keeps thread/inject_items right before the
send, idle only, at most once; a Codex without the method is refused and its sends carry
them instead.

One selection serves both: words only, the newest that fit a 1 MiB encoded bound, in the
order sent, older ones left out and covered. A send with attachments carries nothing, so
history can never push a send past an agent's limits; the per-agent prepare and fit
modules, stopped-image reads and Codex version plumbing go. The injection record is
renamed for what it is. Rewind restatement is split out: a rewind drops stopped
messages with their bubbles, as before, and nothing is injected twice.

* Bound stopped history by one send's size beyond the newest, counting its marker, and keep scanning older messages

* Keep a whole stopped-history delivery within one send, counting either transport's framing

* Budget stopped history by the agent's remaining context beside the send, and carry none when none fits

* Bound stopped history by one send again, not the agent's context: each would have reached the agent unstopped

* Bound stopped history together with the send it rides with

The delivered stopped messages and the send's own words never exceed one send's size, so the agent is never handed more than a send the person could make. If the newest doesn't fit, they all stay owed for a later send.

* Never owe a stopped message too large to deliver beside any send

Such a message could never fit, so it held every older one back for good.

* Owe a stopped message only if it fits beside a one-letter send

The smallest send that carries history has one letter, so the owed bound reserves that, not an empty send.

* Deliver Codex's stopped messages inline, like every other agent

Codex no longer gets stopped messages through thread/inject_items. Its next
turn-opening send carries them at the front, the same text every other agent
gets, and the carrier's own pending row records what it carries. That removes
the injection module, its host-only journal row and reducer handling, the
adapter and router hooks, the unsupported-method fallback flag, and the
<turn_aborted> hiding in Session History that only injected items needed.

A Codex rewind rebuilds kept messages from Codex's copy, which now leads with
the carried words, so a person's own message keeps the words Orca recorded, as
the live fold already does. The per-message size allowance is now the inline
framing itself.

Measured with real Codex 0.159.0 before building: inline acted on "go ahead"
8/8, never on "ignore", and 0/21 on unrelated follow-ups, matching the injected
pair and a real Stop.

* Owe a Codex send's stopped messages again when Codex declines its turn/start

A request Codex answered with an error recorded nothing, so the rejection now
carries the never-reached proof and any stopped messages that send carried are
owed again instead of covered forever. A timeout or transport failure still
proves nothing.
2026-10-14 00:55:02 -07:00

353 lines
14 KiB
TypeScript

// What went wrong in a chat, typed where the host decides it, beside the sentence a person reads.
//
// The host writes both at once: `text`/`reason` stays a complete sentence because released clients
// print it as it is, and this fact is what newer clients choose their copy and action from. A
// provider's own words ride separately as `detail`, set only where Orca composed them from a
// value the provider wrote — never recovered from a string afterwards, since by then nothing can
// tell a provider's sentence from Orca's.
import type { AgentSessionAccountKind } from './agent-session-availability'
import {
readAgentSessionArgumentProblem,
type AgentSessionArgumentProblem
} from './agent-session-argument-problem'
export type { AgentSessionArgumentProblem } from './agent-session-argument-problem'
import { structuralValuesEqualIgnoringUndefined } from './structural-value-equality'
import {
readAgentSessionRefusalReference,
type AgentSessionRefusalReference
} from './agent-session-wire-refusals'
/** One per situation with its own honest next step; a new one needs copy before it compiles. */
export const AGENT_SESSION_FAILURE_KINDS = [
/** Only an observed exit: the provider stopped while starting. */
'providerStartFailed',
/** A start that did not land, with no one to blame: Orca's fault, a failed spawn, a close. */
'startFailed',
'notSignedIn',
/** The agent's CLI is not installed where the chat runs: its spawn found no such file. */
'cliMissing',
'historyTooLarge',
'managedAccountEnvOverride',
'accountSwitchInProgress',
'managedAccountUnsupported',
'launchFolderMissing',
'historyInOtherAccount',
'claudeAccountFolderMissing',
'claudeAccountSetupFailed',
'agentCommandNotRunnable',
'providerExited',
'restartFailed',
'providerRejected',
'attachmentInvalid',
'attachmentUnreadable',
'emptyMessage',
'queueFull',
'writeFailed',
'cancelled',
'chatClosed',
'hostRestarted',
'notDelivered',
/** A conversation command the chat's state refused when its turn to run came. */
'commandRefused',
'compactionFailed',
'compactionUnconfirmed',
'cancelUnconfirmed',
/** A Stop naming no turn reached the agent, which ended nothing while the chat read working. */
'stopRefused',
/** A Stop Orca could not save: only a refusal says it. */
'stopFailed',
/** A background-task Stop the agent answered with the task still running. */
'backgroundTaskNotStopped',
'answerUnconfirmed',
'hostFault',
/** Orca stopped an agent whose start never finished. */
'hostStopped',
/** The provider is retrying a request its API refused; not a failure yet. */
'providerRetrying',
/** A child a Stop could not prove gone: its exit is unverifiable. Kept for rows hosts wrote. */
'previousExitUnverifiable',
/** The agent could not reopen the chat's saved session, so a fresh one without its memory continues it. */
'sessionNotRestored'
] as const
export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number]
export function isAgentSessionFailureKind(value: unknown): value is AgentSessionFailureKind {
return typeof value === 'string' && AGENT_SESSION_FAILURE_KINDS.some((kind) => kind === value)
}
/** Kinds only a status row reports: none is ever why a message was not sent. */
const STATUS_ROW_ONLY_FAILURE_KINDS = [
'compactionFailed',
'compactionUnconfirmed',
'cancelUnconfirmed',
'stopRefused',
'stopFailed',
'backgroundTaskNotStopped',
'answerUnconfirmed',
'providerRetrying',
'previousExitUnverifiable',
'sessionNotRestored'
] as const satisfies readonly AgentSessionFailureKind[]
/** Why a message was not sent. A new failure kind is one of these until listed above. */
export type SubmissionRejectionKind = Exclude<
AgentSessionFailureKind,
(typeof STATUS_ROW_ONLY_FAILURE_KINDS)[number]
>
export function isSubmissionRejectionKind(value: unknown): value is SubmissionRejectionKind {
return (
isAgentSessionFailureKind(value) &&
!STATUS_ROW_ONLY_FAILURE_KINDS.some((statusOnly) => statusOnly === value)
)
}
/** `person`: written by the provider for whoever reads the chat, shown inline. `log`: a stderr
* tail or exit status, shown only behind Details. */
export type ProviderDiagnosticAudience = 'person' | 'log'
export type ProviderDiagnostic = {
text: string
audience: ProviderDiagnosticAudience
}
/** Stderr can be a whole dump; the row keeps enough to act on. The same cap as the exit reason a
* lease record keeps, so a diagnostic never outgrows what the record may store. */
export const MAX_PROVIDER_DIAGNOSTIC_CHARS = 512
/** Which of Orca's checks an image failed. A new one needs a sentence before it compiles. */
export const AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS = [
'empty',
'tooLarge',
'tooMany',
'totalTooLarge',
'unsupportedType',
'notAFile',
'noSource'
] as const
export type AgentSessionAttachmentProblemReason =
(typeof AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS)[number]
export type AgentSessionAttachmentProblem = {
reason: AgentSessionAttachmentProblemReason
/** The limit it broke: bytes for `tooLarge` and `totalTooLarge`, a count for `tooMany`. */
limit?: number
}
/** What the provider said it is retrying, in its own fields. */
export type AgentSessionProviderRetry = {
/** The provider's error type, e.g. `rate_limit` or `overloaded`. */
error?: string
/** The HTTP status the request failed with. */
status?: number
/** The provider's own account of what failed, written for a person: the row's second line. */
cause?: string
/** Which retry this is, counted from 1. */
attempt?: number
/** The most retries the provider makes before it gives up. */
maxRetries?: number
}
export type AgentSessionFailureFact = {
kind: AgentSessionFailureKind
account?: AgentSessionAccountKind
/** Provider-authored only; absent whenever Orca wrote the words. */
detail?: ProviderDiagnostic
/** On `restartFailed` and `startFailed`: the refusal that kept the agent from starting. On
* `commandRefused`: the refusal that kept the command from running. */
refusal?: AgentSessionRefusalReference
/** On `attachmentInvalid`: which check the attachment failed. */
attachment?: AgentSessionAttachmentProblem
/** On `providerRetrying`: why the provider is retrying. */
retry?: AgentSessionProviderRetry
/** A safe option name from Orca's saved Arguments parser, never an error message. */
argumentProblem?: AgentSessionArgumentProblem
/** Written only where Orca proves the agent never received the message: a Stop's withdrawal is
* then owed as stopped history, and a refused carrier owes its history again. Absent means
* unknown. Host-only; clients read the kind. */
neverReachedAgent?: true
/** On `backgroundTaskNotStopped`: one named task, or every task the Stop reached. */
backgroundStop?: AgentSessionBackgroundStop
}
export type AgentSessionBackgroundStop = 'task' | 'all'
/** A fact as a row stores it: its kind may be one a newer host added, so only
* `readAgentSessionFailureFact` or the rejection classifier may place it. */
export type UnreadAgentSessionFailureFact = { kind: string }
/** A fact a rejected message may carry. */
export type SubmissionRejectionFact = AgentSessionFailureFact & { kind: SubmissionRejectionKind }
export function isSubmissionRejectionFact(
fact: AgentSessionFailureFact
): fact is SubmissionRejectionFact {
return isSubmissionRejectionKind(fact.kind)
}
/** Null for empty text, so a writer never records a detail with nothing in it. */
export function providerDiagnostic(
text: string,
audience: ProviderDiagnosticAudience
): ProviderDiagnostic | undefined {
const bounded = text.trim().slice(0, MAX_PROVIDER_DIAGNOSTIC_CHARS).trim()
return bounded ? { text: bounded, audience } : undefined
}
export function agentSessionFailureFact<TKind extends AgentSessionFailureKind>(
kind: TKind,
extra: {
account?: AgentSessionAccountKind
detail?: ProviderDiagnostic
refusal?: AgentSessionRefusalReference
attachment?: AgentSessionAttachmentProblem
retry?: AgentSessionProviderRetry
argumentProblem?: AgentSessionArgumentProblem
neverReachedAgent?: true
backgroundStop?: AgentSessionBackgroundStop
} = {}
): AgentSessionFailureFact & { kind: TKind } {
// Re-bounded here, so no writer can store more than the cap however it built the detail.
const detail = extra.detail
? providerDiagnostic(extra.detail.text, extra.detail.audience)
: undefined
return {
kind,
...(extra.account ? { account: extra.account } : {}),
...(detail ? { detail } : {}),
...(extra.refusal ? { refusal: extra.refusal } : {}),
...(extra.attachment ? { attachment: extra.attachment } : {}),
...(extra.retry ? { retry: extra.retry } : {}),
...(extra.argumentProblem ? { argumentProblem: extra.argumentProblem } : {}),
...(extra.neverReachedAgent ? { neverReachedAgent: true as const } : {}),
...(extra.backgroundStop ? { backgroundStop: extra.backgroundStop } : {})
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
export function isProviderDiagnostic(value: unknown): value is ProviderDiagnostic {
return (
isRecord(value) &&
typeof value.text === 'string' &&
(value.audience === 'person' || value.audience === 'log')
)
}
function readAttachmentProblem(value: unknown): AgentSessionAttachmentProblem | undefined {
if (!isRecord(value)) {
return undefined
}
const reason = AGENT_SESSION_ATTACHMENT_PROBLEM_REASONS.find((known) => known === value.reason)
if (!reason) {
return undefined
}
const limit = value.limit
return typeof limit === 'number' && Number.isFinite(limit) && limit > 0
? { reason, limit }
: { reason }
}
/** A retry as a reader meets it; undefined when it names no field. */
export function readProviderRetry(value: unknown): AgentSessionProviderRetry | undefined {
if (!isRecord(value)) {
return undefined
}
const error =
typeof value.error === 'string' && value.error.trim() ? value.error.trim() : undefined
const status = positiveInteger(value.status)
const cause =
typeof value.cause === 'string' ? providerDiagnostic(value.cause, 'person')?.text : undefined
const attempt = positiveInteger(value.attempt)
// A maximum bounds a retry; alone, or below the retry it bounds, it says nothing.
const max = positiveInteger(value.maxRetries)
const maxRetries = attempt && max && max >= attempt ? max : undefined
return error || status || cause || attempt
? {
...(error ? { error } : {}),
...(status ? { status } : {}),
...(cause ? { cause } : {}),
...(attempt ? { attempt } : {}),
...(maxRetries ? { maxRetries } : {})
}
: undefined
}
function positiveInteger(value: unknown): number | undefined {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined
}
/** A fact as a reader meets it. Undefined for anything this build cannot place, including a kind a
* newer host added, so the reader falls back to what it does for a row with no fact. */
export function readAgentSessionFailureFact(value: unknown): AgentSessionFailureFact | undefined {
if (!isRecord(value) || !isAgentSessionFailureKind(value.kind)) {
return undefined
}
const refusal = readAgentSessionRefusalReference(value.refusal)
const attachment = readAttachmentProblem(value.attachment)
const retry = readProviderRetry(value.retry)
const argumentProblem = readAgentSessionArgumentProblem(value.argumentProblem)
return agentSessionFailureFact(value.kind, {
...(value.account === 'managed' || value.account === 'system'
? { account: value.account }
: {}),
...(isProviderDiagnostic(value.detail) ? { detail: value.detail } : {}),
...(refusal ? { refusal } : {}),
...(attachment ? { attachment } : {}),
...(retry ? { retry } : {}),
...(argumentProblem ? { argumentProblem } : {}),
...(value.neverReachedAgent === true ? { neverReachedAgent: true as const } : {}),
...(value.backgroundStop === 'task' || value.backgroundStop === 'all'
? { backgroundStop: value.backgroundStop }
: {})
})
}
/** The fact only when this build read all of it. Anything it dropped, such as a newer refusal code
* or reason, may change the advice, so a surface that would re-word the fact shows the host's
* sentence instead. */
export function readWholeAgentSessionFailureFact(
value: unknown
): AgentSessionFailureFact | undefined {
const fact = readAgentSessionFailureFact(value)
return fact && structuralValuesEqualIgnoringUndefined(fact, value) ? fact : undefined
}
/** The provider-authored diagnostic an error carries, set only where it was composed. Follows the
* `cause` chain, since wrappers such as the acquisition errors keep the original as their cause. */
export function providerDiagnosticOf(error: unknown): ProviderDiagnostic | undefined {
return providerDiagnosticWithin(error, 0)
}
// One depth bound across `cause` and aggregated errors, so an error that contains itself ends.
function providerDiagnosticWithin(error: unknown, start: number): ProviderDiagnostic | undefined {
let current: unknown = error
for (let depth = start; depth < 6 && current instanceof Error; depth += 1) {
if ('providerDiagnostic' in current && isProviderDiagnostic(current.providerDiagnostic)) {
return current.providerDiagnostic
}
if (current instanceof AggregateError) {
for (const inner of current.errors) {
const found = providerDiagnosticWithin(inner, depth + 1)
if (found) {
return found
}
}
}
current = current.cause
}
return undefined
}
/** Attaches the provider's words to the error Orca built around them. */
export function withProviderDiagnostic<TError extends Error>(
error: TError,
diagnostic: ProviderDiagnostic | undefined
): TError {
return diagnostic ? Object.assign(error, { providerDiagnostic: diagnostic }) : error
}