@sema-agent/core
Version:
Stateless, task-oriented AI agent core
127 lines (126 loc) • 7.59 kB
TypeScript
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>;