Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

752 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Ollama text generation for ElizaOS.
*
* ## Why this module is shaped this way
*
* - **Single AI SDK surface:** We call **`generateText`** and **`streamText`** from `ai` so
* Eliza stays aligned with other provider plugins (OpenAI, OpenRouter). That avoids bespoke
* HTTP clients here.
*
* - **`ollama-ai-provider-v2`:** Older `ollama-ai-provider` exposed AI SDK model spec v1;
* current `ai` requires v2+ and threw `Unsupported model version v1` for every model.
* v2 implements the same contract as the rest of the ecosystem.
*
* - **`responseSchema`:** Core passes JSON Schema (or a full output spec) for pipelines
* that need parseable objects (e.g. FACT_EXTRACTOR). We map that to `Output.object` so
* Ollama receives `format: json` / schema in the wire protocol—without this, those calls
* failed and memory/planner features degraded under Ollama-only setups.
*
* - **`OLLAMA_DISABLE_STRUCTURED_OUTPUT`:** Some local models return invalid JSON or error
* on `format`. Stripping `responseSchema` keeps the agent running; callers may fail
* validation—that is intentional so operators can recover without redeploying code.
*
* - **Native tools / `toolChoice`:** v5 `RESPONSE_HANDLER` passes `messages`, `tools`, and
* `toolChoice: "required"` (see `runV5MessageRuntimeStage1`). With **`stream: false`** (or no
* streaming context), we use **`generateText`** and cast a `GenerateTextResult`-shaped payload
* when needed. With **`stream: true`** and a **tool set**, we use **`streamText`** (see
* **`buildOllamaStreamWithToolsResult`**). Either way matches OpenRouter/OpenAI so
* **`parseMessageHandlerNativeToolCall`** / **`parseMessageHandlerOutput`** can read the plan.
* If both tools and `responseSchema` are present, tools win (schema is omitted for that request).
*
* - **Stop sequences:** Empty `stopSequences` arrays are omitted on the wire (same idea as
* OpenRouter) so we do not send meaningless `[]` to the model.
*
* - **Streaming:** When `stream: true` and there is **no** `responseSchema`, **tools**, or
* `toolChoice`, we call **`streamText`** and return **`TextStreamResult`** so `useModel` can
* forward chunks to SSE callbacks. **Why:** core sets `stream` from chat context; without a
* stream object the runtime never invokes `onStreamChunk` and the UI shows empty replies.
* - **`streamText` + tools:** When `stream: true` and native **tools** are present, we call
* **`streamText`** (same as OpenAI/OpenRouter) so Ollama streams over `/api/chat`. For
* **`RESPONSE_HANDLER`** / **`ACTION_PLANNER`**, `useModel`s streaming branch concatenates
* **`textStream`** into the string passed to **`parseMessageHandlerOutput`** — so we **drain**
* the models text deltas internally and **yield a single trailing chunk** of the first tools
* arguments JSON (the v5 plan payload). **Why:** mixing arbitrary streamed text with that JSON
* would make `JSON.parse` fail on the accumulated `fullText`. Other model types forward every
* text chunk as usual.
* - **Errors during `textStream`:** Failures often surface while **core** iterates **`textStream`**
* (after the handler returned), so they bypass **`handleTextWithModelType`s** outer **`try`/`catch`**.
* **`logOllamaTextFailure`** runs inside the stream wrapper so logs still include **`ollamaResponseBody`**
* (Ollamas JSON error, e.g. insufficient RAM) and the request URL. **Why:** otherwise the process
* exits with a generic “Internal Server Error” and operators cannot see Ollamas message.
* - **`stream: true` + `responseSchema` (no tools):** Still **`generateText`** only — we **log at
* debug** because `ollama-ai-provider-v2` does not combine structured `format: json` with the
* `streamText` path reliably for nested extractors (e.g. `FACT_EXTRACTOR`).
*
* - **`stream: true` + `toolChoice` without tools:** **`generateText`** only — we **log at debug**.
* **Why:** `streamText` in this adapter is only used when a **`ToolSet`** is present; `toolChoice`
* alone is not a supported streaming request shape. Core v5 always passes tools with Stage 1
* `toolChoice`; the log helps custom callers spot a bad param combo.
*
* - **`shouldReturnNative`:** Computed only after the final `outputSpec` (structured output)
* vs tools conflict is resolved, from `hasChatMessages`, `tools`, `toolChoice`, and whether
* structured output is still active. **Why:** the non-streaming return shape must match what we
* actually sent on the wire (`generateText` or the completed stream) so callers do not think
* they got schema-backed JSON when tools won.
*
* - **Usage fallback:** When the chat-messages path is used, token usage estimation uses a
* JSON serialization of `messages` instead of re-rendering the prompt string. **Why:** we
* skip `renderChatMessagesForPrompt` on that path for efficiency; usage is still best-effort
* when the provider omits `usage`.
*
* - **`providerOptions`:** Not forwarded into `generateText` yet. **Why:** Ollamas provider
* surface differs from Anthropic/OpenAI cache hints; forwarding blindly could send unsupported
* fields. Documented in README until explicitly mapped.
*/
import type {
GenerateTextParams,
GenerateTextResult,
IAgentRuntime,
ModelTypeName,
TextStreamResult,
TokenUsage,
ToolCall,
} from "@elizaos/core";
import {
buildCanonicalSystemPrompt,
dropDuplicateLeadingSystemMessage,
logger,
ModelType,
renderChatMessagesForPrompt,
resolveEffectiveSystemPrompt,
} from "@elizaos/core";
import {
generateText,
type JSONSchema7,
jsonSchema,
type LanguageModel,
Output,
streamText,
} from "ai";
import { createOllama } from "ollama-ai-provider-v2";
import {
mapAiSdkToolCallsToCore,
normalizeNativeMessages,
normalizeNativeTools,
normalizeToolChoice,
} from "../utils/ai-sdk-wire";
import {
getActionPlannerModel,
getBaseURL,
getLargeModel,
getMediumModel,
getMegaModel,
getNanoModel,
getResponseHandlerModel,
getSmallModel,
isOllamaStructuredOutputDisabled,
} from "../utils/config";
import { emitModelUsed, estimateUsage, normalizeTokenUsage } from "../utils/modelUsage";
import { ensureModelAvailable } from "./availability";
const TEXT_NANO_MODEL_TYPE = ModelType.TEXT_NANO as ModelTypeName;
const TEXT_MEDIUM_MODEL_TYPE = ModelType.TEXT_MEDIUM as ModelTypeName;
const TEXT_MEGA_MODEL_TYPE = ModelType.TEXT_MEGA as ModelTypeName;
const RESPONSE_HANDLER_MODEL_TYPE = ModelType.RESPONSE_HANDLER as ModelTypeName;
const ACTION_PLANNER_MODEL_TYPE = ModelType.ACTION_PLANNER as ModelTypeName;
type GenerateTextParamsWithNativeOptions = Omit<GenerateTextParams, "responseSchema"> & {
messages?: unknown[];
tools?: unknown;
toolChoice?: unknown;
/** Core passes JSON Schema objects or full AI SDK output specs; typed loosely here for Ollama. */
responseSchema?: unknown;
};
type NativeTextOutput = NonNullable<Parameters<typeof generateText>[0]["output"]>;
type NativeTextModelResult = string & GenerateTextResult;
/**
* Pulls useful fields from Vercel AI SDK errors (`APICallError`, `RetryError`, `NoOutputGeneratedError`, …).
* **Why:** the default `logger.error({ error })` serialization often hides **`responseBody`** (Ollamas JSON
* error string, e.g. OOM) and **`url`**, so operators only see “Internal Server Error”.
*/
function summarizeAiSdkErrorForLogs(error: unknown, depth = 0): Record<string, unknown> {
if (depth > 4) {
return { note: "max depth summarizing nested error" };
}
if (error == null) {
return { raw: String(error) };
}
if (typeof error !== "object") {
return { message: String(error) };
}
const e = error as Record<string, unknown>;
const out: Record<string, unknown> = {};
if (typeof e.name === "string") out.errorName = e.name;
if (typeof e.message === "string") out.message = e.message;
if (typeof e.reason === "string") out.reason = e.reason;
if (typeof e.url === "string") out.requestUrl = e.url;
if (typeof e.statusCode === "number") out.httpStatus = e.statusCode;
if (typeof e.responseBody === "string") out.ollamaResponseBody = e.responseBody;
if (Array.isArray(e.errors)) {
out.attemptErrors = e.errors.map((sub, i) => ({
attempt: i + 1,
...summarizeAiSdkErrorForLogs(sub, depth + 1),
}));
}
if (e.cause != null && typeof e.cause === "object") {
out.cause = summarizeAiSdkErrorForLogs(e.cause, depth + 1);
}
return out;
}
function logOllamaTextFailure(
phase: "generateText" | "streamText.textStream",
modelType: string,
modelId: string,
endpoint: string,
error: unknown
): void {
logger.error(
{
src: "plugin:ollama:text",
phase,
modelType,
modelId,
ollamaApiEndpoint: endpoint,
...summarizeAiSdkErrorForLogs(error),
},
`[Ollama] ${phase} failed (${modelType}, model=${modelId}). See ollamaResponseBody / attemptErrors for Ollamas JSON (e.g. insufficient RAM, model missing).`
);
}
/**
* Builds the AI SDK `output` spec for structured generation.
* Why accept a pre-built object: some tests/advanced callers pass a full output descriptor
* (`responseFormat` + `parseCompleteOutput`); otherwise we wrap JSON Schema with `Output.object`.
*/
function buildStructuredOutput(responseSchema: unknown): NativeTextOutput {
if (
responseSchema &&
typeof responseSchema === "object" &&
"responseFormat" in responseSchema &&
"parseCompleteOutput" in responseSchema
) {
return responseSchema as NativeTextOutput;
}
const schemaOptions =
responseSchema && typeof responseSchema === "object" && "schema" in responseSchema
? (responseSchema as { schema: unknown; name?: string; description?: string })
: { schema: responseSchema };
return Output.object({
schema: jsonSchema(schemaOptions.schema as JSONSchema7),
...(schemaOptions.name ? { name: schemaOptions.name } : {}),
...(schemaOptions.description ? { description: schemaOptions.description } : {}),
}) as NativeTextOutput;
}
/**
* Eliza `useModel` returns a string for text types. The AI SDK may place parsed JSON in
* `result.output` or leave JSON in `result.text`. Why stringify objects: downstream parsers
* (e.g. FACT_EXTRACTOR) accept either a string slice or a plain object; a single JSON string
* keeps the handler contract simple and matches older provider behavior.
*/
function serializeStructuredGenerateTextResult(result: { text: string; output: unknown }): string {
if (result.output !== undefined && result.output !== null) {
return typeof result.output === "string" ? result.output : JSON.stringify(result.output);
}
const trimmed = result.text.trim();
if (trimmed) return trimmed;
throw new Error("[Ollama] Structured generation returned no text or output.");
}
/**
* Builds the object cores v5 parsers read from `useModel` when the call used tools/messages
* without structured `output`. Runtime value is a `GenerateTextResult`; TypeScript still types
* the handler as `string` for historical reasons—same pattern as OpenRouter/OpenAI.
*
* **`providerMetadata.modelName`:** Lets trajectory / debug code attribute the call without
* parsing Ollama response bodies again.
*/
function buildNativeResultCast(
result: Awaited<ReturnType<typeof generateText>>,
modelName: string,
usage: TokenUsage
): string {
const payload: GenerateTextResult = {
text: result.text,
toolCalls: mapAiSdkToolCallsToCore(result.toolCalls as unknown[] | undefined),
finishReason: String(result.finishReason),
usage,
providerMetadata: { modelName },
};
return payload as NativeTextModelResult;
}
type StreamTextParams = Parameters<typeof streamText>[0];
/**
* Plain streaming path for Ollama (`streamText` from the AI SDK).
*
* **When:** `params.stream` is true and the request has no structured `output`, tools, or
* `toolChoice`.
*
* **Why this exists:** `AgentRuntime.useModel` only forwards token chunks to SSE when the
* handler return value satisfies **`isTextStreamResult`** (`textStream`, `text`, `usage`,
* `finishReason` — see `packages/core/src/runtime.ts`). A bare **`string`** skips that branch:
* the model still runs, but the UI gets no chunks (“no streamed text”).
*
* **Usage / `MODEL_USED`:** We resolve **`streamResult.usage`** after the stream completes,
* merge with **`streamResult.text`** for fallback estimation, then **`emitModelUsed`** once.
* **Why await `text` inside the usage hook:** Ollama may omit partial usage until the full
* completion is known; pairing with final text keeps estimates sane when the provider omits
* token fields.
*
* **`textStream` wrapper:** The async generator forwards chunks and, on successful completion,
* awaits **`usagePromise`** in a **`finally`** block so consumers that only drain **`textStream`**
* still trigger accounting—mirroring **`plugin-openrouter`**s pattern.
*/
function buildOllamaStreamTextResult(args: {
runtime: IAgentRuntime;
modelType: TextModelType;
model: string;
/** Resolved `OLLAMA_API_ENDPOINT` — logged when `textStream` fails (errors often happen here, outside `handleTextWithModelType`s try/catch). */
endpoint: string;
streamParams: StreamTextParams;
promptForEstimate: string;
}): TextStreamResult {
const streamResult = streamText(args.streamParams);
// Keep SDK promises settled-or-empty so stream failures surface through the
// textStream generator rather than as unhandled rejections on side promises.
// error-policy:J5 the real failure is observed and rethrown in the textStream
// generator's catch below; these side-promise catches only prevent duplicate
// unhandled-rejection noise for the same error, they do not fabricate a reply.
const textPromise = Promise.resolve(streamResult.text).catch(() => "");
const finishReasonPromise = Promise.resolve(streamResult.finishReason).catch(
() => undefined
) as Promise<string | undefined>;
const usagePromise = Promise.resolve(streamResult.usage)
.then(async (usage) => {
const fullText = await textPromise;
return normalizeTokenUsage(usage) ?? estimateUsage(args.promptForEstimate, fullText);
})
// error-policy:J7 usage/telemetry estimation must not crash the stream; the
// generation itself still surfaces via the textStream generator.
.catch(() => undefined);
async function* textStreamWithUsage(): AsyncIterable<string> {
let completed = false;
try {
for await (const chunk of streamResult.textStream) {
yield chunk;
}
completed = true;
} catch (streamErr) {
logOllamaTextFailure(
"streamText.textStream",
String(args.modelType),
args.model,
args.endpoint,
streamErr
);
throw streamErr;
} finally {
if (completed) {
// error-policy:J7 only reached after a SUCCESSFUL stream; a usage-emit
// failure must not turn a completed generation into an error.
const usage = await usagePromise.catch(() => undefined);
if (usage) {
emitModelUsed(args.runtime, args.modelType, args.model, usage);
}
}
}
}
return {
textStream: textStreamWithUsage(),
text: textPromise,
usage: usagePromise,
finishReason: finishReasonPromise,
};
}
/** Serialized tool `arguments` for v5 `parseMessageHandlerOutput` (expects JSON text). */
function stringifyPlannerToolArgs(arguments_: ToolCall["arguments"]): string {
if (typeof arguments_ === "string") {
return arguments_;
}
return JSON.stringify(arguments_);
}
type OllamaStreamTextWithToolsResult = TextStreamResult & {
/** Mapped tool calls after the stream completes (parity with OpenAI `streamText` + tools). */
toolCalls?: Promise<ToolCall[]>;
};
/**
* `streamText` when the request includes native tools and `stream: true`.
*
* **Why a separate builder from `buildOllamaStreamTextResult`:** Ollamas chat model supports
* tools on the streaming wire; `generateText` would buffer the full completion and skip token
* streaming entirely.
*
* **Planner types (`RESPONSE_HANDLER`, `ACTION_PLANNER`):** `useModel`s streaming path sets
* `result` to the concatenation of **`textStream` chunks only** — it never awaits our **`text`**
* promise. Core then calls **`parseMessageHandlerOutput(fullText)`**, which expects a single JSON
* blob of the plan. We therefore **drain** the SDK `textStream` without yielding those deltas (so
* they are not prepended to the plan JSON) and **yield one chunk**: the first mapped tools
* **`arguments`** as JSON text. **Why:** interleaving arbitrary model text with plan JSON breaks
* `JSON.parse` on the accumulated string.
*
* **Other text model types:** Forward every SDK text chunk; `text` resolves to the final model
* text. **`toolCalls`** is still attached for callers that read it from the result object.
*/
function buildOllamaStreamWithToolsResult(args: {
runtime: IAgentRuntime;
modelType: TextModelType;
model: string;
/** Resolved `OLLAMA_API_ENDPOINT` — logged when streaming fails (core consumes `textStream` after the handler returns). */
endpoint: string;
streamParams: StreamTextParams;
promptForEstimate: string;
}): OllamaStreamTextWithToolsResult {
const streamResult = streamText(args.streamParams);
// error-policy:J5 side-promise catches only dedupe the unhandled rejection; the
// authoritative failure is rethrown from the textStream generator's catch below.
const sdkTextPromise = Promise.resolve(streamResult.text).catch(() => "");
const finishReasonPromise = Promise.resolve(streamResult.finishReason).catch(
() => undefined
) as Promise<string | undefined>;
const toolCallsPromise = Promise.resolve(streamResult.toolCalls)
.then((calls) => mapAiSdkToolCallsToCore(calls as unknown[] | undefined))
// error-policy:J5 a tool-call parse failure is observed when the generator
// awaits this promise and yields the fallback text / rethrows; empty tool
// calls here means "no native plan", the text path still runs.
.catch(() => [] as ToolCall[]);
const usagePromise = Promise.resolve(streamResult.usage)
.then(async (usage) => {
const fullText = await sdkTextPromise;
return normalizeTokenUsage(usage) ?? estimateUsage(args.promptForEstimate, fullText);
})
// error-policy:J7 usage/telemetry estimation must not crash the stream.
.catch(() => undefined);
const isNativePlannerType =
args.modelType === RESPONSE_HANDLER_MODEL_TYPE || args.modelType === ACTION_PLANNER_MODEL_TYPE;
const textPromise: Promise<string> = isNativePlannerType
? toolCallsPromise.then(async (mapped) => {
const first = mapped[0];
if (first) {
return stringifyPlannerToolArgs(first.arguments);
}
return sdkTextPromise;
})
: sdkTextPromise;
async function* textStreamWithUsage(): AsyncIterable<string> {
let completed = false;
try {
if (isNativePlannerType) {
for await (const _ of streamResult.textStream) {
// Drain text deltas; only the trailing plan JSON chunk is yielded (see module comment).
}
const mapped = await toolCallsPromise;
const first = mapped[0];
if (first) {
yield stringifyPlannerToolArgs(first.arguments);
} else {
const fallbackText = await sdkTextPromise;
if (fallbackText) {
yield fallbackText;
}
}
} else {
for await (const chunk of streamResult.textStream) {
yield chunk;
}
}
completed = true;
} catch (streamErr) {
logOllamaTextFailure(
"streamText.textStream",
String(args.modelType),
args.model,
args.endpoint,
streamErr
);
throw streamErr;
} finally {
if (completed) {
// error-policy:J7 only after a SUCCESSFUL stream; usage-emit failure must
// not convert a completed generation into an error.
const usage = await usagePromise.catch(() => undefined);
if (usage) {
emitModelUsed(args.runtime, args.modelType, args.model, usage);
}
}
}
}
return {
textStream: textStreamWithUsage(),
text: textPromise,
usage: usagePromise,
finishReason: finishReasonPromise,
toolCalls: toolCallsPromise,
};
}
type TextModelType =
| typeof TEXT_NANO_MODEL_TYPE
| typeof ModelType.TEXT_SMALL
| typeof TEXT_MEDIUM_MODEL_TYPE
| typeof ModelType.TEXT_LARGE
| typeof TEXT_MEGA_MODEL_TYPE
| typeof RESPONSE_HANDLER_MODEL_TYPE
| typeof ACTION_PLANNER_MODEL_TYPE;
function getModelNameForType(runtime: IAgentRuntime, modelType: TextModelType): string {
switch (modelType) {
case TEXT_NANO_MODEL_TYPE:
return getNanoModel(runtime);
case TEXT_MEDIUM_MODEL_TYPE:
return getMediumModel(runtime);
case ModelType.TEXT_SMALL:
return getSmallModel(runtime);
case ModelType.TEXT_LARGE:
return getLargeModel(runtime);
case TEXT_MEGA_MODEL_TYPE:
return getMegaModel(runtime);
case RESPONSE_HANDLER_MODEL_TYPE:
return getResponseHandlerModel(runtime);
case ACTION_PLANNER_MODEL_TYPE:
return getActionPlannerModel(runtime);
default:
return getLargeModel(runtime);
}
}
/**
* Shared path for all text model types.
*
* **Structured output:** `OLLAMA_DISABLE_STRUCTURED_OUTPUT` strips `responseSchema` so plain
* text runs—**why:** operators can recover broken local models without redeploying code;
* callers that require JSON may then fail validation (intentional trade-off).
*
* **Streaming vs `stream` flag:** Plain chat (`stream` true, no tools / `toolChoice` / schema)
* returns **`TextStreamResult`** from **`streamText`**. **`stream` + tools** uses
* **`buildOllamaStreamWithToolsResult`**. **`stream` + `responseSchema`** without tools still
* uses **`generateText`** — log at **debug**. **`stream` + `toolChoice`** without a resolved
* tool set uses **`generateText`** — log at **debug**; **why:** `streamText` requires tools on
* the wire; this path is unexpected from core.
*
* **Tools vs schema:** If both are present, tools win and structured output is omitted for
* that request—**why:** v5 Stage 1 requires tools; combining `Output.object` with tools in one
* `generateText` is not a portable contract across Ollama models.
*/
async function handleTextWithModelType(
runtime: IAgentRuntime,
modelType: TextModelType,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
const extended = params as GenerateTextParamsWithNativeOptions;
const { prompt, temperature = 0.7, frequencyPenalty = 0.7, presencePenalty = 0.7 } = params;
const maxTokens = params.omitMaxTokens ? undefined : (params.maxTokens ?? 8192);
let modelIdForLog = "";
try {
const structuredDisabled = isOllamaStructuredOutputDisabled(runtime);
let responseSchema: unknown = extended.responseSchema;
if (structuredDisabled && extended.responseSchema) {
logger.debug(
"[Ollama] OLLAMA_DISABLE_STRUCTURED_OUTPUT is set — ignoring responseSchema for this call."
);
responseSchema = undefined;
}
const tools = normalizeNativeTools(extended.tools);
const baseURL = getBaseURL(runtime);
const customFetch = runtime.fetch ?? undefined;
const ollama = createOllama({
...(customFetch ? { fetch: customFetch } : {}),
baseURL,
});
const model = getModelNameForType(runtime, modelType);
modelIdForLog = model;
logger.log(`[Ollama] Using ${modelType} model: ${model}`);
await ensureModelAvailable(model, baseURL, customFetch);
const system = resolveEffectiveSystemPrompt({
params,
fallback: buildCanonicalSystemPrompt({ character: runtime.character }),
});
let outputSpec: NativeTextOutput | undefined =
responseSchema !== undefined && responseSchema !== null
? buildStructuredOutput(responseSchema)
: undefined;
if (tools && outputSpec) {
// Stage-1-style calls need native tools; do not send `output` in the same request.
logger.debug(
"[Ollama] tools and responseSchema both present — omitting structured output for this call."
);
outputSpec = undefined;
}
const wireRaw = dropDuplicateLeadingSystemMessage(
extended.messages as Parameters<typeof dropDuplicateLeadingSystemMessage>[0],
system
);
const normalizedMessages = normalizeNativeMessages(wireRaw);
const hasChatMessages = Array.isArray(normalizedMessages) && normalizedMessages.length > 0;
const toolChoice = tools ? normalizeToolChoice(extended.toolChoice) : undefined;
// After `outputSpec` is final (including tools-vs-schema): native return shape when the
// call used chat messages, tools, toolChoice, or structured output — matches OpenRouter.
const shouldReturnNative = Boolean(
hasChatMessages || tools || extended.toolChoice || outputSpec !== undefined
);
const renderedPrompt = hasChatMessages
? ""
: (renderChatMessagesForPrompt(params.messages, {
...(system ? { omitDuplicateSystem: system } : {}),
}) ??
prompt ??
"");
const promptOrMessages = hasChatMessages
? { messages: normalizedMessages }
: { prompt: renderedPrompt };
const resolvedStopSequences =
Array.isArray(params.stopSequences) && params.stopSequences.length > 0
? params.stopSequences
: undefined;
const promptForUsageEstimate = hasChatMessages
? JSON.stringify(normalizedMessages)
: renderedPrompt;
const baseGenerateArgs = {
model: ollama(model) as LanguageModel,
...promptOrMessages,
system,
temperature,
frequencyPenalty,
presencePenalty,
...(typeof maxTokens === "number" ? { maxOutputTokens: maxTokens } : {}),
...(resolvedStopSequences ? { stopSequences: resolvedStopSequences } : {}),
...(tools ? { tools, ...(toolChoice ? { toolChoice } : {}) } : {}),
...(outputSpec ? { output: outputSpec } : {}),
};
// Streaming branches (order matters):
// 1) tools + stream → streamText+tools (Ollama v2 supports tools on streaming /api/chat).
// 2) stream, no tools, no toolChoice → plain streamText → TextStreamResult for SSE.
// 3) stream + schema only → generateText below + debug (structured format not on streamText).
// 4) stream + toolChoice but no ToolSet → generateText below + debug (invalid streamText shape).
if (params.stream) {
if (tools) {
return buildOllamaStreamWithToolsResult({
runtime,
modelType,
model,
endpoint: baseURL,
streamParams: baseGenerateArgs as StreamTextParams,
promptForEstimate: promptForUsageEstimate,
});
}
if (!extended.toolChoice) {
if (!outputSpec) {
return buildOllamaStreamTextResult({
runtime,
modelType,
model,
endpoint: baseURL,
streamParams: baseGenerateArgs as StreamTextParams,
promptForEstimate: promptForUsageEstimate,
});
}
logger.debug(
{ src: "plugin:ollama:text", modelType },
"[Ollama] stream=true with responseSchema (no tools) — using generateText. Why: ollama-ai-provider-v2 does not support structured JSON output on the streamText path for this adapter."
);
} else {
logger.debug(
{ src: "plugin:ollama:text", modelType },
"[Ollama] stream=true with toolChoice but no tools on wire — using generateText. Why: streamText+tools requires a ToolSet; callers should pass tools alongside toolChoice."
);
}
}
const result = await generateText(baseGenerateArgs as Parameters<typeof generateText>[0]);
const usage =
normalizeTokenUsage(result.usage) ?? estimateUsage(promptForUsageEstimate, result.text);
emitModelUsed(runtime, modelType, model, usage);
if (shouldReturnNative) {
if (outputSpec !== undefined) {
return serializeStructuredGenerateTextResult(result);
}
return buildNativeResultCast(result, model, usage);
}
return result.text;
} catch (error) {
let endpoint = "";
try {
endpoint = getBaseURL(runtime);
} catch {
// error-policy:J6 best-effort enrichment of the failure log only; the real
// error is rethrown below. An unreadable endpoint setting must not mask it.
}
// error-policy:J2 context-adding rethrow — log with endpoint then rethrow.
logOllamaTextFailure(
"generateText",
String(modelType),
modelIdForLog || "(unknown)",
endpoint,
error
);
// Throw, never fabricate a reply. A hardcoded "Error generating text…" string
// would be persisted to memory and sent to the user as the agent's response —
// in the wrong language/voice — and would bypass core's grounded failure-reply
// path (buildFailureReplyPrompt). The canonical providers (openai, anthropic,
// google-genai, elizacloud, openrouter) all throw here; the message pipeline
// handles it.
throw error;
}
}
export async function handleTextSmall(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return handleTextWithModelType(runtime, ModelType.TEXT_SMALL, params);
}
export async function handleTextNano(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return handleTextWithModelType(runtime, TEXT_NANO_MODEL_TYPE, params);
}
export async function handleTextMedium(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return handleTextWithModelType(runtime, TEXT_MEDIUM_MODEL_TYPE, params);
}
export async function handleTextLarge(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return handleTextWithModelType(runtime, ModelType.TEXT_LARGE, params);
}
export async function handleTextMega(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return handleTextWithModelType(runtime, TEXT_MEGA_MODEL_TYPE, params);
}
export async function handleResponseHandler(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return handleTextWithModelType(runtime, RESPONSE_HANDLER_MODEL_TYPE, params);
}
export async function handleActionPlanner(
runtime: IAgentRuntime,
params: GenerateTextParams
): Promise<string | TextStreamResult> {
return handleTextWithModelType(runtime, ACTION_PLANNER_MODEL_TYPE, params);
}