import type { OpenAICompatClient, Message, ToolDef } from '../../llm/openai-compat.js'; import { consumeLlmStream, type ConsumedLLMResponse } from '../llm-stream.js'; import type { EventLogger } from '../../progress/event-log.js'; import { logger } from '../../logger.js'; import { TRANSITION_TOOL_NAME, COMPLETE_TOOL_NAME } from './terminal-control.js'; import type { AgentLoopCallbacks } from './types.js'; import type { MovementWatchdogs } from './watchdogs.js'; export interface LlmIterationResult extends ConsumedLLMResponse { /** Stream wall-clock time from request send to last chunk (ms). */ llmDurationMs: number; } export interface RunLlmIterationArgs { client: OpenAICompatClient; messages: Message[]; tools: ToolDef[]; cancelSignal?: AbortSignal; callbacks?: AgentLoopCallbacks; /** * Mutated in place: the regular (non flow-control) tool names used so far in * this movement. The onToolUse hook appends newly-seen names, matching the * original inline behaviour. */ toolsUsed: string[]; /** Movement watchdogs; markToolUse is fired for every tool the LLM invokes. */ watchdogs: MovementWatchdogs; movementName: string; iteration: number; /** Movement-scoped EventLogger (movementEvents). */ events: EventLogger; userId?: string; } /** * Run one LLM call for the current iteration: emit the start/end trace events, * stream the response through consumeLlmStream (wiring the public callbacks and * filtering the hidden transition/complete control tools out of the UI-facing * channels), fire onLLMCall, and log the usage / text-preview summary. * * Extracted verbatim from executeMovement (agent-loop.ts) to slim the loop * body. Returns the consumed stream plus the call's wall-clock duration. */ export async function runLlmIteration(args: RunLlmIterationArgs): Promise { const { client, messages, tools, cancelSignal, callbacks, toolsUsed, watchdogs, movementName, iteration, events, userId, } = args; logger.info(`[agent-loop] movement=${movementName} sending LLM request (iteration=${iteration})`); // provider.timeoutMinutes に連動(デフォルト10分)。チャンク間の無応答がこの時間を超えたら接続断とみなす const idleTimeoutMs = client.timeoutMs > 0 ? client.timeoutMs : 10 * 60 * 1000; const llmStartedAt = Date.now(); events.emit('llm_call_start', { iteration, messageCount: messages.length, }); callbacks?.onLlmRequestStart?.({ movementName, iteration, messageCount: messages.length }); const consumed = await consumeLlmStream( client, messages, tools, cancelSignal, idleTimeoutMs, { onText: callbacks?.onText, onToolUse: (name, input, callId) => { if (name !== TRANSITION_TOOL_NAME && name !== COMPLETE_TOOL_NAME) { callbacks?.onToolUse?.(name, input, callId); if (!toolsUsed.includes(name)) toolsUsed.push(name); } watchdogs.markToolUse(name); }, onToolCallDelta: (_index, callId, name, chunk) => { // Hidden control tools never stream to the UI. if (name && (name === TRANSITION_TOOL_NAME || name === COMPLETE_TOOL_NAME)) { return; } callbacks?.onToolCallDelta?.(callId, name, chunk); }, onPromptProgress: (progress) => { callbacks?.onPromptProgress?.(progress); }, // Phase A: surface proxy backend identity to the worker. Only // fires for proxy-mode clients that received x-litellm-model-id. onBackend: (backendId, cacheKey) => { callbacks?.onBackendResolved?.({ backendId, cacheKey }); }, onRetry: (info) => { events.emit('llm_call_retry', { iteration, attempt: info.attempt, maxAttempts: info.maxAttempts, reason: info.reason.slice(0, 300), errorClass: info.errorClass, httpStatus: info.httpStatus, delayMs: info.delayMs, }); callbacks?.onLlmRetry?.(info); }, onThinking: (totalChars) => { callbacks?.onThinking?.({ chars: totalChars }); }, }, `movement=${movementName} `, { userId }, ); const llmDurationMs = Date.now() - llmStartedAt; const { accumulatedText, pendingToolCalls, hadError, lastUsage } = consumed; // 診断カラム(分類・リトライ数・thinking 量・担当バックエンド)を1呼び出し // 1行の llm_call_end に集約する。TraceTab の LLM 呼び出しログの一次データ。 const callInfo = { iteration, durationMs: llmDurationMs, promptTokens: lastUsage?.prompt_tokens, completionTokens: lastUsage?.completion_tokens, toolCalls: pendingToolCalls.length, textChars: accumulatedText.length, hadError, errorClass: consumed.errorClass, httpStatus: consumed.httpStatus, retries: consumed.retries, thinkingChars: consumed.thinkingChars, backendId: consumed.backendId, }; events.emit('llm_call_end', callInfo); callbacks?.onLLMCall?.(callInfo); logger.info(`[agent-loop] movement=${movementName} LLM stream ended (iteration=${iteration}, hadError=${hadError}${consumed.errorClass ? ` class=${consumed.errorClass}` : ''}, ${llmDurationMs}ms${lastUsage ? ` in=${lastUsage.prompt_tokens} out=${lastUsage.completion_tokens}` : ''})`); // LLM 応答のサマリーログ logger.info(`[agent-loop] movement=${movementName} response: text=${accumulatedText.length}chars toolCalls=${pendingToolCalls.length} tools=[${pendingToolCalls.map((t) => t.function.name).join(',')}]`); if (accumulatedText.length > 0) { logger.info(`[agent-loop] movement=${movementName} text preview: ${accumulatedText.substring(0, 300)}`); callbacks?.onTextPreview?.(movementName, accumulatedText); } return { ...consumed, llmDurationMs }; }