import type { ContentBlock, Message } from '../session/session-jsonl.js'; import type { Tool } from '../tools/tool-types.js'; import { randomUUID } from 'node:crypto'; import { extractToolInvocationFromPlanText } from '../tools/extract-tool-invocation.js'; import { splitThinkingTagsFromAssistantText } from '../llm/inline-thinking-stream.js'; import { normalizeToolCallInput, syncAssistantToolUseInput, } from './agent-loop-tool-helpers.js'; function extractVisibleTextFromThinkingBlocks(content: ContentBlock[]): string { const parts: string[] = []; for (const block of content) { if (block.type !== 'text' || typeof block.text !== 'string') continue; const { thinkingBodies } = splitThinkingTagsFromAssistantText(block.text); for (const b of thinkingBodies) { const t = b.trim(); if (t) parts.push(t); } } return parts.join('\n\n'); } /** * Read reasoning text from an in-progress assistant message. * * Prefers the new `Message.thinking` array (provider-native reasoning channel, * industry-standard one-shot per-turn reasoning). Falls back to extracting * `...` tag bodies from text content blocks for legacy * sessions and for upstream models that emit inline tags inside `text_delta` * (rather than via a dedicated `thinking_delta` event). */ export function extractThinkingTextFromMessage( thinkingChunks: ReadonlyArray | undefined, content: ContentBlock[], ): string { if (Array.isArray(thinkingChunks) && thinkingChunks.length > 0) { return thinkingChunks.join('\n\n').trim(); } return extractVisibleTextFromThinkingBlocks(content); } export function hasAssistantThinkingHistory(messages: readonly Message[]): boolean { return messages.some( (msg) => msg.role === 'assistant' && Array.isArray(msg.thinking) && msg.thinking.some((chunk) => String(chunk ?? '').trim()), ); } export function isThinkingOnlyAssistantTurn(params: { visibleText: string; toolCallCount: number; thinkingChunks: ReadonlyArray; assistantContent: ContentBlock[]; }): boolean { if (params.visibleText.trim() || params.toolCallCount > 0) return false; if (params.thinkingChunks.length > 0) return true; return params.assistantContent.some((block) => { if (block.type !== 'text' || typeof block.text !== 'string') return false; const head = block.text.trimStart(); return head.startsWith('') || head.startsWith(''); }); } export function buildVisibleAssistantText(params: { textParts: ReadonlyArray; thinkingFallback: string; }): string { return ( params.textParts .join('') .replace(/<\|FunctionCallBegin\|>[\s\S]*?<\|FunctionCallEnd\|>/g, '') .replace(/<\|FunctionCallBegin\|>[\s\S]*$/, '') .trim() || params.thinkingFallback ); } /** * Some reasoning models mention "I will call tool X for URL Y" in visible text * or thinking, but never emit a tool_use. These predicates decide whether the * loop should add one corrective model turn. */ export function shouldNudgeMissingToolInvocationFromPlan( visibleAssistantText: string, namedWebToolRe: RegExp, ): boolean { const t = visibleAssistantText.trim(); if (t.length < 30) return false; if (!/https?:\/\//i.test(t)) return false; if (!namedWebToolRe.test(t)) return false; const firstPersonIntent = /(?:我(?:来|要|去|将|先)|让我).{0,20}调用/i.test(t); return firstPersonIntent; } export function shouldNudgeMissingToolInvocationFromThinking( thinkingText: string, namedWebToolRe: RegExp, ): boolean { const t = thinkingText.trim(); if (t.length < 30) return false; if (!/https?:\/\//i.test(t)) return false; if (!namedWebToolRe.test(t)) return false; const planIntent = /(?:我(?:来|要|去|将|先)|让我|然后|接下来|紧接(?:下来|着)|最后|下一步|下面|首先|随后).{0,20}调用/i.test( t, ); return planIntent; } export type AgentLoopToolCallDraft = { id: string; name: string; input: Record; }; export function injectToolCallFromPlanText(params: { toolCalls: AgentLoopToolCallDraft[]; assistantContent: ContentBlock[]; turnTextParts: string[]; messageThinkingChunks: string[]; toolsForRun: Tool[]; sessionKey: string; logInfo?: (message: string, meta?: Record) => void; }): void { if (params.toolCalls.length > 0) return; const visibleForExtract = params.turnTextParts.join('').trim(); const thinkingForExtract = extractThinkingTextFromMessage( params.messageThinkingChunks, params.assistantContent, ); const planText = [visibleForExtract, thinkingForExtract].filter(Boolean).join('\n\n'); if (!planText) return; const extracted = extractToolInvocationFromPlanText(planText, params.toolsForRun); if (!extracted) return; const injectedId = `host_${randomUUID()}`; const injectedInput = normalizeToolCallInput( { name: extracted.name, input: extracted.input }, params.toolsForRun, { sessionKey: params.sessionKey }, ); params.assistantContent.push({ type: 'tool_use', id: injectedId, name: extracted.name, input: injectedInput, }); params.toolCalls.push({ id: injectedId, name: extracted.name, input: injectedInput }); params.logInfo?.('host tool invocation injected from plan text', { tool: extracted.name, required: extracted.satisfiedRequired, sessionKey: params.sessionKey, }); } export function normalizeAssistantToolCalls(params: { toolCalls: AgentLoopToolCallDraft[]; assistantContent: ContentBlock[]; toolsForRun: Tool[]; sessionKey: string; }): void { for (const call of params.toolCalls) { call.input = normalizeToolCallInput(call, params.toolsForRun, { sessionKey: params.sessionKey }); syncAssistantToolUseInput(params.assistantContent, call); } } export function shouldNudgeMissingToolInvocation(params: { finalText: string; messageThinkingChunks: string[]; assistantContent: ContentBlock[]; namedWebToolRe: RegExp; }): boolean { const visibleHits = !!params.finalText.trim() && shouldNudgeMissingToolInvocationFromPlan(params.finalText, params.namedWebToolRe); if (visibleHits) return true; const thinkingPlanText = extractThinkingTextFromMessage( params.messageThinkingChunks, params.assistantContent, ); return ( !!thinkingPlanText && shouldNudgeMissingToolInvocationFromThinking(thinkingPlanText, params.namedWebToolRe) ); }