UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

127 lines (126 loc) • 7.59 kB
import type { AssistantMessage, StopReason, Tool, ToolResultMessage, Usage, UserMessage } from "../internal/llm.js"; import type { Brain, Model, ModelRef, ModelRole, ModelRoles, TaskSpec, ThinkingLevel } from "./types.js"; import { type DegradationInfo } from "../brain/degrading.js"; /** A tool DEFINITION exposed to the model in a side query (never executed by the engine). */ export interface SideQueryToolDef { name: string; description: string; /** JSON-schema (typebox-compatible) parameters object. */ parameters: Tool["parameters"]; } /** * The side-query INPUT message union — REQUEST shapes, not transcript entries. Each * role keeps only what the brain wire actually consumes; the assistant arm drops the response * metadata (`api`/`provider`/`model`/`usage`/`stopReason`) a transcript entry carries — requiring * those would force callers to FABRICATE attribution, the exact shape-forging this verb exists to * kill. A caller MAY still pass a full transcript {@link Message}; extra fields ride through * untouched. Intake normalizes: missing `timestamp` stamped, a string assistant `content` folded to * one text block, missing assistant response metadata filled with inert placeholders (they never * reach the provider wire — history messages serialize by role/content only). */ export type SideQueryMessage = { role: "user"; content: UserMessage["content"]; timestamp?: number; } | { role: "assistant"; content: string | AssistantMessage["content"]; timestamp?: number; api?: AssistantMessage["api"]; provider?: string; model?: string; usage?: Usage; stopReason?: StopReason; } | { role: "toolResult"; toolCallId: string; toolName: string; content: ToolResultMessage["content"]; isError?: boolean; timestamp?: number; }; export interface SideQuerySpec { /** Model reference — same resolution as TaskSpec.model (pool name / role / @mention). Absent ⇒ the * deployment's default role chain. */ model?: ModelRef; /** Optional role override used when `model` is absent (same semantics as TaskSpec.modelRole). */ modelRole?: ModelRole; thinking?: ThinkingLevel; systemPrompt?: string; /** Full multi-turn message list (user/assistant/toolResult), passed to the brain UNFLATTENED — through the same * tool-turn law the harness applies to a session transcript (`ensureToolTurnInvariants`): an assistant's tool calls * become its trailing blocks, each call is followed by exactly one result, and a call this list leaves unanswered * (a trailing tool call, with the query's own prompt appended after it) is closed with a synthesized * "interrupted, outcome unknown" result and an operator notice. A caller that does not want that close answers or * drops the open call before handing the list over. The caller's array is not modified. */ messages: SideQueryMessage[]; /** Tool definitions the model may call in its reply. The engine NEVER executes them — any calls * come back in `SideQueryResult.toolCalls` for the caller to interpret. */ tools?: SideQueryToolDef[]; /** Per-request output cap (provider max_tokens). */ maxOutputTokens?: number; /** * Per-model auth — MIRRORS {@link TaskSpec.getApiKeyAndHeaders} (same signature, resolved per * call against the RESOLVED model, exactly like the task path's per-call hook). The brain * contract is the route pairing law (route-adjudicator.ts): a per-model credential (this hook, * or an auth header on `Model.headers`) always rides; the deployment credential rides only where * its pairing is verifiable-or-unpinned — a declared `config.baseUrl` with an off-root model is * refused (`route.credential_mismatch` / `route.credential_missing`), never silently followed. * Historically the fallback was unconditional (`options.apiKey ?? config.apiKey`, with a model's * own `baseUrl` outranking the brain's), so a side query routed to a model carrying its own * `baseUrl` + per-model key sent the GATEWAY key to the per-model (possibly external) URL — the * credential leak the pairing law now stops. Absent seat ⇒ no options are minted (byte-identical * to before): on an UNDECLARED root the construction-time credential still applies (the * quick-start posture), while a declared-root brain + off-root model hard-fails the side query * with the loud refusal (`stopReason: "error"`) instead of leaking — there is no primary model * for a side query to fall back to. */ getApiKeyAndHeaders?: TaskSpec["getApiKeyAndHeaders"]; signal?: AbortSignal; } export interface SideQueryResult { /** Concatenated assistant text (may be empty when the reply is tool-calls-only). */ text: string; /** Tool calls the model made against the DEFINITIONS (never executed by the engine). `id` is the engine id; * `wireId` is the provider's own spelling, present only when the two differ (`ToolCall.wireId`, the same * source). A caller replaying a call carries both on its `toolCall` block and keys the result by `id`; one that * talks to the provider itself hands it `wireId ?? id`. */ toolCalls: Array<{ id: string; wireId?: string; name: string; args: unknown; }>; /** The RESOLVED pool model id the query was ROUTED to (the caller's ref resolved). See * `servedModel` for the engine-selected id the reply came back stamped with. */ model: string; /** The ENGINE-SELECTED id the reply came back stamped with (a degrading deployment may fall back * mid-query — attribution follows the entry that ran, not the routing intent); equals `model` when * nothing degraded. Still THIS catalog's id: what the PROVIDER called it is `responseModel` below. */ servedModel: string; /** Structured degradation marker when a fallback model served (from/to/reason/chain). */ degraded?: DegradationInfo; /** The PROVIDER's own name for the model that answered, when it differs from `servedModel` above. * Absent ⇒ it named the same id or named none — one reading, as on `AssistantMessage.responseModel`. */ responseModel?: string; /** Usage with `input` NORMALIZED to the uncached prompt quantity (the Anthropic-protocol * `input_tokens` meaning) for EVERY provider family — the same gate the run loop's accounting * applies (`usage-accounting.ts`). The openai-completions family reports `input` INCLUDING the * cached subset; hand that through raw and a consumer summing `input + cacheRead` double-counts * every cache hit. `cacheRead`/`cacheWrite`/`output`/`totalTokens`/`cost` are the provider's own * figures, untouched. `usageMissing` ⇒ the numbers are UNKNOWN shells, not zeros. */ usage: Usage; usageMissing?: true; stopReason: StopReason; /** Present on `stopReason:"error"|"aborted"` — the query did not produce a usable reply. The verb * RETURNS the honest failure instead of throwing: utility callers uniformly need "no answer + * why", and a throw would lose usage/model attribution for the failed attempt. */ errorMessage?: string; } /** Dependencies the Runner passes through (a strict subset of RunnerDeps — no session/tool machinery). */ export interface SideQueryDeps { brain: Brain; models?: Record<string, Model>; roles?: ModelRoles; } export declare function runSideQuery(spec: SideQuerySpec, deps: SideQueryDeps): Promise<SideQueryResult>;