diff --git a/apps/hub/src/hub-error-handler.test.ts b/apps/hub/src/hub-error-handler.test.ts index 67d6d3537..ab7979b16 100644 --- a/apps/hub/src/hub-error-handler.test.ts +++ b/apps/hub/src/hub-error-handler.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { Hono } from "hono"; import { configureSync, resetSync } from "@intx/log"; +import { InferenceResolutionError } from "@corbits/folded-runs"; import { hubErrorHandler } from "./hub-error-handler"; @@ -84,4 +85,27 @@ describe("hubErrorHandler", () => { expect(records).toHaveLength(1); expect(records[0]?.properties.refId).toBe(body.error.refId); }); + + test("maps InferenceResolutionError to 422 with consumer copy, not 500", async () => { + const app = new Hono(); + app.onError(hubErrorHandler()); + app.get("/wake", () => { + throw new InferenceResolutionError( + "the woken instance", + 'No launchable inference source for model "claude-sonnet-5"', + ); + }); + + const res = await app.request("/wake"); + + expect(res.status).toBe(422); + const body = (await res.json()) as { + error: { code: string; message: string; refId: string }; + }; + expect(body.error.code).toBe("InferenceResolutionError"); + expect(body.error.message).toBe("This agent's model isn't available here."); + expect(body.error.message).not.toContain("claude-sonnet-5"); + expect(body.error.message).not.toMatch(/cannot resolve an inference/); + expect(typeof body.error.refId).toBe("string"); + }); }); diff --git a/apps/hub/src/hub-error-handler.ts b/apps/hub/src/hub-error-handler.ts index e2bc0b3bb..981fb671c 100644 --- a/apps/hub/src/hub-error-handler.ts +++ b/apps/hub/src/hub-error-handler.ts @@ -51,10 +51,9 @@ export function hubErrorHandler() { }); if (hasGuidance(err)) { - return c.json( - { error: { code: err.name, message: err.message, refId } }, - 422, - ); + const message = + err.name === "InferenceResolutionError" ? err.guidance : err.message; + return c.json({ error: { code: err.name, message, refId } }, 422); } return c.json( diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index 429f17f9f..47c8ed17f 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -261,11 +261,15 @@ export function ChatPage({ onOpenProfile={openProfile} registerComposerInsert={registerComposerInsert} settingsOpen={settingsOpen} - onSettingsOpenChange={(open, section) => { + onSettingsOpenChange={(open, section, entityId) => { if (workbenchId === null) return; navigate( open - ? workbenchSettingsPath(workbenchId, section ?? settingsSection) + ? workbenchSettingsPath( + workbenchId, + section ?? settingsSection, + entityId, + ) : workbenchPath(workbenchId), ); }} diff --git a/docs/CHAT.md b/docs/CHAT.md index f641ef8bd..38afe8fbd 100644 --- a/docs/CHAT.md +++ b/docs/CHAT.md @@ -166,6 +166,13 @@ that timeout plus a settle grace (`AGENT_TURN_STALE_MS`) on their next read or write: the room shows a failed turn instead of typing forever, and the reply path can never attribute a later reply to a dead row. +**A model that cannot resolve is a failed turn, not a 500.** Wake and +mint map an unresolvable inference source to a consumer 4xx and a +failed-turn strip ("Jimmy's model isn't available here.") with an +inline picker of tenant-available chat models and a hop into that +agent's Settings — never a raw HTTP 500 or the technical resolution +dump on the timeline. + Proved live end to end by `scripts/e2e/cl-6329-turn-swap-proof.ts`: two agents replying in one room under distinct occurrences, three rapid messages serializing into ordered turns, and a sidecar killed diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index c96ff38bd..95f264e2d 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -14,6 +14,7 @@ import { isAgentAddress } from "@corbits/chat/mentions"; import { Button, EmptyState, toast } from "@corbits/react-ui"; import { reportError } from "@corbits/error-sink"; +import { getResolvedCatalog } from "@corbits/inference-settings"; import { CaretDown, ChatCircle, @@ -34,6 +35,9 @@ import { inviteAgent, listWorkbenches, listInvitableDefinitions, + listWorkbenchAgents, + addAgentCapability, + refreshWorkbenchAgent, pingWorkbenchPresence, pinMessage, toggleReaction, @@ -57,7 +61,7 @@ import { import type { BringInListFailure, BringInMember } from "./mentions"; import { PinnedStrip } from "./pinned-strip"; import { SLASH_COMMANDS } from "./slash-commands"; - +import { failedTurnModelChoices } from "./failed-turn-models"; import { CHAT_STRINGS } from "./strings"; import { displayWorkbenchTitle } from "./workbench-display-title"; import { @@ -73,6 +77,7 @@ import { messageDomId, messageText, } from "./timeline"; +import type { FailedTurnRecovery } from "./timeline"; import { NoUsableModelBanner } from "./no-usable-model-banner"; import { ResumeFailedBanner } from "./resume-failed-banner"; import type { @@ -618,6 +623,7 @@ function ChatWorkspaceInner({ readonly onSettingsOpenChange?: ( open: boolean, section?: WorkbenchSettingsSectionId, + entityId?: string, ) => void; /** Which workbench settings tab is active while the surface is open — * host-controlled the same way `settingsOpen` is, driven from the URL @@ -997,8 +1003,9 @@ function ChatWorkspaceInner({ * that lands is always the one the caller meant to open. */ function openWorkbenchSettings( section: WorkbenchSettingsSectionId = "general", + entityId?: string, ) { - onSettingsOpenChange?.(true, section); + onSettingsOpenChange?.(true, section, entityId); } async function handleInvite(definitionId: string) { @@ -1125,6 +1132,51 @@ function ChatWorkspaceInner({ [handleSend], ); + const catalogQuery = useQuery({ + queryKey: ["tenant", tenantId, "resolved-catalog"], + queryFn: () => getResolvedCatalog(tenantId), + }); + const workbenchAgentsQuery = useQuery({ + queryKey: [ + "tenant", + tenantId, + "chat", + "workbench-agents", + activeWorkbenchId, + ], + queryFn: () => + activeWorkbenchId !== null + ? listWorkbenchAgents(tenantId, activeWorkbenchId) + : Promise.resolve([]), + enabled: activeWorkbenchId !== null, + }); + const failedTurnRecovery = useMemo((): FailedTurnRecovery => { + const definitionIdByAddress: Record = {}; + for (const agent of workbenchAgentsQuery.data ?? []) { + definitionIdByAddress[agent.address] = agent.definitionId; + } + return { + models: failedTurnModelChoices(catalogQuery.data ?? []), + definitionIdByAddress, + onApplyModel: async ({ definitionId, address, canonicalName }) => { + if (activeWorkbenchId === null) return; + await addAgentCapability(tenantId, definitionId, { + kind: "model", + canonicalName, + }); + await refreshWorkbenchAgent(tenantId, activeWorkbenchId, address); + }, + onOpenAgentSettings: (definitionId) => { + openWorkbenchSettings("agents", definitionId); + }, + }; + }, [ + catalogQuery.data, + workbenchAgentsQuery.data, + tenantId, + activeWorkbenchId, + ]); + // The mention popover's "Bring in…" group: only a `workbench` grows its // participants after creation (a chat's counterpart is fixed at // creation — see `workbench-service.ts`'s `joinHumanParticipant`/ @@ -1605,6 +1657,7 @@ function ChatWorkspaceInner({ reactionActions={reactionActions} pinActions={pinActions} onRetryFailedTurn={handleRetryFailedTurn} + failedTurnRecovery={failedTurnRecovery} pendingActions={{ onRetry: retryPendingSend, onDiscard: discardPendingSend, @@ -1753,6 +1806,7 @@ export function ChatWorkspace({ readonly onSettingsOpenChange?: ( open: boolean, section?: WorkbenchSettingsSectionId, + entityId?: string, ) => void; /** Which workbench settings tab is active — host-controlled from the URL * (`/w/:id/settings/:section`). */ diff --git a/packages/chat-ui/src/failed-turn-models.ts b/packages/chat-ui/src/failed-turn-models.ts new file mode 100644 index 000000000..6b5f686f4 --- /dev/null +++ b/packages/chat-ui/src/failed-turn-models.ts @@ -0,0 +1,40 @@ +import { + chatCapableModels, + providerDisplayName, +} from "@corbits/inference-settings"; +import type { ModelInfo } from "@corbits/inference-settings"; + +import { CHAT_STRINGS } from "./strings"; + +export const FAILED_TURN_MODEL_PICKER_LIMIT = 4; + +export type FailedTurnModelChoice = { + readonly canonicalName: string; + readonly label: string; +}; + +/** + * Two-to-four tenant-available chat models for the failed-turn strip + * picker — the same connected, chat-capable filter Settings' agent + * model select uses, capped so the strip stays a quiet inline row. + */ +export function failedTurnModelChoices( + models: readonly ModelInfo[], + limit = FAILED_TURN_MODEL_PICKER_LIMIT, +): readonly FailedTurnModelChoice[] { + return chatCapableModels(models) + .filter((model) => model.offerings.length > 0) + .slice(0, limit) + .map((model) => { + const topOffering = model.offerings[0]; + return { + canonicalName: model.canonicalName, + label: CHAT_STRINGS.workbenchSettingsAgentDetailModelOption( + model.displayName ?? model.canonicalName, + topOffering === undefined + ? "" + : providerDisplayName(topOffering.providerName), + ), + }; + }); +} diff --git a/packages/chat-ui/src/strings.ts b/packages/chat-ui/src/strings.ts index 0bbf02a78..4a9790311 100644 --- a/packages/chat-ui/src/strings.ts +++ b/packages/chat-ui/src/strings.ts @@ -319,6 +319,10 @@ export const CHAT_STRINGS = { `Couldn't resume the running reply — try again. (ref ${refId})`, resumeFailedRetryAction: "Retry", turnFailedTitle: (sender: string) => `${sender} didn't reply`, + turnFailedModelUnavailable: (sender: string) => + `${sender}'s model isn't available here.`, + turnFailedPickModel: "Pick a model", + turnFailedMoreInSettings: "More in Settings", turnCancelledTitle: (sender: string) => `You stopped ${sender}'s turn`, turnFailedSub: "No reply arrived — the agent may be unavailable.", noUsableModelBannerText: diff --git a/packages/chat-ui/src/styles.css b/packages/chat-ui/src/styles.css index 14f493d54..8ed8940b0 100644 --- a/packages/chat-ui/src/styles.css +++ b/packages/chat-ui/src/styles.css @@ -3965,6 +3965,28 @@ font-size: 0.75rem; } +.chat-turn-failed-models { + height: auto; + max-width: 12rem; + padding: 0.05rem 0.45rem; + font-size: 0.75rem; +} + +.chat-turn-failed-settings { + border: 0; + background: transparent; + padding: 0; + font-size: 0.75rem; + color: var(--muted-foreground); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; +} + +.chat-turn-failed-settings:hover { + color: var(--foreground); +} + .chat-turn-failed-disclosure { border: 0; background: transparent; diff --git a/packages/chat-ui/src/timeline.tsx b/packages/chat-ui/src/timeline.tsx index acbecc654..6b3eadfea 100644 --- a/packages/chat-ui/src/timeline.tsx +++ b/packages/chat-ui/src/timeline.tsx @@ -68,6 +68,7 @@ import type { ProfileSubject } from "./profile-subject"; import { profileSubjectFromParticipant } from "./profile-subject"; import { formatRelativeActivity } from "./relative-time"; import { CHAT_STRINGS } from "./strings"; +import type { FailedTurnModelChoice } from "./failed-turn-models"; /** * Which affordance a message's thread row offers: @@ -716,6 +717,22 @@ function EventLine({ ); } +/** + * Host-supplied recovery for a model-unavailable failed turn: tenant + * chat models for the inline picker, the workbench's agent definition + * ids, apply (write the capability + refresh) and a real Settings hop. + */ +export type FailedTurnRecovery = { + readonly models: readonly FailedTurnModelChoice[]; + readonly definitionIdByAddress: Readonly>; + readonly onApplyModel: (input: { + readonly definitionId: string; + readonly address: string; + readonly canonicalName: string; + }) => void | Promise; + readonly onOpenAgentSettings: (definitionId: string) => void; +}; + /** * The general chat timeline's failed-turn treatment (CL-6332, redesigned * CL-6376 to match the timeline's own idiom rather than borrow @@ -727,7 +744,9 @@ function EventLine({ * under the same left gutter every message bubble sits under: muted * danger-tinted copy, a small ghost Retry button, and "What happened" as * a subtle inline disclosure rather than a second button competing for - * attention. `onRetry`/`onWhatHappened` are the host's own actions; a + * attention. A model-unavailable notice (`turnFailedReason`) replaces + * Retry with an inline model picker and a real Settings hop. + * `onRetry`/`onWhatHappened` are the host's own actions; a * host that wires neither still gets the row, just with inert controls — * matching the fixed-disabled framing every other undefined-action port * in this file already falls back to. @@ -736,8 +755,10 @@ function FailedTurnStrip({ item, detailText, retryText, + modelUnavailable, participants, currentUser, + failedTurnRecovery, onRetryFailedTurn, onWhatHappenedFailedTurn, }: { @@ -754,8 +775,10 @@ function FailedTurnStrip({ * `findRetryText` — handed to `onRetryFailedTurn` so Retry has * something to resend rather than nothing. */ readonly retryText?: string; + readonly modelUnavailable?: boolean; readonly participants: readonly ParticipantRecord[]; readonly currentUser: CurrentUser | undefined; + readonly failedTurnRecovery?: FailedTurnRecovery; readonly onRetryFailedTurn?: ( item: TimelineMessageItem, retryText?: string, @@ -769,6 +792,62 @@ function FailedTurnStrip({ // Guards the resend itself against a double-click firing two sends — // not composer state, since Retry never touches the composer any more. const [retrying, setRetrying] = useState(false); + const definitionId = + failedTurnRecovery?.definitionIdByAddress[item.sender.address]; + + if (modelUnavailable === true) { + return ( +
+ + {CHAT_STRINGS.turnFailedModelUnavailable(sender)} + + {failedTurnRecovery !== undefined && + definitionId !== undefined && + failedTurnRecovery.models.length > 0 ? ( + + ) : null} + {failedTurnRecovery !== undefined && definitionId !== undefined ? ( + + ) : null} +
+ ); + } + return (
@@ -1503,6 +1582,7 @@ function MessagePartsInner({ reactionActions, pinActions, pendingActions, + failedTurnRecovery, onRetryFailedTurn, onWhatHappenedFailedTurn, }: { @@ -1559,6 +1639,7 @@ function MessagePartsInner({ retryText?: string, ) => void | Promise; readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; + readonly failedTurnRecovery?: FailedTurnRecovery; }) { // A message this reader's own composer submitted and the server hasn't // issued an id for yet (see `TimelineMessageItem.pendingStatus`) offers @@ -1632,9 +1713,15 @@ function MessagePartsInner({ key={key} item={item} detailText={part.text} + modelUnavailable={ + part.turnFailedReason === "model_unavailable" + } participants={participants} currentUser={currentUser} {...(retryText !== undefined ? { retryText } : {})} + {...(failedTurnRecovery !== undefined + ? { failedTurnRecovery } + : {})} {...(onRetryFailedTurn !== undefined ? { onRetryFailedTurn } : {})} @@ -1926,6 +2013,7 @@ export function WorkbenchTimeline({ reactionActions, pinActions, pendingActions, + failedTurnRecovery, onRetryFailedTurn, onWhatHappenedFailedTurn, scrollRestore, @@ -1999,6 +2087,8 @@ export function WorkbenchTimeline({ /** The failed-turn strip's "what happened" action — same undefined * contract as `onRetryFailedTurn`. */ readonly onWhatHappenedFailedTurn?: (item: TimelineMessageItem) => void; + /** Inline model picker + Settings hop for a model-unavailable notice. */ + readonly failedTurnRecovery?: FailedTurnRecovery; /** The scroll position to restore on mount — the host's own memory of * where this workbench's reader last was, captured via `onScrollSnapshot` * the last time this component unmounted (e.g. opening Settings, which @@ -2197,6 +2287,9 @@ export function WorkbenchTimeline({ {...(onWhatHappenedFailedTurn !== undefined ? { onWhatHappenedFailedTurn } : {})} + {...(failedTurnRecovery !== undefined + ? { failedTurnRecovery } + : {})} /> ); })} diff --git a/packages/chat-ui/test/failed-turn-strip.test.tsx b/packages/chat-ui/test/failed-turn-strip.test.tsx index 5c2193274..e6d869ca5 100644 --- a/packages/chat-ui/test/failed-turn-strip.test.tsx +++ b/packages/chat-ui/test/failed-turn-strip.test.tsx @@ -323,4 +323,96 @@ describe("the failed-turn notice renders through PrFailedTurnStrip", () => { expect(retryButton().disabled).toBe(false); }); + + test("a model-unavailable notice shows named copy, a picker, and a real Settings hop", async () => { + const applied: string[] = []; + const retried: (string | undefined)[] = []; + const opened: string[] = []; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + await act(async () => { + root?.render( + { + applied.push(canonicalName); + }, + onOpenAgentSettings: (definitionId) => { + opened.push(definitionId); + }, + }} + onRetryFailedTurn={(_item, retryText) => { + retried.push(retryText); + }} + />, + ); + }); + + const strip = container.querySelector(".chat-turn-failed"); + expect(strip?.textContent).toContain("Jimmy's model isn't available here."); + expect(strip?.textContent).not.toContain("didn't reply"); + expect(strip?.textContent).not.toMatch(/HTTP/); + expect(strip?.textContent).not.toContain("wfd_echo"); + expect(container.querySelector(".chat-turn-failed-retry")).toBeNull(); + + const select = container.querySelector( + ".chat-turn-failed-models", + ) as HTMLSelectElement; + expect(select).not.toBeNull(); + expect([...select.options].map((option) => option.textContent)).toEqual([ + "Pick a model", + "Sonnet", + "GPT-4.1", + "Gemini", + ]); + + await act(async () => { + select.value = "openai/gpt-4.1"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(applied).toEqual(["openai/gpt-4.1"]); + expect(retried).toEqual(["hi @echo"]); + + act(() => { + ( + container?.querySelector( + ".chat-turn-failed-settings", + ) as HTMLButtonElement + ).click(); + }); + expect(opened).toEqual(["wfd_echo"]); + }); }); diff --git a/packages/chat/src/model-unavailable.test.ts b/packages/chat/src/model-unavailable.test.ts new file mode 100644 index 000000000..f7f81d8ad --- /dev/null +++ b/packages/chat/src/model-unavailable.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { InferenceResolutionError } from "@corbits/folded-runs"; + +import { + consumerTurnError, + isModelUnavailableCause, + MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + ModelUnavailableError, + wrapWakeInferenceError, +} from "./model-unavailable"; + +describe("wrapWakeInferenceError", () => { + test("wraps InferenceResolutionError as ModelUnavailableError with consumer copy", () => { + const cause = new InferenceResolutionError( + "the woken instance", + 'No launchable inference source for model "claude-sonnet-5"', + ); + const wrapped = wrapWakeInferenceError(cause); + expect(wrapped).toBeInstanceOf(ModelUnavailableError); + expect((wrapped as Error).message).toBe(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); + expect((wrapped as Error).message).not.toContain("claude-sonnet-5"); + expect((wrapped as Error).message).not.toMatch(/HTTP/); + expect((wrapped as Error).cause).toBe(cause); + }); + + test("leaves unrelated errors alone", () => { + const err = new Error("the agent is unreachable"); + expect(wrapWakeInferenceError(err)).toBe(err); + }); +}); + +describe("consumerTurnError", () => { + test("never writes the raw resolution dump onto a turn", () => { + const cause = new InferenceResolutionError( + "the woken instance", + 'No launchable inference source for model "claude-sonnet-5"', + ); + expect(consumerTurnError(cause)).toBe(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); + expect(consumerTurnError(new ModelUnavailableError(cause))).toBe( + MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + ); + expect(consumerTurnError(new Error("sidecar unavailable"))).toBe( + "sidecar unavailable", + ); + }); +}); + +describe("isModelUnavailableCause", () => { + test("recognizes the wrapped and unwrapped resolution failures", () => { + const cause = new InferenceResolutionError("launch", "no catalog source"); + expect(isModelUnavailableCause(cause)).toBe(true); + expect(isModelUnavailableCause(new ModelUnavailableError(cause))).toBe( + true, + ); + expect(isModelUnavailableCause(new Error("sidecar unavailable"))).toBe( + false, + ); + }); +}); diff --git a/packages/chat/src/model-unavailable.ts b/packages/chat/src/model-unavailable.ts new file mode 100644 index 000000000..e69024a43 --- /dev/null +++ b/packages/chat/src/model-unavailable.ts @@ -0,0 +1,49 @@ +// Named consumer error for a wake/launch whose tenant catalog cannot +// resolve an inference source. `InferenceResolutionError`'s own +// `message` is a log string (launch label, resolution reason, seed +// instructions) and must never land on `agent_turns.error`, a timeline +// notice, or an HTTP 500 body. +import { InferenceResolutionError } from "@corbits/folded-runs"; + +export const MODEL_UNAVAILABLE_CONSUMER_MESSAGE = + "This agent's model isn't available here."; + +/** + * Thrown at wake (`wakeByAddress` / `ensureAwake`) in place of a raw + * `InferenceResolutionError` so hub `onError` can map it to a 4xx via + * `guidance`, and so `dispatchTurn` can close the turn with this same + * sentence instead of the technical resolution dump. + */ +export class ModelUnavailableError extends Error { + readonly guidance: string; + constructor(cause?: unknown) { + super(MODEL_UNAVAILABLE_CONSUMER_MESSAGE); + this.name = "ModelUnavailableError"; + this.guidance = MODEL_UNAVAILABLE_CONSUMER_MESSAGE; + if (cause !== undefined) { + this.cause = cause; + } + } +} + +export function isModelUnavailableCause(cause: unknown): boolean { + if (cause instanceof ModelUnavailableError) return true; + if (cause instanceof InferenceResolutionError) return true; + return ( + cause instanceof Error && cause.cause instanceof InferenceResolutionError + ); +} + +/** Turn-row `error` text: never a raw `InferenceResolutionError.message`. */ +export function consumerTurnError(err: unknown): string { + if (isModelUnavailableCause(err)) return MODEL_UNAVAILABLE_CONSUMER_MESSAGE; + return err instanceof Error ? err.message : String(err); +} + +/** Wake/sendMail: wrap catalog resolution failure as the named consumer error. */ +export function wrapWakeInferenceError(error: unknown): unknown { + if (error instanceof InferenceResolutionError) { + return new ModelUnavailableError(error); + } + return error; +} diff --git a/packages/chat/src/parts.ts b/packages/chat/src/parts.ts index 0c428784b..4911dd575 100644 --- a/packages/chat/src/parts.ts +++ b/packages/chat/src/parts.ts @@ -14,6 +14,11 @@ export const TextPart = type({ * (`PrFailedTurnStrip`) instead of a plain bubble. Absent on every * other text part. */ "turnFailed?": "boolean", + /** Set with `turnFailed` when the cause is a missing/unresolvable + * model (`InferenceResolutionError`) — the failed-turn strip renders + * named recovery (picker + Settings hop) instead of Retry. Absent on + * every other text part. */ + "turnFailedReason?": "'model_unavailable'", /** Set only on the cancelled-turn notice `postCancelledNotice` * (`./workbench-service.ts`) posts in the cancelled agent's own voice * (CL-7201) — distinct from `turnFailed`: a user cancelling a turn is diff --git a/packages/chat/src/platform-adapter.ts b/packages/chat/src/platform-adapter.ts index 5c47fbbf4..e59b1db7e 100644 --- a/packages/chat/src/platform-adapter.ts +++ b/packages/chat/src/platform-adapter.ts @@ -59,6 +59,7 @@ import { extractPartByPath } from "@intx/mime"; import { workbenchLaunch } from "./schema"; import { isWorkbenchHostDefinitionName } from "./workbench-host-naming"; import { withTimeout } from "./with-timeout"; +import { wrapWakeInferenceError } from "./model-unavailable"; import type { EventCollectorRegistry, SidecarRouter } from "@intx/hub-sessions"; import type { InferencePreference } from "@intx/agent"; import { formatRunAddress } from "@intx/types"; @@ -645,31 +646,35 @@ export function createHubChatPlatform( `No run found for address "${address}" (binding names run "${binding.currentRunId}")`, ); } - if (await isBeyondWake(deps.db, live.run)) { - await relaunchTerminalRun(live); - return; - } - if (isRoutable(live.run.address)) { - await reconcileDriftedRun(address); - return; - } + try { + if (await isBeyondWake(deps.db, live.run)) { + await relaunchTerminalRun(live); + return; + } + if (isRoutable(live.run.address)) { + await reconcileDriftedRun(address); + return; + } - const wakeParams = { - tenantId: binding.tenantId, - instanceId: live.run.id, - triggerAddress: live.run.address, - principalId: live.run.principalId, - foldedBody: binding.foldedBody, - }; - const deployed = await wakeFoldedRun(foldedRunsDeps, { - ...wakeParams, - ...(await deployShapeFor(binding)), - }); - await recordSourcesDigest( - deps.db, - binding.stableId, - deployed.sourcesDigest, - ); + const wakeParams = { + tenantId: binding.tenantId, + instanceId: live.run.id, + triggerAddress: live.run.address, + principalId: live.run.principalId, + foldedBody: binding.foldedBody, + }; + const deployed = await wakeFoldedRun(foldedRunsDeps, { + ...wakeParams, + ...(await deployShapeFor(binding)), + }); + await recordSourcesDigest( + deps.db, + binding.stableId, + deployed.sourcesDigest, + ); + } catch (error) { + throw wrapWakeInferenceError(error); + } } /** @@ -1194,7 +1199,11 @@ export function createHubChatPlatform( // reach `wakeByAddress`'s drift check above through that branch. // Run it unconditionally so every send through this choke point // — not only the ones that needed waking — reconciles staleness. - await reconcileDriftedRun(liveAddress); + try { + await reconcileDriftedRun(liveAddress); + } catch (error) { + throw wrapWakeInferenceError(error); + } const delivery = await requireLive(input.workbenchId); const deliveryAddress = delivery.binding.liveAddress; // Tracking here (not only at launch) brings instances that were @@ -1333,19 +1342,23 @@ export function createHubChatPlatform( if (binding === undefined) { throw new Error(`No workbench_launch binding for address "${address}"`); } - if (lifecycle !== undefined) { - await lifecycle.ensureAwake(binding.liveAddress); - // CL-6588: see the matching note in `sendMail` — routability - // alone is what `lifecycle.ensureAwake` checks, so an - // already-routable-but-stale run needs this run unconditionally. - await reconcileDriftedRun(binding.liveAddress); - return; - } - if (isRoutable(binding.liveAddress)) { - await reconcileDriftedRun(binding.liveAddress); - return; + try { + if (lifecycle !== undefined) { + await lifecycle.ensureAwake(binding.liveAddress); + // CL-6588: see the matching note in `sendMail` — routability + // alone is what `lifecycle.ensureAwake` checks, so an + // already-routable-but-stale run needs this run unconditionally. + await reconcileDriftedRun(binding.liveAddress); + return; + } + if (isRoutable(binding.liveAddress)) { + await reconcileDriftedRun(binding.liveAddress); + return; + } + await wakeByAddressBounded(binding.liveAddress); + } catch (error) { + throw wrapWakeInferenceError(error); } - await wakeByAddressBounded(binding.liveAddress); }, }; diff --git a/packages/chat/src/routes.ts b/packages/chat/src/routes.ts index b67c15d17..749d06686 100644 --- a/packages/chat/src/routes.ts +++ b/packages/chat/src/routes.ts @@ -119,6 +119,7 @@ import { monogramFromName } from "./workbench-share"; import type { FederationTrustStore } from "./federation-trust"; import type { InvitableDefinition as InvitableDefinitionRecord } from "./platform-port"; import { isAgentAddress } from "./mentions"; +import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "./model-unavailable"; export type { WorkbenchEvents, @@ -1195,6 +1196,15 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { if (err instanceof DefinitionProjectionMissingError) { return c.json(ErrorEnvelope("not_launchable", err.guidance), 409); } + if (err instanceof InferenceResolutionError) { + return c.json( + ErrorEnvelope( + "not_launchable", + MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + ), + 409, + ); + } throw err; } @@ -1970,7 +1980,10 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } catch (err) { if (err instanceof InferenceResolutionError) { return c.json( - ErrorEnvelope("not_launchable", err.resolutionMessage), + ErrorEnvelope( + "not_launchable", + MODEL_UNAVAILABLE_CONSUMER_MESSAGE, + ), 409, ); } @@ -2832,7 +2845,7 @@ export function createChatRoutes(deps: CreateChatRoutesDeps): Hono { } catch (err) { if (err instanceof InferenceResolutionError) { return c.json( - ErrorEnvelope("not_launchable", err.resolutionMessage), + ErrorEnvelope("not_launchable", MODEL_UNAVAILABLE_CONSUMER_MESSAGE), 409, ); } diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index 7d47c236d..9bcb010ab 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -9,9 +9,13 @@ import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { reportError } from "@corbits/error-sink"; -import { InferenceResolutionError } from "@corbits/folded-runs"; import { encodeParts } from "./codec"; import type { Part as PartType } from "./parts"; +import { + consumerTurnError, + isModelUnavailableCause, + MODEL_UNAVAILABLE_CONSUMER_MESSAGE, +} from "./model-unavailable"; import { localPartOf } from "./agent-address"; import { deriveDisplayName } from "./display-name"; import { assertNoLeakedInternalId } from "./id-leak-guard"; @@ -1973,7 +1977,7 @@ export async function dispatchTurn( tenantId: input.tenantId, turnId: turn.id, status: "failed", - error: err instanceof Error ? err.message : String(err), + error: consumerTurnError(err), }); } throw err; @@ -1987,14 +1991,16 @@ const CREDENTIAL_UNDELIVERED_NOTICE = "Settings, then I'll pick this up."; const RETRYABLE_UNDELIVERED_NOTICE = "I didn't get that one — send it again and I'll pick it up."; +const MODEL_UNAVAILABLE_UNDELIVERED_NOTICE = MODEL_UNAVAILABLE_CONSUMER_MESSAGE; /** * Whether a dispatch failure is a credential/inference-resolution * problem resending can never fix — as opposed to a genuinely transient * failure (sidecar hiccup, momentary network blip) where "send it again" - * is honest advice. `InferenceResolutionError` (`@corbits/folded-runs`) - * is the launch-time case: the agent's definition has no resolvable - * inference source at all. A dispatch failure whose own status/code + * is honest advice. `InferenceResolutionError` / `ModelUnavailableError` + * is the launch/wake case: the agent's definition has no resolvable + * inference source at all — that gets its own model-unavailable notice, + * not the credential-key copy. A dispatch failure whose own status/code * marks it a 401 `credential_failure` is the runtime case: a source * resolved, but the credential itself was rejected. Every other cause — * unclassified, or missing that shape entirely — is treated as @@ -2003,7 +2009,7 @@ const RETRYABLE_UNDELIVERED_NOTICE = * silence (here, the generic notice) over a wrong attribution. */ function isCredentialDispatchFailure(cause: unknown): boolean { - if (cause instanceof InferenceResolutionError) return true; + if (isModelUnavailableCause(cause)) return false; if (cause !== null && typeof cause === "object") { const status = (cause as { status?: unknown; statusCode?: unknown }).status; const statusCode = (cause as { statusCode?: unknown }).statusCode; @@ -2042,9 +2048,12 @@ async function postUndeliveredNotice( }, ): Promise { try { - const notice = isCredentialDispatchFailure(input.cause) - ? CREDENTIAL_UNDELIVERED_NOTICE - : RETRYABLE_UNDELIVERED_NOTICE; + const modelUnavailable = isModelUnavailableCause(input.cause); + const notice = modelUnavailable + ? MODEL_UNAVAILABLE_UNDELIVERED_NOTICE + : isCredentialDispatchFailure(input.cause) + ? CREDENTIAL_UNDELIVERED_NOTICE + : RETRYABLE_UNDELIVERED_NOTICE; await postRoomMessage(deps, { tenantId: input.tenantId, workbenchId: input.workbenchId, @@ -2055,6 +2064,9 @@ async function postUndeliveredNotice( kind: "text", text: `${notice} (ref ${input.refId})`, turnFailed: true, + ...(modelUnavailable + ? { turnFailedReason: "model_unavailable" as const } + : {}), }, ], }); diff --git a/packages/chat/src/workflow-participant-routes.ts b/packages/chat/src/workflow-participant-routes.ts index 998eebb08..c432af461 100644 --- a/packages/chat/src/workflow-participant-routes.ts +++ b/packages/chat/src/workflow-participant-routes.ts @@ -45,7 +45,10 @@ // invite into a workbench it is not itself in. import { Hono } from "hono"; import { type } from "arktype"; -import { DefinitionProjectionMissingError } from "@corbits/folded-runs"; +import { + DefinitionProjectionMissingError, + InferenceResolutionError, +} from "@corbits/folded-runs"; import { KindIsChatError, @@ -63,6 +66,7 @@ import { pendingConnectionsOf, } from "./connect-pending"; import type { WorkbenchTenancyStore } from "./workbench-tenancy"; +import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "./model-unavailable"; function errorEnvelope(code: string, message: string) { return { error: { code, message } }; @@ -226,6 +230,12 @@ export function createWorkflowParticipantRoutes( if (err instanceof DefinitionProjectionMissingError) { return c.json(errorEnvelope("not_launchable", err.guidance), 409); } + if (err instanceof InferenceResolutionError) { + return c.json( + errorEnvelope("not_launchable", MODEL_UNAVAILABLE_CONSUMER_MESSAGE), + 409, + ); + } if (err instanceof KindIsChatError) { return c.json(errorEnvelope(err.code, err.message), 409); } @@ -322,6 +332,12 @@ export function createWorkflowParticipantRoutes( if (err instanceof DefinitionProjectionMissingError) { return c.json(errorEnvelope("not_launchable", err.guidance), 409); } + if (err instanceof InferenceResolutionError) { + return c.json( + errorEnvelope("not_launchable", MODEL_UNAVAILABLE_CONSUMER_MESSAGE), + 409, + ); + } if (err instanceof KindIsChatError) { return c.json(errorEnvelope(err.code, err.message), 409); } diff --git a/packages/chat/test/agent-turn-dispatch.test.ts b/packages/chat/test/agent-turn-dispatch.test.ts index 7e5b6195c..c2dfae8ba 100644 --- a/packages/chat/test/agent-turn-dispatch.test.ts +++ b/packages/chat/test/agent-turn-dispatch.test.ts @@ -5,6 +5,8 @@ // rather than a race. import { describe, expect, test } from "bun:test"; +import { InferenceResolutionError } from "@corbits/folded-runs"; + import { createInMemoryAgentTurnStore } from "../src/agent-turns"; import { createChatRoutes } from "../src/routes"; import { @@ -135,6 +137,44 @@ describe("dispatchTurn's turn projection", () => { }); expect(turns[0]?.endedAt).not.toBeNull(); }); + + test("an InferenceResolutionError closes the turn with consumer copy, not the raw dump", async () => { + const platform = fakePlatform({ + invitable: [{ id: "wfd_echo", name: "echo" }], + }); + let refuse = false; + const refusing = { + ...platform, + sendMail: async (input: Parameters[0]) => { + if (refuse) { + throw new InferenceResolutionError( + "the woken instance", + 'No launchable inference source for model "claude-sonnet-5"', + ); + } + return platform.sendMail(input); + }, + }; + const { app, workbenchId, agentTurns } = await roomWithAgent({ + platform: refusing, + }); + + refuse = true; + const response = await sendText(app, workbenchId, "hello"); + expect(response.status).toBe(201); + + const turns = await agentTurns.listTurns({ + tenantId: TENANT.id, + workbenchId, + }); + expect(turns).toHaveLength(1); + expect(turns[0]).toMatchObject({ + status: "failed", + error: "This agent's model isn't available here.", + }); + expect(turns[0]?.error).not.toContain("claude-sonnet-5"); + expect(turns[0]?.error).not.toContain("cannot resolve an inference"); + }); }); // CL-6670: overlapping turns in one room must never silently drop a diff --git a/packages/chat/test/parts.test.ts b/packages/chat/test/parts.test.ts index 6f3728666..64f6ec35d 100644 --- a/packages/chat/test/parts.test.ts +++ b/packages/chat/test/parts.test.ts @@ -17,6 +17,16 @@ describe("Part schemas", () => { expect(result instanceof type.errors).toBe(false); }); + test("TextPart accepts turnFailedReason on a failed-turn notice", () => { + const result = TextPart({ + kind: "text", + text: "This agent's model isn't available here.", + turnFailed: true, + turnFailedReason: "model_unavailable", + }); + expect(result instanceof type.errors).toBe(false); + }); + test("ReasoningPart accepts a valid reasoning part", () => { const result = ReasoningPart({ kind: "reasoning", text: "thinking..." }); expect(result instanceof type.errors).toBe(false); diff --git a/packages/chat/test/platform-adapter.test.ts b/packages/chat/test/platform-adapter.test.ts index f99c00053..d247b503f 100644 --- a/packages/chat/test/platform-adapter.test.ts +++ b/packages/chat/test/platform-adapter.test.ts @@ -48,6 +48,7 @@ import { import { SessionLaunchError } from "@intx/hub-sessions"; import type { EventCollectorRegistry, SidecarRouter } from "@intx/hub-sessions"; import type { DefinitionSourceResolution } from "@intx/hub-api"; +import { MODEL_UNAVAILABLE_CONSUMER_MESSAGE } from "../src/model-unavailable"; const actualHubApi = await import("@intx/hub-api"); @@ -1593,7 +1594,7 @@ describe("createHubChatPlatform", () => { definitionId: "wfd_echo", }); await expect(platform.ensureAwake(launched.address)).rejects.toThrow( - /seed a tenant catalog source/, + MODEL_UNAVAILABLE_CONSUMER_MESSAGE, ); }); @@ -1714,7 +1715,7 @@ describe("createHubChatPlatform", () => { definitionId: "wfd_echo", }); await expect(platform.ensureAwake(launched.address)).rejects.toThrow( - /seed a tenant catalog source/, + MODEL_UNAVAILABLE_CONSUMER_MESSAGE, ); expect(resolveDefinitionSourcesCalls).toHaveLength(1); diff --git a/packages/chat/test/routes.test.ts b/packages/chat/test/routes.test.ts index 98b39c50b..32e982212 100644 --- a/packages/chat/test/routes.test.ts +++ b/packages/chat/test/routes.test.ts @@ -368,7 +368,16 @@ describe("POST /workbenches", () => { body: JSON.stringify({ kind: "chat", definitionId: "wfd_echo" }), }); - expect(response.status).toBe(500); + expect(response.status).toBe(409); + const errorBody = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(errorBody.error.code).toBe("not_launchable"); + expect(errorBody.error.message).toBe( + "This agent's model isn't available here.", + ); + expect(errorBody.error.message).not.toMatch(/cannot resolve an inference/); + expect(errorBody.error.message).not.toMatch(/HTTP/); const tenancy = deps.tenancy as ReturnType< typeof createInMemoryWorkbenchTenancyStore @@ -966,7 +975,12 @@ describe("POST /workbenches/:id/invite", () => { }; expect(errorBody.error.code).toBe("not_launchable"); expect(errorBody.error.message).toBe( - "No launchable inference source for that definition", + "This agent's model isn't available here.", + ); + expect(errorBody.error.message).not.toMatch(/cannot resolve an inference/); + expect(errorBody.error.message).not.toMatch(/HTTP/); + expect(errorBody.error.message).not.toMatch( + /No launchable inference source/, ); }); diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index f516c55ab..a0446e889 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -6,7 +6,7 @@ import { describe, expect, test } from "bun:test"; import { InferenceResolutionError } from "@corbits/folded-runs"; import { createChatRoutes } from "../src/routes"; import { decodeParts } from "../src/codec"; -import type { Part } from "../src/parts"; +import type { Part, TextPart } from "../src/parts"; import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { AgentUnreachableError } from "../src/platform-port"; import { @@ -291,7 +291,9 @@ describe("message fan-out", () => { // takes: `InferenceResolutionError` (launch-time, no resolvable // source) and a runtime 401 `credential_failure`. describe("the undelivered notice is cause-aware", () => { - async function noticeTextFor(dispatchFailure: unknown): Promise { + async function noticeTextFor( + dispatchFailure: unknown, + ): Promise { const platform = fakePlatform(); const deliverMail = platform.sendMail.bind(platform); platform.sendMail = async (input) => { @@ -319,32 +321,37 @@ describe("message fan-out", () => { (message) => message.sender.address === "ins_echo1@acme.example", ); const part = notice?.parts[0]; - return part?.kind === "text" ? part.text : ""; + return part?.kind === "text" ? part : undefined; } - test("InferenceResolutionError gets the fix-your-key copy, not 'send it again'", async () => { - const text = await noticeTextFor( + test("InferenceResolutionError gets the model-unavailable copy, not 'send it again' or the raw dump", async () => { + const part = await noticeTextFor( new InferenceResolutionError("test-launch", "no catalog source"), ); - expect(text).toContain("add or check your model key"); - expect(text).not.toContain("send it again"); + expect(part?.text).toContain("model isn't available here"); + expect(part?.turnFailedReason).toBe("model_unavailable"); + expect(part?.text).not.toContain("send it again"); + expect(part?.text).not.toContain("add or check your model key"); + expect(part?.text).not.toContain("cannot resolve an inference source"); + expect(part?.text).not.toContain("no catalog source"); + expect(part?.text).not.toMatch(/HTTP/); }); test("a 401 credential_failure gets the fix-your-key copy", async () => { - const text = await noticeTextFor( + const part = await noticeTextFor( Object.assign(new Error("unauthorized"), { status: 401, category: "credential_failure", }), ); - expect(text).toContain("add or check your model key"); - expect(text).not.toContain("send it again"); + expect(part?.text).toContain("add or check your model key"); + expect(part?.text).not.toContain("send it again"); }); test("a genuinely transient failure keeps the retryable copy", async () => { - const text = await noticeTextFor(new Error("sidecar unavailable")); - expect(text).toContain("send it again"); - expect(text).not.toContain("model key"); + const part = await noticeTextFor(new Error("sidecar unavailable")); + expect(part?.text).toContain("send it again"); + expect(part?.text).not.toContain("model key"); }); // CL-6644: a dispatch failure that never surfaces a logged cause is @@ -352,8 +359,8 @@ describe("message fan-out", () => { // carry a `reportError` refId a person can quote to support, and // that refId must be the one the caller actually logged. test("carries a reportError refId a person can quote to support", async () => { - const text = await noticeTextFor(new Error("sidecar unavailable")); - expect(text).toMatch(/\(ref [^)]+\)$/); + const part = await noticeTextFor(new Error("sidecar unavailable")); + expect(part?.text).toMatch(/\(ref [^)]+\)$/); }); }); diff --git a/packages/folded-runs/src/launch.ts b/packages/folded-runs/src/launch.ts index 738cba373..3cd19e0bd 100644 --- a/packages/folded-runs/src/launch.ts +++ b/packages/folded-runs/src/launch.ts @@ -47,6 +47,8 @@ import type { FoldedRunsDeps } from "./types"; */ export class InferenceResolutionError extends Error { readonly resolutionMessage: string; + /** Consumer-facing sentence an HTTP boundary can return verbatim. */ + readonly guidance: string; constructor(launchLabel: string, resolutionMessage: string) { super( `cannot resolve an inference source for ${launchLabel} ` + @@ -55,6 +57,7 @@ export class InferenceResolutionError extends Error { ); this.name = "InferenceResolutionError"; this.resolutionMessage = resolutionMessage; + this.guidance = "This agent's model isn't available here."; } }