Files
openclaw/src/agents/btw.ts
2026-06-22 17:26:27 +08:00

910 lines
29 KiB
TypeScript

import { randomUUID } from "node:crypto";
/**
* Runs `/btw` side questions against the active conversation without resuming
* or continuing the main task.
*/
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js";
import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import type { SessionEntry as StoredSessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js";
import { streamSimple } from "../llm/stream.js";
import type {
AssistantMessageEvent,
ImageContent,
Message,
Model,
TextContent,
} from "../llm/types.js";
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.js";
import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js";
import { resolveAgentWorkspaceDir, resolveSessionAgentId } from "./agent-scope.js";
import { resolveExternalCliAuthOverlayScopeFromSelection } from "./auth-profiles/external-cli-auth-selection.js";
import { resolveSessionAuthProfileOverride } from "./auth-profiles/session-override.js";
import { readBtwTranscriptMessages, resolveBtwSessionTranscriptPath } from "./btw-transcript.js";
import { executePreparedCliRun } from "./cli-runner/execute.runtime.js";
import { prepareCliRunContext } from "./cli-runner/prepare.runtime.js";
import { EmbeddedBlockChunker, type BlockReplyChunking } from "./embedded-agent-block-chunker.js";
import { resolveModelWithRegistry } from "./embedded-agent-runner/model.js";
import { getActiveEmbeddedRunSnapshot } from "./embedded-agent-runner/runs.js";
import { resolveEmbeddedAgentStreamFn } from "./embedded-agent-runner/stream-resolution.js";
import { ensureSelectedAgentHarnessPlugin } from "./harness/runtime-plugin.js";
import {
resolveAvailableAgentHarnessPolicy,
resolvePluginHarnessPolicyToolsAllow,
selectAgentHarness,
} from "./harness/selection.js";
import type { AgentHarness } from "./harness/types.js";
import {
resolveImageSanitizationLimits,
type ImageSanitizationLimits,
} from "./image-sanitization.js";
import {
ensureAuthProfileStore,
ensureAuthProfileStoreWithoutExternalProfiles,
getApiKeyForModel,
requireApiKey,
} from "./model-auth.js";
import {
isCliRuntimeAliasForProvider,
resolveCliRuntimeExecutionProvider,
} from "./model-runtime-aliases.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "./openai-routing.js";
import { applyPreparedRuntimeAuthToModel } from "./provider-request-config.js";
import { registerProviderStreamForModel } from "./provider-stream.js";
import { stripToolResultDetails } from "./session-transcript-repair.js";
import { resolveAgentTimeoutMs } from "./timeout.js";
import { sanitizeImageBlocks } from "./tool-images.js";
function collectTextContent(content: Array<{ type?: string; text?: string }>): string {
return content
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("");
}
function collectThinkingContent(content: Array<{ type?: string; thinking?: string }>): string {
return content
.filter((part): part is { type: "thinking"; thinking: string } => part.type === "thinking")
.map((part) => part.thinking)
.join("");
}
function buildBtwSystemPrompt(): string {
return [
"You are answering an ephemeral /btw side question about the current conversation.",
"Use the conversation only as background context.",
"Answer only the side question in the last user message.",
"Do not continue, resume, or complete any unfinished task from the conversation.",
"Do not emit tool calls, pseudo-tool calls, shell commands, file writes, patches, or code unless the side question explicitly asks for them.",
"Do not say you will continue the main task after answering.",
"If the question can be answered briefly, answer briefly.",
].join("\n");
}
function resolveReturnedAuthProfileSource(
sessionEntry: StoredSessionEntry | undefined,
authProfileId: string | undefined,
): "auto" | "user" | undefined {
if (!authProfileId?.trim()) {
return undefined;
}
return (
sessionEntry?.authProfileOverrideSource ??
(typeof sessionEntry?.authProfileOverrideCompactionCount === "number" ? "auto" : "user")
);
}
function buildBtwQuestionPrompt(question: string, inFlightPrompt?: string): string {
const lines = [
"Answer this side question only.",
"Ignore any unfinished task in the conversation while answering it.",
];
const trimmedPrompt = inFlightPrompt?.trim();
if (trimmedPrompt) {
lines.push(
"",
"Current in-flight main task request for background context only:",
"<in_flight_main_task>",
trimmedPrompt,
"</in_flight_main_task>",
"Do not continue or complete that task while answering the side question.",
);
}
lines.push("", "<btw_side_question>", question.trim(), "</btw_side_question>");
return lines.join("\n");
}
function collectBtwMessageText(content: Message["content"]): string {
if (typeof content === "string") {
return content.trim();
}
if (!Array.isArray(content)) {
return "";
}
return content
.flatMap((part) => {
if (part.type === "text") {
return part.text;
}
if (part.type === "image") {
return "[Image content omitted from CLI side-question context.]";
}
return [];
})
.join("\n")
.trim();
}
function buildBtwCliPrompt(params: {
messages: Message[];
question: string;
inFlightPrompt?: string;
}): string {
const lines = [
"Use this sanitized conversation history as background context only.",
"Do not continue, resume, or complete any unfinished task from the conversation.",
"",
"<conversation_history>",
];
for (const message of params.messages) {
const text = collectBtwMessageText(message.content);
if (!text) {
continue;
}
lines.push(`${message.role === "assistant" ? "Assistant" : "User"}:`, text, "");
}
lines.push("</conversation_history>", "");
lines.push(buildBtwQuestionPrompt(params.question, params.inFlightPrompt));
return lines.join("\n");
}
function normalizeBtwContentBlocks(content: unknown): unknown[] | undefined {
if (Array.isArray(content)) {
return content;
}
if (content && typeof content === "object") {
return [content];
}
return undefined;
}
function isBtwTextBlock(block: unknown): block is TextContent {
if (!block || typeof block !== "object") {
return false;
}
const record = block as { type?: unknown; text?: unknown };
return normalizeLowercaseStringOrEmpty(record.type) === "text" && typeof record.text === "string";
}
function isBtwImageBlock(block: unknown): block is ImageContent {
if (!block || typeof block !== "object") {
return false;
}
const record = block as { type?: unknown; data?: unknown; mimeType?: unknown };
return (
normalizeLowercaseStringOrEmpty(record.type) === "image" &&
typeof record.data === "string" &&
typeof record.mimeType === "string"
);
}
async function sanitizeBtwUserMessage(params: {
message: Extract<Message, { role: "user" }>;
imageLimits: ImageSanitizationLimits;
}): Promise<Extract<Message, { role: "user" }> | undefined> {
if (typeof params.message.content === "string") {
return params.message;
}
const blocks = normalizeBtwContentBlocks(params.message.content);
if (!blocks) {
return undefined;
}
const content: Array<TextContent | ImageContent> = [];
for (const block of blocks) {
if (isBtwTextBlock(block)) {
content.push({ type: "text", text: block.text });
continue;
}
if (!isBtwImageBlock(block)) {
continue;
}
const { images } = await sanitizeImageBlocks([block], "btw:context", params.imageLimits);
const image = images[0];
if (image) {
content.push(image);
}
}
if (content.length === 0) {
return undefined;
}
return {
...params.message,
content,
};
}
function sanitizeBtwAssistantMessage(
message: Extract<Message, { role: "assistant" }>,
): Extract<Message, { role: "assistant" }> | undefined {
const rawContent = (message as { content?: unknown }).content;
if (typeof rawContent === "string") {
const trimmed = rawContent.trim();
return trimmed.length > 0
? {
...message,
content: [{ type: "text", text: trimmed }],
}
: undefined;
}
const blocks = normalizeBtwContentBlocks(rawContent);
if (!blocks) {
return undefined;
}
const content = blocks.flatMap((block): TextContent[] =>
isBtwTextBlock(block) ? [{ type: "text", text: block.text }] : [],
);
if (content.length === 0) {
return undefined;
}
return {
...message,
content,
};
}
async function toSimpleContextMessages(params: {
messages: unknown[];
imageLimits: ImageSanitizationLimits;
}): Promise<Message[]> {
const contextMessages: Message[] = [];
for (const message of params.messages) {
if (!message || typeof message !== "object") {
continue;
}
const role = (message as { role?: unknown }).role;
if (role === "user") {
const sanitizedMessage = await sanitizeBtwUserMessage({
message: message as Extract<Message, { role: "user" }>,
imageLimits: params.imageLimits,
});
if (sanitizedMessage) {
contextMessages.push(sanitizedMessage);
}
continue;
}
if (role !== "assistant") {
continue;
}
// BTW is a no-tools path, so keep only user-visible blocks from prior
// messages and strip hidden reasoning/tool replay data.
const sanitizedMessage = sanitizeBtwAssistantMessage(
message as Extract<Message, { role: "assistant" }>,
);
if (sanitizedMessage) {
contextMessages.push(sanitizedMessage);
}
}
return stripToolResultDetails(
contextMessages as Parameters<typeof stripToolResultDetails>[0],
) as Message[];
}
async function resolveRuntimeModel(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
agentId?: string;
agentDir: string;
workspaceDir?: string;
sessionEntry?: StoredSessionEntry;
sessionStore?: Record<string, StoredSessionEntry>;
sessionKey?: string;
storePath?: string;
isNewSession: boolean;
}): Promise<{
model: Model;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
}> {
const modelsOptions = params.workspaceDir ? { workspaceDir: params.workspaceDir } : undefined;
await ensureOpenClawModelsJson(params.cfg, params.agentDir, modelsOptions);
const authStorage = discoverAuthStorage(params.agentDir);
const modelRegistry = discoverModels(authStorage, params.agentDir, modelsOptions);
const model = resolveModelWithRegistry({
provider: params.provider,
modelId: params.model,
modelRegistry,
cfg: params.cfg,
});
if (!model) {
throw new Error(`Unknown model: ${params.provider}/${params.model}`);
}
const runtimeProvider = model.provider;
const runtimeModelId = model.id;
const authProfileId = await resolveSessionAuthProfileOverride({
cfg: params.cfg,
provider: runtimeProvider,
acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({
provider: runtimeProvider,
harnessRuntime: resolveAvailableAgentHarnessPolicy({
provider: runtimeProvider,
modelId: runtimeModelId,
config: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
}).runtime,
config: params.cfg,
}),
agentDir: params.agentDir,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
isNewSession: params.isNewSession,
});
return {
model,
authProfileId,
authProfileIdSource: resolveReturnedAuthProfileSource(params.sessionEntry, authProfileId),
};
}
type RunBtwSideQuestionParams = {
cfg: OpenClawConfig;
agentDir: string;
provider: string;
model: string;
question: string;
sessionEntry: StoredSessionEntry;
sessionStore?: Record<string, StoredSessionEntry>;
sessionKey?: string;
sandboxSessionKey?: string;
storePath?: string;
resolvedThinkLevel?: ThinkLevel;
resolvedReasoningLevel: ReasoningLevel;
blockReplyChunking?: BlockReplyChunking;
resolvedBlockStreamingBreak?: "text_end" | "message_end";
opts?: GetReplyOptions;
isNewSession: boolean;
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
messageTo?: string;
messageThreadId?: string | number;
groupId?: string | null;
groupChannel?: string | null;
groupSpace?: string | null;
memberRoleIds?: string[];
spawnedBy?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
senderIsOwner?: boolean;
currentChannelId?: string;
};
async function runCliBtwSideQuestion(params: {
cfg: OpenClawConfig;
model: string;
question: string;
sessionId: string;
sessionFile: string;
sessionEntry: StoredSessionEntry;
sessionKey?: string;
sessionAgentId: string;
workspaceDir: string;
cliProvider: string;
authProfileId?: string;
resolvedThinkLevel?: ThinkLevel;
messages: Message[];
inFlightPrompt?: string;
opts?: GetReplyOptions;
messageChannel?: string;
messageProvider?: string;
currentChannelId?: string;
}): Promise<ReplyPayload> {
const timeoutMs = resolveAgentTimeoutMs({
cfg: params.cfg,
overrideSeconds: params.opts?.timeoutOverrideSeconds,
});
const prepared = await prepareCliRunContext({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionEntry: params.sessionEntry,
agentId: params.sessionAgentId,
trigger: "user",
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
config: params.cfg,
prompt: buildBtwCliPrompt({
messages: params.messages,
question: params.question,
inFlightPrompt: params.inFlightPrompt,
}),
extraSystemPrompt: buildBtwSystemPrompt(),
executionMode: "side-question",
provider: params.cliProvider,
model: params.model,
thinkLevel: params.resolvedThinkLevel,
disableTools: true,
timeoutMs,
runTimeoutOverrideMs: timeoutMs,
runId: params.opts?.runId ?? `btw-${randomUUID()}`,
authProfileId: params.authProfileId,
abortSignal: params.opts?.abortSignal,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
});
try {
const output = await executePreparedCliRun(prepared);
const text = output.text.trim();
if (!text) {
throw new Error(`/btw side question via ${params.cliProvider} produced no answer.`);
}
return { text };
} finally {
await prepared.preparedBackend.cleanup?.();
}
}
/** Answers a side question using sanitized session context and no tool execution. */
export async function runBtwSideQuestion(
params: RunBtwSideQuestionParams,
): Promise<ReplyPayload | undefined> {
const sessionId = params.sessionEntry.sessionId?.trim();
if (!sessionId) {
throw new Error("No active session context.");
}
const sessionFile = resolveBtwSessionTranscriptPath({
sessionId,
sessionEntry: params.sessionEntry,
sessionKey: params.sessionKey,
storePath: params.storePath,
});
if (!sessionFile) {
throw new Error("No active session transcript.");
}
const sessionAgentId = resolveSessionAgentId({
sessionKey: params.sessionKey,
config: params.cfg,
});
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, sessionAgentId);
const preparedHarnesses = new Map<string, AgentHarness>();
const prepareHarness = async (provider: string, modelId: string): Promise<AgentHarness> => {
const key = `${provider}/${modelId}`;
const cached = preparedHarnesses.get(key);
if (cached) {
return cached;
}
await ensureSelectedAgentHarnessPlugin({
provider,
modelId,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
workspaceDir,
});
const harness = selectAgentHarness({
provider,
modelId,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
});
preparedHarnesses.set(key, harness);
return harness;
};
const harness = await prepareHarness(params.provider, params.model);
let runtimeSelection: Awaited<ReturnType<typeof resolveRuntimeModel>> | undefined;
const resolveRuntimeSelection = async () => {
if (!runtimeSelection) {
runtimeSelection = await resolveRuntimeModel({
cfg: params.cfg,
provider: params.provider,
model: params.model,
agentId: sessionAgentId,
agentDir: params.agentDir,
workspaceDir,
sessionEntry: params.sessionEntry,
sessionStore: params.sessionStore,
sessionKey: params.sessionKey,
storePath: params.storePath,
isNewSession: params.isNewSession,
});
}
return runtimeSelection;
};
const runHarnessSideQuestion = async (
selectedHarness: AgentHarness,
runtime: Awaited<ReturnType<typeof resolveRuntimeModel>>,
): Promise<ReplyPayload | undefined> => {
if (!selectedHarness.runSideQuestion) {
throw new Error(
`Selected agent harness "${selectedHarness.id}" does not support /btw side questions.`,
);
}
const toolsAllow = resolvePluginHarnessPolicyToolsAllow({
config: params.cfg,
sessionKey: params.sessionKey,
sandboxSessionKey: params.sandboxSessionKey,
agentId: sessionAgentId,
provider: runtime.model.provider,
modelId: runtime.model.id,
messageProvider: params.messageProvider,
messageChannel: params.messageChannel,
spawnedBy: params.spawnedBy,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
agentAccountId: params.agentAccountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
const result = await selectedHarness.runSideQuestion({
...params,
provider: runtime.model.provider,
model: runtime.model.id,
runtimeModel: runtime.model,
sessionId,
sessionFile,
agentId: sessionAgentId,
workspaceDir,
...(toolsAllow ? { toolsAllow } : {}),
authProfileId: runtime.authProfileId,
authProfileIdSource: runtime.authProfileIdSource,
});
return { text: result.text };
};
if (harness.runSideQuestion) {
return runHarnessSideQuestion(harness, await resolveRuntimeSelection());
}
if (harness.id === "codex") {
throw new Error(`Selected agent harness "${harness.id}" does not support /btw side questions.`);
}
const activeRunSnapshot = getActiveEmbeddedRunSnapshot(sessionId);
const imageLimits = resolveImageSanitizationLimits(params.cfg);
let messages: Message[] = [];
let inFlightPrompt: string | undefined;
if (Array.isArray(activeRunSnapshot?.messages) && activeRunSnapshot.messages.length > 0) {
messages = await toSimpleContextMessages({
messages: activeRunSnapshot.messages,
imageLimits,
});
inFlightPrompt = activeRunSnapshot.inFlightPrompt;
} else if (activeRunSnapshot) {
inFlightPrompt = activeRunSnapshot.inFlightPrompt;
}
if (messages.length === 0) {
messages = await toSimpleContextMessages({
messages: await readBtwTranscriptMessages({
sessionFile,
sessionId,
snapshotLeafId: activeRunSnapshot?.transcriptLeafId,
}),
imageLimits,
});
}
if (messages.length === 0 && !inFlightPrompt?.trim()) {
throw new Error("No active session context.");
}
const fallbackPolicy = resolveAvailableAgentHarnessPolicy({
provider: params.provider,
modelId: params.model,
config: params.cfg,
agentId: sessionAgentId,
sessionKey: params.sessionKey,
});
const fallbackRuntime = fallbackPolicy.runtime.trim();
const sessionAuthProfileId = params.sessionEntry.authProfileOverride?.trim() || undefined;
const sessionAuthProfileSource = resolveReturnedAuthProfileSource(
params.sessionEntry,
sessionAuthProfileId,
);
const cliProviderFromSessionAuth = sessionAuthProfileId
? resolveCliRuntimeExecutionProvider({
provider: params.provider,
cfg: params.cfg,
agentId: sessionAgentId,
modelId: params.model,
authProfileId: sessionAuthProfileId,
})?.trim()
: undefined;
const cliProviderFromAuthOrder =
!sessionAuthProfileId || sessionAuthProfileSource === "auto"
? resolveCliRuntimeExecutionProvider({
provider: params.provider,
cfg: params.cfg,
agentId: sessionAgentId,
modelId: params.model,
})?.trim()
: undefined;
const resolvedCliProvider = cliProviderFromSessionAuth ?? cliProviderFromAuthOrder;
const cliProvider =
resolvedCliProvider ??
(isCliRuntimeAliasForProvider({
runtime: fallbackRuntime,
provider: params.provider,
cfg: params.cfg,
})
? fallbackRuntime
: undefined);
if (cliProvider) {
return runCliBtwSideQuestion({
cfg: params.cfg,
model: params.model,
question: params.question,
sessionId,
sessionFile,
sessionEntry: params.sessionEntry,
sessionKey: params.sessionKey,
sessionAgentId,
workspaceDir,
cliProvider,
authProfileId: cliProviderFromSessionAuth ? sessionAuthProfileId : undefined,
resolvedThinkLevel: params.resolvedThinkLevel,
messages,
inFlightPrompt,
opts: params.opts,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
currentChannelId: params.currentChannelId,
});
}
const runtimeSelectionForHarness = await resolveRuntimeSelection();
// Model resolution can canonicalize a legacy provider alias, so reselect against the resolved
// provider/model instead of reusing the raw route's selection.
const runtimeHarness = await prepareHarness(
runtimeSelectionForHarness.model.provider,
runtimeSelectionForHarness.model.id,
);
if (runtimeHarness.runSideQuestion) {
return runHarnessSideQuestion(runtimeHarness, runtimeSelectionForHarness);
}
if (runtimeHarness.id === "codex") {
throw new Error(
`Selected agent harness "${runtimeHarness.id}" does not support /btw side questions.`,
);
}
const { model, authProfileId, authProfileIdSource } = runtimeSelectionForHarness;
let externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({
provider: model.provider,
cfg: params.cfg,
agentId: sessionAgentId,
modelId: model.id,
workspaceDir,
userLockedAuthProfileId: authProfileIdSource === "user" ? authProfileId : undefined,
});
if (!externalCliAuthScope.providerIds) {
const noExternalAuthStore = ensureAuthProfileStoreWithoutExternalProfiles(params.agentDir, {
allowKeychainPrompt: false,
});
externalCliAuthScope = resolveExternalCliAuthOverlayScopeFromSelection({
provider: model.provider,
cfg: params.cfg,
agentId: sessionAgentId,
modelId: model.id,
workspaceDir,
store: noExternalAuthStore,
userLockedAuthProfileId: authProfileIdSource === "user" ? authProfileId : undefined,
});
}
const authStore = externalCliAuthScope.providerIds
? ensureAuthProfileStore(params.agentDir, {
externalCliProviderIds: externalCliAuthScope.providerIds,
allowKeychainPrompt: false,
})
: undefined;
const effectiveAuthProfileId =
externalCliAuthScope.ignoreAutoPreferredProfile && authProfileIdSource !== "user"
? undefined
: authProfileId;
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.cfg,
profileId: effectiveAuthProfileId,
...(authStore ? { store: authStore } : {}),
agentDir: params.agentDir,
});
const resolvedAuthProfileId = apiKeyInfo.profileId ?? effectiveAuthProfileId;
let runtimeModel = model;
let apiKey =
apiKeyInfo.mode === "aws-sdk" && !apiKeyInfo.apiKey
? undefined
: requireApiKey(apiKeyInfo, model.provider);
if (apiKey) {
const preparedAuth = await prepareProviderRuntimeAuth({
provider: model.provider,
config: params.cfg,
workspaceDir,
env: process.env,
context: {
config: params.cfg,
agentDir: params.agentDir,
workspaceDir,
env: process.env,
provider: model.provider,
modelId: model.id,
model,
apiKey,
authMode: apiKeyInfo.mode,
profileId: resolvedAuthProfileId,
},
});
runtimeModel = applyPreparedRuntimeAuthToModel(runtimeModel, preparedAuth);
if (preparedAuth?.apiKey) {
apiKey = preparedAuth.apiKey;
}
}
// Use the provider's own stream fn so providers like Ollama (which build
// `/api/chat` or `/v1/chat/completions` paths based on api mode) construct
// URLs correctly. Without this, streamSimple hits the provider's baseUrl
// directly and 404s on endpoints like Ollama Cloud (#68336).
const providerStreamFn = registerProviderStreamForModel({
model: runtimeModel,
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir,
env: process.env,
});
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: streamSimple,
providerStreamFn,
sessionId,
signal: params.opts?.abortSignal,
model: runtimeModel,
resolvedApiKey: apiKey,
authProfileId: resolvedAuthProfileId,
});
const chunker =
params.opts?.onBlockReply && params.blockReplyChunking
? new EmbeddedBlockChunker(params.blockReplyChunking)
: undefined;
let emittedBlocks = 0;
let blockEmitChain: Promise<void> = Promise.resolve();
let answerText = "";
let reasoningText = "";
let assistantStarted = false;
let sawTextEvent = false;
const emitBlockChunk = async (text: string) => {
const trimmed = text.trim();
if (!trimmed || !params.opts?.onBlockReply) {
return;
}
emittedBlocks += 1;
blockEmitChain = blockEmitChain.then(async () => {
await params.opts?.onBlockReply?.({
text,
btw: { question: params.question },
});
});
await blockEmitChain;
};
const stream = await streamWithPayloadPatch(
streamFn,
runtimeModel,
{
systemPrompt: buildBtwSystemPrompt(),
messages: [
...messages,
{
role: "user",
content: [
{
type: "text",
text: buildBtwQuestionPrompt(params.question, inFlightPrompt),
},
],
timestamp: Date.now(),
},
],
},
{
apiKey,
// BTW is intentionally a lightweight side question path. Keep provider
// reasoning off so we reliably receive answer text instead of thinking-only output.
reasoning: undefined,
signal: params.opts?.abortSignal,
},
(payloadObj) => {
// BTW is intentionally tool-less. Some OpenAI-compatible providers reject
// the empty tools arrays injected for generic tool-history replay.
if (Array.isArray(payloadObj.tools) && payloadObj.tools.length === 0) {
delete payloadObj.tools;
}
},
);
let finalEvent:
| Extract<AssistantMessageEvent, { type: "done" }>
| Extract<AssistantMessageEvent, { type: "error" }>
| undefined;
for await (const event of stream) {
finalEvent = event.type === "done" || event.type === "error" ? event : finalEvent;
if (!assistantStarted && (event.type === "text_start" || event.type === "start")) {
assistantStarted = true;
await params.opts?.onAssistantMessageStart?.();
}
if (event.type === "text_delta") {
sawTextEvent = true;
answerText += event.delta;
chunker?.append(event.delta);
if (chunker && params.resolvedBlockStreamingBreak === "text_end") {
chunker.drain({ force: false, emit: (chunk) => void emitBlockChunk(chunk) });
}
continue;
}
if (event.type === "text_end" && chunker && params.resolvedBlockStreamingBreak === "text_end") {
chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) });
continue;
}
if (event.type === "thinking_delta") {
reasoningText += event.delta;
if (params.resolvedReasoningLevel !== "off") {
await params.opts?.onReasoningStream?.({ text: reasoningText, isReasoning: true });
}
continue;
}
if (event.type === "thinking_end" && params.resolvedReasoningLevel !== "off") {
await params.opts?.onReasoningEnd?.();
}
}
if (chunker && params.resolvedBlockStreamingBreak !== "text_end" && chunker.hasBuffered()) {
chunker.drain({ force: true, emit: (chunk) => void emitBlockChunk(chunk) });
}
await blockEmitChain;
if (finalEvent?.type === "error") {
const message = collectTextContent(finalEvent.error.content);
throw new Error(message || finalEvent.error.errorMessage || "BTW failed.");
}
const finalMessage = finalEvent?.type === "done" ? finalEvent.message : undefined;
if (finalMessage) {
if (!sawTextEvent) {
answerText = collectTextContent(finalMessage.content);
}
if (!reasoningText) {
collectThinkingContent(finalMessage.content);
}
}
const answer = answerText.trim();
if (!answer) {
throw new Error("No BTW response generated.");
}
if (emittedBlocks > 0) {
return undefined;
}
return { text: answer };
}