mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 03:57:35 +00:00
* fix(native-chat): agents need only a minimum version for chat; newer releases and prereleases run OpenCode chats accepted only the 1.x and 2.x lines and Pi only 0.x and 1.x, every prerelease was refused, and a slow or failed --version run counted as unsupported. A new major would open as a terminal and existing chats said "Orca can't run this agent in a chat here." Each agent now lists ascending minimum versions; a version must reach the latest one on or below its own major line, compared by semver. The check answers ready, too old, unknown (slow, failed or wordless run) or missing (did not start). Only too old refuses a launch, as Codex already did, with "<Agent> X is too old for chats. Update to Y or newer." carried in a new optional refusal fact agentInstallation; Codex keeps sending codexInstallation for older clients. Codex's reader and error fold into the same check. * refactor(native-chat): a launch check declares its minimum versions; create support and the agent health snapshot run the one shared check * fix(native-chat): read an agent's version from stderr too, so Pi 0.73 is known too old instead of unreadable
217 lines
8.6 KiB
TypeScript
217 lines
8.6 KiB
TypeScript
// A chat write that did not happen, as a fact: what the person was doing, and what the host said.
|
|
// Saved with a queued message, so it holds only what stays true after a reload; the words are
|
|
// chosen from it when it is shown (`agent-session-refusal-notice.ts`).
|
|
|
|
import type { AgentSessionAccountKind } from './agent-session-availability'
|
|
import {
|
|
readAgentSessionRefusalDetails,
|
|
type AgentSessionRefusalReason
|
|
} from './agent-session-refusal-details'
|
|
import type { AgentSessionRewindReason } from './agent-session-rewind'
|
|
import type { AgentSessionArgumentProblem } from './agent-session-argument-problem'
|
|
import type { TuiAgent } from './tui-agent'
|
|
import type { CodexInstallationProblem } from './codex-cli-installation'
|
|
import type { AgentCliInstallationProblem } from './agent-cli-installation-problem'
|
|
import {
|
|
isAgentSessionWireRefusalCode,
|
|
readAgentSessionRefusalReference,
|
|
type AgentSessionOwnerVerdict,
|
|
type AgentSessionRefusalReference,
|
|
type AgentSessionWireRefusal,
|
|
type AgentSessionWireRefusalCode
|
|
} from './agent-session-wire-refusals'
|
|
|
|
/** What the person was doing, which decides what the notice says did not happen. `send` keeps
|
|
* the message behind a Retry control; `composer-send` puts it back in the composer, as the phone
|
|
* does. `read-history` is opening the chat to show its history, which writes nothing. */
|
|
export type AgentSessionWriteKind =
|
|
| 'read-history'
|
|
| 'send'
|
|
| 'composer-send'
|
|
| 'stop'
|
|
| 'stop-task'
|
|
| 'stop-tasks'
|
|
| 'answer'
|
|
| 'option'
|
|
| 'command'
|
|
/** Saving an edit of a queued message. */
|
|
| 'queued-edit'
|
|
/** A /clear or /compact: refused while the agent works, in words of its own. */
|
|
| 'clear'
|
|
| 'compact'
|
|
| 'goal'
|
|
|
|
/** The kind of write an `agentSession.*` call stands for. */
|
|
export function agentSessionWriteKindForMethod(
|
|
fingerprintMethod: string,
|
|
fields: Record<string, unknown>
|
|
): AgentSessionWriteKind {
|
|
if (fingerprintMethod === 'agentSession.send') {
|
|
return 'send'
|
|
}
|
|
if (fingerprintMethod === 'agentSession.cancel') {
|
|
// A background-task stop never asked the agent to stop.
|
|
if (fields.scope !== 'background-tasks') {
|
|
return 'stop'
|
|
}
|
|
return typeof fields.taskId === 'string' ? 'stop-task' : 'stop-tasks'
|
|
}
|
|
if (fingerprintMethod.startsWith('agentSession.respondTo')) {
|
|
return 'answer'
|
|
}
|
|
if (fingerprintMethod === 'agentSession.setOption') {
|
|
return 'option'
|
|
}
|
|
if (fingerprintMethod === 'agentSession.threadGoal') {
|
|
return 'goal'
|
|
}
|
|
if (fingerprintMethod === 'agentSession.queuedMessageUpdate') {
|
|
return 'queued-edit'
|
|
}
|
|
if (
|
|
fingerprintMethod === 'agentSession.conversationCommand' &&
|
|
(fields.command === 'clear' || fields.command === 'compact')
|
|
) {
|
|
return fields.command
|
|
}
|
|
return 'command'
|
|
}
|
|
|
|
/** What a saved refusal keeps beside its reason: facts about the refused operation that stay true
|
|
* after a reload. Never the fence, revision or resolution, which move, nor any provider text. */
|
|
type DurableRefusalFacts = {
|
|
agent_session_operation_invalid: {
|
|
rewindReason?: AgentSessionRewindReason
|
|
account?: AgentSessionAccountKind
|
|
argumentProblem?: AgentSessionArgumentProblem
|
|
agent?: TuiAgent
|
|
agentInstallation?: AgentCliInstallationProblem
|
|
codexInstallation?: CodexInstallationProblem
|
|
}
|
|
agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason }
|
|
/** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */
|
|
agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict }
|
|
}
|
|
|
|
const DURABLE_FACT_KEYS: Partial<Record<AgentSessionWireRefusalCode, readonly string[]>> = {
|
|
agent_session_operation_invalid: [
|
|
'rewindReason',
|
|
'account',
|
|
'argumentProblem',
|
|
'agent',
|
|
'agentInstallation',
|
|
'codexInstallation'
|
|
],
|
|
agent_session_operation_unknown: ['rewindReason'],
|
|
agent_session_ownership_unknown: ['ownerVerdict']
|
|
} satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] }
|
|
|
|
export type AgentSessionWriteRefusalDetails<C extends AgentSessionWireRefusalCode> = {
|
|
reason?: AgentSessionRefusalReason<C>
|
|
} & (C extends keyof DurableRefusalFacts ? DurableRefusalFacts[C] : Record<never, never>)
|
|
|
|
/** A refused write: its code, and what the host said about it that outlives the reply. */
|
|
export type AgentSessionWriteRefusal = {
|
|
[C in AgentSessionWireRefusalCode]: {
|
|
kind: 'refused'
|
|
code: C
|
|
/** Absent from older hosts and from entries saved before refusals carried a reason. */
|
|
details?: AgentSessionWriteRefusalDetails<C>
|
|
}
|
|
}[AgentSessionWireRefusalCode]
|
|
|
|
/** Why a write did not happen, or that nothing proves it did not. */
|
|
export type AgentSessionWriteFailure =
|
|
| AgentSessionWriteRefusal
|
|
/** The request failed without a refusal before the host ran it, so nothing is known about why. */
|
|
| { kind: 'failed' }
|
|
/** The request failed where the host may already have run it (a timeout, a lost connection, an
|
|
* error inside the method). */
|
|
| { kind: 'unconfirmed' }
|
|
|
|
/** The refusal as a write keeps it. Details are read against the code, so a reason or verdict
|
|
* another build added is dropped and the code's own words stand. */
|
|
function agentSessionWriteRefusal(
|
|
code: AgentSessionWireRefusalCode,
|
|
details: unknown
|
|
): AgentSessionWriteRefusal {
|
|
const durable: readonly string[] = ['reason', ...(DURABLE_FACT_KEYS[code] ?? [])]
|
|
const kept = Object.fromEntries(
|
|
Object.entries(readAgentSessionRefusalDetails(code, details) ?? {}).filter(([key]) =>
|
|
durable.includes(key)
|
|
)
|
|
)
|
|
const refusal = {
|
|
kind: 'refused',
|
|
code,
|
|
...(Object.keys(kept).length > 0 ? { details: kept } : {})
|
|
}
|
|
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `kept` holds only a reason and facts read against `code` and allowed for it above.
|
|
return refusal as AgentSessionWriteRefusal
|
|
}
|
|
|
|
export function agentSessionRefusalFailure(
|
|
refusal: Pick<AgentSessionWireRefusal, 'code' | 'details'>
|
|
): AgentSessionWriteRefusal {
|
|
// A newer host can send a code this client has never heard of; its details mean nothing here.
|
|
return isAgentSessionWireRefusalCode(refusal.code)
|
|
? agentSessionWriteRefusal(refusal.code, refusal.details)
|
|
: { kind: 'refused', code: refusal.code }
|
|
}
|
|
|
|
/** A request that threw, from the RPC error code the host answered with (undefined when none came
|
|
* back). Only a host that turned it away before running the method proves the write did not
|
|
* happen. */
|
|
export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure {
|
|
if (code === 'method_not_found' || code === 'method_not_supported') {
|
|
return { kind: 'refused', code: 'structured_agent_session_unsupported' }
|
|
}
|
|
return code === 'invalid_argument' || code === 'unauthorized'
|
|
? { kind: 'refused', code: 'agent_session_operation_invalid' }
|
|
: { kind: 'unconfirmed' }
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
/** The refusal a failed request's error carries in its data. Takes both shapes a client meets: an
|
|
* RPC call's thrown error, whose payload is on `response.error`, and the payload a stream hands
|
|
* its error callback. Undefined from an older host, or for a failure that was not a refusal. */
|
|
export function readAgentSessionErrorRefusal(
|
|
error: unknown
|
|
): AgentSessionRefusalReference | undefined {
|
|
const payload =
|
|
isRecord(error) && isRecord(error.response) && isRecord(error.response.error)
|
|
? error.response.error
|
|
: error
|
|
const data = isRecord(payload) ? payload.data : undefined
|
|
return isRecord(data) ? readAgentSessionRefusalReference(data.refusal) : undefined
|
|
}
|
|
|
|
/** What to say about a request that threw: the host's refusal when its error carried one, else
|
|
* what the RPC error code proves. Words only: whether the write may have happened stays the
|
|
* caller's own classification. */
|
|
export function agentSessionThrownFailure(
|
|
error: unknown,
|
|
rpcCode: string | undefined
|
|
): AgentSessionWriteFailure {
|
|
const refusal = readAgentSessionErrorRefusal(error)
|
|
return refusal ? agentSessionRefusalFailure(refusal) : agentSessionRpcErrorFailure(rpcCode)
|
|
}
|
|
|
|
/** A saved failure, or undefined when it is not one this build wrote. */
|
|
export function parseAgentSessionWriteFailure(
|
|
value: unknown
|
|
): AgentSessionWriteFailure | undefined {
|
|
if (typeof value !== 'object' || value === null || !('kind' in value)) {
|
|
return undefined
|
|
}
|
|
if (value.kind === 'failed') {
|
|
return { kind: 'failed' }
|
|
}
|
|
return value.kind === 'refused' && 'code' in value && isAgentSessionWireRefusalCode(value.code)
|
|
? agentSessionWriteRefusal(value.code, 'details' in value ? value.details : undefined)
|
|
: undefined
|
|
}
|