package ai.intelliswarm.swarmai.agent;

import ai.intelliswarm.swarmai.agent.streaming.AgentEvent;
import ai.intelliswarm.swarmai.api.PublicApi;
import ai.intelliswarm.swarmai.config.ModelContextConfig;
import ai.intelliswarm.swarmai.event.SwarmEvent;
import ai.intelliswarm.swarmai.event.SwarmEventBus;
import ai.intelliswarm.swarmai.memory.Memory;
import ai.intelliswarm.swarmai.knowledge.Knowledge;
import ai.intelliswarm.swarmai.tool.base.BaseTool;
import ai.intelliswarm.swarmai.tool.base.PermissionLevel;
import ai.intelliswarm.swarmai.tool.base.ToolHook;
import ai.intelliswarm.swarmai.tool.base.ToolHookContext;
import ai.intelliswarm.swarmai.tool.base.ToolHookResult;
import ai.intelliswarm.swarmai.skill.GeneratedSkill;
import ai.intelliswarm.swarmai.tool.mcp.McpToolAdapter;
import ai.intelliswarm.swarmai.task.Task;
import ai.intelliswarm.swarmai.task.output.TaskOutput;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.util.StringUtils;

import ai.intelliswarm.swarmai.exception.AgentExecutionException;

import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Supplier;

@PublicApi(since = "1.0")
public class Agent {

    private static final Logger logger = LoggerFactory.getLogger(Agent.class);
    /**
     * Maximum LLM-call attempts before {@link #callWithRetry} gives up.
     * Bumped from 3 → 5 in 1.0.24-SNAPSHOT after observing that some
     * upstream providers (notably OpenAI) intermittently return HTTP 200
     * with empty response bodies in bursts longer than 3 attempts × 6 s
     * exponential backoff. 5 attempts gives the framework enough room to
     * ride out the worst sustained flake we've observed without forcing
     * users to set a per-agent override. Backoff sequence is now
     * 2 s / 4 s / 8 s / 16 s = 30 s wall ceiling per call.
     */
    private static final int DEFAULT_MAX_RETRIES = 5;
    private static final int DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes — used as cold-start fallback

    /**
     * Shared latency tracker across all Agent instances. Adapts timeouts based on
     * observed LLM responsiveness (P95 × safetyMultiplier, clamped to a floor/ceiling).
     * Keyed by model name so fast and slow models don't pollute each other's history.
     */
    private static final ai.intelliswarm.swarmai.agent.resilience.LlmLatencyTracker LATENCY_TRACKER =
            new ai.intelliswarm.swarmai.agent.resilience.LlmLatencyTracker();

    /** Accessor for the shared latency tracker — used by LlmHealthChecker.benchmarkAndSeed(). */
    public static ai.intelliswarm.swarmai.agent.resilience.LlmLatencyTracker getLatencyTracker() {
        return LATENCY_TRACKER;
    }
    private static final int DEFAULT_MAX_TURNS = 10; // reactive loop safety cap

    @NotNull
    private final String id;
    
    @NotBlank
    private final String role;
    
    @NotBlank
    private final String goal;
    
    @NotBlank
    private final String backstory;
    
    /**
     * Tool list. Backed by {@link java.util.concurrent.CopyOnWriteArrayList}
     * so {@link #registerTool} and {@link #registerTools} can hot-add new
     * tools to a running Agent without coordination — the LLM-driven loop
     * iterates a snapshot and any in-flight call sees a consistent view.
     */
    private final List<BaseTool> tools;
    private final boolean verbose;
    private final boolean allowDelegation;
    private final Integer maxIter;
    private final Integer maxRpm;
    private final Double temperature;
    private final Integer maxExecutionTime;
    private final boolean systemTemplate;
    private final boolean stepCallback;
    
    @JsonIgnore
    private ChatClient chatClient;

    /**
     * Optional provider-neutral LLM SPI. When set, {@link #callLlm} routes
     * through this client instead of {@link #chatClient}, unlocking native
     * Anthropic features (prompt caching, extended thinking, adaptive
     * thinking, citations) that Spring AI's ChatClient doesn't expose.
     * Tool-bearing calls still fall back to the Spring AI path because the
     * LlmClient SPI itself doesn't orchestrate the agentic tool loop.
     */
    @JsonIgnore
    private ai.intelliswarm.swarmai.agent.llm.LlmClient llmClient;

    /**
     * Optional lifecycle-hook registry. When set, {@link Agent} dispatches
     * {@link ai.intelliswarm.swarmai.agent.hook.LifecycleEvent}s at the
     * relevant points in the agent loop (UserPromptSubmit before each LLM
     * call, Stop at the end of executeTask). Hooks can BLOCK to abort or
     * MODIFY to rewrite event payloads. Default {@code null} = no
     * lifecycle dispatch (existing behaviour, zero overhead).
     */
    @JsonIgnore
    private ai.intelliswarm.swarmai.agent.hook.LifecycleHookRegistry lifecycleHooks;

    @JsonIgnore
    private Memory memory;

    @JsonIgnore
    private Knowledge knowledge;

    private final String modelName;

    @JsonIgnore
    private final ModelContextConfig contextConfig;

    private final Integer maxTurns;
    private final PermissionLevel permissionMode;
    private final List<ToolHook> toolHooks;
    private final CompactionConfig compactionConfig;

    private final LocalDateTime createdAt;
    private final Map<String, Object> metadata;
    private Integer executionCount = 0;

    private Agent(Builder builder) {
        this.id = builder.id != null ? builder.id : UUID.randomUUID().toString();
        this.role = builder.role;
        this.goal = builder.goal;
        this.backstory = builder.backstory;
        this.tools = new java.util.concurrent.CopyOnWriteArrayList<>(builder.tools);
        this.verbose = builder.verbose;
        this.allowDelegation = builder.allowDelegation;
        this.maxIter = builder.maxIter;
        this.maxRpm = builder.maxRpm;
        this.temperature = builder.temperature;
        this.maxExecutionTime = builder.maxExecutionTime;
        this.systemTemplate = builder.systemTemplate;
        this.stepCallback = builder.stepCallback;
        this.chatClient = builder.chatClient;
        this.llmClient = builder.llmClient;
        this.lifecycleHooks = builder.lifecycleHooks;
        this.memory = builder.memory;
        this.knowledge = builder.knowledge;
        this.modelName = builder.modelName;
        this.contextConfig = ModelContextConfig.forModel(builder.modelName);
        this.maxTurns = builder.maxTurns;
        this.permissionMode = builder.permissionMode;
        this.toolHooks = new ArrayList<>(builder.toolHooks);
        this.compactionConfig = builder.compactionConfig != null
                ? builder.compactionConfig
                : CompactionConfig.forModel(this.contextConfig);
        this.createdAt = LocalDateTime.now();
        this.metadata = new HashMap<>(builder.metadata);
    }

    public CompletableFuture<TaskOutput> executeTaskAsync(Task task, List<TaskOutput> context) {
        return CompletableFuture.supplyAsync(() -> executeTask(task, context));
    }

    public TaskOutput executeTask(Task task, List<TaskOutput> context) {
        // If maxTurns > 1, use the reactive multi-turn loop
        if (maxTurns != null && maxTurns > 1) {
            return executeTaskReactive(task, context);
        }
        return executeSingleShot(task, context);
    }

    /**
     * Executes a task using the reactive multi-turn loop.
     * The agent can work across multiple reasoning turns, accumulating context.
     * Each turn may involve LLM calls with tool usage (handled by Spring AI).
     * The loop continues while the agent signals {@code <CONTINUE>} or until maxTurns is reached.
     */
    public TaskOutput executeTaskReactive(Task task, List<TaskOutput> context) {
        incrementExecutionCount();
        long startTime = System.currentTimeMillis();
        int turns = maxTurns != null ? maxTurns : DEFAULT_MAX_TURNS;

        try {
            // Emit inside the try so that a misbehaving listener can't bypass
            // AgentExecutionException wrapping and AGENT_FAILED emission. The
            // bus itself also swallows listener exceptions defensively.
            publishAgentStarted(task);

            AgentConversation conversation = new AgentConversation();
            String systemPrompt = buildReactiveSystemPrompt();

            for (int turn = 0; turn < turns; turn++) {
                String userPrompt;
                if (turn == 0) {
                    userPrompt = buildUserPrompt(task, context);
                } else {
                    userPrompt = buildContinuationPrompt(task, conversation);
                }

                userPrompt = enforcePromptBudget(systemPrompt, userPrompt);

                if (verbose) {
                    logger.info("Agent [{}] reactive turn {}/{} ({} chars prompt): {}",
                            role, turn + 1, turns, userPrompt.length(),
                            truncate(task.getDescription(), 80));
                }

                long llmStart = System.currentTimeMillis();
                ChatResponse chatResponse = callLlm(systemPrompt, userPrompt);
                long llmDurationMs = System.currentTimeMillis() - llmStart;
                String response = chatResponse.getResult().getOutput().getText();

                long promptTokens = extractTokenCount(chatResponse, true);
                long completionTokens = extractTokenCount(chatResponse, false);

                publishLlmRequest(promptTokens, completionTokens, llmDurationMs, response);

                conversation.addTurn(new ConversationTurn(
                        turn, response, promptTokens, completionTokens, System.currentTimeMillis()));

                publishAgentMessage(turn, response);

                if (verbose) {
                    logger.info("Agent [{}] turn {} complete ({} chars, continuation={})",
                            role, turn + 1,
                            response != null ? response.length() : 0,
                            AgentConversation.shouldContinue(response));
                }

                if (!AgentConversation.shouldContinue(response)) {
                    break;
                }

                // Auto-compaction: summarize older turns when token budget is exceeded
                if (compactionConfig.enabled()
                        && ConversationCompactor.shouldCompact(conversation, compactionConfig)) {
                    CompactionResult compactionResult =
                            ConversationCompactor.compact(conversation, compactionConfig);
                    if (compactionResult.wasCompacted()) {
                        conversation.applyCompaction(compactionResult);
                        if (verbose) {
                            logger.info("Agent [{}] auto-compacted: removed {} turns, {} active turns remain",
                                    role, compactionResult.removedTurnCount(),
                                    conversation.getActiveTurnCount());
                        }
                    }
                }
            }

            String finalResponse = conversation.getFinalResponse();
            long executionTimeMs = System.currentTimeMillis() - startTime;

            saveToMemory(task, finalResponse, executionTimeMs);

            if (verbose) {
                logger.info("Agent [{}] reactive execution done: {} turns, {} ms, {} total tokens",
                        role, conversation.getTurnCount(), executionTimeMs,
                        conversation.getCumulativeTotalTokens());
            }

            publishAgentCompleted(finalResponse, conversation.getTurnCount(),
                    conversation.getCumulativeTotalTokens(), executionTimeMs);

            TaskOutput.Builder outputBuilder = TaskOutput.builder()
                    .agentId(id)
                    .taskId(task.getId())
                    .rawOutput(finalResponse)
                    .description(task.getDescription())
                    .summary(extractSummary(finalResponse))
                    .executionTimeMs(executionTimeMs)
                    .promptTokens(conversation.getCumulativePromptTokens())
                    .completionTokens(conversation.getCumulativeCompletionTokens())
                    .totalTokens(conversation.getCumulativeTotalTokens())
                    .metadata("turns", conversation.getTurnCount());

            if (conversation.hasBeenCompacted()) {
                outputBuilder.metadata("compactedTurns", conversation.getCompactedTurnCount());
            }

            // Auto-parse into the declared outputType when present (multi-turn path).
            if (task.getOutputType() != null && finalResponse != null && !finalResponse.isBlank()) {
                outputBuilder.typedOutput(tryParseTypedOutput(finalResponse, task.getOutputType()));
            }

            return outputBuilder.build();

        } catch (AgentExecutionException e) {
            publishAgentFailed(e);
            throw e;
        } catch (Exception e) {
            long executionTimeMs = System.currentTimeMillis() - startTime;
            logger.error("Agent [{}] reactive execution failed after {} ms: {}", role, executionTimeMs, e.getMessage());
            publishAgentFailed(e);
            throw new AgentExecutionException("Failed to execute task: " + task.getId(), e, id, task.getId());
        }
    }

    /**
     * Token-level streaming execution. Returns a {@link Flux} of {@link AgentEvent}s
     * driven by Spring AI's {@code ChatModel.stream()}. Each LLM token (or chunk)
     * arrives as an {@link AgentEvent.TextDelta}; the stream is bracketed by an
     * initial {@link AgentEvent.AgentStarted} and a terminal {@link AgentEvent.AgentFinished}
     * carrying the fully-accumulated {@link TaskOutput}.
     *
     * <p>Design choices for Phase 1 (text-only streaming):
     * <ul>
     *   <li>Single-turn — no multi-turn looping like {@link #executeTaskReactive}. A
     *       multi-turn streaming variant is straightforward to layer on top later
     *       by chaining {@code Flux}es per turn, but most chat-UI use cases are
     *       single-turn so we ship that first.</li>
     *   <li>No tool calls — Phase 2 will emit {@link AgentEvent.ToolCallStart}/
     *       {@link AgentEvent.ToolCallEnd} bracket events around tool execution,
     *       which requires Spring AI's reactive tool-callback wiring. Skipped here
     *       to keep the surface stable.</li>
     *   <li>Errors become {@link AgentEvent.AgentError} on the success channel so
     *       SSE consumers don't need to handle a separate {@code onError} signal.
     *       The Flux always completes normally.</li>
     *   <li>Side-effect events still publish through the existing
     *       {@link SwarmEventBus} so observability stays consistent across modes.</li>
     * </ul>
     *
     * <p>Example (chat UI consumer):
     * <pre>{@code
     * agent.executeTaskStreaming(task, ctx).subscribe(evt -> {
     *     if (evt instanceof AgentEvent.TextDelta d) ui.append(d.text());
     * });
     * }</pre>
     *
     * @since 1.1.0
     */
    public Flux<AgentEvent> executeTaskStreaming(Task task, List<TaskOutput> context) {
        final String aId = id;
        final long startMs = System.currentTimeMillis();

        // Build prompts off the reactive thread — same prompt semantics as
        // executeTaskReactive's first turn. enforcePromptBudget protects against
        // pathologically large contexts blowing the model's window.
        final String systemPrompt;
        final String userPrompt;
        try {
            String sys = buildReactiveSystemPrompt();
            String raw = buildUserPrompt(task, context);
            systemPrompt = sys;
            userPrompt   = enforcePromptBudget(sys, raw);
        } catch (RuntimeException prepError) {
            // Prompt-build failure becomes an AgentError event — never bubbled as onError.
            return Flux.just(new AgentEvent.AgentError(
                    aId, prepError.getClass().getSimpleName(),
                    prepError.getMessage() == null ? "" : prepError.getMessage(),
                    System.currentTimeMillis()));
        }

        AgentEvent started = new AgentEvent.AgentStarted(
                aId, role, task.getDescription(), startMs);

        // Mirror the side-effect event so observability tooling sees streaming
        // executions just like reactive/single-shot ones.
        publishAgentStarted(task);

        // Accumulate the streamed text so the terminal AgentFinished can carry
        // a real TaskOutput with rawOutput populated. StringBuilder is fine here
        // because the upstream Flux is sequential per subscription.
        final StringBuilder accum = new StringBuilder();

        Flux<AgentEvent> deltas = chatClient.prompt()
                .system(systemPrompt)
                .user(userPrompt)
                .stream()
                .chatResponse()
                .mapNotNull(resp -> {
                    if (resp == null
                            || resp.getResult() == null
                            || resp.getResult().getOutput() == null) {
                        return null;
                    }
                    String text = resp.getResult().getOutput().getText();
                    if (text == null || text.isEmpty()) return null;
                    accum.append(text);
                    return (AgentEvent) new AgentEvent.TextDelta(
                            aId, text, System.currentTimeMillis());
                });

        Mono<AgentEvent> finished = Mono.fromSupplier(() -> {
            long elapsed = System.currentTimeMillis() - startMs;
            TaskOutput out = TaskOutput.builder()
                    .taskId(task.getId())
                    .agentId(aId)
                    .rawOutput(accum.toString())
                    .description(task.getDescription())
                    .executionTimeMs(elapsed)
                    .build();
            // Mirror the side-effect bus event for observability parity.
            publishAgentMessage(0, accum.toString());
            return new AgentEvent.AgentFinished(aId, out, System.currentTimeMillis());
        });

        return Flux.just(started)
                .concatWith(deltas)
                .concatWith(finished)
                .onErrorResume(err -> {
                    publishAgentFailed(err instanceof Exception ? (Exception) err
                            : new RuntimeException(err));
                    return Flux.just(new AgentEvent.AgentError(
                            aId, err.getClass().getSimpleName(),
                            err.getMessage() == null ? "" : err.getMessage(),
                            System.currentTimeMillis()));
                });
    }

    /**
     * Original single-shot execution: one LLM call per task.
     */
    private TaskOutput executeSingleShot(Task task, List<TaskOutput> context) {
        incrementExecutionCount();
        long startTime = System.currentTimeMillis();

        try {
            // Emit inside the try (see executeTaskReactive for rationale).
            publishAgentStarted(task);

            String systemPrompt = buildSystemPrompt();
            String userPrompt = buildUserPrompt(task, context);
            userPrompt = enforcePromptBudget(systemPrompt, userPrompt);

            if (verbose) {
                logger.info("Agent [{}] executing task ({} chars prompt, {} token context): {}",
                        role, userPrompt.length(), contextConfig.getContextWindowTokens(),
                        truncate(task.getDescription(), 80));
            }

            long llmStart = System.currentTimeMillis();
            ChatResponse chatResponse = callLlm(systemPrompt, userPrompt);
            long llmDurationMs = System.currentTimeMillis() - llmStart;
            String response = chatResponse.getResult().getOutput().getText();
            long executionTimeMs = System.currentTimeMillis() - startTime;

            Long promptTokens = null, completionTokens = null, totalTokens = null;
            if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
                var usage = chatResponse.getMetadata().getUsage();
                promptTokens = toLong(usage.getPromptTokens());
                completionTokens = toLong(usage.getCompletionTokens());
                totalTokens = toLong(usage.getTotalTokens());
            }

            publishLlmRequest(
                    promptTokens != null ? promptTokens : 0L,
                    completionTokens != null ? completionTokens : 0L,
                    llmDurationMs, response);

            saveToMemory(task, response, executionTimeMs);

            if (verbose) {
                logger.info("Agent [{}] completed task in {} ms ({} chars, {} prompt tokens, {} completion tokens)",
                        role, executionTimeMs,
                        response != null ? response.length() : 0,
                        promptTokens != null ? promptTokens : "N/A",
                        completionTokens != null ? completionTokens : "N/A");
            }

            publishAgentCompleted(response, 1,
                    totalTokens != null ? totalTokens : 0L, executionTimeMs);

            // Auto-parse into the declared outputType when present.
            // We do this best-effort: parse failures are logged but do not fail the
            // task. The caller can still introspect rawOutput.
            Object typedOutput = null;
            if (task.getOutputType() != null && response != null && !response.isBlank()) {
                typedOutput = tryParseTypedOutput(response, task.getOutputType());
            }

            return TaskOutput.builder()
                .agentId(id)
                .taskId(task.getId())
                .rawOutput(response)
                .description(task.getDescription())
                .summary(extractSummary(response))
                .executionTimeMs(executionTimeMs)
                .promptTokens(promptTokens)
                .completionTokens(completionTokens)
                .totalTokens(totalTokens)
                .typedOutput(typedOutput)
                .build();

        } catch (AgentExecutionException e) {
            publishAgentFailed(e);
            throw e;
        } catch (Exception e) {
            long executionTimeMs = System.currentTimeMillis() - startTime;
            logger.error("Agent [{}] failed task after {} ms: {}", role, executionTimeMs, e.getMessage());
            publishAgentFailed(e);
            throw new AgentExecutionException("Failed to execute task: " + task.getId(), e, id, task.getId());
        }
    }

    /**
     * Best-effort parse of an LLM response into the declared outputType.
     * Strips common Markdown code-fence wrappers (```json ... ```) before
     * delegating to Spring AI's {@link BeanOutputConverter}. Returns {@code null}
     * on any parse failure so the calling code path is unbroken — the raw
     * output is still attached to the {@link TaskOutput}.
     */
    @SuppressWarnings({"unchecked", "rawtypes"})
    private Object tryParseTypedOutput(String response, Class<?> targetType) {
        String body = response.trim();
        // Strip ```json ... ``` or generic ``` ... ``` fences if the model wrapped its response.
        if (body.startsWith("```")) {
            int firstNewline = body.indexOf('\n');
            int closingFence = body.lastIndexOf("```");
            if (firstNewline > 0 && closingFence > firstNewline) {
                body = body.substring(firstNewline + 1, closingFence).trim();
            }
        }
        try {
            return new BeanOutputConverter<>((Class) targetType).convert(body);
        } catch (Exception parseFailure) {
            logger.warn("Agent [{}] could not parse response into {}: {}. " +
                            "Raw output preserved on TaskOutput.",
                    role, targetType.getSimpleName(), parseFailure.getMessage());
            return null;
        }
    }

    // ==================== LLM Call Infrastructure ====================

    /**
     * Unified LLM call method: builds the request with permitted tools (filtered by
     * permission level) and wraps dynamic tool execution with registered hooks.
     */
    private ChatResponse callLlm(String systemPrompt, String userPrompt) {
        // Lifecycle hook dispatch — UserPromptSubmit. Hooks may BLOCK to
        // veto the call entirely (returning a typed exception) or MODIFY the
        // prompt (typically for redaction, prompt-injection sanitisation, or
        // PII scrubbing). When unset, this is a no-op with zero overhead.
        if (lifecycleHooks != null && lifecycleHooks.size() > 0) {
            ai.intelliswarm.swarmai.agent.hook.LifecycleEvent.UserPromptSubmit evt =
                    new ai.intelliswarm.swarmai.agent.hook.LifecycleEvent.UserPromptSubmit(
                            id, id, java.time.Instant.now(), userPrompt, java.util.Map.of());
            ai.intelliswarm.swarmai.agent.hook.HookResult hr = lifecycleHooks.dispatch(evt);
            switch (hr.action()) {
                case BLOCK -> throw new AgentExecutionException(
                        "LLM call blocked by lifecycle hook: " + hr.message(), null, id, null);
                case MODIFY -> {
                    Object modified = hr.modifiedPayload();
                    if (modified instanceof String s) {
                        userPrompt = s;
                    }
                }
                case PROCEED -> { /* no change */ }
                case ASK -> { /* TODO: surface to AskUserQuestion when wired */ }
            }
        }

        // Provider-neutral SPI path: when the caller plugged in an LlmClient
        // AND this call doesn't need tool-calling (the SPI doesn't orchestrate
        // tool loops — that's the caller's job), bypass Spring AI entirely.
        // Returns a synthesised ChatResponse so the rest of Agent (token
        // extraction, observability, retry logic) keeps working unchanged.
        if (llmClient != null && getPermittedTools().isEmpty()) {
            return callLlmViaSpi(systemPrompt, userPrompt);
        }

        var requestBuilder = chatClient.prompt()
                .system(systemPrompt)
                .user(userPrompt);

        if (modelName != null && !modelName.isBlank()) {
            requestBuilder.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
                    .model(modelName)
                    .build());
        }

        List<BaseTool> permittedTools = getPermittedTools();

        if (!permittedTools.isEmpty()) {
            List<String> springToolNames = new ArrayList<>();
            List<BaseTool> dynamicTools = new ArrayList<>();

            for (BaseTool tool : permittedTools) {
                // Dynamic tools (MCP adapters, generated skills, container-backed
                // tools, anything created in code rather than registered as a
                // Spring bean) must be passed as FunctionToolCallback objects.
                // Spring-bean tools are passed by name and resolved by Spring AI.
                if (tool instanceof McpToolAdapter || tool instanceof GeneratedSkill
                        || tool.isDynamic()) {
                    dynamicTools.add(tool);
                } else {
                    springToolNames.add(tool.getFunctionName());
                }
            }

            if (!springToolNames.isEmpty()) {
                requestBuilder.toolNames(springToolNames.toArray(new String[0]));
            }

            if (!dynamicTools.isEmpty()) {
                List<org.springframework.ai.tool.ToolCallback> callbacks = new ArrayList<>();
                for (BaseTool tool : dynamicTools) {
                    if (tool instanceof McpToolAdapter) {
                        callbacks.add(org.springframework.ai.tool.function.FunctionToolCallback
                                .builder(tool.getFunctionName(),
                                        (java.util.function.Function<McpToolInput, String>)
                                                input -> {
                                            Map<String, Object> params = new HashMap<>();
                                            if (input.url() != null) params.put("url", input.url());
                                            if (input.query() != null) params.put("query", input.query());
                                            if (input.input() != null) params.put("input", input.input());
                                            return executeToolWithHooks(tool, params);
                                        })
                                .description(tool.getDescription())
                                .inputType(McpToolInput.class)
                                .build());
                    } else {
                        // Generic dynamic tool. We use DynamicToolInput +
                        // @JsonAnySetter to absorb any field the LLM sends, but
                        // Spring AI generates the JSON schema from the inputType.
                        // DynamicToolInput has no concrete fields, so the inferred
                        // schema would be empty and the LLM wouldn't know which
                        // parameters to pass. When the tool exposes a non-empty
                        // parameterSchema (e.g. ContainerBackedBaseTool, manifest-
                        // backed tools), serialise it and inject via inputSchema()
                        // — the LLM then sees the real parameter list.
                        var dynamicBuilder = org.springframework.ai.tool.function.FunctionToolCallback
                                .builder(tool.getFunctionName(),
                                        (java.util.function.Function<DynamicToolInput, String>)
                                                input -> {
                                            Map<String, Object> params = new HashMap<>(input.getParams());
                                            return executeToolWithHooks(tool, params);
                                        })
                                .description(tool.getDescription())
                                .inputType(DynamicToolInput.class);
                        Map<String, Object> paramSchema = tool.getParameterSchema();
                        if (paramSchema != null && !paramSchema.isEmpty()) {
                            try {
                                String schemaJson = new com.fasterxml.jackson.databind.ObjectMapper()
                                        .writeValueAsString(paramSchema);
                                dynamicBuilder.inputSchema(schemaJson);
                            } catch (Exception e) {
                                logger.warn("Could not serialise parameterSchema for tool {}: {}",
                                        tool.getFunctionName(), e.getMessage());
                            }
                        }
                        callbacks.add(dynamicBuilder.build());
                    }
                }
                requestBuilder.toolCallbacks(callbacks.toArray(new org.springframework.ai.tool.ToolCallback[0]));
            }
        }

        // Dynamic timeout: use tracker's suggestion based on recent P95 latency
        // for this model. Explicit maxExecutionTime overrides the dynamic value.
        String latencyKey = modelName != null ? modelName : "default";
        int timeoutMs = maxExecutionTime != null
                ? maxExecutionTime
                : (int) LATENCY_TRACKER.suggestedTimeoutMs(latencyKey);

        long callStart = System.currentTimeMillis();
        ChatResponse result = callWithRetry(() -> requestBuilder.call().chatResponse(),
                DEFAULT_MAX_RETRIES, timeoutMs);
        // Record successful call latency for future timeout calculations.
        // Failures are not recorded — keeps the window biased toward realistic healthy latencies.
        LATENCY_TRACKER.recordSuccess(latencyKey, System.currentTimeMillis() - callStart);
        return result;
    }

    /**
     * LlmClient SPI branch of {@link #callLlm}. Translates Agent's
     * (systemPrompt, userPrompt) pair into an {@link ai.intelliswarm.swarmai.agent.llm.LlmRequest},
     * dispatches via the SPI, then synthesises a minimal Spring AI
     * {@link ChatResponse} so downstream code (token extraction, observability,
     * the retry/timeout wrapper) sees the same shape it would from the
     * Spring AI path.
     */
    private ChatResponse callLlmViaSpi(String systemPrompt, String userPrompt) {
        ai.intelliswarm.swarmai.agent.llm.LlmRequest.Builder b =
                ai.intelliswarm.swarmai.agent.llm.LlmRequest.builder()
                        .model(modelName != null ? modelName : "default")
                        .system(systemPrompt)
                        .message(ai.intelliswarm.swarmai.agent.llm.LlmMessage.user(userPrompt));
        if (temperature != null) {
            b.temperature(temperature);
        }
        ai.intelliswarm.swarmai.agent.llm.LlmRequest req = b.build();

        String latencyKey = modelName != null ? modelName : "default";
        int timeoutMs = maxExecutionTime != null
                ? maxExecutionTime
                : (int) LATENCY_TRACKER.suggestedTimeoutMs(latencyKey);

        long callStart = System.currentTimeMillis();
        ai.intelliswarm.swarmai.agent.llm.LlmResponse spiResp =
                callWithRetry(() -> llmClient.send(req), DEFAULT_MAX_RETRIES, timeoutMs);
        LATENCY_TRACKER.recordSuccess(latencyKey, System.currentTimeMillis() - callStart);

        return toSpringChatResponse(spiResp);
    }

    /**
     * Wrap a substrate {@link ai.intelliswarm.swarmai.agent.llm.LlmResponse}
     * in a minimal Spring AI {@link ChatResponse} so the rest of {@link Agent}
     * keeps reading {@code response.getResult().getOutput().getText()} +
     * {@code response.getMetadata().getUsage()} without branching.
     */
    private static ChatResponse toSpringChatResponse(
            ai.intelliswarm.swarmai.agent.llm.LlmResponse spi) {
        org.springframework.ai.chat.messages.AssistantMessage msg =
                new org.springframework.ai.chat.messages.AssistantMessage(spi.text());
        org.springframework.ai.chat.model.Generation gen =
                new org.springframework.ai.chat.model.Generation(msg);
        org.springframework.ai.chat.metadata.ChatResponseMetadata md =
                org.springframework.ai.chat.metadata.ChatResponseMetadata.builder()
                        .id(spi.requestId() != null ? spi.requestId() : "")
                        .model(spi.modelId() != null ? spi.modelId() : "")
                        .usage(new org.springframework.ai.chat.metadata.DefaultUsage(
                                Math.toIntExact(spi.tokenUsage().inputTokens()),
                                Math.toIntExact(spi.tokenUsage().outputTokens()),
                                Math.toIntExact(spi.tokenUsage().inputTokens()
                                        + spi.tokenUsage().outputTokens())))
                        .build();
        return new ChatResponse(java.util.List.of(gen), md);
    }

    /**
     * Executes a tool with pre/post hook interception.
     * Pre-hooks can deny execution; post-hooks can modify output.
     */
    private String executeToolWithHooks(BaseTool tool, Map<String, Object> params) {
        // --- Pre-hooks ---
        if (!toolHooks.isEmpty()) {
            ToolHookContext preCtx = ToolHookContext.before(tool.getFunctionName(), params, id, null);
            for (ToolHook hook : toolHooks) {
                ToolHookResult result = hook.beforeToolUse(preCtx);
                if (result.action() == ToolHookResult.Action.DENY) {
                    logger.warn("Agent [{}] tool {} denied by hook: {}",
                            role, tool.getFunctionName(), result.message());
                    return "Tool execution denied: " + result.message();
                }
                if (result.action() == ToolHookResult.Action.WARN && result.message() != null) {
                    logger.warn("Agent [{}] tool {} hook warning: {}",
                            role, tool.getFunctionName(), result.message());
                }
            }
        }

        publishToolStarted(tool, params);

        // --- Execute ---
        long toolStart = System.currentTimeMillis();
        String output;
        Throwable toolError = null;
        try {
            output = String.valueOf(tool.execute(params));
        } catch (Exception e) {
            toolError = e;
            long elapsed = System.currentTimeMillis() - toolStart;
            publishToolFailed(tool, params, e, elapsed);
            // Run post-hooks for error case
            if (!toolHooks.isEmpty()) {
                ToolHookContext errCtx = ToolHookContext.error(
                        tool.getFunctionName(), params, elapsed, e, id, null);
                for (ToolHook hook : toolHooks) {
                    hook.afterToolUse(errCtx);
                }
            }
            throw e;
        }
        long toolElapsed = System.currentTimeMillis() - toolStart;

        // --- Post-hooks (may redact output via modifiedOutput, or DENY it entirely) ---
        // Note: completion events are intentionally emitted AFTER this loop so the
        // trace records the final, possibly redacted/filtered output — not the raw
        // tool result. Otherwise a SanitizeToolHook would leak sensitive bytes into
        // observability even though the caller sees the redacted version.
        boolean postHookDenied = false;
        String postHookDenyMessage = null;
        if (!toolHooks.isEmpty()) {
            ToolHookContext postCtx = ToolHookContext.after(
                    tool.getFunctionName(), params, output, toolElapsed, id, null);
            for (ToolHook hook : toolHooks) {
                ToolHookResult result = hook.afterToolUse(postCtx);
                if (result.modifiedOutput() != null) {
                    output = result.modifiedOutput();
                }
                if (result.action() == ToolHookResult.Action.DENY) {
                    logger.warn("Agent [{}] tool {} output denied by post-hook: {}",
                            role, tool.getFunctionName(), result.message());
                    postHookDenied = true;
                    postHookDenyMessage = result.message();
                    output = "Tool output filtered: " + (result.message() != null ? result.message() : "");
                    break;
                }
                if (result.action() == ToolHookResult.Action.WARN && result.message() != null) {
                    logger.warn("Agent [{}] tool {} post-hook warning: {}",
                            role, tool.getFunctionName(), result.message());
                }
            }
        }

        if (postHookDenied) {
            // Report as TOOL_FAILED with deniedByHook=true so observability can
            // distinguish a hook deny from a tool execution failure, and so the
            // completion event is never emitted with success semantics.
            publishToolDenied(tool, params, postHookDenyMessage, toolElapsed);
        } else {
            publishToolCompleted(tool, params, output, toolElapsed);
        }

        return output;
    }

    // ==================== Event emission ====================

    private void publishAgentStarted(Task task) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("role", role);
        md.put("id", id);
        if (modelName != null) md.put("model", modelName);
        if (task != null) md.put("taskId", task.getId());
        SwarmEventBus.publish(this, SwarmEvent.Type.AGENT_STARTED,
                "Agent started: " + role, null, md);
    }

    private void publishAgentCompleted(String finalResponse, int turns, long totalTokens, long durationMs) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("output", finalResponse);
        md.put("turns", turns);
        md.put("totalTokens", totalTokens);
        md.put("durationMs", durationMs);
        SwarmEventBus.publish(this, SwarmEvent.Type.AGENT_COMPLETED,
                "Agent completed: " + role, null, md);
    }

    private void publishAgentFailed(Throwable e) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("error", e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
        SwarmEventBus.publish(this, SwarmEvent.Type.AGENT_FAILED,
                "Agent failed: " + role, null, md);
    }

    private void publishLlmRequest(long promptTokens, long completionTokens,
                                   long durationMs, String content) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("model", modelName != null ? modelName : "");
        md.put("promptTokens", promptTokens);
        md.put("completionTokens", completionTokens);
        md.put("durationMs", durationMs);
        if (content != null) {
            md.put("content", content.length() > 2000 ? content.substring(0, 2000) + "…" : content);
        }
        SwarmEventBus.publish(this, SwarmEvent.Type.LLM_REQUEST,
                "LLM call complete", null, md);
    }

    private void publishAgentMessage(int turn, String content) {
        if (!SwarmEventBus.isActive() || content == null) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("role", "assistant");
        md.put("turn", turn + 1);
        md.put("content", content.length() > 2000 ? content.substring(0, 2000) + "…" : content);
        SwarmEventBus.publish(this, SwarmEvent.Type.AGENT_MESSAGE,
                "Agent turn " + (turn + 1), null, md);
    }

    private void publishToolStarted(BaseTool tool, Map<String, Object> params) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("tool", tool.getFunctionName());
        md.put("input", params);
        SwarmEventBus.publish(this, SwarmEvent.Type.TOOL_STARTED,
                "Tool started: " + tool.getFunctionName(), null, md);
    }

    private void publishToolCompleted(BaseTool tool, Map<String, Object> params,
                                      String output, long durationMs) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("tool", tool.getFunctionName());
        md.put("output", output != null && output.length() > 2000 ? output.substring(0, 2000) + "…" : output);
        md.put("durationMs", durationMs);
        SwarmEventBus.publish(this, SwarmEvent.Type.TOOL_COMPLETED,
                "Tool completed: " + tool.getFunctionName(), null, md);
    }

    private void publishToolFailed(BaseTool tool, Map<String, Object> params, Throwable e, long durationMs) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("tool", tool.getFunctionName());
        md.put("error", e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName());
        md.put("durationMs", durationMs);
        SwarmEventBus.publish(this, SwarmEvent.Type.TOOL_FAILED,
                "Tool failed: " + tool.getFunctionName(), null, md);
    }

    /**
     * Tool executed successfully but a post-hook denied its output (e.g. redaction policy,
     * compliance filter). Reported as TOOL_FAILED with {@code deniedByHook=true} so listeners
     * can distinguish a policy deny from an execution error.
     */
    private void publishToolDenied(BaseTool tool, Map<String, Object> params, String reason, long durationMs) {
        if (!SwarmEventBus.isActive()) return;
        Map<String, Object> md = new HashMap<>();
        md.put("agent", role);
        md.put("tool", tool.getFunctionName());
        md.put("error", reason != null && !reason.isBlank() ? reason : "Post-hook denied tool output");
        md.put("durationMs", durationMs);
        md.put("deniedByHook", true);
        SwarmEventBus.publish(this, SwarmEvent.Type.TOOL_FAILED,
                "Tool output denied by post-hook: " + tool.getFunctionName(), null, md);
    }

    /**
     * Returns tools filtered by this agent's permission mode.
     * If no permission mode is set, all tools are returned.
     * Denied tools are logged at WARN level for auditing.
     */
    private List<BaseTool> getPermittedTools() {
        if (permissionMode == null) {
            return tools;
        }
        List<BaseTool> permitted = new ArrayList<>();
        for (BaseTool tool : tools) {
            if (tool.getPermissionLevel().isPermittedBy(permissionMode)) {
                permitted.add(tool);
            } else {
                logger.warn("[PERMISSION] Agent [{}] denied tool '{}' (requires {}, agent has {})",
                        role, tool.getFunctionName(), tool.getPermissionLevel(), permissionMode);
            }
        }
        if (permitted.size() < tools.size()) {
            logger.info("[PERMISSION] Agent [{}]: {}/{} tools permitted (mode={})",
                    role, permitted.size(), tools.size(), permissionMode);
        }
        return permitted;
    }

    private <T> T callWithRetry(Supplier<T> llmCall, int maxRetries, int timeoutMs) {
        Exception lastException = null;
        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            CompletableFuture<T> future = null;
            try {
                future = CompletableFuture.supplyAsync(llmCall::get);
                return future.get(timeoutMs, TimeUnit.MILLISECONDS);
            } catch (TimeoutException e) {
                if (future != null) future.cancel(true);
                lastException = new AgentExecutionException("LLM call timed out after " + timeoutMs + "ms", e, id, null);
                if (attempt < maxRetries) {
                    long backoffMs = (long) Math.pow(2, attempt) * 1000;
                    logger.warn("Agent [{}] LLM call timed out (attempt {}/{}, classifier=RETRYABLE). Retrying in {} ms...",
                            role, attempt, maxRetries, backoffMs);
                    try {
                        Thread.sleep(backoffMs);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new AgentExecutionException("Interrupted during retry backoff", ie, id, null);
                    }
                }
            } catch (Exception e) {
                lastException = e;
                Throwable cause = e.getCause() != null ? e.getCause() : e;
                String msg = cause.getMessage() != null ? cause.getMessage() : "";

                // Backward-compatible retry rule (matches pre-1.0.24 1.0.16 baseline):
                // fail-fast ONLY on confirmed-non-retryable client errors that have
                // proven impossible to recover from via retry. Everything else
                // retries up to maxRetries with exponential backoff. This wide-net
                // retry is what the May-2026 regression baseline showed succeeding
                // on 24/24 workflows; replacing it with classifier-driven throws
                // (the brief 1.0.24-SNAPSHOT-pre-fix shape) made transient OpenAI
                // empty-body responses hard-fail on attempt 1.
                //
                // The ErrorClassifier output is computed here for observability
                // and for downstream lifecycle hooks (compression-on-context-overflow,
                // credential rotation, multi-provider fallback) but is NOT used
                // to suppress retries. Hooks that want to act on the reason can
                // observe it via logs / a future ErrorClassifiedEvent.
                ai.intelliswarm.swarmai.agent.resilience.FailoverReason reason =
                        ai.intelliswarm.swarmai.agent.resilience.ErrorClassifier.classify(cause);

                if (msg.contains("400") || msg.contains("401") || msg.contains("403")
                        || msg.contains("context_length_exceeded") || msg.contains("NonTransient")) {
                    throw new AgentExecutionException(
                            "LLM call failed (non-retryable, classifier=" + reason + "): " + msg,
                            e, id, null);
                }
                if (attempt < maxRetries) {
                    long backoffMs = (long) Math.pow(2, attempt) * 1000;
                    logger.warn("Agent [{}] LLM call attempt {}/{} failed (classifier={}): {}. "
                                    + "Retrying in {} ms...",
                            role, attempt, maxRetries, reason, cause.getMessage(), backoffMs);
                    try {
                        Thread.sleep(backoffMs);
                    } catch (InterruptedException ie) {
                        Thread.currentThread().interrupt();
                        throw new AgentExecutionException("Interrupted during retry backoff", ie, id, null);
                    }
                }
            }
        }
        throw new AgentExecutionException("LLM call failed after " + maxRetries + " attempts", lastException, id, null);
    }

    // ==================== Prompt Building ====================

    private String buildSystemPrompt() {
        StringBuilder system = new StringBuilder();
        system.append("You are ").append(role).append(".\n");
        system.append("Your goal is: ").append(goal).append("\n");
        system.append("Your backstory: ").append(backstory).append("\n");

        // Date awareness
        system.append("\nToday's date is: ").append(java.time.LocalDate.now()).append("\n");

        if (!tools.isEmpty()) {
            system.append("\nYou have access to the following tools. " +
                "You MUST actively call these tools to gather real data — do NOT answer from general knowledge alone. " +
                "Call the appropriate tool function for every step that requires external data or action.\n\n");
            for (BaseTool tool : tools) {
                system.append("- **").append(tool.getFunctionName()).append("**");
                system.append(" [").append(tool.getCategory()).append("]");
                system.append(": ").append(tool.getDescription()).append("\n");
                if (tool.getTriggerWhen() != null) {
                    system.append("  USE WHEN: ").append(tool.getTriggerWhen()).append("\n");
                }
                if (tool.getAvoidWhen() != null) {
                    system.append("  AVOID WHEN: ").append(tool.getAvoidWhen()).append("\n");
                }
                if (!tool.getTags().isEmpty()) {
                    system.append("  Tags: ").append(String.join(", ", tool.getTags())).append("\n");
                }
            }
            system.append("\nIMPORTANT: Always call the tool functions above to gather data. " +
                "Never produce a final answer without first calling at least one tool.\n");
        }

        // Anti-hallucination guardrails
        system.append("\nCRITICAL RULES YOU MUST FOLLOW:\n");
        system.append("1. NEVER fabricate data, statistics, scores, or company information. " +
                "If you don't have data, explicitly state: \"DATA NOT AVAILABLE\" or \"NO INFORMATION FOUND\".\n");
        system.append("2. If a tool search returns no useful results, report that the search found nothing " +
                "rather than inventing information. Say: \"Search returned no relevant results for [topic]\".\n");
        system.append("3. Distinguish between facts you are confident about and estimates/opinions. " +
                "Mark facts as [CONFIRMED] and estimates as [ESTIMATE].\n");
        system.append("4. If the topic you are asked about does not exist or you have no knowledge of it, " +
                "say so clearly: \"No public information found about [topic]. This may be a pre-announcement, " +
                "internal codename, or misspelling.\"\n");
        system.append("5. Use today's date for any timelines or recommendations. Do not reference past dates " +
                "as future actions.\n");
        system.append("6. SELF-ADJUSTMENT: If a tool call fails, times out, or returns an error, " +
                "you MUST adapt your approach immediately. Try a narrower scope, different parameters, " +
                "or an alternative tool. For example: if scanning an entire subnet times out, scan " +
                "individual hosts; if a full port scan times out, use --top-ports 100.\n");
        system.append("7. OUTPUT FILES: When saving scan results or output files, ALWAYS write them to " +
                "/app/output/ directory (e.g., /app/output/nmap_results.txt). This directory persists " +
                "outside the container. Never write to the current directory or /tmp.\n");

        return system.toString();
    }

    private String buildUserPrompt(Task task, List<TaskOutput> context) {
        StringBuilder prompt = new StringBuilder();

        // Dynamic context budget based on model's context window
        int contextBudget = contextConfig.getMaxPriorContextChars();

        // Add context from previous tasks — budget shared across all prior outputs
        if (context != null && !context.isEmpty()) {
            int perTaskBudget = Math.max(500, contextBudget / context.size());
            prompt.append("Context from previous tasks:\n");
            for (TaskOutput ctx : context) {
                prompt.append("--- ").append(ctx.getDescription() != null ? ctx.getDescription() : "Previous task").append(" ---\n");
                String output = ctx.getRawOutput();
                if (output != null) {
                    prompt.append(truncate(output, perTaskBudget)).append("\n");
                }
                prompt.append("\n");
            }
        }

        // Add memory context
        if (memory != null) {
            List<String> relevantMemories = memory.search(task.getDescription(), 5);
            if (relevantMemories != null && !relevantMemories.isEmpty()) {
                prompt.append("Relevant memories from previous executions:\n");
                for (String mem : relevantMemories) {
                    prompt.append("- ").append(mem).append("\n");
                }
                prompt.append("\n");
            }
        }

        // Add knowledge context
        if (knowledge != null) {
            String knowledgeContext = knowledge.query(task.getDescription());
            if (StringUtils.hasText(knowledgeContext)) {
                prompt.append("Relevant knowledge:\n").append(knowledgeContext).append("\n\n");
            }
        }

        // Add task description and expected output
        prompt.append("Task: ").append(task.getDescription()).append("\n");
        if (StringUtils.hasText(task.getExpectedOutput())) {
            prompt.append("Expected Output: ").append(task.getExpectedOutput()).append("\n");
        }

        // Inject JSON-schema format instructions when the task declared a target type.
        // Spring AI's BeanOutputConverter generates schema-aligned instructions that
        // most modern chat models follow reliably.
        if (task.getOutputType() != null) {
            String formatInstructions = new BeanOutputConverter<>(task.getOutputType()).getFormat();
            prompt.append("\n").append(formatInstructions).append("\n");
        }

        return prompt.toString();
    }

    /**
     * Builds an extended system prompt with multi-turn reasoning instructions.
     */
    private String buildReactiveSystemPrompt() {
        String base = buildSystemPrompt();
        return base + "\n" +
                "MULTI-TURN REASONING:\n" +
                "You can work in multiple reasoning turns to complete complex tasks.\n" +
                "After each response:\n" +
                "- If you need to do more work (call more tools, analyze further, refine results), " +
                "end your response with <CONTINUE>\n" +
                "- When you have completed the task fully, end your response with <DONE>\n" +
                "The marker must be the last non-whitespace content in your response.\n" +
                "If you use neither marker, your response is treated as final.\n";
    }

    /**
     * Builds a continuation prompt for subsequent turns in the reactive loop,
     * including accumulated conversation context.
     */
    private String buildContinuationPrompt(Task task, AgentConversation conversation) {
        StringBuilder prompt = new StringBuilder();
        prompt.append("You are continuing work on this task:\n");
        prompt.append("Task: ").append(task.getDescription()).append("\n");
        if (StringUtils.hasText(task.getExpectedOutput())) {
            prompt.append("Expected Output: ").append(task.getExpectedOutput()).append("\n");
        }
        prompt.append("\nYour previous reasoning:\n");
        prompt.append(conversation.toContextString());
        prompt.append("\n\nContinue working. Use <CONTINUE> if you need more turns, or <DONE> when finished.\n");
        return prompt.toString();
    }

    /**
     * Truncates the user prompt if it exceeds the model's context budget.
     */
    private String enforcePromptBudget(String systemPrompt, String userPrompt) {
        int maxPromptChars = contextConfig.getMaxTotalPromptChars() - systemPrompt.length();
        if (userPrompt.length() > maxPromptChars) {
            logger.warn("Agent [{}] prompt too large ({} chars), truncating to {} chars (model: {}, context: {} tokens)",
                    role, userPrompt.length(), maxPromptChars,
                    modelName != null ? modelName : "default",
                    contextConfig.getContextWindowTokens());
            return userPrompt.substring(0, maxPromptChars)
                    + "\n\n[... content truncated to fit model context window ...]";
        }
        return userPrompt;
    }

    private long extractTokenCount(ChatResponse chatResponse, boolean prompt) {
        if (chatResponse.getMetadata() != null && chatResponse.getMetadata().getUsage() != null) {
            var usage = chatResponse.getMetadata().getUsage();
            Long val = toLong(prompt ? usage.getPromptTokens() : usage.getCompletionTokens());
            return val != null ? val : 0L;
        }
        return 0L;
    }

    private void saveToMemory(Task task, String response, long executionTimeMs) {
        if (memory != null) {
            memory.save(id,
                    "Task: " + truncate(task.getDescription(), 200) +
                    "\nResult: " + truncate(response, 500),
                    Map.of("taskId", task.getId(), "executionTimeMs", executionTimeMs));
        }
    }

    private String extractSummary(String response) {
        if (response == null || response.length() <= 200) {
            return response;
        }
        return response.substring(0, 197) + "...";
    }

    private String truncate(String text, int maxLength) {
        if (text == null || text.length() <= maxLength) {
            return text;
        }
        return text.substring(0, maxLength - 3) + "...";
    }

    /**
     * Generic input record for MCP tools. Covers common tool parameter patterns.
     * Spring AI will generate a proper JSON schema from this record's fields.
     */
    public record McpToolInput(
            @com.fasterxml.jackson.annotation.JsonProperty("url") String url,
            @com.fasterxml.jackson.annotation.JsonProperty("query") String query,
            @com.fasterxml.jackson.annotation.JsonProperty("input") String input
    ) {}

    /**
     * Generic input class for dynamically generated skills.
     * Uses @JsonAnySetter to capture ALL parameters the LLM sends,
     * regardless of field name — no need to enumerate every possible field.
     */
    public static class DynamicToolInput {
        private final Map<String, String> params = new HashMap<>();

        @com.fasterxml.jackson.annotation.JsonAnySetter
        public void set(String key, Object value) {
            params.put(key, value != null ? value.toString() : null);
        }

        public Map<String, String> getParams() { return params; }
    }

    private String buildToolInputSchema(BaseTool tool) {
        // Try to get schema from the tool itself
        Map<String, Object> schema = tool.getParameterSchema();
        if (schema != null && !schema.isEmpty()) {
            try {
                return new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(schema);
            } catch (Exception e) {
                logger.debug("Failed to serialize tool schema, using default");
            }
        }
        // Default schema based on tool name — common MCP tools
        String toolName = tool.getFunctionName();
        return switch (toolName) {
            case "fetch" -> """
                {"type":"object","properties":{"url":{"type":"string","description":"URL to fetch"},"max_length":{"type":"integer","description":"Maximum content length","default":5000},"raw":{"type":"boolean","description":"Return raw content without conversion","default":false}},"required":["url"]}""";
            case "brave_search" -> """
                {"type":"object","properties":{"query":{"type":"string","description":"Search query"},"count":{"type":"integer","description":"Number of results","default":5}},"required":["query"]}""";
            default -> """
                {"type":"object","properties":{"input":{"type":"string","description":"Input for the tool"}},"required":["input"]}""";
        };
    }

    private static Long toLong(Object value) {
        if (value == null) return null;
        if (value instanceof Long l) return l;
        if (value instanceof Number n) return n.longValue();
        return null;
    }

    private synchronized void incrementExecutionCount() {
        this.executionCount++;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private String id;
        private String role;
        private String goal;
        private String backstory;
        private List<BaseTool> tools = new ArrayList<>();
        private boolean verbose = false;
        private boolean allowDelegation = false;
        private Integer maxIter;
        private Integer maxRpm;
        private Double temperature;
        private Integer maxExecutionTime;
        private boolean systemTemplate = false;
        private boolean stepCallback = false;
        private ChatClient chatClient;
        private ai.intelliswarm.swarmai.agent.llm.LlmClient llmClient;
        private ai.intelliswarm.swarmai.agent.hook.LifecycleHookRegistry lifecycleHooks;
        private Memory memory;
        private Knowledge knowledge;
        private String modelName;
        private Map<String, Object> metadata = new HashMap<>();
        private Integer maxTurns;
        private PermissionLevel permissionMode;
        private List<ToolHook> toolHooks = new ArrayList<>();
        private CompactionConfig compactionConfig;

        public Builder id(String id) {
            this.id = id;
            return this;
        }

        public Builder role(String role) {
            this.role = role;
            return this;
        }

        public Builder goal(String goal) {
            this.goal = goal;
            return this;
        }

        public Builder backstory(String backstory) {
            this.backstory = backstory;
            return this;
        }

        public Builder tools(List<BaseTool> tools) {
            this.tools = new ArrayList<>(tools);
            return this;
        }

        public Builder tool(BaseTool tool) {
            this.tools.add(tool);
            return this;
        }

        public Builder verbose(boolean verbose) {
            this.verbose = verbose;
            return this;
        }

        public Builder allowDelegation(boolean allowDelegation) {
            this.allowDelegation = allowDelegation;
            return this;
        }

        public Builder maxIter(Integer maxIter) {
            this.maxIter = maxIter;
            return this;
        }

        public Builder maxRpm(Integer maxRpm) {
            this.maxRpm = maxRpm;
            return this;
        }

        public Builder temperature(Double temperature) {
            this.temperature = temperature;
            return this;
        }

        public Builder maxExecutionTime(Integer maxExecutionTime) {
            this.maxExecutionTime = maxExecutionTime;
            return this;
        }

        public Builder systemTemplate(boolean systemTemplate) {
            this.systemTemplate = systemTemplate;
            return this;
        }

        public Builder stepCallback(boolean stepCallback) {
            this.stepCallback = stepCallback;
            return this;
        }

        public Builder chatClient(ChatClient chatClient) {
            this.chatClient = chatClient;
            return this;
        }

        /**
         * Plug an opt-in {@link ai.intelliswarm.swarmai.agent.llm.LlmClient}
         * SPI implementation so {@link Agent#callLlm} bypasses Spring AI for
         * single-shot text-only calls. Use this to unlock native Anthropic
         * features (prompt caching, extended/adaptive thinking, inline
         * citations) on agents that don't need Spring AI's tool-call
         * orchestration. The Spring AI path remains the fallback for
         * tool-bearing calls and for streaming.
         */
        public Builder llmClient(ai.intelliswarm.swarmai.agent.llm.LlmClient llmClient) {
            this.llmClient = llmClient;
            return this;
        }

        /**
         * Plug a {@link ai.intelliswarm.swarmai.agent.hook.LifecycleHookRegistry}
         * so the agent dispatches lifecycle events (UserPromptSubmit, Stop, …)
         * during {@link Agent#executeTask}. Hooks can BLOCK to abort or
         * MODIFY to rewrite event payloads. Default: no dispatch.
         */
        public Builder lifecycleHookRegistry(
                ai.intelliswarm.swarmai.agent.hook.LifecycleHookRegistry registry) {
            this.lifecycleHooks = registry;
            return this;
        }

        public Builder memory(Memory memory) {
            this.memory = memory;
            return this;
        }

        public Builder knowledge(Knowledge knowledge) {
            this.knowledge = knowledge;
            return this;
        }

        public Builder modelName(String modelName) {
            this.modelName = modelName;
            return this;
        }

        public Builder metadata(String key, Object value) {
            this.metadata.put(key, value);
            return this;
        }

        /**
         * Sets the maximum number of reasoning turns for the reactive agent loop.
         * When set to a value > 1, the agent uses multi-turn execution.
         * Default: 1 (single-shot, backward compatible).
         */
        public Builder maxTurns(Integer maxTurns) {
            this.maxTurns = maxTurns;
            return this;
        }

        /**
         * Sets the permission mode for this agent, restricting which tools it can invoke.
         * Tools with a {@link PermissionLevel} above this mode will be filtered out.
         * Default: null (no restriction).
         */
        public Builder permissionMode(PermissionLevel permissionMode) {
            this.permissionMode = permissionMode;
            return this;
        }

        /**
         * Adds a tool hook that intercepts every tool invocation with pre/post callbacks.
         */
        public Builder toolHook(ToolHook hook) {
            this.toolHooks.add(hook);
            return this;
        }

        /**
         * Sets all tool hooks at once.
         */
        public Builder toolHooks(List<ToolHook> hooks) {
            this.toolHooks = new ArrayList<>(hooks);
            return this;
        }

        /**
         * Sets the auto-compaction configuration for the reactive agent loop.
         * Controls when older turns are summarized to reclaim context window space.
         * Default: enabled, triggers at 80% of model context window, preserves 4 recent turns.
         */
        public Builder compactionConfig(CompactionConfig compactionConfig) {
            this.compactionConfig = compactionConfig;
            return this;
        }

        public Agent build() {
            Objects.requireNonNull(role, "Role cannot be null");
            Objects.requireNonNull(goal, "Goal cannot be null");
            Objects.requireNonNull(backstory, "Backstory cannot be null");
            Objects.requireNonNull(chatClient, "ChatClient cannot be null");
            
            return new Agent(this);
        }
    }

    // Getters
    public String getId() { return id; }
    public String getRole() { return role; }
    public String getGoal() { return goal; }
    public String getBackstory() { return backstory; }
    public List<BaseTool> getTools() { return new ArrayList<>(tools); }
    public boolean isVerbose() { return verbose; }
    public boolean isAllowDelegation() { return allowDelegation; }
    public Integer getMaxIter() { return maxIter; }
    public Integer getMaxRpm() { return maxRpm; }
    public Double getTemperature() { return temperature; }
    public Integer getMaxExecutionTime() { return maxExecutionTime; }
    public boolean isSystemTemplate() { return systemTemplate; }
    public boolean isStepCallback() { return stepCallback; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public Map<String, Object> getMetadata() { return new HashMap<>(metadata); }
    public Integer getExecutionCount() { return executionCount; }
    public String getModelName() { return modelName; }
    public ModelContextConfig getContextConfig() { return contextConfig; }
    public ChatClient getChatClient() { return chatClient; }
    public Memory getMemory() { return memory; }
    public Knowledge getKnowledge() { return knowledge; }
    public Integer getMaxTurns() { return maxTurns; }
    public PermissionLevel getPermissionMode() { return permissionMode; }
    public List<ToolHook> getToolHooks() { return new ArrayList<>(toolHooks); }
    public CompactionConfig getCompactionConfig() { return compactionConfig; }

    /**
     * Hot-register a new tool onto this Agent in place. The next LLM
     * iteration sees the tool in its callable set without rebuilding the
     * agent. Safe to call concurrently with execution because the
     * underlying tool list is a {@link java.util.concurrent.CopyOnWriteArrayList}.
     *
     * <p>Returns {@code this} for fluent registration of multiple tools.
     *
     * <p>Use cases:
     * <ul>
     *   <li>Self-improving loop: build a new container, read its manifest,
     *       materialise BaseTools, register them — agent uses them next turn.</li>
     *   <li>Capability handoff: another agent or a planning phase
     *       discovered a new tool and wants to add it to a running auditor.</li>
     * </ul>
     *
     * <p>Skips duplicates by function name to avoid registering the same
     * capability twice when a capability re-discovers itself across phases.
     */
    public Agent registerTool(BaseTool tool) {
        if (tool == null) return this;
        String name = tool.getFunctionName();
        for (BaseTool existing : tools) {
            if (name.equals(existing.getFunctionName())) {
                logger.debug("[{}] tool '{}' already registered; skipping", role, name);
                return this;
            }
        }
        tools.add(tool);
        logger.info("[{}] registered new tool '{}' (total tools: {})",
                role, name, tools.size());
        return this;
    }

    /** Bulk variant of {@link #registerTool(BaseTool)}. */
    public Agent registerTools(List<BaseTool> newTools) {
        if (newTools == null) return this;
        for (BaseTool t : newTools) registerTool(t);
        return this;
    }

    /**
     * Create a copy of this agent with additional tools added to its toolkit.
     * Used by SelfImprovingProcess to expand agent capabilities at runtime.
     */
    public Agent withAdditionalTools(List<BaseTool> newTools) {
        List<BaseTool> allTools = new ArrayList<>(this.tools);
        allTools.addAll(newTools);
        Agent copy = Agent.builder()
            .role(this.role)
            .goal(this.goal)
            .backstory(this.backstory)
            .chatClient(this.chatClient)
            .llmClient(this.llmClient)
            .tools(allTools)
            .verbose(this.verbose)
            .allowDelegation(this.allowDelegation)
            .maxRpm(this.maxRpm != null ? this.maxRpm : 0)
            .temperature(this.temperature != null ? this.temperature : 0.7)
            .modelName(this.modelName)
            .maxTurns(this.maxTurns)
            .permissionMode(this.permissionMode)
            .toolHooks(this.toolHooks)
            .compactionConfig(this.compactionConfig)
            .build();
        copy.memory = this.memory;
        copy.knowledge = this.knowledge;
        return copy;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Agent)) return false;
        Agent agent = (Agent) o;
        return Objects.equals(id, agent.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }

    @Override
    public String toString() {
        return "Agent{" +
                "id='" + id + '\'' +
                ", role='" + role + '\'' +
                ", goal='" + goal + '\'' +
                '}';
    }
}