项目文件夹

文件
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 行
30 KiB
TypeScript

/**
* 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 model’s text deltas internally and **yield a single trailing chunk** of the first tool’s
* 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`**
* (Ollama’s 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 Ollama’s 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:** Ollama’s 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`** (Ollama’s 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 Ollama’s 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 core’s 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`:** Ollama’s 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 tool’s
* **`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);
}