{"repo": "bytequay/bytequay", "files": {"backend/src/main/java/com/bytequay/app/service/threads/LogicLoopThreadAgent.java": {"7ca60244a527eb5aaf72396bd831098530712955": {"t": 1786067594.0540316, "n": 66868, "h": "f09441bf634e914e", "c": "/*\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\npackage com.bytequay.app.service.threads;\n\nimport com.bytequay.app.domain.AgentMetrics;\nimport com.bytequay.app.domain.CredentialType;\nimport com.bytequay.app.domain.PermissionDecision;\nimport com.bytequay.app.domain.StreamEvent;\nimport com.bytequay.app.domain.Thread;\nimport com.bytequay.app.domain.ThreadKind;\nimport com.bytequay.app.domain.ThreadMessage;\nimport com.bytequay.app.domain.ThreadScope;\nimport com.bytequay.app.domain.ThreadStatus;\nimport com.bytequay.app.domain.WorkModel;\nimport com.bytequay.app.domain.WorkModelKind;\nimport com.bytequay.app.repository.ThreadStore;\nimport com.bytequay.app.service.CredentialService;\nimport com.bytequay.app.service.agents.AgentContextCompiler;\nimport com.bytequay.app.service.agents.ToolCall;\nimport com.bytequay.app.service.agents.ToolExecutor;\nimport com.bytequay.app.service.agents.TurnHooks;\nimport com.bytequay.app.service.agents.TurnResult;\nimport com.bytequay.app.service.agents.TurnRunner;\nimport com.bytequay.app.service.agents.TurnSpec;\nimport com.bytequay.app.service.ai.ModelPricing;\nimport com.bytequay.app.service.local.ds4.Ds4Instrumentation;\nimport com.bytequay.app.service.local.ds4.Ds4LifecycleService;\nimport com.bytequay.app.service.local.ds4.Ds4State;\nimport com.bytequay.app.service.skills.ManagedSkill;\nimport com.bytequay.app.service.skills.ManagedSkillBundle;\nimport com.bytequay.app.service.threads.tools.AgentTool;\nimport com.bytequay.app.service.threads.tools.AgentToolContext;\nimport com.bytequay.app.service.threads.tools.LogicLoopToolRegistry;\nimport com.bytequay.app.service.threads.tools.ToolPermissionMediator;\nimport com.bytequay.app.service.tools.AgentRole;\nimport com.bytequay.app.service.tools.Gating;\nimport com.bytequay.app.service.workspaces.WorkspaceDocumentLoader;\nimport com.fasterxml.jackson.databind.JsonNode;\nimport com.fasterxml.jackson.databind.ObjectMapper;\nimport com.fasterxml.jackson.databind.node.ArrayNode;\nimport com.fasterxml.jackson.databind.node.ObjectNode;\nimport org.slf4j.Logger;\nimport org.slf4j.LoggerFactory;\n\nimport java.io.IOException;\nimport java.net.http.HttpClient;\nimport java.nio.file.Files;\nimport java.nio.file.Path;\nimport java.time.Duration;\nimport java.time.Instant;\nimport java.util.ArrayList;\nimport java.util.Base64;\nimport java.util.List;\nimport java.util.Optional;\nimport java.util.OptionalInt;\nimport java.util.Set;\nimport java.util.UUID;\nimport java.util.concurrent.CompletableFuture;\nimport java.util.concurrent.CompletionStage;\nimport java.util.concurrent.ConcurrentHashMap;\nimport java.util.concurrent.CopyOnWriteArrayList;\nimport java.util.concurrent.ExecutionException;\nimport java.util.concurrent.ExecutorService;\nimport java.util.concurrent.TimeUnit;\nimport java.util.concurrent.TimeoutException;\nimport java.util.concurrent.atomic.AtomicBoolean;\nimport java.util.concurrent.atomic.AtomicLong;\nimport java.util.concurrent.atomic.AtomicReference;\nimport java.util.function.Consumer;\nimport java.util.function.Supplier;\n\nimport static java.util.Objects.requireNonNull;\n\n/**\n * In-JVM API-lane {@link ThreadAgent}. Drives the loop by calling the\n * resolved provider's HTTP API directly and translating streaming deltas\n * into the same {@link StreamEvent} shapes the CLI lane emits.\n *\n *

B5 multi-provider transport: Anthropic (SSE Messages API), OpenAI\n * (chat-completions SSE), and DeepSeek (OpenAI-compatible surface, cloud\n * and local ds4 variant) are all wired. Tools route through\n * {@link LogicLoopToolRegistry} on all three providers; the Anthropic path\n * uses {@code tool_use} content blocks while OpenAI/DeepSeek use the\n * {@code tool_calls} message role.\n *\n *

History replay limits to {@value REPLAY_HISTORY_LIMIT} recent text\n * messages per turn. Tool-call / tool-result rows from prior turns are\n * excluded — only the final assistant text survives across turn boundaries.\n *\n *

Mirrors {@link ClaudeCodeCliThreadAgent} on the contract that matters\n * for callers: same {@link ThreadStore#appendMessage} writes, same event\n * ordering (SessionStarted → AssistantTextDelta… → AssistantText →\n * UsageUpdated → TurnDone → SessionEnded), same lifecycle state machine\n * (IDLE/RUNNING/AWAITING/STOPPED), so the frontend renders trunk and task\n * panes identically across lanes.\n */\npublic class LogicLoopThreadAgent\n implements ThreadAgent\n{\n private static final Logger log = LoggerFactory.getLogger(LogicLoopThreadAgent.class);\n\n // ── Anthropic ─────────────────────────────────────────────────────────\n private static final String ANTHROPIC_PROVIDER_ID = \"anthropic\";\n private static final String ANTHROPIC_MESSAGES_URL = \"https://api.anthropic.com/v1/messages\";\n\n // ── OpenAI ────────────────────────────────────────────────────────────\n private static final String OPENAI_PROVIDER_ID = \"openai\";\n private static final String OPENAI_COMPLETIONS_URL = \"https://api.openai.com/v1/chat/completions\";\n\n // ── DeepSeek ──────────────────────────────────────────────────────────\n private static final String DEEPSEEK_PROVIDER_ID = \"deepseek\";\n private static final String DEEPSEEK_COMPLETIONS_URL = \"https://api.deepseek.com/chat/completions\";\n /** Local ds4 model served by the Ds4LifecycleService subprocess. */\n /** Fallback local URL when no Ds4LifecycleService is wired (test\n * paths). Production resolves the URL dynamically from the\n * supervisor's status endpoint so a user-configured ds4.port\n * flows through end-to-end.\n *

Note the {@code /v1/} prefix — ds4-server's README pins\n * the chat-completions endpoint at {@code POST /v1/chat/completions};\n * hitting {@code /chat/completions} (without {@code /v1/}) was\n * the bug that surfaced as the trunk thread flipping to ERRORED. */\n private static final String DEEPSEEK_LOCAL_COMPLETIONS_URL = \"http://127.0.0.1:8000/v1/chat/completions\";\n private static final String DEEPSEEK_LOCAL_MODEL_ID = \"deepseek-v4-flash\";\n /** Placeholder token the local ds4 server accepts; the real gate is\n * \"server running\", not \"key present\". */\n private static final String DEEPSEEK_LOCAL_AUTH_TOKEN = \"dsv4-local\";\n\n private static final int MAX_OUTPUT_TOKENS = 4_096;\n /** History tail replayed to the provider on each turn. Bound by row\n * count rather than token budget for simplicity in v1. */\n private static final int REPLAY_HISTORY_LIMIT = 80;\n /** Safety cap on the per-turn tool-use ↔ result loop. Reset on\n * every {@code send()}. */\n private static final int MAX_TOOL_ITERATIONS = 12;\n\n // ── Fields ────────────────────────────────────────────────────────────\n\n private final String threadId;\n /** Task attribution for the current scheduler turn. A BRAIN_AGENT starts\n * with its 1:1 parent task; stage agents receive the explicit turn task\n * immediately before dispatch. */\n private volatile String activeTaskId;\n private final ThreadKind kind;\n private final ThreadStore store;\n /** Stage of the in-flight turn, set by the scheduler before each send;\n * emitted messages inherit it as their explicit stage_id. */\n private volatile String activeStageId;\n /** AgentRun episode of the in-flight turn, when this turn belongs to one. */\n private volatile String activeAgentRunId;\n private volatile ManagedSkillBundle managedSkillBundle = ManagedSkillBundle.empty();\n private volatile List activeManagedSkills = List.of();\n /** Null keeps the legacy role/kind filter; non-null is the scheduler's\n * bounded tool set, including an intentionally empty set. */\n private volatile Set activeToolNames;\n private volatile Supplier preTurnHook = () -> null;\n private final ObjectMapper mapper;\n private final ExecutorService executor;\n private final CredentialService credentialService;\n private final WorkModel resolvedModel;\n private final String workingDir;\n private final String branchName;\n private final String roleSkillText;\n private final String sessionId;\n private final long sessionStartedMs;\n private final HttpClient httpClient;\n /** Shared provider tool-round loop. The agent supplies wire-level\n * config per turn; the runner owns the transport + round loop and\n * calls back into this class for tool execution, events, and\n * persistence. */\n private final TurnRunner turnRunner;\n /** Tools the model is told about and can call. Null on the\n * legacy text-only constructor used by older test paths. */\n private final LogicLoopToolRegistry toolRegistry;\n /** Local ds4 supervisor — null when the agent is built without\n * the local lane wired (e.g. unit tests). Used to (a) build the\n * current local endpoint URL so a user-configured ds4.port is\n * honoured, and (b) refuse to dispatch when the server isn't\n * RUNNING instead of silently 404'ing. */\n private final Ds4LifecycleService ds4;\n /** Metrics ring the local-ds4 path records each round into so\n * the Metrics tab reflects thread turns alongside review calls.\n * Null on the legacy paths. */\n private final Ds4Instrumentation ds4Instrumentation;\n /** Coordinates Allow / Deny prompts with {@link ThreadAgent#decide}.\n * Null when the agent is built outside the production wiring\n * (legacy tests); the BridgedTool falls back to refusing gated\n * calls when this is missing. */\n private final McpPermissionGate permissionGate;\n private final CopyOnWriteArrayList> listeners = new CopyOnWriteArrayList<>();\n\n private final AtomicReference status = new AtomicReference<>();\n /** Detail of the most recent fatal failure, surfaced via\n * {@link #lastErrorDetail} so the scheduler can record the real cause\n * on a turn that ended ERRORED without throwing. */\n private final AtomicReference lastError = new AtomicReference<>();\n private final AtomicReference> currentTurn = new AtomicReference<>();\n private final AtomicBoolean userInterrupted = new AtomicBoolean(false);\n private final AtomicLong nextSeq = new AtomicLong();\n /** Per-stage seq counters — a STAGE-scoped message uses its stage's own\n * seq space (stage_messages), so concurrent per-stage agents don't\n * collide on the thread-global (thread_id, seq). */\n private final ConcurrentHashMap stageNextSeq = new ConcurrentHashMap<>();\n private final AtomicLong runningTokensIn = new AtomicLong();\n private final AtomicLong runningTokensOut = new AtomicLong();\n private final AtomicLong runningCostUsdMilli = new AtomicLong();\n // Per-turn token overlay: rounds accumulate here as they complete so\n // the polled thread vitals climb mid-turn. Folded into the running\n // totals (and reset) when the turn finalises or errors, leaving the\n // committed total identical to summing the turn once at the end.\n private final AtomicLong liveTurnTokensIn = new AtomicLong();\n private final AtomicLong liveTurnTokensOut = new AtomicLong();\n private final AtomicReference activeModel = new AtomicReference<>(\"\");\n /** Per-tool auto-approval budget the user granted via \"Allow next\n * N\". {@code -1} is the sentinel for \"always for this tool\" until\n * the session ends. Lives only for the session — a stop /\n * failed thread drops the map. */\n private final ConcurrentHashMap toolBudget = new ConcurrentHashMap<>();\n /** Pending Allow / Deny prompts so {@link #stop} / {@link #interrupt}\n * can cancel them rather than leak a waiting handler thread. */\n private final Set pendingPermissions = ConcurrentHashMap.newKeySet();\n private static final int BUDGET_ALWAYS = -1;\n /** Default cap on how long we wait for a user Allow / Deny\n * before treating the prompt as denied. Five minutes matches\n * the CLI lane's MCP gate. */\n private static final Duration PERMISSION_WAIT_TIMEOUT = Duration.ofMinutes(5);\n\n /** Trunk turns are planning conversations — they read memory,\n * inspect existing PRs / tasks, and spawn new tasks, but they\n * don't execute the work themselves. Restricting the tool\n * catalog to this allowlist on trunk turns cuts ~2-3 K input\n * tokens per round (mostly schema bloat from publish + shell\n * tools the trunk would never call). Task turns get the full\n * catalog because they're the surface that actually executes.\n *\n *

Tools that intentionally stay off the trunk:\n * every {@code PublishToolHandlers} mutator (push, merge_pr,\n * approve_pr, open_pr, post_comment, …), {@code run_shell},\n * {@code run_checks}, and provider-driven skill discovery. Add a name here only\n * after confirming the trunk can complete its planning loop without\n * it. */\n // Package-private so the characterization test can pin the contract:\n // the trunk must be able to plan and queue, not just cut one task.\n static final Set TRUNK_TOOL_ALLOWLIST = Set.of(\n \"recall_memory\",\n \"lookup_memory\",\n \"read_workspace_memory\",\n \"recall_thread\",\n \"list_prs\",\n \"read_pr\",\n \"read_issue\",\n \"read_task\",\n \"read_current_repository\",\n // The trunk reads files so it can plan against real source — and\n // so a pasted image (saved to a path it's handed) can be opened.\n \"read_file\",\n // Explicitly replace the stable planning snapshot mid-cycle.\n \"sync_repo\",\n \"create_task\",\n // Planning the queue is core trunk work — without these the\n // trunk can cut a task but never line one up behind the active\n // one, reorder the plan, or drop a stale entry.\n \"queue_task\",\n \"reorder_queue\",\n \"drop_queued_task\",\n // trunk-role.md already instructs the trunk extensively to ask\n // rather than assume — this was the missing mechanical half.\n \"ask_user_question\");\n\n /** The read-only tool surface the brain agent is allowed to call.\n * Enforced the same way as {@link #TRUNK_TOOL_ALLOWLIST}: the rendered\n * tool list is filtered to these names, so the brain agent never even\n * sees a write tool. The handlers register in {@code LogicLoopToolRegistry}\n * via {@code @AgentTool}; until they land the list renders empty. */\n public static final Set BRAIN_TOOL_ALLOWLIST = Set.of(\n \"codegraph_explore\",\n \"count_operations\",\n \"read_commit_summary\",\n \"read_diff_summary\",\n \"check_test_coverage\",\n \"read_stage_metrics\",\n \"read_phase_history\",\n \"read_review_panel_findings\",\n \"read_remote_pr_status\",\n \"list_unresolved_comments\",\n // The brain's one write tool — records the structured plan during\n // a PlanStage. The handler no-ops (errors) when no PlanStage is\n // open, so it's inert outside planning.\n \"record_plan\",\n // Lets the brain re-read the finalized plan it (or the trunk) recorded.\n \"read_plan_summary\",\n // Brain adversarial review (plan-rail-runs.md R20-R24): the plan\n // self-review turn and the two code lock-point review turns all\n // run on this same brain thread, so it needs the dev-context read\n // tools plus its own comment + verdict writers.\n \"read_dev_report\",\n \"read_dev_conversation\",\n \"record_pr_comment\",\n \"record_review_verdict\");\n\n /** System prompt for the read-only brain agent. A later change prepends\n * a digest of the task's recent iteration summaries; this is the static\n * role portion. */\n static final String BRAIN_SYSTEM_PROMPT = \"\"\"\n You are the read-only brain agent for a developer task. You can \\\n introspect the task's stages, iterations, phases, commits, and PR \\\n state via the provided tools. You cannot edit files, run commands, \\\n push, or post to GitHub. During a stage-scoped planning or \\\n adversarial-review turn, you may use only the local planning/review \\\n write tools that the turn explicitly asks for, such as record_plan, \\\n record_pr_comment, or record_review_verdict.\n\n Answer the user's question concisely (target 6 sentences or fewer). \\\n When you reference a stage or iteration, mention it by full name \\\n (e.g. \"CiFixingStage iteration #1\"); the frontend renders these as \\\n clickable chips.\n\n If you need precise data, call a tool. Don't speculate when you can \\\n introspect.\"\"\";\n\n /** Mediator passed into every {@link AgentToolContext} so the\n * bridged-CLI catalog can route through the permission gate.\n * Lazy-stateless; one instance per agent. */\n private final ToolPermissionMediator permissionMediator = this::admitToolCall;\n\n public LogicLoopThreadAgent(\n Thread thread,\n ThreadStore store,\n ObjectMapper mapper,\n ExecutorService executor,\n CredentialService credentialService,\n WorkModel resolvedModel,\n String workingDir,\n String roleSkillText)\n {\n this(thread, store, mapper, executor, credentialService,\n resolvedModel, workingDir, roleSkillText, /* toolRegistry */ null,\n /* ds4 */ null, /* ds4Instrumentation */ null,\n /* permissionGate */ null);\n }\n\n public LogicLoopThreadAgent(\n Thread thread,\n ThreadStore store,\n ObjectMapper mapper,\n ExecutorService executor,\n CredentialService credentialService,\n WorkModel resolvedModel,\n String workingDir,\n String roleSkillText,\n LogicLoopToolRegistry toolRegistry)\n {\n this(thread, store, mapper, executor, credentialService,\n resolvedModel, workingDir, roleSkillText, toolRegistry,\n /* ds4 */ null, /* ds4Instrumentation */ null,\n /* permissionGate */ null);\n }\n\n public LogicLoopThreadAgent(\n Thread thread,\n ThreadStore store,\n ObjectMapper mapper,\n ExecutorService executor,\n CredentialService credentialService,\n WorkModel resolvedModel,\n String workingDir,\n String roleSkillText,\n LogicLoopToolRegistry toolRegistry,\n Ds4LifecycleService ds4,\n Ds4Instrumentation ds4Instrumentation)\n {\n this(thread, store, mapper, executor, credentialService,\n resolvedModel, workingDir, roleSkillText, toolRegistry,\n ds4, ds4Instrumentation, /* permissionGate */ null);\n }\n\n public LogicLoopThreadAgent(\n Thread thread,\n ThreadStore store,\n ObjectMapper mapper,\n ExecutorService executor,\n CredentialService credentialService,\n WorkModel resolvedModel,\n String workingDir,\n String roleSkillText,\n LogicLoopToolRegistry toolRegistry,\n Ds4LifecycleService ds4,\n Ds4Instrumentation ds4Instrumentation,\n McpPermissionGate permissionGate)\n {\n this.threadId = thread.id();\n this.activeTaskId = thread.parentTaskId();\n this.kind = thread.kind();\n this.store = requireNonNull(store, \"store is null\");\n this.mapper = requireNonNull(mapper, \"mapper is null\");\n this.executor = requireNonNull(executor, \"executor is null\");\n this.credentialService = requireNonNull(credentialService, \"credentialService is null\");\n this.resolvedModel = requireNonNull(resolvedModel, \"resolvedModel is null\");\n this.workingDir = workingDir;\n // A LOGIC_LOOP / brain thread is read-only — it owns no dev branch.\n this.branchName = null;\n this.roleSkillText = roleSkillText;\n this.sessionId = thread.agentSessionId() == null\n ? \"logic-loop-\" + UUID.randomUUID()\n : thread.agentSessionId();\n this.status.set(thread.status() == null ? ThreadStatus.IDLE : thread.status());\n this.sessionStartedMs = System.currentTimeMillis();\n this.httpClient = HttpClient.newBuilder()\n .connectTimeout(Duration.ofSeconds(15))\n .build();\n this.turnRunner = new TurnRunner(httpClient, mapper);\n this.runningTokensIn.set(thread.tokensIn());\n this.runningTokensOut.set(thread.tokensOut());\n this.runningCostUsdMilli.set(thread.costUsdMilli());\n this.activeModel.set(thread.model() == null ? \"\" : thread.model());\n this.toolRegistry = toolRegistry;\n this.ds4 = ds4;\n this.ds4Instrumentation = ds4Instrumentation;\n this.permissionGate = permissionGate;\n store.maxMessageSeq(threadId).ifPresent(max -> nextSeq.set(max + 1));\n }\n\n // ── ThreadAgent interface ─────────────────────────────────────────────\n\n @Override\n public String id()\n {\n return sessionId;\n }\n\n @Override\n public ThreadKind kind()\n {\n return kind;\n }\n\n @Override\n public String provider()\n {\n return resolvedModel.agentOrProvider();\n }\n\n @Override\n public String model()\n {\n String live = activeModel.get();\n if (live != null && !live.isEmpty()) {\n return live;\n }\n return resolvedModel.model();\n }\n\n @Override\n public String workingDir()\n {\n return workingDir;\n }\n\n @Override\n public String branchName()\n {\n return branchName;\n }\n\n @Override\n public ThreadStatus status()\n {\n return status.get();\n }\n\n @Override\n public String lastErrorDetail()\n {\n return lastError.get();\n }\n\n @Override\n public AgentMetrics metrics()\n {\n long runtime = Math.max(0L, System.currentTimeMillis() - sessionStartedMs);\n return new AgentMetrics(\n runtime,\n runningCostUsdMilli.get(),\n runningTokensIn.get(),\n runningTokensOut.get(),\n /* toolCallCount */ 0,\n /* filesTouched */ 0);\n }\n\n @Override\n public List history()\n {\n return store.listMessages(threadId);\n }\n\n @Override\n public CompletionStage send(String userInput)\n {\n ThreadStatus current = status.get();\n if (current == ThreadStatus.RUNNING) {\n CompletableFuture done = new CompletableFuture<>();\n done.completeExceptionally(new IllegalStateException(\n \"a turn is already in flight\"));\n return done;\n }\n if (current == ThreadStatus.COMPLETED\n || current == ThreadStatus.ARCHIVED\n || current == ThreadStatus.ERRORED) {\n CompletableFuture done = new CompletableFuture<>();\n done.complete(null);\n return done;\n }\n userInterrupted.set(false);\n lastError.set(null);\n status.set(ThreadStatus.RUNNING);\n // Persist RUNNING synchronously — a turn can run many autonomous\n // steps over several minutes, and the thread row is the only signal\n // GET /api/threads/{id} (and so the sidebar dot + the trunk's\n // Working banner) has. Without this, the row sits at its pre-turn\n // status for the whole turn and only gets rewritten once, back to\n // IDLE, when the turn finishes — the UI shows nothing is happening\n // until some unrelated write (like the user's own next message)\n // happens to persist a fresher snapshot.\n persistThreadProgress();\n CompletableFuture turn = CompletableFuture.runAsync(\n () -> runTurn(userInput), executor);\n currentTurn.set(turn);\n return turn;\n }\n\n // ── Turn dispatch ─────────────────────────────────────────────────────\n\n private void runTurn(String userInput)\n {\n String note = null;\n try {\n note = preTurnHook.get();\n }\n catch (RuntimeException e) {\n log.warn(\"Pre-turn hook for thread {} failed: {}\", threadId, e.getMessage());\n }\n Instant now = Instant.now();\n liveTurnTokensIn.set(0);\n liveTurnTokensOut.set(0);\n // Pasted images ride inside userInput as a MessageAttachments\n // envelope (see its doc) — decode once here. Unlike the CLI agents,\n // this transport builds the outgoing request JSON directly, so it\n // inlines real multimodal image content blocks instead of a\n // file-path pointer (see buildMessageHistory/buildOpenAiMessages).\n MessageAttachments.Decoded decoded = MessageAttachments.decode(mapper, userInput);\n persistUserMessage(decoded.text(), now, decoded.images());\n publish(new StreamEvent.UserMessage(now, decoded.text(), decoded.images()));\n publish(new StreamEvent.SessionStarted(now, sessionId, workingDir, model()));\n // A pre-turn note (e.g. \"planning base moved\") reaches the model\n // input for this turn only; the persisted user row stays clean.\n String modelInput = note == null || note.isBlank()\n ? decoded.text()\n : note + \"\\n\\n\" + decoded.text();\n\n try {\n String provider = resolvedModel.agentOrProvider();\n if (ANTHROPIC_PROVIDER_ID.equalsIgnoreCase(provider)) {\n runAnthropicTurn(modelInput, decoded.images(), now);\n }\n else if (OPENAI_PROVIDER_ID.equalsIgnoreCase(provider)\n || DEEPSEEK_PROVIDER_ID.equalsIgnoreCase(provider)) {\n runOpenAiCompatibleTurn(modelInput, decoded.images(), now);\n }\n else {\n emitFatal(\"Provider '\" + provider + \"' is not supported by the API lane. \"\n + \"Supported providers: anthropic, openai, deepseek.\", now);\n }\n }\n catch (UnsupportedProviderException e) {\n emitFatal(e.getMessage(), now);\n }\n catch (RuntimeException e) {\n if (userInterrupted.get()) {\n publish(new StreamEvent.SessionEnded(Instant.now(), 0, null));\n status.set(ThreadStatus.IDLE);\n return;\n }\n log.warn(\"LogicLoopThreadAgent turn failed for thread {}: {}\", threadId, e.getMessage());\n emitFatal(e.getMessage() == null ? \"Provider call failed\" : e.getMessage(), now);\n }\n finally {\n currentTurn.set(null);\n }\n }\n\n /** Run a lightweight lifecycle check before each API-lane turn. Trunk\n * sessions use this to sync the planning worktree to the latest fetched\n * base; a non-blank return value is prepended to this turn's model\n * input (the persisted transcript stays clean). */\n public void setPreTurnHook(Supplier hook)\n {\n this.preTurnHook = hook == null ? () -> null : hook;\n }\n\n // ── Anthropic transport ───────────────────────────────────────────────\n\n private void runAnthropicTurn(String userInput, List images, Instant turnStart)\n {\n if (resolvedModel.kind() != WorkModelKind.API) {\n throw new UnsupportedProviderException(\n \"LogicLoopThreadAgent expected an API work model but got \"\n + resolvedModel.kind());\n }\n String account = resolvedModel.account();\n Optional apiKey = account == null || account.isBlank()\n ? credentialService.getSecret(CredentialType.AI, ANTHROPIC_PROVIDER_ID)\n : credentialService.getSecret(CredentialType.AI, ANTHROPIC_PROVIDER_ID, account);\n String key = apiKey.orElseThrow(() -> new IllegalStateException(\n \"No Anthropic API key on file\" + (account == null ? \"\" : \" for account \" + account)\n + \". Add one in Settings → Credentials.\"));\n String modelId = resolvedModel.model() == null || resolvedModel.model().isBlank()\n ? \"claude-sonnet-4-6\"\n : resolvedModel.model();\n activeModel.set(modelId);\n\n String system = composeSystemPrompt();\n ArrayNode messages = buildMessageHistory(userInput, images);\n // Trunk/brain turns get narrow allowlists; ordinary task turns get the\n // task-role catalog, still filtered by thread kind.\n Set toolFilter = toolNameFilter();\n ArrayNode toolsArray = toolRegistry == null\n ? null\n : toolRegistry.renderAsAnthropicTools(mapper, toolFilter, activeAgentRole(), kind);\n\n TurnResult result = turnRunner.runTurn(\n new TurnSpec(\n TurnSpec.Transport.ANTHROPIC, ANTHROPIC_MESSAGES_URL, key, modelId,\n system, messages, toolsArray,\n MAX_OUTPUT_TOKENS, MAX_TOOL_ITERATIONS),\n this::executeLoopTool,\n loopHooks(/* isLocalDs4 */ false));\n if (result.end() == TurnResult.End.INTERRUPTED) {\n return;\n }\n finalizeTurn(modelId, turnStart, renderFinalText(result),\n result.tokensIn(), result.tokensOut());\n }\n\n /** Never persist a silent blank: when the turn produced no final text\n * (the wrap-up round still came back empty, or the model returned\n * nothing), surface a clear message instead of an empty bubble. */\n private static String renderFinalText(TurnResult result)\n {\n String text = result.finalText();\n if (text != null && !text.isBlank()) {\n return text;\n }\n if (result.end() == TurnResult.End.MAX_STEPS) {\n return \"I hit the tool-step limit before I could finish gathering everything for \"\n + \"this. Try narrowing it (e.g. one repo) and I'll answer directly.\";\n }\n return \"The model returned an empty response. Try rephrasing the request.\";\n }\n\n // ── OpenAI-compatible transport (OpenAI + DeepSeek) ───────────────────\n\n private void runOpenAiCompatibleTurn(String userInput, List images, Instant turnStart)\n {\n if (resolvedModel.kind() != WorkModelKind.API) {\n throw new UnsupportedProviderException(\n \"LogicLoopThreadAgent expected an API work model but got \"\n + resolvedModel.kind());\n }\n String provider = resolvedModel.agentOrProvider();\n String modelId = resolvedModel.model() == null || resolvedModel.model().isBlank()\n ? defaultModelForProvider(provider)\n : resolvedModel.model();\n activeModel.set(modelId);\n\n String url;\n String token;\n\n if (DEEPSEEK_PROVIDER_ID.equalsIgnoreCase(provider)) {\n if (DEEPSEEK_LOCAL_MODEL_ID.equals(modelId)) {\n url = resolveLocalDs4Url();\n token = DEEPSEEK_LOCAL_AUTH_TOKEN;\n }\n else {\n url = DEEPSEEK_COMPLETIONS_URL;\n token = credentialService.getSecret(CredentialType.AI, DEEPSEEK_PROVIDER_ID)\n .orElseThrow(() -> new IllegalStateException(\n \"No DeepSeek API key on file. Add one in Settings → Credentials.\"));\n }\n }\n else {\n // openai\n url = OPENAI_COMPLETIONS_URL;\n token = credentialService.getSecret(CredentialType.AI, OPENAI_PROVIDER_ID)\n .orElseThrow(() -> new IllegalStateException(\n \"No OpenAI API key on file. Add one in Settings → Credentials.\"));\n }\n\n String system = composeSystemPrompt();\n ArrayNode messages = buildOpenAiMessages(system, userInput, images);\n Set toolFilter = toolNameFilter();\n ArrayNode toolsArray = toolRegistry == null\n ? null\n : toolRegistry.renderAsOpenAiTools(mapper, toolFilter, activeAgentRole(), kind);\n\n boolean isLocalDs4 = DEEPSEEK_PROVIDER_ID.equalsIgnoreCase(provider)\n && DEEPSEEK_LOCAL_MODEL_ID.equals(modelId);\n\n TurnResult result = turnRunner.runTurn(\n new TurnSpec(\n TurnSpec.Transport.OPENAI_COMPAT, url, token, modelId,\n /* system */ null, messages, toolsArray,\n MAX_OUTPUT_TOKENS, MAX_TOOL_ITERATIONS),\n this::executeLoopTool,\n loopHooks(isLocalDs4));\n if (result.end() == TurnResult.End.INTERRUPTED) {\n return;\n }\n finalizeTurn(modelId, turnStart, renderFinalText(result),\n result.tokensIn(), result.tokensOut());\n }\n\n /** Stream-event + persistence wiring for one runner-driven turn.\n * The runner owns the wire round loop; everything this agent\n * must observe (deltas, usage, tool rows, ds4 metrics,\n * interruption) rides these hooks in the same order the loop\n * produced them before the extraction. */\n private TurnHooks loopHooks(boolean isLocalDs4)\n {\n return new TurnHooks()\n {\n @Override\n public void onTextDelta(int blockIndex, String chunk)\n {\n publish(new StreamEvent.AssistantTextDelta(Instant.now(), blockIndex, chunk));\n }\n\n @Override\n public void onUsage(long tokensIn, long tokensOut)\n {\n publish(new StreamEvent.UsageUpdated(Instant.now(), tokensIn, tokensOut));\n }\n\n @Override\n public void onToolCallStarted(String callId, String toolName, String inputJson)\n {\n publish(new StreamEvent.ToolCallStarted(Instant.now(), callId, toolName, inputJson));\n persistToolCall(callId, toolName, inputJson);\n }\n\n @Override\n public void onToolCallDone(String callId, String resultText, boolean isError)\n {\n String outputJson;\n try {\n outputJson = mapper.writeValueAsString(\n mapper.createObjectNode().put(\"text\", resultText));\n }\n catch (Exception e) {\n outputJson = \"{\\\"text\\\":\\\"\\\"}\";\n }\n publish(new StreamEvent.ToolCallDone(Instant.now(), callId, outputJson, isError));\n persistToolResult(callId, resultText, isError);\n }\n\n @Override\n public void onRoundCompleted(long tokensIn, long tokensOut, long elapsedNanos)\n {\n recordLocalDs4Sample(isLocalDs4, tokensIn, tokensOut, elapsedNanos);\n // Fold the round's tokens into the live overlay and flush so\n // the thread vitals grow during the turn rather than jumping\n // only at turn end.\n liveTurnTokensIn.addAndGet(tokensIn);\n liveTurnTokensOut.addAndGet(tokensOut);\n persistThreadProgress();\n }\n\n @Override\n public boolean interrupted()\n {\n return userInterrupted.get();\n }\n };\n }\n\n /** {@link ToolExecutor} body for runner-driven turns — registry\n * dispatch with the same context + permission mediation the loop\n * always had. */\n private ToolExecutor.ToolCallResult executeLoopTool(ToolCall call)\n {\n AgentTool.Result result = invokeTool(call.name(), call.input());\n return new ToolExecutor.ToolCallResult(result.text(), result.isError());\n }\n\n /** Build the local ds4 chat-completions URL from the supervisor's\n * live status so a user-configured ds4.port is honoured. Pre-\n * validates the server is RUNNING; turning a 404 from a stopped\n * server into a clear \"open Settings → Local AI (ds4) to Start\n * it\" message saves the user from chasing an opaque ERRORED. */\n private String resolveLocalDs4Url()\n {\n if (ds4 == null) {\n return DEEPSEEK_LOCAL_COMPLETIONS_URL;\n }\n Ds4State state = ds4.status().state();\n if (state != Ds4State.RUNNING) {\n throw new IllegalStateException(\n \"Local ds4 server is \" + state + \"; open Settings → Local AI (ds4) \"\n + \"to Start it (or pick a different work model).\");\n }\n String endpoint = ds4.status().endpoint();\n if (endpoint == null || endpoint.isBlank()) {\n return DEEPSEEK_LOCAL_COMPLETIONS_URL;\n }\n return endpoint + \"/v1/chat/completions\";\n }\n\n /** Record one completed local-ds4 round into the metrics ring so\n * the Metrics tab reflects thread turns alongside review calls.\n * No-op when the round wasn't local-ds4 or the instrumentation\n * isn't wired (test paths). */\n private void recordLocalDs4Sample(boolean isLocalDs4, long tokensIn, long tokensOut, long elapsedNanos)\n {\n if (!isLocalDs4 || ds4Instrumentation == null) {\n return;\n }\n long elapsedMs = Math.max(1L, elapsedNanos / 1_000_000L);\n double tps = tokensOut == 0 ? 0.0 : (tokensOut * 1000.0) / elapsedMs;\n String caller = activeTaskId() == null ? \"trunk\" : \"task\";\n ds4Instrumentation.record(Ds4Instrumentation.Sample.of(\n caller, \"/v1/chat/completions\",\n tokensIn, tokensOut, tps, elapsedMs, \"200\"));\n }\n\n private JsonNode parseToolInput(String raw)\n {\n if (raw == null || raw.isEmpty()) {\n return mapper.createObjectNode();\n }\n try {\n return mapper.readTree(raw);\n }\n catch (Exception e) {\n return mapper.createObjectNode();\n }\n }\n\n private AgentTool.Result invokeTool(String name, JsonNode input)\n {\n if (toolRegistry == null) {\n return AgentTool.Result.error(\n \"No tool registry wired for this session — text-only mode.\");\n }\n Set allowedNames = toolNameFilter();\n if (allowedNames != null && !allowedNames.contains(name)) {\n return AgentTool.Result.error(\n \"Tool '\" + name + \"' is not available to this turn.\");\n }\n Optional tool = toolRegistry.find(name);\n if (tool.isEmpty()) {\n return AgentTool.Result.error(\n \"Unknown tool: \" + name + \". Available: \" + toolRegistry.list().stream()\n .map(AgentTool::name).toList());\n }\n Path cwd = workingDir == null ? null : Path.of(workingDir);\n String taskId = activeTaskId();\n try {\n return tool.get().invoke(input, new AgentToolContext(\n threadId, taskId, cwd, permissionMediator,\n activeStageId, activeAgentRunId, kind));\n }\n catch (RuntimeException e) {\n log.warn(\"Tool {} threw on thread {}: {}\", name, threadId, e.getMessage());\n return AgentTool.Result.error(\"Tool '\" + name + \"' failed: \" + e.getMessage());\n }\n }\n\n // ── Persistence helpers ───────────────────────────────────────────────\n\n private void persistToolCall(String callId, String toolName, String inputJson)\n {\n long seq = nextSeq.getAndIncrement();\n ObjectNode body = mapper.createObjectNode();\n body.put(\"callId\", callId);\n body.put(\"toolName\", toolName);\n body.set(\"input\", parseToolInput(inputJson));\n String contentJson;\n try {\n contentJson = mapper.writeValueAsString(body);\n }\n catch (Exception e) {\n contentJson = \"{}\";\n }\n appendStamped(new ThreadMessage(\n UUID.randomUUID().toString(), threadId, activeTaskId(), seq,\n \"tool\", \"tool_call\", contentJson,\n null, null, null, null, Instant.now()));\n }\n\n private void persistToolResult(String callId, String text, boolean isError)\n {\n long seq = nextSeq.getAndIncrement();\n ObjectNode body = mapper.createObjectNode();\n body.put(\"callId\", callId);\n body.put(\"text\", text == null ? \"\" : text);\n body.put(\"isError\", isError);\n String contentJson;\n try {\n contentJson = mapper.writeValueAsString(body);\n }\n catch (Exception e) {\n contentJson = \"{}\";\n }\n appendStamped(new ThreadMessage(\n UUID.randomUUID().toString(), threadId, activeTaskId(), seq,\n \"tool\", \"tool_result\", contentJson,\n null, null, null, null, Instant.now()));\n }\n\n // ── Inner types ───────────────────────────────────────────────────────\n\n // ── Prompt / history builders ─────────────────────────────────────────\n\n private String composeSystemPrompt()\n {\n String role;\n if (kind == ThreadKind.BRAIN_AGENT) {\n // The registry composes the brain prompt (role template + context\n // digest) and passes it as roleSkillText at session creation; fall\n // back to the bare template if it wasn't supplied.\n role = roleSkillText == null || roleSkillText.isBlank()\n ? BRAIN_SYSTEM_PROMPT\n : roleSkillText;\n }\n else {\n role = roleSkillText;\n }\n RoleAndKnowledge separated = splitRoleAndKnowledge(role);\n String compiled = AgentContextCompiler.compilePrompt(\n separated.role(),\n WorkspaceDocumentLoader.load(workingDir),\n separated.knowledge(),\n activeManagedSkills).systemPrompt();\n return compiled.isBlank() ? null : compiled;\n }\n\n private static RoleAndKnowledge splitRoleAndKnowledge(String prompt)\n {\n String marker = \"\\n\\n# Workspace memory and knowledge\\n\\n\";\n if (prompt == null) {\n return new RoleAndKnowledge(null, null);\n }\n int split = prompt.indexOf(marker);\n if (split < 0) {\n return new RoleAndKnowledge(prompt, null);\n }\n return new RoleAndKnowledge(\n prompt.substring(0, split),\n prompt.substring(split + marker.length()));\n }\n\n private record RoleAndKnowledge(String role, String knowledge) {}\n\n /** The tool-name allowlist for this turn: brain agents get the\n * brain review surface, trunk turns get the narrow trunk allowlist,\n * task turns rely on role/kind filtering only (null = no name filter). */\n private Set toolNameFilter()\n {\n if (activeToolNames != null) {\n return activeToolNames;\n }\n if (kind == ThreadKind.BRAIN_AGENT) {\n return BRAIN_TOOL_ALLOWLIST;\n }\n return isTrunkTurn() ? TRUNK_TOOL_ALLOWLIST : null;\n }\n\n private AgentRole activeAgentRole()\n {\n return activeTaskId() == null ? AgentRole.TRUNK : AgentRole.TASK;\n }\n\n /** Build the Anthropic message history array (no system message —\n * Anthropic takes the system prompt as a top-level field). */\n private ArrayNode buildMessageHistory(String userInput, List images)\n {\n ArrayNode messages = mapper.createArrayNode();\n List tail = store.listRecentMessages(threadId, REPLAY_HISTORY_LIMIT);\n for (ThreadMessage row : tail) {\n String role = row.role();\n if (!\"user\".equals(role) && !\"assistant\".equals(role)) {\n continue;\n }\n if (!\"text\".equals(row.type())) {\n continue;\n }\n String text = extractText(row.contentJson());\n if (text == null || text.isEmpty()) {\n continue;\n }\n messages.add(userOrAssistantContentNode(role, text, extractImages(row.contentJson()), true));\n }\n messages.add(userOrAssistantContentNode(\"user\", userInput, images, true));\n return messages;\n }\n\n /** Build the OpenAI-compatible message list. The system prompt goes as\n * the first role:system message; OpenAI does not accept a top-level\n * {@code system} field. */\n private ArrayNode buildOpenAiMessages(String system, String userInput, List images)\n {\n ArrayNode messages = mapper.createArrayNode();\n if (system != null && !system.isEmpty()) {\n ObjectNode sysMsg = mapper.createObjectNode();\n sysMsg.put(\"role\", \"system\");\n sysMsg.put(\"content\", system.trim());\n messages.add(sysMsg);\n }\n List tail = store.listRecentMessages(threadId, REPLAY_HISTORY_LIMIT);\n for (ThreadMessage row : tail) {\n String role = row.role();\n if (!\"user\".equals(role) && !\"assistant\".equals(role)) {\n continue;\n }\n if (!\"text\".equals(row.type())) {\n continue;\n }\n String text = extractText(row.contentJson());\n if (text == null || text.isEmpty()) {\n continue;\n }\n messages.add(userOrAssistantContentNode(role, text, extractImages(row.contentJson()), false));\n }\n messages.add(userOrAssistantContentNode(\"user\", userInput, images, false));\n return messages;\n }\n\n /** One message entry: a plain {@code content} string when there are no\n * images (identical to the pre-image-support shape), or a multimodal\n * content-block array — image block(s) then a text block — when there\n * are. {@code anthropicStyle} picks the block shape: Anthropic's\n * {@code {\"type\":\"image\",\"source\":{...}}} vs OpenAI's {@code\n * {\"type\":\"image_url\",\"image_url\":{\"url\":...}}}; DeepSeek rides the\n * OpenAI shape too, though its local ds4 model likely has no vision\n * support — untested, narrow enough to leave for whoever hits it. */\n private ObjectNode userOrAssistantContentNode(\n String role, String text, List images, boolean anthropicStyle)\n {\n ObjectNode msg = mapper.createObjectNode();\n msg.put(\"role\", role);\n if (images == null || images.isEmpty()) {\n msg.put(\"content\", text == null ? \"\" : text);\n return msg;\n }\n ArrayNode parts = msg.putArray(\"content\");\n for (String path : images) {\n ObjectNode block = imageContentBlock(path, anthropicStyle);\n if (block != null) {\n parts.add(block);\n }\n }\n ObjectNode textBlock = mapper.createObjectNode();\n textBlock.put(\"type\", \"text\");\n textBlock.put(\"text\", text == null ? \"\" : text);\n parts.add(textBlock);\n return msg;\n }\n\n /** Reads an attached image fresh off disk and base64-encodes it into a\n * content block. Returns null (dropping the block, not the whole turn)\n * if the file is gone — e.g. an old message referencing a since-deleted\n * attachments dir — logging instead of failing the turn over one image. */\n private ObjectNode imageContentBlock(String path, boolean anthropicStyle)\n {\n byte[] bytes;\n try {\n bytes = Files.readAllBytes(Path.of(path));\n }\n catch (IOException e) {\n log.warn(\"Could not read attached image {} for thread {}: {}\", path, threadId, e.getMessage());\n return null;\n }\n String base64 = Base64.getEncoder().encodeToString(bytes);\n String mimeType = MessageAttachments.mimeTypeFor(path);\n ObjectNode node = mapper.createObjectNode();\n if (anthropicStyle) {\n node.put(\"type\", \"image\");\n ObjectNode source = node.putObject(\"source\");\n source.put(\"type\", \"base64\");\n source.put(\"media_type\", mimeType);\n source.put(\"data\", base64);\n }\n else {\n node.put(\"type\", \"image_url\");\n node.putObject(\"image_url\").put(\"url\", \"data:\" + mimeType + \";base64,\" + base64);\n }\n return node;\n }\n\n private String extractText(String contentJson)\n {\n if (contentJson == null || contentJson.isEmpty()) {\n return null;\n }\n try {\n JsonNode node = mapper.readTree(contentJson);\n return node.path(\"text\").asText(null);\n }\n catch (Exception e) {\n return null;\n }\n }\n\n /** The image file paths a persisted {@code text} message carries, if\n * any — see {@link MessageAttachments#encodeMessage}. */\n private List extractImages(String contentJson)\n {\n if (contentJson == null || contentJson.isEmpty()) {\n return List.of();\n }\n try {\n JsonNode node = mapper.readTree(contentJson);\n List images = new ArrayList<>();\n node.path(\"images\").forEach(n -> images.add(n.asText()));\n return images;\n }\n catch (Exception e) {\n return List.of();\n }\n }\n\n // ── Cost / model helpers ──────────────────────────────────────────────\n\n /** Look up per-model pricing and compute the turn cost in milli-USD.\n * Falls back to Sonnet-class pricing for unrecognised model ids. */\n private static long estimateCostMilli(String modelId, long tokensIn, long tokensOut)\n {\n return ModelPricing.estimateCostMilli(modelId, tokensIn, tokensOut);\n }\n\n private static String defaultModelForProvider(String provider)\n {\n if (DEEPSEEK_PROVIDER_ID.equalsIgnoreCase(provider)) {\n return \"deepseek-chat\";\n }\n // openai\n return \"gpt-4o-mini\";\n }\n\n // ── Turn finalisation ─────────────────────────────────────────────────\n\n /** Shared post-loop bookkeeping: persist the assistant message, emit\n * the summary events, update running totals, and mark the session idle. */\n private void finalizeTurn(\n String modelId, Instant turnStart, String finalText,\n long totalTokensIn, long totalTokensOut)\n {\n Instant finishedAt = Instant.now();\n long durationMs = Math.max(0L, finishedAt.toEpochMilli() - turnStart.toEpochMilli());\n // The overlay already reflected these tokens live during the turn;\n // clear it before committing the authoritative total so the persist\n // below lands baseline + total exactly once.\n liveTurnTokensIn.set(0);\n liveTurnTokensOut.set(0);\n runningTokensIn.addAndGet(totalTokensIn);\n runningTokensOut.addAndGet(totalTokensOut);\n long turnCostMilli = estimateCostMilli(modelId, totalTokensIn, totalTokensOut);\n runningCostUsdMilli.addAndGet(turnCostMilli);\n\n persistAssistantMessage(finalText, finishedAt, durationMs,\n totalTokensIn, totalTokensOut, turnCostMilli);\n publish(new StreamEvent.AssistantText(finishedAt, finalText));\n publish(new StreamEvent.TurnDone(finishedAt, durationMs, turnCostMilli,\n totalTokensIn, totalTokensOut));\n status.set(ThreadStatus.IDLE);\n persistThreadProgress();\n }\n\n // ── Message persistence ───────────────────────────────────────────────\n\n private void persistUserMessage(String text, Instant ts, List images)\n {\n long seq = nextSeq.getAndIncrement();\n String contentJson = MessageAttachments.encodeMessage(mapper, text, images, activeManagedSkillNames());\n appendStamped(new ThreadMessage(\n UUID.randomUUID().toString(), threadId, activeTaskId(), seq,\n \"user\", \"text\", contentJson,\n /* durationMs */ null, /* tokensIn */ null, /* tokensOut */ null,\n /* costUsdMilli */ null, ts));\n }\n\n private void persistAssistantMessage(\n String text, Instant ts, long durationMs,\n long tokensIn, long tokensOut, long costMilli)\n {\n long seq = nextSeq.getAndIncrement();\n appendStamped(new ThreadMessage(\n UUID.randomUUID().toString(), threadId, activeTaskId(), seq,\n \"assistant\", \"text\", encodeText(text),\n durationMs, tokensIn, tokensOut, costMilli, ts));\n }\n\n /** The task this turn's rows and tools attribute to. */\n @Override\n public String activeTaskId()\n {\n return activeTaskId;\n }\n\n @Override\n public void setActiveTask(String taskId)\n {\n this.activeTaskId = taskId;\n }\n\n @Override\n public void setActiveStage(String stageId)\n {\n this.activeStageId = stageId;\n }\n\n @Override\n public String activeStageId()\n {\n return activeStageId;\n }\n\n @Override\n public void setActiveAgentRun(String agentRunId)\n {\n this.activeAgentRunId = agentRunId;\n }\n\n @Override\n public void setManagedSkillBundle(ManagedSkillBundle bundle)\n {\n this.managedSkillBundle = bundle == null ? ManagedSkillBundle.empty() : bundle;\n this.activeManagedSkills = List.of();\n }\n\n @Override\n public void setActiveManagedSkillNames(List names)\n {\n this.activeManagedSkills = managedSkillBundle.select(names);\n }\n\n @Override\n public void setActiveManagedSkills(List skills)\n {\n this.activeManagedSkills = skills == null ? List.of() : List.copyOf(skills);\n }\n\n @Override\n public void setActiveToolNames(Set names)\n {\n this.activeToolNames = names == null ? null : Set.copyOf(names);\n }\n\n private List activeManagedSkillNames()\n {\n return activeManagedSkills.stream()\n .map(ManagedSkill::name)\n .toList();\n }\n\n /** Persist a message stamped with the turn's explicit stage + scope. A\n * STAGE-scoped row is re-keyed into its stage's own seq space and the\n * decoupled stage_messages store; the thread seq the caller pre-allocated\n * is left unused (a harmless gap in the thread's seq). */\n private void appendStamped(ThreadMessage message)\n {\n if (activeStageId != null && !activeStageId.isBlank()) {\n long seq = nextStageSeq(activeStageId);\n store.appendStageMessage(new ThreadMessage(\n message.id(), message.threadId(), message.taskId(), seq,\n message.role(), message.type(), message.contentJson(),\n message.durationMs(), message.tokensIn(), message.tokensOut(),\n message.costUsdMilli(), message.ts(), activeStageId, ThreadScope.STAGE));\n }\n else {\n store.appendMessage(message.withStageScope(\n activeStageId, ThreadScope.of(message.taskId(), activeStageId)));\n }\n }\n\n /** Next seq in a stage's own space, seeded lazily from the stage store. */\n private long nextStageSeq(String stageId)\n {\n return stageNextSeq\n .computeIfAbsent(stageId, id ->\n new AtomicLong(store.maxStageMessageSeq(id).map(m -> m + 1).orElse(0L)))\n .getAndIncrement();\n }\n\n /** True when this turn is happening at the trunk (planning) level\n * rather than inside a task's execution window. Reused by the\n * tool-catalog filter — see {@link #TRUNK_TOOL_ALLOWLIST}.\n *\n *

\"No active task on the thread\" is a good-enough proxy for\n * \"this is a trunk turn\" because the trunk agent only runs when\n * the user typed into the trunk composer (which doesn't open a\n * task), and the task agent only runs while a task is active.\n * If we later allow trunk turns while a task is also active\n * (e.g. background planning while a task executes), we'll plumb\n * an explicit {@code isTrunk} flag through the constructor and\n * switch this to read it. */\n private boolean isTrunkTurn()\n {\n return activeTaskId() == null;\n }\n\n private String encodeText(String text)\n {\n try {\n ObjectNode node = mapper.createObjectNode();\n node.put(\"text\", text == null ? \"\" : text);\n return mapper.writeValueAsString(node);\n }\n catch (Exception e) {\n throw new IllegalStateException(\"Failed to encode message text\", e);\n }\n }\n\n // ── Event publishing ──────────────────────────────────────────────────\n\n private void publish(StreamEvent event)\n {\n for (Consumer listener : listeners) {\n try {\n listener.accept(event);\n }\n catch (RuntimeException e) {\n log.warn(\"LogicLoopThreadAgent subscriber failed on {}: {}\",\n event.getClass().getSimpleName(), e.getMessage());\n }\n }\n }\n\n private void emitFatal(String message, Instant turnStart)\n {\n Instant now = Instant.now();\n lastError.set(message);\n publish(new StreamEvent.ErrorOccurred(now, message, /* recoverable */ false));\n publish(new StreamEvent.SessionEnded(now, /* exitCode */ 1, message));\n status.set(ThreadStatus.ERRORED);\n long durationMs = Math.max(0L, now.toEpochMilli() - turnStart.toEpochMilli());\n publish(new StreamEvent.TurnDone(now, durationMs, 0L, 0L, 0L));\n // Commit whatever tokens the failed turn did consume so the count\n // doesn't snap back to the pre-turn baseline on the next poll.\n runningTokensIn.addAndGet(liveTurnTokensIn.getAndSet(0));\n runningTokensOut.addAndGet(liveTurnTokensOut.getAndSet(0));\n persistThreadProgress();\n }\n\n /** Mirror the agent's running totals back to the Thread row so the\n * metrics strip and the dashboard's per-thread spend stay in sync. */\n private void persistThreadProgress()\n {\n Optional current = store.findThreadById(threadId);\n if (current.isEmpty()) {\n return;\n }\n Thread t = current.get();\n Thread next = new Thread(\n t.id(), t.kind(), t.provider(), t.agentSessionId(),\n t.title(), status.get(),\n model(),\n runningCostUsdMilli.get(),\n runningTokensIn.get() + liveTurnTokensIn.get(),\n runningTokensOut.get() + liveTurnTokensOut.get(),\n t.createdAt(), Instant.now(),\n t.endedAt(), t.errorMessage(),\n t.flow(), t.workspaceId(), t.workModel());\n store.saveThread(next);\n }\n\n // ── Lifecycle ─────────────────────────────────────────────────────────\n\n @Override\n public void interrupt()\n {\n userInterrupted.set(true);\n cancelPendingPermissions();\n CompletableFuture turn = currentTurn.get();\n if (turn != null) {\n turn.cancel(true);\n }\n }\n\n @Override\n public void resume()\n {\n // ARCHIVED (auto-archived for inactivity) revives to IDLE so the\n // user can pick the panel back up.\n if (status.get() == ThreadStatus.ARCHIVED) {\n status.set(ThreadStatus.IDLE);\n }\n }\n\n @Override\n public void stop()\n {\n userInterrupted.set(true);\n cancelPendingPermissions();\n CompletableFuture turn = currentTurn.get();\n if (turn != null) {\n turn.cancel(true);\n }\n status.set(ThreadStatus.COMPLETED);\n publish(new StreamEvent.SessionEnded(Instant.now(), 0, null));\n }\n\n /** Cancel every outstanding Allow / Deny prompt so a stop /\n * interrupt doesn't leak handler threads blocked on the gate. */\n private void cancelPendingPermissions()\n {\n if (permissionGate == null) {\n return;\n }\n for (String callId : pendingPermissions) {\n permissionGate.cancel(callId);\n }\n pendingPermissions.clear();\n }\n\n @Override\n public void notifyPermissionRequested(String callId, String toolName, String summary)\n {\n // The mediator publishes its own PermissionRequested event,\n // so this hook is only useful as a structural log for\n // surprise registrations.\n log.debug(\"notifyPermissionRequested: callId={}, tool={}\", callId, toolName);\n }\n\n @Override\n public boolean decide(String callId, PermissionDecision decision)\n {\n return permissionGate != null && permissionGate.decide(callId, decision);\n }\n\n @Override\n public void grantToolBudget(String toolName, int count)\n {\n if (toolName == null || toolName.isBlank()) {\n return;\n }\n toolBudget.merge(toolName, count, (existing, add) -> {\n if (existing == BUDGET_ALWAYS || add == BUDGET_ALWAYS) {\n return BUDGET_ALWAYS;\n }\n return existing + add;\n });\n // Drain any pending Allow / Deny prompts for the same tool —\n // a user who just clicked \"Allow next 5\" shouldn't have to\n // click Approve N more times for the calls that piled up.\n if (permissionGate != null) {\n for (String pendingCallId : permissionGate.pendingCallIdsFor(toolName)) {\n OptionalInt slot = tryConsumeToolBudget(toolName);\n if (slot.isEmpty()) {\n break;\n }\n permissionGate.decide(pendingCallId, PermissionDecision.ALLOW);\n publish(new StreamEvent.PermissionAutoAllowed(\n Instant.now(), pendingCallId, toolName, slot.getAsInt()));\n }\n }\n }\n\n @Override\n public OptionalInt tryConsumeToolBudget(String toolName)\n {\n if (toolName == null) {\n return OptionalInt.empty();\n }\n // Atomic decrement with the sentinel kept intact for ALWAYS\n // grants. computeIfPresent returns the new value (or null if\n // the entry was removed) so the caller knows what's left.\n Integer remaining = toolBudget.computeIfPresent(toolName, (k, v) -> {\n if (v == BUDGET_ALWAYS) {\n return BUDGET_ALWAYS;\n }\n int next = v - 1;\n return next <= 0 ? null : next;\n });\n if (remaining == null) {\n // Either no budget existed, or the last slot was just\n // consumed and the entry removed.\n return toolBudget.containsKey(toolName) ? OptionalInt.empty() : OptionalInt.of(0);\n }\n if (remaining == BUDGET_ALWAYS) {\n return OptionalInt.of(BUDGET_ALWAYS);\n }\n return OptionalInt.of(remaining);\n }\n\n @Override\n public void notifyPermissionAutoAllowed(String callId, String toolName, int remaining)\n {\n publish(new StreamEvent.PermissionAutoAllowed(Instant.now(), callId, toolName, remaining));\n }\n\n /** {@link ToolPermissionMediator} body. Lives on the agent so it\n * can drive the gate, the budget, and the event stream the UI\n * listens to — none of those are accessible from inside the\n * registry singleton. */\n private PermissionDecision admitToolCall(String callId, String toolName, Gating gating, String summary)\n {\n if (gating == Gating.AUTO) {\n return PermissionDecision.ALLOW;\n }\n if (gating == Gating.PARKED) {\n // Parked tools (request_review, push, merge_pr, …) ride\n // the CLI lane's notification + publish-service flow.\n // Refusing here is safer than running on the model's\n // word; the bridge surfaces a clear pointer to the CLI\n // lane in its error envelope.\n return PermissionDecision.DENY;\n }\n // GATED — consult the per-tool budget first, then prompt.\n OptionalInt slot = tryConsumeToolBudget(toolName);\n if (slot.isPresent()) {\n int left = slot.getAsInt();\n publish(new StreamEvent.PermissionAutoAllowed(Instant.now(), callId, toolName, left));\n return PermissionDecision.ALLOW;\n }\n if (permissionGate == null) {\n // No gate wired — refuse rather than silently allow.\n return PermissionDecision.DENY;\n }\n CompletableFuture future = permissionGate.register(callId, toolName);\n pendingPermissions.add(callId);\n publish(new StreamEvent.PermissionRequested(Instant.now(), callId, toolName, summary));\n try {\n PermissionDecision decision = future.get(\n PERMISSION_WAIT_TIMEOUT.toSeconds(), TimeUnit.SECONDS);\n publish(new StreamEvent.PermissionDecided(Instant.now(), callId, decision));\n return decision;\n }\n catch (TimeoutException e) {\n permissionGate.cancel(callId);\n publish(new StreamEvent.PermissionDecided(Instant.now(), callId, PermissionDecision.DENY));\n return PermissionDecision.DENY;\n }\n catch (InterruptedException e) {\n java.lang.Thread.currentThread().interrupt();\n permissionGate.cancel(callId);\n return PermissionDecision.DENY;\n }\n catch (ExecutionException e) {\n log.warn(\"Permission gate future failed for {}/{}: {}\", callId, toolName, e.getMessage());\n return PermissionDecision.DENY;\n }\n finally {\n pendingPermissions.remove(callId);\n }\n }\n\n @Override\n public Runnable subscribeToEvents(Consumer listener)\n {\n requireNonNull(listener, \"listener is null\");\n listeners.add(listener);\n return () -> listeners.remove(listener);\n }\n\n /** Distinct exception so the runTurn catch can branch on it and show\n * a clear \"wrong provider\" message instead of a generic failure. */\n private static final class UnsupportedProviderException\n extends RuntimeException\n {\n UnsupportedProviderException(String message)\n {\n super(message);\n }\n }\n}\n"}}}}