Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
274 lines
11 KiB
TypeScript
274 lines
11 KiB
TypeScript
import type { ChannelAttachmentDto, RoutingDecisionInfo } from '@mosaicstack/types';
|
|
|
|
/**
|
|
* The single chat execution strategy resolved by {@link ChatRuntimeRouter}.
|
|
*
|
|
* Exactly one runtime is live per process. There is no union that lets a
|
|
* `pi-rpc` deployment silently fall back to embedded execution: an unmet
|
|
* `pi-rpc` precondition is a typed startup failure, never a downgrade.
|
|
*/
|
|
export type ChatRuntimeMode = 'legacy' | 'pi-rpc';
|
|
|
|
export type ChatRuntimeKind = 'embedded' | 'harness';
|
|
|
|
/** The resolved runtime. Slice Zero exposes only its immutable {@link ChatRuntimeKind}. */
|
|
export interface ChatRuntime {
|
|
readonly kind: ChatRuntimeKind;
|
|
}
|
|
|
|
/** Why the `pi-rpc` runtime could not be made ready. Both are hard startup failures. */
|
|
export type ChatRuntimeUnavailableReason =
|
|
| 'adapter_unavailable'
|
|
| 'conversation_service_unavailable';
|
|
|
|
/**
|
|
* Raised at module init when `pi-rpc` mode is selected but its preconditions are
|
|
* unmet. Carries only fixed, browser-safe text — never a raw exception message,
|
|
* stack, or provider detail — and reports the frozen ack code `runtime_unsupported`.
|
|
*/
|
|
export class ChatRuntimeUnavailableError extends Error {
|
|
readonly code = 'runtime_unsupported' as const;
|
|
readonly reason: ChatRuntimeUnavailableReason;
|
|
|
|
constructor(reason: ChatRuntimeUnavailableReason) {
|
|
super(
|
|
reason === 'adapter_unavailable'
|
|
? 'The pi-rpc chat runtime is unavailable: no "pi" harness adapter is registered.'
|
|
: 'The pi-rpc chat runtime is unavailable: the harness conversation service is not bound.',
|
|
);
|
|
this.name = 'ChatRuntimeUnavailableError';
|
|
this.reason = reason;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves the process-wide chat runtime mode from the environment. Anything other
|
|
* than the exact opt-in token `pi-rpc` keeps the legacy embedded runtime.
|
|
*/
|
|
export function resolveChatRuntimeMode(
|
|
env: Record<string, string | undefined> = process.env,
|
|
): ChatRuntimeMode {
|
|
return env['CHAT_HARNESS_RUNTIME'] === 'pi-rpc' ? 'pi-rpc' : 'legacy';
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Transitional embedded chat port (Task Five).
|
|
//
|
|
// The legacy embedded browser behaviour is moved behind this exact interface so
|
|
// neither the controller nor the gateway retains AgentService, RoutingEngine,
|
|
// session, `piSession`, metric, listener, or channel access. `EmbeddedChatRuntime`
|
|
// implements the port; `ChatRuntimeRouter` exposes the same narrowly named
|
|
// operations and returns `runtime_unsupported` before touching Embedded for legacy
|
|
// browser operations when the mode is `pi-rpc`.
|
|
//
|
|
// The names are frozen (spec jarvis-brain@1c629b06). Legacy REST completion,
|
|
// legacy Socket streaming, P3 harness turns, and verified Discord are distinct
|
|
// transport/trust capabilities — there is deliberately no generic
|
|
// `sendConversationTurn` nor an AgentService-shaped mirror on the router.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Phantom brand keeping {@link OwnedConversationContext} nominally distinct so browser
|
|
* DTOs are never structurally assignable to it. The factory that mints one may be called
|
|
* only after authentication with `scopeFromUser(...)`, never with payload authority fields.
|
|
*/
|
|
declare const ownedConversationContextBrand: unique symbol;
|
|
|
|
/** Gateway-only ownership context. Embedded rechecks owner+tenant on every operation. */
|
|
export interface OwnedConversationContext {
|
|
readonly [ownedConversationContextBrand]: true;
|
|
readonly conversationId: string;
|
|
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
|
}
|
|
|
|
/**
|
|
* Every non-`ok` legacy runtime outcome. Missing, foreign, and no-longer-owned
|
|
* conversations all collapse to `conversation_unavailable`. Ownership/mode/validation
|
|
* failures are total results and never throw.
|
|
*/
|
|
export type LegacyRuntimeFailure =
|
|
| { readonly ok: false; readonly code: 'runtime_unsupported'; readonly retryable: false }
|
|
| { readonly ok: false; readonly code: 'conversation_unavailable'; readonly retryable: false }
|
|
| { readonly ok: false; readonly code: 'request_invalid'; readonly retryable: false }
|
|
| {
|
|
readonly ok: false;
|
|
readonly code: 'thinking_level_invalid';
|
|
readonly retryable: false;
|
|
readonly availableThinkingLevels: readonly string[];
|
|
}
|
|
| { readonly ok: false; readonly code: 'runtime_unavailable'; readonly retryable: true }
|
|
| { readonly ok: false; readonly code: 'turn_already_dispatched'; readonly retryable: false }
|
|
| { readonly ok: false; readonly code: 'operation_failed'; readonly retryable: boolean }
|
|
| { readonly ok: false; readonly code: 'timeout'; readonly retryable: true };
|
|
|
|
/** Total result: an `ok` value or one of the fixed {@link LegacyRuntimeFailure} codes. */
|
|
export type LegacyRuntimeResult<T> =
|
|
| { readonly ok: true; readonly value: T }
|
|
| LegacyRuntimeFailure;
|
|
|
|
/** User-facing session projection. Carries no session object, handle, or credential path. */
|
|
export interface LegacySessionPresentation {
|
|
readonly provider: string;
|
|
readonly modelId: string;
|
|
readonly thinkingLevel: string;
|
|
readonly availableThinkingLevels: readonly string[];
|
|
readonly agentName?: string;
|
|
readonly routingDecision?: RoutingDecisionInfo;
|
|
}
|
|
|
|
/** Terminal usage stats, normalized by Embedded from AgentService metrics. */
|
|
export interface LegacyUsage {
|
|
readonly provider: string;
|
|
readonly modelId: string;
|
|
readonly thinkingLevel: string;
|
|
readonly tokens: Readonly<{
|
|
input: number;
|
|
output: number;
|
|
cacheRead: number;
|
|
cacheWrite: number;
|
|
total: number;
|
|
}>;
|
|
readonly cost: number;
|
|
readonly context: Readonly<{ percent: number | null; window: number }>;
|
|
}
|
|
|
|
/**
|
|
* Normalized stream event. Exposes no `AgentSession`, `piSession`, native handle, raw
|
|
* exception, tool arguments, or credential-bearing path — the gateway sees only these.
|
|
*/
|
|
export type LegacyRuntimeEvent =
|
|
| { readonly type: 'started' }
|
|
| { readonly type: 'text_delta'; readonly text: string }
|
|
| { readonly type: 'thinking_delta'; readonly text: string }
|
|
| {
|
|
readonly type: 'tool_started';
|
|
readonly toolCallId: string;
|
|
readonly toolName: string;
|
|
}
|
|
| {
|
|
readonly type: 'tool_finished';
|
|
readonly toolCallId: string;
|
|
readonly toolName: string;
|
|
readonly isError: boolean;
|
|
}
|
|
| { readonly type: 'settled'; readonly usage?: LegacyUsage };
|
|
|
|
/** Legacy browser message input. Authority fields are advisory only; scope comes from the context. */
|
|
export interface LegacyBrowserMessagePayload {
|
|
readonly content: string;
|
|
readonly provider?: string;
|
|
readonly modelId?: string;
|
|
readonly agentId?: string;
|
|
readonly attachments?: readonly ChannelAttachmentDto[];
|
|
}
|
|
|
|
/** A prepared-but-not-yet-dispatched legacy socket turn. */
|
|
export interface LegacySocketTurnLease {
|
|
readonly presentation: LegacySessionPresentation;
|
|
/**
|
|
* Atomically one-shot and scope-rechecking. A second call returns
|
|
* `turn_already_dispatched` and performs zero prompt/tool effects.
|
|
*/
|
|
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
|
/** Idempotent, non-throwing. Removes listener and channel, including partial setup. */
|
|
dispose(): Promise<void>;
|
|
}
|
|
|
|
/**
|
|
* Phantom brand for {@link VerifiedDiscordIngressContext}. Minted only after service-token
|
|
* auth plus signature, allowlist, binding, expected-route, replay, configured-agent,
|
|
* forced-scope, and attachment-normalization checks.
|
|
*/
|
|
declare const verifiedDiscordIngressContextBrand: unique symbol;
|
|
|
|
/** Fully-verified Discord ingress. Contains no socket, envelope, signature, token, or escape hatch. */
|
|
export interface VerifiedDiscordIngressContext {
|
|
readonly [verifiedDiscordIngressContextBrand]: true;
|
|
readonly conversationId: string;
|
|
readonly scope: Readonly<{ userId: string; tenantId: string }>;
|
|
readonly configuredAgent: Readonly<{ agentConfigId: string; instanceId: string }>;
|
|
readonly content: string;
|
|
readonly attachments?: readonly ChannelAttachmentDto[];
|
|
readonly correlationId: string;
|
|
readonly discordMessageId: string;
|
|
readonly discordUserId: string;
|
|
}
|
|
|
|
/** Verified-Discord turn lease. Same atomic one-shot dispatch and idempotent dispose rules. */
|
|
export interface VerifiedDiscordTurnLease {
|
|
readonly presentation: LegacySessionPresentation;
|
|
dispatch(): Promise<LegacyRuntimeResult<void>>;
|
|
dispose(): Promise<void>;
|
|
}
|
|
|
|
/** Server-owned egress projection the runtime pushes normalized events into. */
|
|
export interface LegacyRuntimeStream {
|
|
/** Server-derived, e.g. `websocket:<socket-id>`. Never client-supplied. */
|
|
readonly channelId: string;
|
|
onEvent(event: LegacyRuntimeEvent): void;
|
|
}
|
|
|
|
/**
|
|
* The exact transitional port. `EmbeddedChatRuntime` implements it; `ChatRuntimeRouter`
|
|
* mirrors the operation names and fails closed with `runtime_unsupported` for legacy
|
|
* browser operations under `pi-rpc`.
|
|
*/
|
|
export interface LegacyEmbeddedChatPort {
|
|
completeLegacyRestTurn(
|
|
context: OwnedConversationContext,
|
|
input: Readonly<{ content: string }>,
|
|
): Promise<
|
|
LegacyRuntimeResult<Readonly<{ text: string; presentation: LegacySessionPresentation }>>
|
|
>;
|
|
|
|
prepareLegacySocketTurn(
|
|
context: OwnedConversationContext,
|
|
input: LegacyBrowserMessagePayload,
|
|
stream: LegacyRuntimeStream,
|
|
): Promise<LegacyRuntimeResult<LegacySocketTurnLease>>;
|
|
|
|
setLegacyThinking(
|
|
context: OwnedConversationContext,
|
|
level: string,
|
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
|
|
|
abortLegacyTurn(context: OwnedConversationContext): Promise<LegacyRuntimeResult<void>>;
|
|
|
|
applyLegacyModelOverride(
|
|
context: OwnedConversationContext,
|
|
modelId: string,
|
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
|
|
|
readLegacySessionPresentation(
|
|
context: OwnedConversationContext,
|
|
): LegacyRuntimeResult<LegacySessionPresentation>;
|
|
|
|
dispatchVerifiedDiscordIngress(
|
|
context: VerifiedDiscordIngressContext,
|
|
stream: LegacyRuntimeStream,
|
|
): Promise<LegacyRuntimeResult<VerifiedDiscordTurnLease>>;
|
|
}
|
|
|
|
/**
|
|
* Mints an {@link OwnedConversationContext} from a server-derived scope. Callers must pass
|
|
* a scope produced by `scopeFromUser(...)` after authentication — never a client-supplied
|
|
* authority field. The brand is phantom, so this is the only way to obtain the branded type.
|
|
*/
|
|
export function ownConversation(
|
|
conversationId: string,
|
|
scope: Readonly<{ userId: string; tenantId: string }>,
|
|
): OwnedConversationContext {
|
|
return { conversationId, scope } as unknown as OwnedConversationContext;
|
|
}
|
|
|
|
/**
|
|
* Mints a {@link VerifiedDiscordIngressContext}. Callers must have already completed every
|
|
* ingress check (service-token auth, signature, allowlist, binding, expected-route, replay,
|
|
* configured-agent, forced-scope, attachment normalization) before calling this.
|
|
*/
|
|
export function verifyDiscordIngress(
|
|
fields: Omit<VerifiedDiscordIngressContext, typeof verifiedDiscordIngressContextBrand>,
|
|
): VerifiedDiscordIngressContext {
|
|
return { ...fields } as unknown as VerifiedDiscordIngressContext;
|
|
}
|