/**
 * AgentController loop with post-stream parallel tool dispatch.
 *
 * Tool calls are collected while the assistant response streams, then executed
 * concurrently after the message completes.
 *
 * Steering is checked once after all tools complete, not between each tool.
 */
import { EventStream } from '../ai/event-stream.js';
import { validateToolArguments } from '../ai/validation.js';
import { ABORT_AGENT_MESSAGE } from '../utils/abort.js';
import { DEFAULT_MAX_CHARS, ERROR_MAX_CHARS_LLM, truncateErrorOutput, truncateOutput } from '../utils/truncate.js';
import { ToolResultLedger } from './agent-loop-ledger.js';
import { extractToolCalls, synthesizeOrphanedToolResults } from './agent-loop-orphan-repair.js';
import { AgentLoopEventType, AssistantMessageEventType, InFlightOpKind } from './core-types.js';
import { getAgentErrorMessage } from './error-classification.js';
import { getErrorDetails } from './session/session-error-recovery.js';
import { isToolExecutionError } from './tool-execution-error.js';
import { getActiveUserMessageId } from './user-message-id.js';
/** Yield to the event loop every N stream events to prevent microtask starvation. */
const STREAM_YIELD_INTERVAL = 5;
const GENERIC_LLM_STREAM_FAILURE = 'LLM stream failed';
const TEMPORARY_PROVIDER_FAILURE_TEXT = 'The model provider had a temporary issue after automatic retries. Please try again.';
function yieldToEventLoop() {
    if (typeof setImmediate === 'function') {
        return new Promise((resolve) => setImmediate(resolve));
    }
    return new Promise((resolve) => setTimeout(resolve, 0));
}
function shouldUseTemporaryProviderFailureText(reason, message) {
    if (reason !== GENERIC_LLM_STREAM_FAILURE)
        return false;
    const details = message ? getErrorDetails(message) : undefined;
    if (details?.retryable !== true)
        return false;
    return (details.providerErrorType === 'internal' ||
        details.providerErrorType === 'network' ||
        details.status === 503);
}
function formatAssistantErrorText(reason, message) {
    if (shouldUseTemporaryProviderFailureText(reason, message)) {
        return TEMPORARY_PROVIDER_FAILURE_TEXT;
    }
    return `[Request failed]\n${truncateErrorOutput(reason, ERROR_MAX_CHARS_LLM)}`;
}
/**
 * Start an agent loop with a new prompt message.
 * The prompt is added to the context and events are emitted for it.
 */
export function agentLoop(prompts, context, config, signal, streamFn) {
    const stream = createAgentStream();
    (async () => {
        const newMessages = [...prompts];
        const currentContext = {
            ...context,
            messages: [...context.messages, ...prompts]
        };
        try {
            stream.push({ type: AgentLoopEventType.AgentStart });
            stream.push({ type: AgentLoopEventType.TurnStart });
            for (const prompt of prompts) {
                stream.push({ type: AgentLoopEventType.MessageStart, message: prompt });
                stream.push({ type: AgentLoopEventType.MessageEnd, message: prompt });
            }
            await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
        }
        catch (error) {
            finalizeProducerError(stream, currentContext, newMessages, config, error);
        }
    })();
    return stream;
}
/**
 * Continue an agent loop from the current context without adding a new message.
 * Used for retries - context already has user message or tool results.
 *
 * **Important:** The last message in context must convert to a `user` or `toolResult` message
 * via `convertToLlm`. If it doesn't, the LLM provider will reject the request.
 * This cannot be validated here since `convertToLlm` is only called once per turn.
 */
export function agentLoopContinue(context, config, signal, streamFn) {
    if (context.messages.length === 0) {
        throw new Error('Cannot continue: no messages in context');
    }
    if (context.messages[context.messages.length - 1].role === 'assistant') {
        throw new Error('Cannot continue from message role: assistant');
    }
    const stream = createAgentStream();
    (async () => {
        const newMessages = [];
        const currentContext = { ...context };
        try {
            stream.push({ type: AgentLoopEventType.AgentStart });
            stream.push({ type: AgentLoopEventType.TurnStart });
            await runLoop(currentContext, newMessages, config, signal, stream, streamFn);
        }
        catch (error) {
            finalizeProducerError(stream, currentContext, newMessages, config, error);
        }
    })();
    return stream;
}
function createAgentStream() {
    return new EventStream((event) => event.type === AgentLoopEventType.AgentEnd, (event) => (event.type === AgentLoopEventType.AgentEnd ? event.messages : []));
}
/**
 * Terminate the stream after an unhandled producer error so the consumer never
 * sees a stranded run (no AgentEnd, heartbeats forever).
 *
 * When the throw lands mid-stream, `streamAssistantResponse` has already
 * pushed a partial assistant into context and emitted MessageStart for it
 * (but no MessageEnd). Close that partial in place rather than appending
 * a second adjacent assistant — two assistants in a row break the
 * assistant/toolResult alternation contract expected by LLM providers and
 * leave `state.streamMessage` dangling in the reducer.
 *
 * Also synthesizes tool_results for any orphaned tool_use blocks on the
 * closing assistant so the conversation stays retryable.
 */
function finalizeProducerError(stream, context, newMessages, config, error) {
    const reason = getAgentErrorMessage(error);
    console.warn(`[agent-loop] producer threw: ${reason}`);
    const partial = findDanglingPartial(context.messages);
    const errorText = formatAssistantErrorText(reason, partial);
    let terminalMessage;
    if (partial) {
        partial.stopReason = 'error';
        partial.errorMessage = reason;
        if (partial.content.length === 0) {
            partial.content.push({ type: 'text', text: errorText });
        }
        terminalMessage = partial;
        newMessages.push(terminalMessage);
        stream.push({ type: AgentLoopEventType.MessageEnd, message: terminalMessage });
    }
    else {
        terminalMessage = {
            role: 'assistant',
            content: [{ type: 'text', text: errorText }],
            api: config.model.api,
            provider: config.model.provider,
            model: config.model.id,
            usage: {
                input: 0,
                output: 0,
                cacheRead: 0,
                cacheWrite: 0,
                totalTokens: 0,
                cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
            },
            stopReason: 'error',
            errorMessage: reason,
            timestamp: Date.now()
        };
        context.messages.push(terminalMessage);
        newMessages.push(terminalMessage);
        stream.push({ type: AgentLoopEventType.MessageStart, message: terminalMessage });
        stream.push({ type: AgentLoopEventType.MessageEnd, message: terminalMessage });
    }
    const orphanResults = repairOrphanedToolCalls(terminalMessage, context.messages, `Tool call failed: ${reason}`);
    for (const result of orphanResults) {
        emitToolResultMessage(stream, result);
        context.messages.push(result);
        newMessages.push(result);
    }
    stream.push({
        type: AgentLoopEventType.TurnEnd,
        message: terminalMessage,
        toolResults: orphanResults
    });
    stream.push({ type: AgentLoopEventType.AgentEnd, messages: newMessages });
    stream.end(newMessages);
}
/**
 * A dangling partial is an assistant `streamAssistantResponse` pushed at the
 * Start event but never finalized — finalized messages always carry a
 * `stopReason`, so its absence is the reliable signal.
 */
function findDanglingPartial(messages) {
    const last = messages[messages.length - 1];
    if (last?.role !== 'assistant')
        return undefined;
    const assistant = last;
    return assistant.stopReason === undefined ? assistant : undefined;
}
/**
 * Synthesize tool_result messages for any toolCalls on `assistant` that lack
 * a matching tool_result later in the message history. Mirrors
 * `synthesizeOrphanedToolResults`, but resolves "already satisfied" against
 * the message array rather than a per-turn ledger (the catch path has no
 * access to the turn's live ledger).
 */
function repairOrphanedToolCalls(assistant, messages, errorText) {
    const toolCalls = extractToolCalls(assistant);
    if (toolCalls.length === 0)
        return [];
    const assistantIdx = messages.lastIndexOf(assistant);
    const resolvedIds = new Set();
    for (let i = assistantIdx + 1; i < messages.length; i++) {
        const msg = messages[i];
        if (msg.role === 'toolResult') {
            resolvedIds.add(msg.toolCallId);
        }
    }
    return toolCalls
        .filter((tc) => !resolvedIds.has(tc.id))
        .map((tc) => ({
        role: 'toolResult',
        toolCallId: tc.id,
        toolName: tc.name,
        content: [{ type: 'text', text: errorText }],
        isError: true,
        timestamp: Date.now()
    }));
}
// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------
/**
 * Main loop logic shared by agentLoop and agentLoopContinue.
 */
async function runLoop(currentContext, newMessages, config, signal, stream, streamFn) {
    const requestAssistantResponse = config.requestAssistantResponse ?? streamAssistantResponse;
    let firstTurn = true;
    // Check for steering messages at start (user may have typed while waiting)
    let pendingMessages = (await config.getSteeringMessages?.()) || [];
    // Outer loop: continues when queued follow-up messages arrive after agent would stop
    while (true) {
        let hasMoreToolCalls = true;
        let steeringAfterTools = null;
        // Inner loop: process tool calls and steering messages
        while (hasMoreToolCalls || pendingMessages.length > 0) {
            if (!firstTurn) {
                stream.push({ type: AgentLoopEventType.TurnStart });
            }
            else {
                firstTurn = false;
            }
            // Process pending messages (inject before next assistant response)
            if (pendingMessages.length > 0) {
                for (const message of pendingMessages) {
                    stream.push({ type: AgentLoopEventType.MessageStart, message });
                    stream.push({ type: AgentLoopEventType.MessageEnd, message });
                    currentContext.messages.push(message);
                    newMessages.push(message);
                }
                pendingMessages = [];
            }
            // -------------------------------------------------------------------
            // Tool dispatch: collect tool calls while streaming, then execute them
            // concurrently after the assistant message completes.
            // -------------------------------------------------------------------
            const activeConfiguration = config.getActiveConfiguration?.();
            if (activeConfiguration) {
                config.agentMode = activeConfiguration.agentMode;
                currentContext.systemPrompt = activeConfiguration.systemPrompt;
                currentContext.tools = activeConfiguration.tools;
            }
            const activeUserMessageId = getActiveUserMessageId(currentContext.messages);
            if (activeUserMessageId) {
                config.userMessageId = activeUserMessageId;
            }
            else {
                delete config.userMessageId;
            }
            const ledger = new ToolResultLedger();
            const deferredWriteToolCalls = [];
            // Callback invoked by streamAssistantResponse when a toolcall_end arrives
            const onToolCallReady = (toolCall) => {
                ledger.plan([toolCall]);
                deferredWriteToolCalls.push(toolCall);
            };
            const llmCall = requestAssistantResponse(currentContext, config, signal, stream, streamFn, onToolCallReady);
            const message = await (config.heartbeatWrap
                ? config.heartbeatWrap(llmCall, InFlightOpKind.Llm, { label: 'Thinking…' })
                : llmCall);
            newMessages.push(message);
            if (message.stopReason === 'error' || message.stopReason === 'aborted') {
                // Ensure the assistant message is never blank — inject error text so
                // retries give the LLM useful context instead of an empty message.
                // Skip for aborts: user-initiated cancellations are handled by the UI
                // abort label and don't need "[Request failed: ...]" injected.
                if (message.stopReason !== 'aborted' &&
                    message.content.length === 0 &&
                    message.errorMessage) {
                    message.content.push({
                        type: 'text',
                        text: formatAssistantErrorText(message.errorMessage, message)
                    });
                }
                // If the errored/aborted response contains tool calls, synthesize
                // error tool_results so the tool_use→tool_result contract is never
                // violated. Without this, the orphaned tool_use blocks make every
                // subsequent API call fail with a 400.
                const errorText = message.stopReason === 'aborted'
                    ? ABORT_AGENT_MESSAGE
                    : `Tool call failed: ${message.errorMessage || 'unknown error'}`;
                const errorToolResults = synthesizeOrphanedToolResults(message, errorText, ledger);
                for (const result of errorToolResults) {
                    if (ledger.isEmitted(result.toolCallId))
                        continue;
                    emitToolResultMessage(stream, result);
                    ledger.markEmitted(result.toolCallId);
                    newMessages.push(result);
                }
                stream.push({ type: AgentLoopEventType.TurnEnd, message, toolResults: errorToolResults });
                stream.push({ type: AgentLoopEventType.AgentEnd, messages: newMessages });
                stream.end(newMessages);
                return;
            }
            // Check for tool calls
            const toolCalls = extractToolCalls(message);
            hasMoreToolCalls = toolCalls.length > 0;
            ledger.plan(toolCalls);
            if (message.stopReason === 'length' && hasMoreToolCalls) {
                const orphanResults = synthesizeOrphanedToolResults(message, 'Tool call truncated due to output token limit', ledger);
                for (const result of orphanResults) {
                    if (ledger.isEmitted(result.toolCallId))
                        continue;
                    emitToolResultMessage(stream, result);
                    ledger.markEmitted(result.toolCallId);
                    newMessages.push(result);
                }
                stream.push({ type: AgentLoopEventType.TurnEnd, message, toolResults: orphanResults });
                stream.push({ type: AgentLoopEventType.AgentEnd, messages: newMessages });
                stream.end(newMessages);
                return;
            }
            const toolResults = [];
            if (hasMoreToolCalls) {
                // Tool calls present in the final message but not seen via onToolCallReady
                // (happens when requestAssistantResponse doesn't invoke the callback, e.g.
                // non-streaming mocks or custom wrappers). Execute them after the stream
                // like the rest of the tools.
                for (const tc of toolCalls) {
                    if (!deferredWriteToolCalls.some((candidate) => candidate.id === tc.id)) {
                        deferredWriteToolCalls.push(tc);
                    }
                }
                // Execute all tools concurrently after the assistant message completes.
                await Promise.all(deferredWriteToolCalls.map(async (toolCall) => {
                    const tool = currentContext.tools?.find((t) => t.name === toolCall.name);
                    const result = await executeSingleToolCall(tool, toolCall, signal, stream, config.getToolContext, { emitMessageEvents: false });
                    ledger.recordResult(toolCall, result);
                }));
                // Collect all results in the order they appear in the assistant message
                toolResults.push(...ledger.collectResultsInMessageOrder(toolCalls));
                for (const result of toolResults) {
                    if (!ledger.isEmitted(result.toolCallId)) {
                        emitToolResultMessage(stream, result);
                        ledger.markEmitted(result.toolCallId);
                    }
                }
                for (const result of toolResults) {
                    currentContext.messages.push(result);
                    newMessages.push(result);
                }
                // Tool execution may finish after the user has already cancelled the run.
                // In that case the product contract is to stop cleanly after the tool
                // boundary, not ask the model for another turn based on a cancelled tool result.
                if (signal?.aborted) {
                    stream.push({ type: AgentLoopEventType.TurnEnd, message, toolResults });
                    stream.push({ type: AgentLoopEventType.AgentEnd, messages: newMessages });
                    stream.end(newMessages);
                    return;
                }
                // Check steering once after all tools complete
                const steering = await config.getSteeringMessages?.();
                steeringAfterTools = steering?.length ? steering : null;
            }
            stream.push({ type: AgentLoopEventType.TurnEnd, message, toolResults });
            // Get steering messages after turn completes
            if (steeringAfterTools && steeringAfterTools.length > 0) {
                pendingMessages = steeringAfterTools;
                steeringAfterTools = null;
            }
            else {
                pendingMessages = (await config.getSteeringMessages?.()) || [];
            }
        }
        // AgentController would stop here. Check for follow-up messages.
        const followUpMessages = (await config.getFollowUpMessages?.()) || [];
        if (followUpMessages.length > 0) {
            // Set as pending so inner loop processes them
            pendingMessages = followUpMessages;
            continue;
        }
        // No more messages, exit
        break;
    }
    stream.push({ type: AgentLoopEventType.AgentEnd, messages: newMessages });
    stream.end(newMessages);
}
// ---------------------------------------------------------------------------
// Single tool execution
// ---------------------------------------------------------------------------
/**
 * Execute a single tool call: validate, run, truncate, emit events.
 * Used for post-stream parallel tool dispatch.
 */
async function executeSingleToolCall(tool, toolCall, signal, stream, getToolContext, options) {
    const startedAt = Date.now();
    stream.push({
        type: AgentLoopEventType.ToolExecutionStart,
        toolCallId: toolCall.id,
        toolName: toolCall.name,
        args: toolCall.arguments,
        startedAt
    });
    let result;
    let isError = false;
    try {
        if (!tool)
            throw new Error(`Tool ${toolCall.name} not found`);
        const validatedArgs = validateToolArguments(tool, toolCall);
        const ctx = getToolContext?.();
        result = await tool.execute(toolCall.id, validatedArgs, signal, (partialResult) => {
            stream.push({
                type: AgentLoopEventType.ToolExecutionUpdate,
                toolCallId: toolCall.id,
                toolName: toolCall.name,
                args: toolCall.arguments,
                partialResult
            });
        }, ctx);
        isError = result.isError === true;
    }
    catch (e) {
        result = {
            content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }],
            details: isToolExecutionError(e) ? e.details : {}
        };
        isError = true;
    }
    // Truncate to prevent bloated context. Errors get a tighter limit.
    const maxChars = isError ? ERROR_MAX_CHARS_LLM : DEFAULT_MAX_CHARS;
    result = {
        ...result,
        content: result.content.map((block) => {
            if (block.type === 'text' && block.text.length > maxChars) {
                return {
                    ...block,
                    text: isError
                        ? truncateErrorOutput(block.text, maxChars)
                        : truncateOutput(block.text, maxChars)
                };
            }
            return block;
        })
    };
    stream.push({
        type: AgentLoopEventType.ToolExecutionEnd,
        toolCallId: toolCall.id,
        toolName: toolCall.name,
        result,
        isError,
        startedAt,
        endedAt: Date.now()
    });
    const toolResultMessage = {
        role: 'toolResult',
        toolCallId: toolCall.id,
        toolName: toolCall.name,
        content: result.content,
        details: result.details,
        isError,
        timestamp: Date.now()
    };
    if (options?.emitMessageEvents !== false) {
        stream.push({ type: AgentLoopEventType.MessageStart, message: toolResultMessage });
        stream.push({ type: AgentLoopEventType.MessageEnd, message: toolResultMessage });
    }
    return toolResultMessage;
}
function emitToolResultMessage(stream, result) {
    stream.push({ type: AgentLoopEventType.MessageStart, message: result });
    stream.push({ type: AgentLoopEventType.MessageEnd, message: result });
}
// ---------------------------------------------------------------------------
// Stream assistant response
// ---------------------------------------------------------------------------
/**
 * Get an assistant response via streaming.
 * Emits message_start, message_update deltas, then message_end.
 *
 * The optional `onToolCallReady` callback fires each time a complete tool call
 * arrives during streaming, enabling the caller to record tool calls before
 * the full response is available.
 */
export async function streamAssistantResponse(context, config, signal, stream, streamFn, onToolCallReady) {
    // Apply context transform if configured (AgentMessage[] → AgentMessage[])
    let messages = context.messages;
    if (config.transformContext) {
        messages = await config.transformContext(messages, signal);
    }
    // Convert to LLM-compatible messages (AgentMessage[] → Message[])
    const llmMessages = await config.convertToLlm(messages);
    const llmContext = {
        systemPrompt: context.systemPrompt,
        messages: llmMessages,
        tools: context.tools
    };
    if (!streamFn) {
        throw new Error('AgentController runtime requires an explicit stream function.');
    }
    const resolvedApiKey = (config.getApiKey ? await config.getApiKey(config.model.provider) : undefined) || config.apiKey;
    const response = await streamFn(config.model, llmContext, {
        ...config,
        apiKey: resolvedApiKey,
        signal
    });
    let partialMessage = null;
    let addedPartial = false;
    // microtask starvation that blocks TUI rendering on slower machines.
    let eventCount = 0;
    for await (const event of response) {
        if (++eventCount % STREAM_YIELD_INTERVAL === 0) {
            await yieldToEventLoop();
        }
        switch (event.type) {
            case AssistantMessageEventType.Start:
                partialMessage = event.partial;
                context.messages.push(partialMessage);
                addedPartial = true;
                stream.push({ type: AgentLoopEventType.MessageStart, message: { ...partialMessage } });
                break;
            case AssistantMessageEventType.TextStart:
            case AssistantMessageEventType.TextDelta:
            case AssistantMessageEventType.TextEnd:
            case AssistantMessageEventType.ThinkingStart:
            case AssistantMessageEventType.ThinkingDelta:
            case AssistantMessageEventType.ThinkingEnd:
            case AssistantMessageEventType.ToolCallStart:
            case AssistantMessageEventType.ToolCallDelta:
                if (partialMessage) {
                    partialMessage = event.partial;
                    context.messages[context.messages.length - 1] = partialMessage;
                    stream.push({
                        type: AgentLoopEventType.MessageUpdate,
                        assistantMessageEvent: event,
                        message: { ...partialMessage }
                    });
                }
                break;
            case AssistantMessageEventType.ToolCallEnd:
                if (partialMessage) {
                    partialMessage = event.partial;
                    context.messages[context.messages.length - 1] = partialMessage;
                    stream.push({
                        type: AgentLoopEventType.MessageUpdate,
                        assistantMessageEvent: event,
                        message: { ...partialMessage }
                    });
                    // Notify caller that a complete tool call has arrived in the stream
                    if ('toolCall' in event) {
                        onToolCallReady?.(event.toolCall);
                    }
                }
                break;
            case AssistantMessageEventType.Done:
            case AssistantMessageEventType.Error: {
                const finalMessage = await response.result();
                if (addedPartial) {
                    context.messages[context.messages.length - 1] = finalMessage;
                }
                else {
                    context.messages.push(finalMessage);
                }
                if (!addedPartial) {
                    stream.push({ type: AgentLoopEventType.MessageStart, message: { ...finalMessage } });
                }
                stream.push({ type: AgentLoopEventType.MessageEnd, message: finalMessage });
                return finalMessage;
            }
        }
    }
    return await response.result();
}
//# sourceMappingURL=agent-loop.js.map