149 lines
5.7 KiB
TypeScript
149 lines
5.7 KiB
TypeScript
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<LlmIterationResult> {
|
||
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 };
|
||
}
|