mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* 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.
353 lines
14 KiB
TypeScript
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
|
|
}
|