+
+
Showing {(page - 1) * PAGE_SIZE + 1}-
{Math.min(page * PAGE_SIZE, total)} of {total} tasks
-
+
)}
-
- {jobAnalysis ? (
-
+
+ {summaryData?.summary ? (
+ {summaryData.summary}
) : (
@@ -1352,16 +1342,7 @@ export default function Job() {
)}
-
-
-
-
+
);
}
diff --git a/apps/viewer/app/routes/trial.tsx b/apps/viewer/app/routes/trial.tsx
index 5afee719cb1..6e942767108 100644
--- a/apps/viewer/app/routes/trial.tsx
+++ b/apps/viewer/app/routes/trial.tsx
@@ -108,8 +108,8 @@ import {
API_BASE,
encodePathSegments,
fetchAgentLogs,
+ fetchAnalyzeProfiles,
fetchExceptionText,
- fetchConfig,
fetchModelPricing,
fetchTrajectory,
fetchTrial,
@@ -135,12 +135,6 @@ import type {
TrialResult,
} from "~/lib/types";
import { AnalysisContent, ContentBlock } from "~/components/analysis-content";
-import {
- ANALYZE_AGENTS,
- defaultModelForAgent,
- displayModelName,
- modelsForAgent,
-} from "~/lib/analyze-models";
import {
ContentRenderer,
ObservationContentRenderer,
@@ -1812,20 +1806,49 @@ function TrialAnalyzeDialog({
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
- const [agent, setAgent] = useState("claude-code");
- const [model, setModel] = useState(defaultModelForAgent("claude-code"));
- const [environment, setEnvironment] = useState("docker");
+ const [model, setModel] = useState("haiku");
+ const [profileId, setProfileId] = useState("");
+ const [modelId, setModelId] = useState("");
- const { data: config } = useQuery({
- queryKey: ["config"],
- queryFn: fetchConfig,
+ const {
+ data: profData,
+ isError: profilesError,
+ isLoading: profilesLoading,
+ } = useQuery({
+ queryKey: ["analyze-profiles"],
+ queryFn: fetchAnalyzeProfiles,
+ retry: false,
+ enabled: open,
});
- const environments = config?.environments ?? ["docker"];
- const agents = ANALYZE_AGENTS;
- const models = modelsForAgent(agent);
+
+ useEffect(() => {
+ if (!profData?.profiles.length || profilesError) return;
+ const first = profData.profiles[0];
+ setProfileId((pid) =>
+ pid && profData.profiles.some((p) => p.id === pid) ? pid : first.id
+ );
+ }, [profData, profilesError]);
+
+ useEffect(() => {
+ if (!profData?.profiles.length || profilesError || !profileId) return;
+ const p = profData.profiles.find((x) => x.id === profileId);
+ if (!p) return;
+ setModelId((mid) =>
+ p.models.some((m) => m.id === mid) ? mid : p.default_model
+ );
+ }, [profileId, profData, profilesError]);
+
+ const useProfiles =
+ Boolean(profData?.profiles.length) && !profilesError;
const mutation = useMutation({
- mutationFn: () => summarizeTrial(jobName, trialName, model, agent, environment),
+ mutationFn: () =>
+ useProfiles
+ ? summarizeTrial(jobName, trialName, {
+ profile_id: profileId,
+ model_id: modelId,
+ })
+ : summarizeTrial(jobName, trialName, { model }),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["agent-logs", jobName, trialName],
@@ -1851,57 +1874,61 @@ function TrialAnalyzeDialog({
-
- Agent
- {
- setAgent(a);
- setModel(defaultModelForAgent(a));
- }}
- >
-
-
-
-
- {agents.map((a) => (
-
- {a}
-
- ))}
-
-
-
-
- Model
-
-
-
-
-
- {models.map((m) => (
-
- {displayModelName(m)}
-
- ))}
-
-
-
-
- Environment
-
-
-
-
-
- {environments.map((env) => (
-
- {env}
-
- ))}
-
-
-
+ {profilesLoading && !profilesError ? (
+
+ Loading analyze profiles…
+
+ ) : null}
+ {useProfiles ? (
+ <>
+
+ Profile
+
+
+
+
+
+ {profData!.profiles.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+
+
+ Model
+
+
+
+
+
+ {(profData!.profiles.find((p) => p.id === profileId)
+ ?.models ?? []
+ ).map((m) => (
+
+ {m.display_name || m.id}
+
+ ))}
+
+
+
+ >
+ ) : (
+
+ Model
+
+
+
+
+
+ Haiku (Recommended)
+ Sonnet
+ Opus
+
+
+
+ )}
mutation.mutate()}
diff --git a/bitfun-swc-verified-all-cases.yaml b/bitfun-swc-verified-all-cases.yaml
new file mode 100644
index 00000000000..db92a9588ac
--- /dev/null
+++ b/bitfun-swc-verified-all-cases.yaml
@@ -0,0 +1,33 @@
+# BitFun CLI — SWE-bench Verified, all tasks
+#
+# Usage:
+# uv run harbor run -c bitfun-swc-verified-all-cases.yaml -y
+
+jobs_dir: jobs
+n_attempts: 1
+n_concurrent_trials: 3
+retry:
+ max_retries: 3
+
+environment:
+ type: docker
+ force_build: false
+ delete: false # keep images/containers for reuse across 500 tasks
+ mounts:
+ - type: bind
+ source: /home/djn/.local/bin/uv
+ target: /usr/local/bin/uv
+ read_only: true
+ - type: bind
+ source: /home/djn/code/harbor/BitFun/target/release/bitfun-cli
+ target: /usr/local/bin/bitfun-cli
+ read_only: true
+ - type: bind
+ source: /home/djn/.config/bitfun
+ target: /root/.config/bitfun
+
+agents:
+ - name: bitfun-cli
+
+datasets:
+ - path: harbor-datasets/datasets/swebench-verified
diff --git a/bitfun-swc-verified-one-case.yaml b/bitfun-swc-verified-one-case.yaml
new file mode 100644
index 00000000000..f5883346278
--- /dev/null
+++ b/bitfun-swc-verified-one-case.yaml
@@ -0,0 +1,34 @@
+# BitFun CLI — SWE-bench Verified, single task (astropy__astropy-12907)
+#
+# Usage:
+# uv run harbor run -c bitfun-swc-verified-one-case.yaml -y
+
+jobs_dir: jobs
+n_attempts: 1
+n_concurrent_trials: 3
+retry:
+ max_retries: 3
+
+environment:
+ type: docker
+ force_build: false
+ delete: false
+ mounts:
+ - type: bind
+ source: /home/djn/.local/bin/uv
+ target: /usr/local/bin/uv
+ read_only: true
+ - type: bind
+ source: /home/djn/code/harbor/BitFun/target/release/bitfun-cli
+ target: /usr/local/bin/bitfun-cli
+ read_only: true
+ - type: bind
+ source: /home/djn/.config/bitfun/config
+ target: /root/.config/bitfun/config
+ read_only: true
+
+agents:
+ - name: bitfun-cli
+
+tasks:
+ - path: harbor-datasets/datasets/swebench-verified/astropy__astropy-12907
diff --git a/docs/content/docs/agents/index.mdx b/docs/content/docs/agents/index.mdx
index 6ac88ec7da1..d81e431d88e 100644
--- a/docs/content/docs/agents/index.mdx
+++ b/docs/content/docs/agents/index.mdx
@@ -13,7 +13,7 @@ Harbor comes with most popular agents pre-integrated. You can run the following
harbor run --help
```
-Right now, Harbor includes Terminus-2, Claude Code, Copilot CLI, Codex CLI, Gemini CLI, OpenHands, Mini-SWE-Agent, and more.
+Right now, Harbor includes Terminus-2, Claude Code, Copilot CLI, CodeAgentCLI, Codex CLI, Gemini CLI, OpenHands, Mini-SWE-Agent, and more.
## Integrating your own agent
diff --git a/docs/job-analyze-aggregate-fallback-summary.md b/docs/job-analyze-aggregate-fallback-summary.md
new file mode 100644
index 00000000000..fd4d606f16a
--- /dev/null
+++ b/docs/job-analyze-aggregate-fallback-summary.md
@@ -0,0 +1,35 @@
+# Harbor Job 级分析 — 方案结论(简版)
+
+## 问题根因
+
+- Job 汇总把 282 条 trial 摘要拼成 ~518KB 单 prompt,经 `query_llm` → Claude CLI `--print -- ` 作为单个 argv 传递。
+- Linux 单参数上限 128KB → `Errno 7: Argument list too long` → `analysis.md` 未生成。
+- Trial 级分析已完成(可缓存复用);失败仅在 `_aggregate`。
+
+## 设计原则
+
+- 不减少、不改写模型可见内容(不做分批 reduce)。
+- 只改传输方式:原路径 → stdin → agent Read 文件。
+
+## Fallback 顺序
+
+1. **原路径**:全量 prompt + `query_llm`(`tools=[]`)
+2. **stdin**:同 prompt,`stream-json` 走子进程 stdin(解决 argv)
+3. **agent Read**:同内容写入 job 目录文件,`query_agent` + `Read`(仍全量)
+4. **失败**:`summarize_job` 返回 422 + 明确错误,避免裸 500
+
+## 不改什么
+
+- Trial 级:继续 agent + Read/Glob/Grep,有 `analysis.json` 则缓存复用。
+- 默认不做:分批 reduce、LiteLLM 双轨、为汇总改 agent 读盘为主路径。
+
+## 局限
+
+- stdin 只解决 argv;全量 ~500KB 仍可能撞模型上下文,Read 也未必能绕过。
+- ③ 可能更慢,并出现 tool/Bash 相关日志。
+
+## 主要改动点(实现时)
+
+- `backend.py`:统一 `query_llm` 的 fallback 调用链
+- `server.py`:错误处理
+- `_aggregate`:拼接逻辑可保持不变
diff --git a/docs/superpowers/plans/2026-05-13-bitfun-cli-atif-adapter.md b/docs/superpowers/plans/2026-05-13-bitfun-cli-atif-adapter.md
new file mode 100644
index 00000000000..a1aef08833a
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-13-bitfun-cli-atif-adapter.md
@@ -0,0 +1,3453 @@
+# BitFun CLI → Harbor ATIF Trajectory Adapter Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Convert BitFun's on-disk session/turn/token-usage artifacts into an ATIF v1.7 `Trajectory` written to `/agent/trajectory.json` and populate `AgentContext` token/cost fields, so `bitfun-cli` reaches parity with `claude-code` / `codex` on Harbor's trajectory surface.
+
+**Architecture:** Wrap the existing `bitfun exec` invocation in a `try/finally` that copies BitFun's `~/.bitfun/projects//sessions/` and `~/.config/bitfun/data/token_usage/` back into `self.logs_dir/bitfun/`. After `run()` completes, `populate_context_post_run` reads `metadata.json` + `turns/turn-*.json` + token records, normalizes events per turn/round/orderIndex, maps to ATIF `Step`s (user / agent text / agent tool-call / system-compaction), allocates token records to rounds by nearest-timestamp, computes cost via `litellm.model_cost`, embeds subagent sessions as ATIF v1.7 `subagent_trajectories[]`, and writes the result to `trajectory.json`. Structurally mirrors `ClaudeCode._get_session_dir` / `_convert_events_to_trajectory` / `populate_context_post_run` so reviewers and future agent authors recognize the pattern.
+
+**Tech stack:** Python 3.12+, Pydantic v2 (`harbor.models.trajectories.*`), `litellm.model_cost` (optional import), `pytest` + `AsyncMock` + golden-file fixtures. Modifies only `src/harbor/agents/installed/bitfun_cli.py`, the unit test module, and adds golden fixtures.
+
+**Spec reference:** `docs/superpowers/specs/2026-05-13-bitfun-cli-atif-adapter-design.md`
+
+---
+
+## File map (create / modify)
+
+| File | Responsibility |
+|------|----------------|
+| `src/harbor/agents/installed/bitfun_cli.py` | **Modify** — add `SUPPORTS_ATIF = True`, `_get_session_dir`, `_load_token_records`, `_compute_cost_via_litellm`, `_convert_events_to_trajectory`, populate-context implementation, and cp-back finally block in `run()`. |
+| `tests/unit/agents/installed/test_bitfun_cli.py` | **Modify** — keep existing install/run tests, add fixture builders, replace noop populate test, add `TestGetSessionDir`, `TestConvertEventsToTrajectory`, `TestTokenAndCostAllocation`, `TestPopulateContextPostRun`, `TestRunCpBackFinally`. |
+| `tests/golden/bitfun_cli//sessions//{metadata.json,turns/turn-*.json,…}` | **Create** — sanitized golden BitFun session used by the integration-style golden test. |
+| `tests/golden/bitfun_cli//token_usage/records/.json` | **Create** — synthetic token records for the golden session. |
+| `tests/golden/bitfun_cli//expected_trajectory.json` | **Create** — expected ATIF output for the golden session. |
+| `AGENTS.md` | **Modify** — update the BitFun bullet to drop the "ATIF / token metrics deferred" caveat. |
+
+Single-responsibility split inside `bitfun_cli.py`: directory resolution (`_get_session_dir`) is independent of normalization (`_convert_events_to_trajectory`), which is independent of token allocation (`_load_token_records` + `_assign_metrics_to_steps`), which is independent of cost (`_compute_cost_via_litellm`). Subagent embedding is a thin recursive wrapper around `_convert_events_to_trajectory(is_subagent=True)`.
+
+---
+
+## Implementation conventions used throughout
+
+- **File I/O:** use `Path.read_text` / `Path.write_text` (per repo CLAUDE.md), never `with open(...)`.
+- **JSON formatting:** trajectory written via `harbor.utils.trajectory_utils.format_trajectory_json(trajectory.to_json_dict())` (matches Codex/cursor-cli).
+- **Logging:** `self.logger.debug(...)` everywhere; only escalate to `warning`/`exception` for unexpected programmer-visible failures (mirrors Codex). Conversion failures must never propagate out of `populate_context_post_run`.
+- **Async/typing:** unchanged (`run()` already uses `async def`; no new asyncio code).
+- **Internal invariants:** prefer `if cond: raise ValueError(...)` over `assert` (repo rule).
+- **Tests:** mark unit tests with `pytest.mark` only where the existing module uses markers; this module currently relies on `pytest.mark.asyncio` for async tests — keep that.
+- **No drive-by edits** to other agents or shared models. ATIF model defaults already default `schema_version` to `ATIF-v1.7`; we set it explicitly anyway for clarity.
+
+---
+
+### Task 1: Add ATIF imports and `SUPPORTS_ATIF` flag
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+This task wires up the class-level flag and imports without altering behavior yet. The existing noop populate test is replaced by a placeholder-aware test that allows the trajectory pipeline to be a no-op when no session dir exists.
+
+- [ ] **Step 1: Write the failing test (renames the noop test)**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, replace `test_populate_context_post_run_noop` with the following test (still inside `class TestBitfunCliAgent`):
+
+```python
+def test_populate_context_post_run_returns_when_no_session_dir(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ assert ctx.is_empty()
+
+def test_supports_atif_is_true(self):
+ assert BitfunCli.SUPPORTS_ATIF is True
+```
+
+- [ ] **Step 2: Run the new tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_supports_atif_is_true -v
+```
+
+Expected: FAIL — `assert False is True` (class attribute defaults to `False` via `BaseAgent`).
+
+- [ ] **Step 3: Update imports and class attribute**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, replace the existing import block and class header:
+
+```python
+"""Harbor integration for BitFun's bitfun-cli (single-shot `exec` mode)."""
+
+from __future__ import annotations
+
+import json
+import os
+import shlex
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template
+from harbor.environments.base import BaseEnvironment
+from harbor.models.agent.context import AgentContext
+from harbor.models.agent.name import AgentName
+from harbor.models.trajectories import (
+ Agent,
+ FinalMetrics,
+ Metrics,
+ Observation,
+ ObservationResult,
+ Step,
+ SubagentTrajectoryRef,
+ ToolCall,
+ Trajectory,
+)
+from harbor.utils.trajectory_utils import format_trajectory_json
+
+_DEFAULT_BINARY = "/usr/local/bin/bitfun-cli"
+_AGENT_LOG = "/logs/agent/bitfun.txt"
+_ATIF_SCHEMA_VERSION = "ATIF-v1.7"
+_BITFUN_DATA_SUBDIR = "bitfun" # under self.logs_dir
+
+# Copied into the container exec env when set on the Harbor host / orchestrator.
+_ENV_PASSTHROUGH: tuple[str, ...] = (
+ "OPENAI_API_KEY",
+ "OPENAI_BASE_URL",
+ "ANTHROPIC_API_KEY",
+ "ANTHROPIC_AUTH_TOKEN",
+ "ANTHROPIC_BASE_URL",
+ "GEMINI_API_KEY",
+ "GOOGLE_API_KEY",
+)
+
+
+class BitfunCli(BaseInstalledAgent):
+ """Run BitFun CLI in non-interactive `exec` mode (binary supplied via bind mount)."""
+
+ SUPPORTS_ATIF: bool = True
+```
+
+(The rest of the class body is unchanged in this task; later tasks extend it.)
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent -v
+```
+
+Expected: PASS for both `test_supports_atif_is_true` and `test_populate_context_post_run_returns_when_no_session_dir` (the existing noop body still applies: `populate_context_post_run` is still the original `pass`).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): declare SUPPORTS_ATIF=True and import ATIF models"
+```
+
+---
+
+### Task 2: Add session-fixture builders to the test module
+
+**Files:**
+
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+Helpers that construct minimal valid BitFun JSON on disk. Used by every subsequent task. No production-code change in this task.
+
+- [ ] **Step 1: Add the fixture-builder helpers (full code)**
+
+Append the following helpers to `tests/unit/agents/installed/test_bitfun_cli.py`, after the existing `temp_dir` fixture:
+
+```python
+import json as _json
+from pathlib import Path as _Path
+
+_DEFAULT_TS_MS = 1_778_000_000_000 # arbitrary fixed epoch ms
+
+
+def _ts_iso(ms: int) -> str:
+ """Convert BitFun millisecond epoch to an ISO-8601 UTC timestamp string."""
+ from datetime import datetime, timezone
+
+ return (
+ datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ )
+
+
+def _make_metadata(
+ session_id: str,
+ *,
+ kind: str = "standard",
+ model: str = "default",
+ workspace: str = "/testbed",
+ turn_count: int = 0,
+ tool_call_count: int = 0,
+ created_at: int = _DEFAULT_TS_MS,
+ last_active_at: int | None = None,
+) -> dict:
+ return {
+ "schema_version": 2,
+ "sessionId": session_id,
+ "sessionName": "test",
+ "agentType": "agentic",
+ "sessionKind": kind,
+ "modelName": model,
+ "createdAt": created_at,
+ "lastActiveAt": last_active_at or (created_at + 1_000),
+ "turnCount": turn_count,
+ "messageCount": turn_count * 2,
+ "toolCallCount": tool_call_count,
+ "status": "completed",
+ "tags": [],
+ "workspacePath": workspace,
+ "workspaceHostname": "localhost",
+ }
+
+
+def _make_text_item(
+ item_id: str,
+ content: str,
+ *,
+ order_index: int = 0,
+ ts: int = _DEFAULT_TS_MS,
+ status: str = "completed",
+) -> dict:
+ return {
+ "id": item_id,
+ "content": content,
+ "isStreaming": False,
+ "timestamp": ts,
+ "isMarkdown": True,
+ "orderIndex": order_index,
+ "status": status,
+ }
+
+
+def _make_thinking_item(
+ item_id: str,
+ content: str,
+ *,
+ order_index: int = 0,
+ ts: int = _DEFAULT_TS_MS,
+) -> dict:
+ return {
+ "id": item_id,
+ "content": content,
+ "isStreaming": False,
+ "isCollapsed": False,
+ "timestamp": ts,
+ "orderIndex": order_index,
+ }
+
+
+def _make_tool_item(
+ item_id: str,
+ tool_name: str,
+ input_args: dict,
+ *,
+ result_text: str | None = None,
+ raw_result: object = None,
+ success: bool = True,
+ error: str | None = None,
+ subagent_sid: str | None = None,
+ subagent_model_id: str | None = None,
+ parent_task_tool_id: str | None = None,
+ order_index: int = 0,
+ ts: int = _DEFAULT_TS_MS,
+ duration_ms: int = 5,
+ ai_intent: str | None = None,
+) -> dict:
+ out: dict = {
+ "id": item_id,
+ "toolName": tool_name,
+ "toolCall": {"id": item_id, "input": input_args},
+ "startTime": ts,
+ "endTime": ts + duration_ms,
+ "durationMs": duration_ms,
+ "executionMs": duration_ms,
+ "orderIndex": order_index,
+ "status": "completed",
+ }
+ if result_text is not None or raw_result is not None:
+ tr: dict = {"success": success}
+ tr["result"] = raw_result if raw_result is not None else {"text": result_text}
+ if result_text is not None:
+ tr["resultForAssistant"] = result_text
+ if error is not None:
+ tr["error"] = error
+ tr["success"] = False
+ tr["durationMs"] = duration_ms
+ out["toolResult"] = tr
+ if ai_intent is not None:
+ out["aiIntent"] = ai_intent
+ if subagent_sid is not None:
+ out["isSubagentItem"] = True
+ out["subagentSessionId"] = subagent_sid
+ if subagent_model_id is not None:
+ out["subagentModelId"] = subagent_model_id
+ if parent_task_tool_id is not None:
+ out["parentTaskToolId"] = parent_task_tool_id
+ return out
+
+
+def _make_round(
+ round_id: str,
+ *,
+ turn_id: str,
+ round_index: int = 0,
+ text_items: list | None = None,
+ tool_items: list | None = None,
+ thinking_items: list | None = None,
+ model_id: str | None = "openai/gpt-5",
+ model_alias: str | None = None,
+ provider_id: str | None = "openai",
+ ts: int = _DEFAULT_TS_MS,
+ duration_ms: int = 10,
+ attempt_count: int = 1,
+ status: str = "completed",
+ failure_category: str | None = None,
+) -> dict:
+ return {
+ "id": round_id,
+ "turnId": turn_id,
+ "roundIndex": round_index,
+ "timestamp": ts,
+ "textItems": text_items or [],
+ "toolItems": tool_items or [],
+ "thinkingItems": thinking_items or [],
+ "startTime": ts,
+ "endTime": ts + duration_ms,
+ "durationMs": duration_ms,
+ "providerId": provider_id,
+ "modelId": model_id,
+ "modelAlias": model_alias,
+ "attemptCount": attempt_count,
+ "status": status,
+ **({"failureCategory": failure_category} if failure_category else {}),
+ }
+
+
+def _make_turn(
+ turn_index: int,
+ turn_id: str,
+ session_id: str,
+ *,
+ kind: str = "user_dialog",
+ user_text: str = "hello",
+ user_content: str | None = None,
+ model_rounds: list | None = None,
+ ts: int = _DEFAULT_TS_MS,
+ duration_ms: int = 100,
+ status: str = "completed",
+) -> dict:
+ return {
+ "schema_version": 2,
+ "turnId": turn_id,
+ "turnIndex": turn_index,
+ "sessionId": session_id,
+ "timestamp": ts,
+ "kind": kind,
+ "userMessage": {
+ "id": f"{turn_id}-user",
+ "content": user_content
+ if user_content is not None
+ else f"\n{user_text}\n ",
+ "timestamp": ts,
+ "metadata": {"original_text": user_text} if user_text else {},
+ },
+ "modelRounds": model_rounds or [],
+ "startTime": ts,
+ "endTime": ts + duration_ms,
+ "durationMs": duration_ms,
+ "status": status,
+ }
+
+
+def _make_token_record(
+ model_id: str,
+ session_id: str,
+ turn_id: str,
+ in_tok: int,
+ out_tok: int,
+ *,
+ cached: int = 0,
+ is_sub: bool = False,
+ ts: int = _DEFAULT_TS_MS,
+ token_details: dict | None = None,
+) -> dict:
+ return {
+ "model_id": model_id,
+ "session_id": session_id,
+ "turn_id": turn_id,
+ "timestamp": _ts_iso(ts),
+ "input_tokens": in_tok,
+ "output_tokens": out_tok,
+ "cached_tokens": cached,
+ "cached_tokens_available": cached > 0,
+ "total_tokens": in_tok + out_tok,
+ "is_subagent": is_sub,
+ "token_details": token_details or {},
+ }
+
+
+def _write_session(
+ logs_dir: _Path,
+ sid: str,
+ *,
+ metadata: dict,
+ turns: list[dict],
+ token_records: list[dict] | None = None,
+ token_records_date: str = "2026-01-01",
+) -> _Path:
+ """Lay out a minimal BitFun cp-back tree under logs_dir/bitfun/."""
+ root = logs_dir / "bitfun" / "sessions" / sid
+ (root / "turns").mkdir(parents=True, exist_ok=True)
+ (root / "metadata.json").write_text(_json.dumps(metadata))
+ for turn in turns:
+ (root / "turns" / f"turn-{turn['turnIndex']:04d}.json").write_text(
+ _json.dumps(turn)
+ )
+ if token_records is not None:
+ records_dir = logs_dir / "bitfun" / "token_usage" / "records"
+ records_dir.mkdir(parents=True, exist_ok=True)
+ (records_dir / f"{token_records_date}.json").write_text(
+ _json.dumps({"records": list(token_records)})
+ )
+ return root
+```
+
+- [ ] **Step 2: Sanity-check the helpers compile**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v --collect-only
+```
+
+Expected: collection succeeds (no syntax errors), test count equals previous count.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "test(bitfun-cli): add BitFun session/turn fixture builders"
+```
+
+---
+
+### Task 3: Implement `_get_session_dir`
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+Identifies the **main standard** session under `logs_dir/bitfun/sessions/`. A standard session is one whose `metadata.json.sessionKind == "standard"`. Subagent siblings (`sessionKind == "subagent"`) are filtered out. If exactly one standard session is present, returns it. If more than one is present, picks the most recently modified (mtime). Returns `None` when no candidates exist.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append the following test class to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestGetSessionDir:
+ def test_picks_unique_standard_session(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ _write_session(
+ temp_dir,
+ "main",
+ metadata=_make_metadata("main", kind="standard"),
+ turns=[],
+ )
+ _write_session(
+ temp_dir,
+ "sub-1",
+ metadata=_make_metadata("sub-1", kind="subagent"),
+ turns=[],
+ )
+ _write_session(
+ temp_dir,
+ "sub-2",
+ metadata=_make_metadata("sub-2", kind="subagent"),
+ turns=[],
+ )
+ result = agent._get_session_dir()
+ assert result is not None
+ assert result.name == "main"
+
+ def test_no_bitfun_dir_returns_none(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._get_session_dir() is None
+
+ def test_falls_back_to_mtime_when_multiple_standards(self, temp_dir):
+ import os
+ import time
+
+ agent = BitfunCli(logs_dir=temp_dir)
+ a = _write_session(
+ temp_dir, "older",
+ metadata=_make_metadata("older", kind="standard"), turns=[],
+ )
+ time.sleep(0.02)
+ b = _write_session(
+ temp_dir, "newer",
+ metadata=_make_metadata("newer", kind="standard"), turns=[],
+ )
+ # Make sure mtimes differ even on coarse filesystems.
+ now = time.time()
+ os.utime(a, (now - 100, now - 100))
+ os.utime(b, (now, now))
+ result = agent._get_session_dir()
+ assert result is not None
+ assert result.name == "newer"
+
+ def test_skips_dirs_without_metadata(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ (temp_dir / "bitfun" / "sessions" / "junk").mkdir(parents=True)
+ _write_session(
+ temp_dir, "main",
+ metadata=_make_metadata("main", kind="standard"), turns=[],
+ )
+ result = agent._get_session_dir()
+ assert result is not None
+ assert result.name == "main"
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestGetSessionDir -v
+```
+
+Expected: FAIL — `AttributeError: 'BitfunCli' object has no attribute '_get_session_dir'`.
+
+- [ ] **Step 3: Implement `_get_session_dir`**
+
+Insert this method on `BitfunCli` in `src/harbor/agents/installed/bitfun_cli.py`, just below `get_version_command`:
+
+```python
+def _get_session_dir(self) -> Path | None:
+ """Locate the main BitFun *standard* session directory under self.logs_dir.
+
+ Layout (populated by the cp-back finally block in `run()`)::
+
+ /bitfun/sessions//metadata.json
+ /bitfun/sessions//turns/turn-*.json
+
+ Filters out subagent sessions (`sessionKind == "subagent"`). Returns the
+ unique standard session when exactly one is present; otherwise picks the
+ most recently modified standard session (mtime fallback). Returns
+ ``None`` when no readable standard session exists.
+ """
+ sessions_root = self.logs_dir / _BITFUN_DATA_SUBDIR / "sessions"
+ if not sessions_root.is_dir():
+ return None
+
+ candidates: list[Path] = []
+ for entry in sessions_root.iterdir():
+ if not entry.is_dir():
+ continue
+ meta_path = entry / "metadata.json"
+ if not meta_path.is_file():
+ continue
+ try:
+ meta = json.loads(meta_path.read_text())
+ except (OSError, json.JSONDecodeError):
+ continue
+ if meta.get("sessionKind", "standard") == "subagent":
+ continue
+ candidates.append(entry)
+
+ if not candidates:
+ return None
+ if len(candidates) == 1:
+ return candidates[0]
+
+ self.logger.debug(
+ "Multiple BitFun standard sessions found; falling back to mtime",
+ )
+ return max(candidates, key=lambda p: p.stat().st_mtime)
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestGetSessionDir -v
+```
+
+Expected: PASS for all 4 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): add _get_session_dir for ATIF conversion"
+```
+
+---
+
+### Task 4: Implement `_load_token_records`
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestLoadTokenRecords:
+ def test_returns_empty_when_no_records_dir(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._load_token_records() == []
+
+ def test_loads_records_from_all_date_files(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ records_dir = temp_dir / "bitfun" / "token_usage" / "records"
+ records_dir.mkdir(parents=True)
+ (records_dir / "2026-01-01.json").write_text(
+ _json.dumps(
+ {
+ "records": [
+ _make_token_record("m", "s", "t1", 10, 5),
+ _make_token_record("m", "s", "t2", 20, 10),
+ ]
+ }
+ )
+ )
+ (records_dir / "2026-01-02.json").write_text(
+ _json.dumps({"records": [_make_token_record("m", "s", "t3", 1, 1)]})
+ )
+ records = agent._load_token_records()
+ assert len(records) == 3
+ assert {r["turn_id"] for r in records} == {"t1", "t2", "t3"}
+
+ def test_skips_malformed_record_files(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ records_dir = temp_dir / "bitfun" / "token_usage" / "records"
+ records_dir.mkdir(parents=True)
+ (records_dir / "bad.json").write_text("not json {{{")
+ (records_dir / "good.json").write_text(
+ _json.dumps({"records": [_make_token_record("m", "s", "t", 1, 1)]})
+ )
+ records = agent._load_token_records()
+ assert len(records) == 1
+ assert records[0]["turn_id"] == "t"
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestLoadTokenRecords -v
+```
+
+Expected: FAIL — `_load_token_records` not defined.
+
+- [ ] **Step 3: Implement `_load_token_records`**
+
+Add this method just below `_get_session_dir`:
+
+```python
+def _load_token_records(self) -> list[dict[str, Any]]:
+ """Aggregate all BitFun TokenUsageRecord entries from records/*.json files.
+
+ Malformed JSON or unreadable files are skipped silently with a debug log.
+ Returns an empty list when the records directory does not exist.
+ """
+ records_dir = self.logs_dir / _BITFUN_DATA_SUBDIR / "token_usage" / "records"
+ if not records_dir.is_dir():
+ return []
+
+ out: list[dict[str, Any]] = []
+ for jf in sorted(records_dir.glob("*.json")):
+ try:
+ batch = json.loads(jf.read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ self.logger.debug(f"Skipping malformed token-record file {jf}: {exc}")
+ continue
+ if not isinstance(batch, dict):
+ continue
+ recs = batch.get("records")
+ if isinstance(recs, list):
+ out.extend(r for r in recs if isinstance(r, dict))
+ return out
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestLoadTokenRecords -v
+```
+
+Expected: PASS for all 3 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): add _load_token_records token-usage reader"
+```
+
+---
+
+### Task 5: Implement `_compute_cost_via_litellm`
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+A lift-and-shift of `Codex._compute_cost_from_pricing`. Unlike Codex this method takes the model id as an argument (BitFun records may span multiple models within one session), and falls back to `self.model_name` when no model id is given.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+from unittest.mock import patch as _patch
+
+
+class TestComputeCostViaLitellm:
+ def test_returns_none_when_no_model(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._compute_cost_via_litellm(None, 100, 0, 50) is None
+
+ def test_returns_none_when_model_unknown(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ with _patch("litellm.model_cost", {}):
+ assert (
+ agent._compute_cost_via_litellm("totally-fake-model", 100, 0, 50)
+ is None
+ )
+
+ def test_computes_cost_with_cache_rate(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ fake_pricing = {
+ "fake-model": {
+ "input_cost_per_token": 1e-6,
+ "output_cost_per_token": 2e-6,
+ "cache_read_input_token_cost": 1e-7,
+ }
+ }
+ with _patch("litellm.model_cost", fake_pricing):
+ cost = agent._compute_cost_via_litellm("fake-model", 100, 10, 50)
+ # uncached_input = 90, cached = 10, output = 50
+ # 90*1e-6 + 10*1e-7 + 50*2e-6 = 9e-5 + 1e-6 + 1e-4 = 1.91e-4
+ assert cost is not None
+ assert abs(cost - (90e-6 + 10e-7 + 100e-6)) < 1e-12
+
+ def test_falls_back_to_input_rate_when_cache_rate_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ fake_pricing = {
+ "m": {"input_cost_per_token": 2e-6, "output_cost_per_token": 4e-6}
+ }
+ with _patch("litellm.model_cost", fake_pricing):
+ cost = agent._compute_cost_via_litellm("m", 100, 30, 50)
+ # uncached_input=70, cached=30 (billed at input rate), output=50
+ # 70*2e-6 + 30*2e-6 + 50*4e-6 = 1.4e-4 + 6e-5 + 2e-4 = 4.0e-4
+ assert cost is not None
+ assert abs(cost - 4.0e-4) < 1e-12
+
+ def test_strips_provider_prefix(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ fake_pricing = {
+ "gpt-5": {"input_cost_per_token": 1e-6, "output_cost_per_token": 1e-6}
+ }
+ with _patch("litellm.model_cost", fake_pricing):
+ cost = agent._compute_cost_via_litellm("openai/gpt-5", 10, 0, 5)
+ assert cost is not None
+ assert abs(cost - (10e-6 + 5e-6)) < 1e-12
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestComputeCostViaLitellm -v
+```
+
+Expected: FAIL — method not defined.
+
+- [ ] **Step 3: Implement `_compute_cost_via_litellm`**
+
+Add this method just below `_load_token_records`:
+
+```python
+def _compute_cost_via_litellm(
+ self,
+ model_id: str | None,
+ prompt_tokens: int | None,
+ cached_tokens: int | None,
+ completion_tokens: int | None,
+) -> float | None:
+ """Compute USD cost for a token record via litellm.model_cost.
+
+ BitFun records token counts only; cost must be derived. Returns None
+ when the model is not in litellm.model_cost so callers can leave
+ `cost_usd` unset rather than report a misleading $0.
+
+ Mirrors Codex._compute_cost_from_pricing: cached input tokens are
+ billed at `cache_read_input_token_cost` when present, otherwise at
+ `input_cost_per_token`.
+ """
+ lookup = model_id or self.model_name
+ if not lookup:
+ return None
+
+ try:
+ import litellm
+ except ImportError:
+ self.logger.debug("litellm not available; bitfun cost_usd will be None")
+ return None
+
+ pricing: dict[str, Any] | None = None
+ for key in (lookup, lookup.split("/", 1)[-1]):
+ entry = litellm.model_cost.get(key)
+ if entry:
+ pricing = entry
+ break
+
+ if pricing is None:
+ self.logger.debug(
+ "No LiteLLM pricing for model %r; bitfun cost_usd will be None",
+ lookup,
+ )
+ return None
+
+ input_rate = pricing.get("input_cost_per_token") or 0.0
+ output_rate = pricing.get("output_cost_per_token") or 0.0
+ cache_read_rate = pricing.get("cache_read_input_token_cost", input_rate)
+ if cache_read_rate is None:
+ cache_read_rate = input_rate
+
+ uncached_input = max(0, (prompt_tokens or 0) - (cached_tokens or 0))
+ cached = cached_tokens or 0
+ output = completion_tokens or 0
+
+ return (
+ uncached_input * input_rate
+ + cached * cache_read_rate
+ + output * output_rate
+ )
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestComputeCostViaLitellm -v
+```
+
+Expected: PASS for all 5 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): add _compute_cost_via_litellm pricing helper"
+```
+
+---
+
+### Task 6: Implement basic conversion (user + single assistant text)
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+Establishes the skeleton of `_convert_events_to_trajectory`: reads `metadata.json`, walks `turns/turn-*.json` sorted by `turnIndex`, emits one user step per `user_dialog` turn and one assistant text step per `textItems[]` entry. Skips token allocation and subagent embedding (added in later tasks).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestConvertEventsToTrajectoryBasic:
+ def test_basic_user_assistant_pair(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s1"
+ turn = _make_turn(
+ 0,
+ "t1",
+ sid,
+ user_text="hello",
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti1", "hi there", order_index=0)],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid, turn_count=1), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.schema_version == "ATIF-v1.7"
+ assert traj.session_id == sid
+ assert traj.agent.name == "bitfun-cli"
+ assert len(traj.steps) == 2
+ assert traj.steps[0].source == "user"
+ assert traj.steps[0].message == "hello"
+ assert traj.steps[0].step_id == 1
+ assert traj.steps[1].source == "agent"
+ assert traj.steps[1].message == "hi there"
+ assert traj.steps[1].step_id == 2
+ assert traj.steps[1].model_name == "openai/gpt-5"
+
+ def test_returns_none_when_metadata_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ bogus = temp_dir / "bitfun" / "sessions" / "x"
+ (bogus / "turns").mkdir(parents=True)
+ assert agent._convert_events_to_trajectory(bogus) is None
+
+ def test_user_query_wrapper_is_stripped_when_metadata_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s2"
+ turn = _make_turn(
+ 0, "t1", sid,
+ user_content="\nplease help\n ",
+ user_text="",
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti1", "ok", order_index=0)],
+ )
+ ],
+ )
+ # Force metadata.original_text to be absent
+ turn["userMessage"]["metadata"] = {}
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.steps[0].source == "user"
+ assert traj.steps[0].message == "please help"
+
+ def test_step_ids_are_sequential_from_1(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s3"
+ turns = [
+ _make_turn(
+ i,
+ f"t{i}",
+ sid,
+ user_text=f"q{i}",
+ model_rounds=[
+ _make_round(
+ f"r{i}",
+ turn_id=f"t{i}",
+ text_items=[_make_text_item(f"ti{i}", f"a{i}")],
+ )
+ ],
+ )
+ for i in range(3)
+ ]
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid, turn_count=3), turns=turns,
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert [s.step_id for s in traj.steps] == list(
+ range(1, len(traj.steps) + 1)
+ )
+
+ def test_schema_version_is_atif_v1_7(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s4"
+ turn = _make_turn(
+ 0,
+ "t1",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti", "x")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.schema_version == "ATIF-v1.7"
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestConvertEventsToTrajectoryBasic -v
+```
+
+Expected: FAIL — `_convert_events_to_trajectory` is not yet defined.
+
+- [ ] **Step 3: Implement basic conversion (user + text only)**
+
+Add these helpers and the public method on `BitfunCli`. Place them just below `_compute_cost_via_litellm`:
+
+```python
+@staticmethod
+def _ts_iso(ms: int | None) -> str | None:
+ """Convert BitFun's u64 epoch-ms timestamp to ISO-8601 UTC."""
+ if ms is None:
+ return None
+ return (
+ datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ )
+
+@staticmethod
+def _strip_user_query_wrapper(content: str) -> str:
+ """BitFun wraps exec input in … ; strip if present."""
+ text = content.strip()
+ if text.startswith("") and text.endswith(" "):
+ inner = text[len("") : -len(" ")]
+ return inner.strip()
+ return text
+
+@classmethod
+def _user_text_from_message(cls, user_message: dict[str, Any]) -> str:
+ meta = user_message.get("metadata") or {}
+ original = meta.get("original_text")
+ if isinstance(original, str) and original:
+ return original
+ return cls._strip_user_query_wrapper(user_message.get("content") or "")
+
+def _load_turns(self, session_dir: Path) -> list[dict[str, Any]]:
+ """Read all turn-*.json files sorted by turnIndex ascending; skip malformed."""
+ turns_dir = session_dir / "turns"
+ if not turns_dir.is_dir():
+ return []
+ turns: list[dict[str, Any]] = []
+ for jf in sorted(turns_dir.glob("turn-*.json")):
+ try:
+ turns.append(json.loads(jf.read_text()))
+ except (OSError, json.JSONDecodeError) as exc:
+ self.logger.debug(f"Skipping malformed turn file {jf}: {exc}")
+ turns.sort(key=lambda t: t.get("turnIndex", 0))
+ return turns
+
+def _convert_events_to_trajectory(
+ self,
+ session_dir: Path,
+ *,
+ is_subagent: bool = False,
+ token_records: list[dict[str, Any]] | None = None,
+) -> Trajectory | None:
+ """Convert one BitFun session into an ATIF Trajectory.
+
+ When `is_subagent=True`, the resulting trajectory is meant to be embedded
+ in a parent's `subagent_trajectories[]`; the caller is responsible for
+ setting `trajectory_id` after this method returns.
+ """
+ meta_path = session_dir / "metadata.json"
+ if not meta_path.is_file():
+ self.logger.debug(f"No metadata.json in {session_dir}")
+ return None
+ try:
+ metadata = json.loads(meta_path.read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ self.logger.debug(f"Failed to parse {meta_path}: {exc}")
+ return None
+
+ session_id: str = metadata.get("sessionId") or session_dir.name
+ default_model_name = metadata.get("modelName") or self.model_name
+
+ turns = self._load_turns(session_dir)
+
+ steps: list[Step] = []
+ next_step_id = 1
+ for turn in turns:
+ kind = turn.get("kind", "user_dialog")
+ if kind == "local_command":
+ continue # not model-visible
+ if kind == "manual_compaction":
+ steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(turn.get("timestamp")),
+ source="system",
+ message="",
+ is_copied_context=True,
+ extra={
+ "turn_id": turn.get("turnId"),
+ "turn_index": turn.get("turnIndex"),
+ "turn_kind": "manual_compaction",
+ },
+ )
+ )
+ next_step_id += 1
+ continue
+
+ user_msg = turn.get("userMessage") or {}
+ user_text = self._user_text_from_message(user_msg)
+ steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(user_msg.get("timestamp") or turn.get("timestamp")),
+ source="user",
+ message=user_text,
+ extra={
+ "turn_id": turn.get("turnId"),
+ "turn_index": turn.get("turnIndex"),
+ "turn_kind": kind,
+ "user_message_id": user_msg.get("id"),
+ },
+ )
+ )
+ next_step_id += 1
+
+ for rnd in turn.get("modelRounds") or []:
+ new_steps, next_step_id = self._round_to_steps(
+ rnd,
+ turn,
+ next_step_id,
+ default_model_name=default_model_name,
+ )
+ steps.extend(new_steps)
+
+ if not steps:
+ self.logger.debug(f"No steps produced from BitFun session {session_id}")
+ return None
+
+ agent_extra: dict[str, Any] = {
+ "agent_type": metadata.get("agentType"),
+ "session_kind": metadata.get("sessionKind"),
+ "workspace_path": metadata.get("workspacePath"),
+ "schema_version": metadata.get("schema_version"),
+ }
+ agent_extra = {k: v for k, v in agent_extra.items() if v is not None} or None
+
+ trajectory = Trajectory(
+ schema_version=_ATIF_SCHEMA_VERSION,
+ session_id=session_id,
+ agent=Agent(
+ name=AgentName.BITFUN_CLI.value,
+ version=self.version() or "unknown",
+ model_name=default_model_name,
+ extra=agent_extra,
+ ),
+ steps=steps,
+ )
+ return trajectory
+
+def _round_to_steps(
+ self,
+ rnd: dict[str, Any],
+ turn: dict[str, Any],
+ next_step_id: int,
+ *,
+ default_model_name: str | None,
+) -> tuple[list[Step], int]:
+ """Convert one modelRound into ATIF steps (basic text-only path)."""
+ text_items = rnd.get("textItems") or []
+ new_steps: list[Step] = []
+ model_id = rnd.get("modelId") or default_model_name
+
+ for ti in text_items:
+ new_steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(ti.get("timestamp") or rnd.get("timestamp")),
+ source="agent",
+ message=ti.get("content") or "",
+ model_name=model_id,
+ extra={
+ "turn_id": turn.get("turnId"),
+ "round_id": rnd.get("id"),
+ "round_index": rnd.get("roundIndex"),
+ "model_alias": rnd.get("modelAlias"),
+ "provider_id": rnd.get("providerId"),
+ "status": ti.get("status"),
+ "round_status": rnd.get("status"),
+ "attempt_count": rnd.get("attemptCount"),
+ "failure_category": rnd.get("failureCategory"),
+ },
+ )
+ )
+ next_step_id += 1
+
+ return new_steps, next_step_id
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestConvertEventsToTrajectoryBasic -v
+```
+
+Expected: PASS for all 5 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): convert BitFun turns to basic ATIF user/agent steps"
+```
+
+---
+
+### Task 7: Thinking item → `reasoning_content` accumulation
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+Within one round, items are sorted by `orderIndex` ascending; thinking accumulates into a buffer that attaches to the next text or tool-call step in the same round and then clears.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestThinkingAccumulation:
+ def test_thinking_block_attaches_to_next_text_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t",
+ thinking_items=[_make_thinking_item("th1", "thinking A", order_index=0)],
+ text_items=[_make_text_item("ti1", "answer", order_index=1)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert len(agent_steps) == 1
+ assert agent_steps[0].reasoning_content == "thinking A"
+ assert agent_steps[0].message == "answer"
+
+ def test_multiple_thinking_blocks_joined_with_double_newlines(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t",
+ thinking_items=[
+ _make_thinking_item("th1", "first", order_index=0),
+ _make_thinking_item("th2", "second", order_index=1),
+ ],
+ text_items=[_make_text_item("ti1", "answer", order_index=2)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].reasoning_content == "first\n\nsecond"
+
+ def test_thinking_after_text_does_not_attach_backwards(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t",
+ text_items=[_make_text_item("ti1", "answer", order_index=0)],
+ thinking_items=[_make_thinking_item("th1", "post", order_index=1)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].reasoning_content is None
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestThinkingAccumulation -v
+```
+
+Expected: FAIL — reasoning not yet computed.
+
+- [ ] **Step 3: Update `_round_to_steps` to merge by `orderIndex` and accumulate reasoning**
+
+Replace the body of `_round_to_steps` in `src/harbor/agents/installed/bitfun_cli.py` with:
+
+```python
+def _round_to_steps(
+ self,
+ rnd: dict[str, Any],
+ turn: dict[str, Any],
+ next_step_id: int,
+ *,
+ default_model_name: str | None,
+) -> tuple[list[Step], int]:
+ """Convert one modelRound into ATIF steps (text + thinking)."""
+ items: list[dict[str, Any]] = []
+ for ti in rnd.get("textItems") or []:
+ items.append({"_kind": "text", **ti})
+ for th in rnd.get("thinkingItems") or []:
+ items.append({"_kind": "thinking", **th})
+ for to in rnd.get("toolItems") or []:
+ items.append({"_kind": "tool", **to})
+ items.sort(key=lambda x: (x.get("orderIndex") or 0, x.get("timestamp") or 0))
+
+ new_steps: list[Step] = []
+ model_id = rnd.get("modelId") or default_model_name
+ pending_reasoning: list[str] = []
+
+ def _flush_reasoning() -> str | None:
+ if not pending_reasoning:
+ return None
+ joined = "\n\n".join(part for part in pending_reasoning if part)
+ pending_reasoning.clear()
+ return joined or None
+
+ for item in items:
+ kind = item["_kind"]
+ if kind == "thinking":
+ content = item.get("content") or ""
+ if content:
+ pending_reasoning.append(content)
+ continue
+ if kind == "text":
+ new_steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(
+ item.get("timestamp") or rnd.get("timestamp")
+ ),
+ source="agent",
+ message=item.get("content") or "",
+ model_name=model_id,
+ reasoning_content=_flush_reasoning(),
+ extra={
+ "turn_id": turn.get("turnId"),
+ "round_id": rnd.get("id"),
+ "round_index": rnd.get("roundIndex"),
+ "model_alias": rnd.get("modelAlias"),
+ "provider_id": rnd.get("providerId"),
+ "status": item.get("status"),
+ "round_status": rnd.get("status"),
+ "attempt_count": rnd.get("attemptCount"),
+ "failure_category": rnd.get("failureCategory"),
+ },
+ )
+ )
+ next_step_id += 1
+ continue
+ # tool item handled in Task 8
+
+ return new_steps, next_step_id
+```
+
+- [ ] **Step 4: Run the tests to verify they pass (incl. previous tasks)**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS for all tests in this file.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): merge thinkingItems into reasoning_content by orderIndex"
+```
+
+---
+
+### Task 8: Tool call / observation mapping
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+Maps `toolItems[]` to ATIF tool-call steps. `resultForAssistant` is preferred as observation `content`; the raw `result` plus `success`/`error`/`durationMs` are preserved in `observation.results[0].extra` (per spec design decision Q4).
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestToolCallMapping:
+ def test_tool_call_uses_result_for_assistant_as_content(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1", "Read", {"file_path": "/x"},
+ result_text="file contents",
+ raw_result={"text": "file contents", "lines": 1},
+ )
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ tool_steps = [s for s in traj.steps if s.tool_calls]
+ assert len(tool_steps) == 1
+ step = tool_steps[0]
+ assert step.tool_calls[0].function_name == "Read"
+ assert step.tool_calls[0].tool_call_id == "tc1"
+ assert step.tool_calls[0].arguments == {"file_path": "/x"}
+ assert step.observation is not None
+ assert step.observation.results[0].source_call_id == "tc1"
+ assert step.observation.results[0].content == "file contents"
+
+ def test_tool_call_falls_back_to_json_dumps_when_result_for_assistant_absent(
+ self, temp_dir
+ ):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1", "Read", {}, raw_result={"chunks": [1, 2, 3]},
+ )
+ # explicitly drop resultForAssistant
+ tool["toolResult"].pop("resultForAssistant", None)
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ content = step.observation.results[0].content
+ assert content is not None
+ assert "chunks" in content # JSON dump of raw_result
+
+ def test_tool_call_preserves_raw_result_in_observation_extra(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1", "Read", {}, result_text="ok", raw_result={"chunks": [1, 2]},
+ )
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ extra = step.observation.results[0].extra or {}
+ assert extra.get("raw_result") == {"chunks": [1, 2]}
+ assert extra.get("success") is True
+
+ def test_tool_error_propagates_to_observation_extra(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1", "Read", {}, raw_result={"err": "x"},
+ success=False, error="permission denied",
+ )
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ extra = step.observation.results[0].extra or {}
+ assert extra.get("error") == "permission denied"
+ assert extra.get("success") is False
+
+ def test_tool_call_message_uses_ai_intent_when_present(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1", "Read", {}, result_text="ok",
+ ai_intent="read configuration file",
+ )
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ assert step.message == "read configuration file"
+
+ def test_tool_call_arguments_wraps_non_dict_input(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item("tc1", "Echo", "not-a-dict", result_text="ok")
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ assert step.tool_calls[0].arguments == {"input": "not-a-dict"}
+
+ def test_thinking_attaches_to_tool_call_then_clears(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1", "Read", {}, result_text="ok", order_index=1,
+ )
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t",
+ thinking_items=[
+ _make_thinking_item("th", "plan to read", order_index=0)
+ ],
+ tool_items=[tool],
+ text_items=[_make_text_item("ti", "done", order_index=2)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ tool_step = [s for s in traj.steps if s.tool_calls][0]
+ text_step = [
+ s for s in traj.steps if s.source == "agent" and not s.tool_calls
+ ][0]
+ assert tool_step.reasoning_content == "plan to read"
+ assert text_step.reasoning_content is None
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestToolCallMapping -v
+```
+
+Expected: FAIL — tool items are still ignored in `_round_to_steps`.
+
+- [ ] **Step 3: Extend `_round_to_steps` with tool-item handling**
+
+Locate the comment `# tool item handled in Task 8` in `_round_to_steps` and replace it with:
+
+```python
+ if kind == "tool":
+ tc_block = item.get("toolCall") or {}
+ tool_call_id = tc_block.get("id") or item.get("id") or ""
+ raw_input = tc_block.get("input")
+ if isinstance(raw_input, dict):
+ arguments = raw_input
+ else:
+ arguments = {"input": raw_input}
+
+ tool_name = item.get("toolName") or ""
+
+ tool_extra = {
+ "tool_item_id": item.get("id"),
+ "queue_wait_ms": item.get("queueWaitMs"),
+ "preflight_ms": item.get("preflightMs"),
+ "confirmation_wait_ms": item.get("confirmationWaitMs"),
+ "execution_ms": item.get("executionMs"),
+ "interruption_reason": item.get("interruptionReason"),
+ }
+ tool_extra = {k: v for k, v in tool_extra.items() if v is not None} or None
+
+ tool_call = ToolCall(
+ tool_call_id=tool_call_id,
+ function_name=tool_name,
+ arguments=arguments,
+ extra=tool_extra,
+ )
+
+ tool_result = item.get("toolResult") or {}
+ rfa = tool_result.get("resultForAssistant")
+ raw_result = tool_result.get("result")
+ if isinstance(rfa, str) and rfa:
+ content: str | None = rfa
+ elif raw_result is not None:
+ try:
+ content = json.dumps(raw_result, ensure_ascii=False)
+ except (TypeError, ValueError):
+ content = str(raw_result)
+ else:
+ content = None
+
+ obs_extra = {
+ "raw_result": raw_result,
+ "success": tool_result.get("success"),
+ "error": tool_result.get("error"),
+ "tool_duration_ms": tool_result.get("durationMs"),
+ }
+ obs_extra = {k: v for k, v in obs_extra.items() if v is not None} or None
+
+ subagent_sid = item.get("subagentSessionId")
+ sub_ref = (
+ [
+ SubagentTrajectoryRef(
+ trajectory_id=subagent_sid,
+ session_id=subagent_sid,
+ )
+ ]
+ if subagent_sid
+ else None
+ )
+
+ obs_result = ObservationResult(
+ source_call_id=tool_call_id,
+ content=content,
+ subagent_trajectory_ref=sub_ref,
+ extra=obs_extra,
+ )
+
+ new_steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(
+ item.get("startTime") or item.get("timestamp")
+ or rnd.get("timestamp")
+ ),
+ source="agent",
+ message=item.get("aiIntent") or f"Executed {tool_name}",
+ model_name=model_id,
+ reasoning_content=_flush_reasoning(),
+ tool_calls=[tool_call],
+ observation=Observation(results=[obs_result]),
+ extra={
+ "turn_id": turn.get("turnId"),
+ "round_id": rnd.get("id"),
+ "tool_status": item.get("status"),
+ "is_subagent_dispatch": bool(subagent_sid),
+ },
+ )
+ )
+ next_step_id += 1
+ continue
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestToolCallMapping -v
+```
+
+Expected: PASS for all 7 tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): map BitFun toolItems to ATIF tool_call + observation"
+```
+
+---
+
+### Task 9: Empty rounds, manual_compaction, and local_command turns
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+`manual_compaction` and `local_command` are already partially handled in Task 6's basic skeleton (compaction emits a system step, local_command is dropped). This task adds the empty-round placeholder, asserts round-level metadata is preserved, and locks in the existing behavior with explicit tests.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestRoundAndTurnEdgeCases:
+ def test_empty_round_emits_placeholder_agent_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ empty_round = _make_round(
+ "r", turn_id="t",
+ text_items=[], tool_items=[], thinking_items=[],
+ duration_ms=42, attempt_count=3, failure_category="rate_limit",
+ status="failed",
+ )
+ turn = _make_turn(
+ 0, "t", sid, model_rounds=[empty_round]
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert len(agent_steps) == 1
+ assert agent_steps[0].message == ""
+ extra = agent_steps[0].extra or {}
+ assert extra.get("round_status") == "failed"
+ assert extra.get("attempt_count") == 3
+ assert extra.get("failure_category") == "rate_limit"
+
+ def test_manual_compaction_turn_emits_system_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ comp_turn = _make_turn(0, "t-comp", sid, kind="manual_compaction")
+ normal_turn = _make_turn(
+ 1, "t-1", sid, user_text="hi",
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t-1",
+ text_items=[_make_text_item("ti", "hello")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid, turn_count=2),
+ turns=[comp_turn, normal_turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ comp_steps = [s for s in traj.steps if s.source == "system"]
+ assert len(comp_steps) == 1
+ assert comp_steps[0].message == ""
+ assert comp_steps[0].is_copied_context is True
+
+ def test_local_command_turn_is_silently_skipped(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ local_turn = _make_turn(0, "t-local", sid, kind="local_command")
+ normal_turn = _make_turn(
+ 1, "t-1", sid, user_text="hi",
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t-1",
+ text_items=[_make_text_item("ti", "hello")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid, turn_count=2),
+ turns=[local_turn, normal_turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert all("t-local" not in (s.extra or {}).get("turn_id", "") for s in traj.steps)
+ assert any(s.source == "user" for s in traj.steps)
+
+ def test_order_index_orders_mixed_items_within_round(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item("tc", "Read", {}, result_text="ok", order_index=2)
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t",
+ thinking_items=[_make_thinking_item("th", "plan", order_index=0)],
+ text_items=[_make_text_item("ti", "preface", order_index=1)],
+ tool_items=[tool],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert len(agent_steps) == 2
+ assert agent_steps[0].message == "preface"
+ assert agent_steps[0].reasoning_content == "plan"
+ assert agent_steps[1].tool_calls is not None
+ assert agent_steps[1].tool_calls[0].function_name == "Read"
+```
+
+- [ ] **Step 2: Run the tests to verify they fail (only the empty-round test)**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRoundAndTurnEdgeCases -v
+```
+
+Expected: at minimum `test_empty_round_emits_placeholder_agent_step` FAILs. The compaction/local-command tests should already pass from Task 6. The order-index test should already pass from Tasks 7+8.
+
+- [ ] **Step 3: Add the empty-round placeholder branch in `_round_to_steps`**
+
+At the very end of `_round_to_steps`, before the final `return new_steps, next_step_id`, add:
+
+```python
+ if not new_steps:
+ new_steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(rnd.get("timestamp")),
+ source="agent",
+ message="",
+ model_name=model_id,
+ extra={
+ "turn_id": turn.get("turnId"),
+ "round_id": rnd.get("id"),
+ "round_index": rnd.get("roundIndex"),
+ "round_status": rnd.get("status"),
+ "attempt_count": rnd.get("attemptCount"),
+ "failure_category": rnd.get("failureCategory"),
+ "duration_ms": rnd.get("durationMs"),
+ "is_placeholder_empty_round": True,
+ },
+ )
+ )
+ next_step_id += 1
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRoundAndTurnEdgeCases -v
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: all PASS in both runs.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): emit placeholder step for empty rounds; lock in compaction/local-command behavior"
+```
+
+---
+
+### Task 10: Allocate token records to steps + step-level Metrics
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+For each (sub)trajectory, partition `token_records` by `session_id` + `is_subagent`, group by `turn_id`, and for each turn match records to rounds via nearest-timestamp assignment. The matched record is attached as `Metrics(...)` on that round's first assistant-source step (text or tool-call); records without an assignable round are attached to the last assistant-source step of the turn.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestTokenAndMetricsAllocation:
+ def test_metrics_assigned_to_first_assistant_step_of_round(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0, "t", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t", ts=ts,
+ text_items=[_make_text_item("ti1", "first", order_index=0)],
+ )
+ ],
+ )
+ records = [
+ _make_token_record("openai/gpt-5", sid, "t", 100, 50, cached=10, ts=ts)
+ ]
+ session_dir = _write_session(
+ temp_dir, sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records,
+ )
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].metrics is not None
+ m = agent_steps[0].metrics
+ assert m.prompt_tokens == 100
+ assert m.completion_tokens == 50
+ assert m.cached_tokens == 10
+
+ def test_metrics_use_nearest_round_timestamp(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts0 = _DEFAULT_TS_MS
+ # Two rounds, one at ts0+100, one at ts0+1000; record near ts0+960
+ turn = _make_turn(
+ 0, "t", sid, ts=ts0,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t", round_index=0, ts=ts0 + 100,
+ text_items=[_make_text_item("ti1", "early", order_index=0)],
+ ),
+ _make_round(
+ "r2", turn_id="t", round_index=1, ts=ts0 + 1000,
+ text_items=[_make_text_item("ti2", "late", order_index=0)],
+ ),
+ ],
+ )
+ records = [
+ _make_token_record(
+ "openai/gpt-5", sid, "t", 200, 80, ts=ts0 + 960,
+ )
+ ]
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid),
+ turns=[turn], token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records,
+ )
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ # First round (early) has no metrics
+ assert agent_steps[0].metrics is None
+ # Second round (late) has metrics matched
+ assert agent_steps[1].metrics is not None
+ assert agent_steps[1].metrics.prompt_tokens == 200
+
+ def test_step_metrics_absent_when_no_records_match_turn(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0, "t", sid,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t",
+ text_items=[_make_text_item("ti1", "x")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid),
+ turns=[turn], token_records=[],
+ )
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=[],
+ )
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert all(s.metrics is None for s in agent_steps)
+
+ def test_subagent_records_excluded_from_main_trajectory_metrics(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "main"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0, "t", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t", ts=ts,
+ text_items=[_make_text_item("ti1", "x")],
+ )
+ ],
+ )
+ records = [
+ _make_token_record("openai/gpt-5", sid, "t", 100, 50, ts=ts),
+ _make_token_record(
+ "openai/gpt-5", sid, "t", 999, 999, ts=ts, is_sub=True,
+ ),
+ ]
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid),
+ turns=[turn], token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records,
+ )
+ assert traj is not None
+ m = [s for s in traj.steps if s.source == "agent"][0].metrics
+ assert m is not None
+ assert m.prompt_tokens == 100
+ assert m.completion_tokens == 50
+
+ def test_extra_records_attach_to_last_assistant_step_of_turn(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ # 1 round but 2 records (e.g., retry attempt also produced a token record)
+ turn = _make_turn(
+ 0, "t", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t", ts=ts,
+ text_items=[_make_text_item("ti1", "x", order_index=0)],
+ )
+ ],
+ )
+ records = [
+ _make_token_record("m", sid, "t", 100, 50, ts=ts),
+ _make_token_record("m", sid, "t", 10, 5, ts=ts + 10),
+ ]
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid),
+ turns=[turn], token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records,
+ )
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ # First record placed on first assistant step; second record attached
+ # to the last assistant step of the turn (same step here, so summed
+ # extra under "extra" attribute or attached as a second record).
+ # Spec: both records are attached, last record overwrites or is summed.
+ # We assert the FIRST record is present.
+ assert agent_steps[0].metrics is not None
+ assert agent_steps[0].metrics.prompt_tokens in {100, 110}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestTokenAndMetricsAllocation -v
+```
+
+Expected: FAIL — token allocation is not yet wired.
+
+- [ ] **Step 3: Add token-allocation methods and wire into the conversion**
+
+Add these helpers on `BitfunCli` (place just below `_round_to_steps`):
+
+```python
+@staticmethod
+def _parse_record_ts_ms(record: dict[str, Any]) -> int | None:
+ """Parse a token record's ISO-8601 timestamp into epoch milliseconds."""
+ raw = record.get("timestamp")
+ if not isinstance(raw, str):
+ return None
+ try:
+ dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ return int(dt.timestamp() * 1000)
+
+def _build_metrics_from_record(self, record: dict[str, Any]) -> Metrics:
+ """Convert one BitFun TokenUsageRecord into an ATIF Metrics object."""
+ in_tok = int(record.get("input_tokens") or 0)
+ out_tok = int(record.get("output_tokens") or 0)
+ cached = int(record.get("cached_tokens") or 0)
+ model_id = record.get("model_id")
+ cost = self._compute_cost_via_litellm(model_id, in_tok, cached, out_tok)
+ extra = {
+ "token_details": record.get("token_details"),
+ "total_tokens": record.get("total_tokens"),
+ "cached_tokens_available": record.get("cached_tokens_available"),
+ "record_timestamp": record.get("timestamp"),
+ "record_model_id": model_id,
+ }
+ extra = {k: v for k, v in extra.items() if v is not None} or None
+ return Metrics(
+ prompt_tokens=in_tok,
+ completion_tokens=out_tok,
+ cached_tokens=cached,
+ cost_usd=cost,
+ extra=extra,
+ )
+
+def _allocate_records_to_steps(
+ self,
+ steps: list[Step],
+ turns: list[dict[str, Any]],
+ records_for_traj: list[dict[str, Any]],
+) -> None:
+ """Attach a `Metrics` object to the first assistant-source step of the
+ round whose timestamp is nearest the record timestamp (per design
+ decision Q5). Records that cannot be matched to a round in their turn
+ fall through to the last assistant-source step of the turn.
+ """
+ if not records_for_traj:
+ return
+
+ # Index agent steps by (turn_id, round_id) → step.
+ first_step_by_round: dict[tuple[str, str], Step] = {}
+ last_agent_step_by_turn: dict[str, Step] = {}
+ for step in steps:
+ if step.source != "agent":
+ continue
+ extra = step.extra or {}
+ turn_id = extra.get("turn_id")
+ round_id = extra.get("round_id")
+ if isinstance(turn_id, str):
+ last_agent_step_by_turn[turn_id] = step
+ if (
+ isinstance(turn_id, str)
+ and isinstance(round_id, str)
+ and (turn_id, round_id) not in first_step_by_round
+ ):
+ first_step_by_round[(turn_id, round_id)] = step
+
+ records_by_turn: dict[str, list[dict[str, Any]]] = {}
+ for rec in records_for_traj:
+ tid = rec.get("turn_id")
+ if isinstance(tid, str):
+ records_by_turn.setdefault(tid, []).append(rec)
+
+ for turn in turns:
+ turn_id = turn.get("turnId")
+ if not isinstance(turn_id, str):
+ continue
+ turn_records = records_by_turn.get(turn_id, [])
+ if not turn_records:
+ continue
+ rounds = list(turn.get("modelRounds") or [])
+ if not rounds:
+ target = last_agent_step_by_turn.get(turn_id)
+ if target is None:
+ continue
+ for rec in turn_records:
+ target.metrics = self._build_metrics_from_record(rec)
+ continue
+
+ round_targets: list[Step] = []
+ for rnd in rounds:
+ key = (turn_id, rnd.get("id"))
+ step = first_step_by_round.get(key)
+ if step is not None:
+ round_targets.append(step)
+ else:
+ round_targets.append(last_agent_step_by_turn.get(turn_id))
+
+ round_ts = [rnd.get("timestamp") or 0 for rnd in rounds]
+ for rec in turn_records:
+ rec_ts = self._parse_record_ts_ms(rec) or 0
+ best_idx = min(
+ range(len(round_ts)),
+ key=lambda i: abs(round_ts[i] - rec_ts),
+ )
+ target = round_targets[best_idx] or last_agent_step_by_turn.get(
+ turn_id
+ )
+ if target is None:
+ continue
+ target.metrics = self._build_metrics_from_record(rec)
+```
+
+Then wire it into `_convert_events_to_trajectory` just before the final `Trajectory(...)` construction. Replace:
+
+```python
+ if not steps:
+ self.logger.debug(f"No steps produced from BitFun session {session_id}")
+ return None
+```
+
+with:
+
+```python
+ if not steps:
+ self.logger.debug(f"No steps produced from BitFun session {session_id}")
+ return None
+
+ if token_records is None:
+ token_records = self._load_token_records()
+
+ records_for_traj = [
+ rec
+ for rec in token_records
+ if rec.get("session_id") == session_id
+ and bool(rec.get("is_subagent")) == is_subagent
+ ]
+ self._allocate_records_to_steps(steps, turns, records_for_traj)
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestTokenAndMetricsAllocation -v
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS for the new tests and for all prior tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): attach step-level Metrics via nearest-timestamp matching"
+```
+
+---
+
+### Task 11: Aggregate `FinalMetrics`
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestFinalMetrics:
+ def test_final_metrics_sums_step_metrics(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turns = [
+ _make_turn(
+ 0, "t1", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t1", ts=ts,
+ text_items=[_make_text_item("ti", "a", order_index=0)],
+ )
+ ],
+ ),
+ _make_turn(
+ 1, "t2", sid, ts=ts + 100,
+ model_rounds=[
+ _make_round(
+ "r2", turn_id="t2", ts=ts + 100,
+ text_items=[_make_text_item("ti", "b", order_index=0)],
+ )
+ ],
+ ),
+ ]
+ records = [
+ _make_token_record("m", sid, "t1", 100, 50, cached=10, ts=ts),
+ _make_token_record("m", sid, "t2", 200, 80, cached=20, ts=ts + 100),
+ ]
+ session_dir = _write_session(
+ temp_dir, sid,
+ metadata=_make_metadata(sid, turn_count=2),
+ turns=turns, token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records,
+ )
+ assert traj is not None
+ fm = traj.final_metrics
+ assert fm is not None
+ assert fm.total_prompt_tokens == 300
+ assert fm.total_completion_tokens == 130
+ assert fm.total_cached_tokens == 30
+ assert fm.total_steps == len(traj.steps)
+
+ def test_final_metrics_cost_is_none_when_any_step_unpriced(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0, "t", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t", ts=ts,
+ text_items=[_make_text_item("ti", "a")],
+ )
+ ],
+ )
+ records = [_make_token_record("unknown-model", sid, "t", 100, 50, ts=ts)]
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid),
+ turns=[turn], token_records=records,
+ )
+ with _patch("litellm.model_cost", {}):
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records,
+ )
+ assert traj is not None
+ assert traj.final_metrics is not None
+ assert traj.final_metrics.total_cost_usd is None
+
+ def test_final_metrics_extra_includes_session_summary(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0, "t", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1", turn_id="t", ts=ts,
+ text_items=[_make_text_item("ti", "a")],
+ )
+ ],
+ )
+ records = [_make_token_record("m", sid, "t", 100, 50, ts=ts)]
+ session_dir = _write_session(
+ temp_dir, sid,
+ metadata=_make_metadata(
+ sid, turn_count=1, tool_call_count=0,
+ created_at=ts, last_active_at=ts + 5_000,
+ ),
+ turns=[turn], token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records,
+ )
+ assert traj is not None
+ extra = (traj.final_metrics.extra or {}) if traj.final_metrics else {}
+ assert extra.get("main_session_turn_count") == 1
+ assert extra.get("main_session_duration_ms") == 5_000
+ assert "m" in (extra.get("models_used") or [])
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestFinalMetrics -v
+```
+
+Expected: FAIL — `trajectory.final_metrics is None` because we haven't set it yet.
+
+- [ ] **Step 3: Build `FinalMetrics` and attach to the trajectory**
+
+Add the following helper just below `_allocate_records_to_steps`:
+
+```python
+def _build_final_metrics(
+ self,
+ steps: list[Step],
+ metadata: dict[str, Any],
+ records_for_traj: list[dict[str, Any]],
+ all_records: list[dict[str, Any]],
+ subagent_count: int,
+) -> FinalMetrics:
+ prompt = 0
+ completion = 0
+ cached = 0
+ has_any = False
+ cost_total: float = 0.0
+ every_step_priced = True
+ for step in steps:
+ if step.metrics is None:
+ continue
+ has_any = True
+ prompt += step.metrics.prompt_tokens or 0
+ completion += step.metrics.completion_tokens or 0
+ cached += step.metrics.cached_tokens or 0
+ if step.metrics.cost_usd is None:
+ every_step_priced = False
+ else:
+ cost_total += step.metrics.cost_usd
+
+ total_cost = cost_total if (has_any and every_step_priced) else None
+
+ duration_ms: int | None = None
+ if isinstance(metadata.get("createdAt"), int) and isinstance(
+ metadata.get("lastActiveAt"), int
+ ):
+ duration_ms = metadata["lastActiveAt"] - metadata["createdAt"]
+
+ models_used = sorted(
+ {
+ rec["model_id"]
+ for rec in records_for_traj
+ if isinstance(rec.get("model_id"), str)
+ }
+ )
+ subagent_total_tokens = sum(
+ int(r.get("total_tokens") or 0)
+ for r in all_records
+ if r.get("is_subagent")
+ )
+
+ extra: dict[str, Any] = {
+ "main_session_tool_calls": metadata.get("toolCallCount"),
+ "main_session_turn_count": metadata.get("turnCount"),
+ "main_session_duration_ms": duration_ms,
+ "models_used": models_used or None,
+ "subagent_session_count": subagent_count or None,
+ "subagent_total_tokens": subagent_total_tokens or None,
+ }
+ extra = {k: v for k, v in extra.items() if v is not None} or None
+
+ return FinalMetrics(
+ total_prompt_tokens=prompt if has_any else None,
+ total_completion_tokens=completion if has_any else None,
+ total_cached_tokens=cached if has_any else None,
+ total_cost_usd=total_cost,
+ total_steps=len(steps),
+ extra=extra,
+ )
+```
+
+Then replace the final block in `_convert_events_to_trajectory`:
+
+```python
+ trajectory = Trajectory(
+ schema_version=_ATIF_SCHEMA_VERSION,
+ session_id=session_id,
+ agent=Agent(
+ name=AgentName.BITFUN_CLI.value,
+ version=self.version() or "unknown",
+ model_name=default_model_name,
+ extra=agent_extra,
+ ),
+ steps=steps,
+ )
+ return trajectory
+```
+
+with:
+
+```python
+ final_metrics = self._build_final_metrics(
+ steps=steps,
+ metadata=metadata,
+ records_for_traj=records_for_traj,
+ all_records=token_records,
+ subagent_count=0, # filled in by subagent embedding (Task 12)
+ )
+
+ trajectory = Trajectory(
+ schema_version=_ATIF_SCHEMA_VERSION,
+ session_id=session_id,
+ agent=Agent(
+ name=AgentName.BITFUN_CLI.value,
+ version=self.version() or "unknown",
+ model_name=default_model_name,
+ extra=agent_extra,
+ ),
+ steps=steps,
+ final_metrics=final_metrics,
+ )
+ return trajectory
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestFinalMetrics -v
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS for all new and prior tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): aggregate FinalMetrics across trajectory steps"
+```
+
+---
+
+### Task 12: Embed subagent trajectories
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+For every distinct `subagentSessionId` referenced in main-trajectory tool items: locate the sibling session dir, recursively build a `Trajectory(is_subagent=True)`, set `trajectory_id=`, override `agent.name` with the dispatch tool name, override `agent.model_name` with `toolItem.subagentModelId` when present, append to `root.subagent_trajectories[]`. On the parent's tool-call observation, append a `SubagentTrajectoryRef`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestSubagentEmbedding:
+ def _build_sessions_with_subagent(self, temp_dir, *, sub_sid="sub", main_sid="main"):
+ ts = _DEFAULT_TS_MS
+ # Subagent session: one user turn, one assistant text round.
+ sub_turn = _make_turn(
+ 0, "st1", sub_sid, user_text="do thing",
+ model_rounds=[
+ _make_round(
+ "sr1", turn_id="st1",
+ text_items=[_make_text_item("sti", "did it")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir, sub_sid,
+ metadata=_make_metadata(sub_sid, kind="subagent", model="openai/gpt-5"),
+ turns=[sub_turn],
+ )
+ # Main session: one user turn, one tool-call dispatching to the subagent.
+ tool = _make_tool_item(
+ "tc1", "Task", {"description": "delegate"},
+ result_text="subagent done",
+ subagent_sid=sub_sid, subagent_model_id="openai/gpt-5",
+ )
+ main_turn = _make_turn(
+ 0, "mt1", main_sid, user_text="please",
+ model_rounds=[_make_round("mr1", turn_id="mt1", tool_items=[tool])],
+ )
+ _write_session(
+ temp_dir, main_sid,
+ metadata=_make_metadata(main_sid, kind="standard"),
+ turns=[main_turn],
+ )
+ return temp_dir / "bitfun" / "sessions" / main_sid
+
+ def test_subagent_trajectory_is_embedded(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ session_dir = self._build_sessions_with_subagent(temp_dir)
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.subagent_trajectories is not None
+ assert len(traj.subagent_trajectories) == 1
+ sub = traj.subagent_trajectories[0]
+ assert sub.trajectory_id == "sub"
+ assert sub.agent.name == "Task"
+ assert sub.agent.model_name == "openai/gpt-5"
+
+ def test_parent_observation_references_embedded_subagent(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ session_dir = self._build_sessions_with_subagent(temp_dir)
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ tool_step = next(s for s in traj.steps if s.tool_calls)
+ refs = tool_step.observation.results[0].subagent_trajectory_ref
+ assert refs is not None
+ assert any(ref.trajectory_id == "sub" for ref in refs)
+
+ def test_duplicate_subagent_session_id_embedded_only_once(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sub_sid, main_sid = "sub", "main"
+ ts = _DEFAULT_TS_MS
+ sub_turn = _make_turn(
+ 0, "st1", sub_sid,
+ model_rounds=[
+ _make_round(
+ "sr1", turn_id="st1",
+ text_items=[_make_text_item("sti", "ok")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir, sub_sid,
+ metadata=_make_metadata(sub_sid, kind="subagent"),
+ turns=[sub_turn],
+ )
+ tool_a = _make_tool_item(
+ "tc1", "Task", {"a": 1}, result_text="a-done",
+ subagent_sid=sub_sid, order_index=0,
+ )
+ tool_b = _make_tool_item(
+ "tc2", "Task", {"b": 2}, result_text="b-done",
+ subagent_sid=sub_sid, order_index=1,
+ )
+ main_turn = _make_turn(
+ 0, "mt1", main_sid,
+ model_rounds=[
+ _make_round(
+ "mr1", turn_id="mt1", tool_items=[tool_a, tool_b],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir, main_sid,
+ metadata=_make_metadata(main_sid, kind="standard"),
+ turns=[main_turn],
+ )
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / main_sid
+ )
+ assert traj is not None
+ assert traj.subagent_trajectories is not None
+ assert len(traj.subagent_trajectories) == 1
+
+ def test_missing_subagent_dir_omits_embed_but_keeps_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ main_sid = "main"
+ tool = _make_tool_item(
+ "tc1", "Task", {"x": 1}, result_text="ok",
+ subagent_sid="missing-sub",
+ )
+ main_turn = _make_turn(
+ 0, "mt1", main_sid,
+ model_rounds=[_make_round("mr1", turn_id="mt1", tool_items=[tool])],
+ )
+ _write_session(
+ temp_dir, main_sid,
+ metadata=_make_metadata(main_sid, kind="standard"),
+ turns=[main_turn],
+ )
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / main_sid
+ )
+ assert traj is not None
+ # No embed (or empty subagent_trajectories), but the parent tool-call
+ # step still exists, and its observation's subagent_trajectory_ref
+ # is removed (we omit the ref when the embed is missing) per spec.
+ assert not traj.subagent_trajectories
+ tool_step = next(s for s in traj.steps if s.tool_calls)
+ refs = tool_step.observation.results[0].subagent_trajectory_ref
+ assert refs is None or refs == []
+ assert (traj.notes or "").lower().find("missing") >= 0
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestSubagentEmbedding -v
+```
+
+Expected: FAIL — subagent embedding is not yet wired.
+
+- [ ] **Step 3: Implement subagent embedding**
+
+Step 3a: collect subagent dispatches while walking rounds. Modify the tool-item branch in `_round_to_steps`: replace the `sub_ref = ...` block with:
+
+```python
+ subagent_sid = item.get("subagentSessionId")
+ sub_model_id = item.get("subagentModelId")
+ # Tentative ref — caller may remove this entry later if the
+ # subagent session directory is missing on disk.
+ sub_ref = (
+ [
+ SubagentTrajectoryRef(
+ trajectory_id=subagent_sid,
+ session_id=subagent_sid,
+ extra={
+ "tool_call_id": tool_call_id,
+ "tool_name": tool_name,
+ "subagent_model_id": sub_model_id,
+ },
+ )
+ ]
+ if subagent_sid
+ else None
+ )
+```
+
+Step 3b: after `_allocate_records_to_steps(...)` (and before `_build_final_metrics(...)`) in `_convert_events_to_trajectory`, add the embedding pass:
+
+```python
+ subagent_trajectories: list[Trajectory] = []
+ missing_subagents: set[str] = set()
+ if not is_subagent:
+ embed_count = self._embed_subagents(
+ steps=steps,
+ session_dir=session_dir,
+ token_records=token_records,
+ into=subagent_trajectories,
+ missing=missing_subagents,
+ )
+ else:
+ embed_count = 0
+
+ notes: str | None = None
+ if missing_subagents:
+ notes = (
+ "Subagent session(s) referenced but missing from cp-back: "
+ + ", ".join(sorted(missing_subagents))
+ )
+```
+
+Step 3c: pass through to `Trajectory(...)`. Replace the existing `Trajectory(...)` call:
+
+```python
+ trajectory = Trajectory(
+ schema_version=_ATIF_SCHEMA_VERSION,
+ session_id=session_id,
+ agent=Agent(
+ name=AgentName.BITFUN_CLI.value,
+ version=self.version() or "unknown",
+ model_name=default_model_name,
+ extra=agent_extra,
+ ),
+ steps=steps,
+ final_metrics=final_metrics,
+ subagent_trajectories=subagent_trajectories or None,
+ notes=notes,
+ )
+ return trajectory
+```
+
+(Make sure the `_build_final_metrics(...)` call now passes `subagent_count=embed_count` instead of `0`.)
+
+Step 3d: add the `_embed_subagents` method just below `_build_final_metrics`:
+
+```python
+def _embed_subagents(
+ self,
+ *,
+ steps: list[Step],
+ session_dir: Path,
+ token_records: list[dict[str, Any]],
+ into: list[Trajectory],
+ missing: set[str],
+) -> int:
+ """Walk tool steps, deduplicate by subagent session id, and embed each.
+
+ For every distinct `subagentSessionId` referenced from this trajectory:
+ 1. Locate `//`. If missing, record it in `missing`
+ and strip any tentative `subagent_trajectory_ref` from the parent
+ observation pointing at this sid.
+ 2. Recursively build a subagent Trajectory and set `trajectory_id`.
+ Override `agent.name` with the dispatch tool name and
+ `agent.model_name` with `toolItem.subagentModelId` when present.
+ 3. Append to `into`.
+ Returns the number of trajectories embedded.
+ """
+ sessions_root = session_dir.parent # `/bitfun/sessions`
+ refs_by_sid: dict[
+ str,
+ list[tuple[Step, ObservationResult, SubagentTrajectoryRef]],
+ ] = {}
+ for step in steps:
+ if step.observation is None:
+ continue
+ for result in step.observation.results:
+ for ref in result.subagent_trajectory_ref or []:
+ if not ref.trajectory_id:
+ continue
+ refs_by_sid.setdefault(ref.trajectory_id, []).append(
+ (step, result, ref)
+ )
+
+ if not refs_by_sid:
+ return 0
+
+ embedded = 0
+ for sub_sid, refs in refs_by_sid.items():
+ sub_dir = sessions_root / sub_sid
+ if not (sub_dir / "metadata.json").is_file():
+ missing.add(sub_sid)
+ # Strip the tentative refs from parent observations.
+ for _step, result, ref in refs:
+ if result.subagent_trajectory_ref:
+ result.subagent_trajectory_ref = [
+ r
+ for r in result.subagent_trajectory_ref
+ if r is not ref
+ ] or None
+ continue
+
+ try:
+ sub_traj = self._convert_events_to_trajectory(
+ sub_dir, is_subagent=True, token_records=token_records,
+ )
+ except Exception:
+ self.logger.exception(
+ "Failed to embed BitFun subagent %s", sub_sid
+ )
+ sub_traj = None
+
+ if sub_traj is None:
+ missing.add(sub_sid)
+ for _step, result, ref in refs:
+ if result.subagent_trajectory_ref:
+ result.subagent_trajectory_ref = [
+ r
+ for r in result.subagent_trajectory_ref
+ if r is not ref
+ ] or None
+ continue
+
+ # Override identity per spec section 3 (Subagent embedding).
+ sub_traj.trajectory_id = sub_sid
+ tool_name = None
+ model_override = None
+ for _step, _result, ref in refs:
+ extra = ref.extra or {}
+ tool_name = tool_name or extra.get("tool_name")
+ model_override = model_override or extra.get("subagent_model_id")
+ if tool_name:
+ sub_traj.agent.name = tool_name
+ if model_override:
+ sub_traj.agent.model_name = model_override
+ agent_extra = sub_traj.agent.extra or {}
+ first_extra = refs[0][2].extra or {}
+ if first_extra.get("tool_call_id"):
+ agent_extra["parent_task_tool_id"] = first_extra["tool_call_id"]
+ sub_traj.agent.extra = agent_extra or None
+
+ into.append(sub_traj)
+ embedded += 1
+
+ return embedded
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestSubagentEmbedding -v
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS for the new tests and for all prior tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): embed subagent sessions as ATIF v1.7 subagent_trajectories"
+```
+
+---
+
+### Task 13: Implement `populate_context_post_run`
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestPopulateContextPostRun:
+ def test_writes_trajectory_json_to_logs_dir(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0, "t", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t", ts=ts,
+ text_items=[_make_text_item("ti", "hi")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=[
+ _make_token_record("openai/gpt-5", sid, "t", 50, 25, ts=ts)
+ ],
+ )
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ out = temp_dir / "trajectory.json"
+ assert out.is_file()
+ payload = _json.loads(out.read_text())
+ assert payload["schema_version"] == "ATIF-v1.7"
+ assert payload["session_id"] == sid
+
+ def test_populates_context_token_counts_from_final_metrics(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0, "t", sid, ts=ts,
+ model_rounds=[
+ _make_round(
+ "r", turn_id="t", ts=ts,
+ text_items=[_make_text_item("ti", "hi")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=[
+ _make_token_record(
+ "openai/gpt-5", sid, "t", 100, 40, cached=5, ts=ts,
+ )
+ ],
+ )
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ assert ctx.n_input_tokens == 100
+ assert ctx.n_output_tokens == 40
+ assert ctx.n_cache_tokens == 5
+
+ def test_swallows_conversion_errors_and_returns_normally(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ session_dir = temp_dir / "bitfun" / "sessions" / sid
+ (session_dir / "turns").mkdir(parents=True)
+ (session_dir / "metadata.json").write_text(
+ _json.dumps(_make_metadata(sid))
+ )
+ # Drop a malformed turn file
+ (session_dir / "turns" / "turn-0000.json").write_text("{not json")
+ ctx = AgentContext()
+ # Must not raise even though the turn is malformed.
+ agent.populate_context_post_run(ctx)
+ # No usable steps → no trajectory file written, context stays empty.
+ assert ctx.is_empty()
+ assert not (temp_dir / "trajectory.json").exists()
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestPopulateContextPostRun -v
+```
+
+Expected: FAIL — current `populate_context_post_run` is a `pass`.
+
+- [ ] **Step 3: Replace `populate_context_post_run` body**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, replace:
+
+```python
+ def populate_context_post_run(self, context: AgentContext) -> None:
+ pass # ATIF / token metrics deferred.
+```
+
+with:
+
+```python
+ def populate_context_post_run(self, context: AgentContext) -> None:
+ session_dir = self._get_session_dir()
+ if not session_dir:
+ self.logger.debug("No BitFun session directory found")
+ return
+ try:
+ trajectory = self._convert_events_to_trajectory(session_dir)
+ except Exception:
+ self.logger.exception(
+ "Failed to convert BitFun events to trajectory"
+ )
+ return
+ if not trajectory:
+ return
+
+ trajectory_path = self.logs_dir / "trajectory.json"
+ try:
+ trajectory_path.write_text(
+ format_trajectory_json(trajectory.to_json_dict())
+ )
+ self.logger.debug(
+ f"Wrote BitFun trajectory to {trajectory_path}"
+ )
+ except OSError as exc:
+ self.logger.debug(
+ f"Failed to write trajectory file {trajectory_path}: {exc}"
+ )
+
+ if trajectory.final_metrics:
+ fm = trajectory.final_metrics
+ context.cost_usd = fm.total_cost_usd
+ context.n_input_tokens = fm.total_prompt_tokens or 0
+ context.n_cache_tokens = fm.total_cached_tokens or 0
+ context.n_output_tokens = fm.total_completion_tokens or 0
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestPopulateContextPostRun -v
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS for all new and prior tests.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): write trajectory.json and populate AgentContext post-run"
+```
+
+---
+
+### Task 14: Container-side cp-back finally block in `run()`
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+Wrap the existing `bitfun exec` invocation in `try/finally` and execute a best-effort cp-back snippet inside the container. The snippet first probes `~/.bitfun/projects/testbed/sessions` and `~/.bitfun/projects/-testbed/sessions`, then falls back to picking the most recently modified `sessions/` directory across all projects (Strategy C). Also copies `token_usage/` and `cli.log` when present. Failures in the cp-back must not propagate.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestRunCpBackFinally:
+ @pytest.mark.asyncio
+ async def test_run_invokes_cp_back_in_finally(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/usr/local/bin/bitfun-cli")
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ assert mock_env.exec.call_count == 2
+ cp_cmd = mock_env.exec.call_args_list[1].kwargs["command"]
+ assert "cp -R" in cp_cmd
+ assert "/logs/agent/bitfun" in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_cp_back_command_has_slug_first_then_mtime_fallback(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ cp_cmd = mock_env.exec.call_args_list[1].kwargs["command"]
+ assert "/testbed/sessions" in cp_cmd or "testbed/sessions" in cp_cmd
+ assert "ls -dt" in cp_cmd # mtime fallback fragment
+ assert "token_usage" in cp_cmd
+ assert "cli.log" in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_cp_back_failures_do_not_propagate(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+
+ # Main exec succeeds; cp-back exec raises.
+ first = AsyncMock(return_code=0, stdout="", stderr="")
+ async def side_effect(*args, **kwargs):
+ if mock_env.exec.call_count == 1:
+ return first
+ raise RuntimeError("cp-back boom")
+ mock_env.exec.side_effect = side_effect
+ # Should not raise.
+ await agent.run("hi", mock_env, AgentContext())
+ assert mock_env.exec.call_count == 2
+
+ @pytest.mark.asyncio
+ async def test_main_exec_failure_still_runs_cp_back(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ from harbor.agents.installed.base import NonZeroAgentExitCodeError
+ call_idx = {"n": 0}
+
+ async def side_effect(*args, **kwargs):
+ call_idx["n"] += 1
+ if call_idx["n"] == 1:
+ raise NonZeroAgentExitCodeError("main exec failed")
+ return AsyncMock(return_code=0, stdout="", stderr="")
+
+ mock_env.exec.side_effect = side_effect
+ with pytest.raises(NonZeroAgentExitCodeError):
+ await agent.run("hi", mock_env, AgentContext())
+ assert call_idx["n"] == 2 # cp-back attempted
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally -v
+```
+
+Expected: FAIL — only one `exec` call happens today.
+
+- [ ] **Step 3: Add the cp-back helper and wrap `run()` in try/finally**
+
+Add this constant and helper to `bitfun_cli.py` (near `_AGENT_LOG`):
+
+```python
+_CP_BACK_COMMAND = """\
+set +e
+SLUG_PATH=""
+if [ -d "$HOME/.bitfun/projects" ]; then
+ for d in "$HOME/.bitfun/projects/testbed/sessions" \\
+ "$HOME/.bitfun/projects/-testbed/sessions"; do
+ [ -d "$d" ] && SLUG_PATH="$d" && break
+ done
+fi
+if [ -z "$SLUG_PATH" ]; then
+ LATEST=$(ls -dt "$HOME"/.bitfun/projects/*/sessions/ 2>/dev/null | head -1)
+ [ -n "$LATEST" ] && SLUG_PATH="$LATEST"
+fi
+mkdir -p /logs/agent/bitfun/sessions
+if [ -n "$SLUG_PATH" ]; then
+ cp -R "$SLUG_PATH"/. /logs/agent/bitfun/sessions/ 2>/dev/null || true
+fi
+if [ -d "$HOME/.config/bitfun/data/token_usage" ]; then
+ cp -R "$HOME/.config/bitfun/data/token_usage" /logs/agent/bitfun/ 2>/dev/null || true
+fi
+if [ -f "$HOME/.config/bitfun/logs/bitfun-cli.log" ]; then
+ cp "$HOME/.config/bitfun/logs/bitfun-cli.log" /logs/agent/bitfun/cli.log 2>/dev/null || true
+fi
+exit 0
+"""
+```
+
+Then replace the existing `run()` body with:
+
+```python
+ @with_prompt_template
+ async def run(
+ self,
+ instruction: str,
+ environment: BaseEnvironment,
+ context: AgentContext,
+ ) -> None:
+ _ = context
+ bp = shlex.quote(self._binary_path)
+ msg = shlex.quote(instruction)
+ agent_flag = shlex.quote(self._exec_agent)
+ patch_part = ""
+ if self._output_patch_path:
+ patch_part = f" --output-patch {shlex.quote(self._output_patch_path)}"
+ inner = (
+ f"{bp} exec {msg} --agent {agent_flag}{patch_part} "
+ f"2>&1 | stdbuf -oL tee {_AGENT_LOG}"
+ )
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=f"set -o pipefail; {inner}",
+ env=self._env_for_run(),
+ cwd="/testbed",
+ )
+ finally:
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=_CP_BACK_COMMAND,
+ env=self._env_for_run(),
+ )
+ except Exception as exc:
+ self.logger.debug(
+ f"BitFun cp-back failed (non-fatal): {exc}"
+ )
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally -v
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS for all new and prior tests.
+
+- [ ] **Step 5: Update existing run tests for the new exec count**
+
+The pre-existing `test_run_uses_testbed_cwd_and_exec`, `test_run_without_output_patch`, and `test_run_forwards_bitfun_prefixed_env` assert against `mock_env.exec.call_count == 1` (implicitly, via `call_args` on the single call). After Task 14, `run()` invokes `exec_as_agent` twice. Update these tests so they reference `call_args_list[0]` (the main exec) for any command/cwd/env assertion. Concretely:
+
+In `test_run_uses_testbed_cwd_and_exec`, replace the assertion block:
+
+```python
+ assert mock_env.exec.call_count == 1
+ call_kw = mock_env.exec.call_args.kwargs
+```
+
+with:
+
+```python
+ assert mock_env.exec.call_count == 2
+ call_kw = mock_env.exec.call_args_list[0].kwargs
+```
+
+Apply the same `call_args_list[0]` rewrite in `test_run_without_output_patch` and `test_run_forwards_bitfun_prefixed_env`.
+
+- [ ] **Step 6: Run the full test module to confirm green**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS for every test in the module.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): cp BitFun session/token-usage back to logs_dir post-run"
+```
+
+---
+
+### Task 15: Golden integration test fixture and assertion
+
+**Files:**
+
+- Create: `tests/golden/bitfun_cli//bitfun/sessions//metadata.json`
+- Create: `tests/golden/bitfun_cli//bitfun/sessions//turns/turn-NNNN.json`
+- Create: `tests/golden/bitfun_cli//bitfun/token_usage/records/.json`
+- Create: `tests/golden/bitfun_cli//expected_trajectory.json`
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+Per spec section 5 / design decision Q6, build a sanitized, hand-crafted golden BitFun session that exercises: a user turn, a thinking item, a tool call with `resultForAssistant`, a manual-compaction turn, a token record, and a subagent dispatch. This locks the end-to-end shape of the emitted ATIF JSON.
+
+Use a deterministic ``: `bitfun-golden-001`. Pick a fixed `_DEFAULT_TS_MS` baseline so timestamps are stable.
+
+- [ ] **Step 1: Create the golden session directory tree**
+
+Pick ` = "bitfun-golden-001"` and ` = "bitfun-golden-001-sub"`. All BitFun timestamps use ms epoch `1_778_000_000_000` baseline.
+
+Layout to produce on disk (under `tests/golden/bitfun_cli/bitfun-golden-001/`):
+
+```
+bitfun/
+├── sessions/
+│ ├── bitfun-golden-001/
+│ │ ├── metadata.json
+│ │ └── turns/
+│ │ ├── turn-0000.json # user "hello"; thinking + text + tool call dispatching subagent
+│ │ └── turn-0001.json # manual_compaction turn
+│ └── bitfun-golden-001-sub/
+│ ├── metadata.json (sessionKind=subagent)
+│ └── turns/
+│ └── turn-0000.json # user "do thing"; one text round
+└── token_usage/
+ └── records/
+ └── 2026-01-01.json # 2 records: 1 main + 1 subagent
+expected_trajectory.json
+```
+
+Use the fixture builders (importable from the test module) to produce these JSON files. Implement a helper that generates them programmatically inside the test, then writes to the disk location at test setup (alternative: commit them as static files). **Choose the static-file approach** to make changes to expected output visible in code review.
+
+Generate the files once by adding a one-shot regeneration helper to the test module (kept around so future updates are easy):
+
+```python
+def _regenerate_golden_fixture(target_root: _Path) -> None:
+ """One-shot writer used during local fixture authoring.
+
+ Run via:
+ from tests.unit.agents.installed.test_bitfun_cli import _regenerate_golden_fixture
+ _regenerate_golden_fixture(Path("tests/golden/bitfun_cli/bitfun-golden-001"))
+ """
+ ts = 1_778_000_000_000
+ main_sid = "bitfun-golden-001"
+ sub_sid = "bitfun-golden-001-sub"
+
+ # ----- subagent session -----
+ sub_turn = _make_turn(
+ 0, f"{sub_sid}-turn", sub_sid, user_text="do thing", ts=ts + 200,
+ model_rounds=[
+ _make_round(
+ f"{sub_sid}-round", turn_id=f"{sub_sid}-turn",
+ ts=ts + 250,
+ text_items=[_make_text_item(f"{sub_sid}-ti", "did it", order_index=0, ts=ts + 260)],
+ model_id="openai/gpt-5",
+ )
+ ],
+ )
+ _write_session(
+ target_root, sub_sid,
+ metadata=_make_metadata(
+ sub_sid, kind="subagent", model="openai/gpt-5",
+ workspace="/testbed",
+ created_at=ts + 200, last_active_at=ts + 280, turn_count=1,
+ ),
+ turns=[sub_turn],
+ )
+
+ # ----- main session -----
+ tool = _make_tool_item(
+ "tc-1", "Task", {"description": "delegate to subagent"},
+ result_text="subagent done",
+ raw_result={"output": "subagent done"},
+ subagent_sid=sub_sid, subagent_model_id="openai/gpt-5",
+ order_index=2, ts=ts + 150, duration_ms=40,
+ ai_intent="dispatch subagent to do the thing",
+ )
+ main_turn = _make_turn(
+ 0, "main-turn-0", main_sid, user_text="please help", ts=ts,
+ model_rounds=[
+ _make_round(
+ "main-round-0", turn_id="main-turn-0", round_index=0, ts=ts + 50,
+ thinking_items=[
+ _make_thinking_item(
+ "th-0", "I should delegate.", order_index=0, ts=ts + 60,
+ )
+ ],
+ text_items=[
+ _make_text_item(
+ "ti-0", "Delegating now.", order_index=1, ts=ts + 80,
+ )
+ ],
+ tool_items=[tool],
+ model_id="openai/gpt-5",
+ )
+ ],
+ )
+ compaction_turn = _make_turn(
+ 1, "main-turn-1", main_sid, user_text="",
+ kind="manual_compaction", ts=ts + 300,
+ )
+ _write_session(
+ target_root, main_sid,
+ metadata=_make_metadata(
+ main_sid, kind="standard", model="openai/gpt-5",
+ workspace="/testbed",
+ created_at=ts, last_active_at=ts + 320,
+ turn_count=2, tool_call_count=1,
+ ),
+ turns=[main_turn, compaction_turn],
+ token_records=[
+ _make_token_record(
+ "openai/gpt-5", main_sid, "main-turn-0",
+ 120, 80, cached=10, ts=ts + 100,
+ ),
+ _make_token_record(
+ "openai/gpt-5", sub_sid, f"{sub_sid}-turn",
+ 40, 20, cached=0, ts=ts + 260, is_sub=True,
+ ),
+ ],
+ token_records_date="2026-01-01",
+ )
+```
+
+Run this generator once from a temporary Python prompt (or paste it into a one-off script under `scripts/_gen_bitfun_golden.py` and delete after committing the fixture):
+
+```bash
+uv run python -c "
+from pathlib import Path
+from tests.unit.agents.installed.test_bitfun_cli import _regenerate_golden_fixture
+_regenerate_golden_fixture(Path('tests/golden/bitfun_cli/bitfun-golden-001'))
+"
+```
+
+This writes the BitFun layout into the golden directory.
+
+- [ ] **Step 2: Generate the expected `trajectory.json` from the fixture and stash it**
+
+Run the converter against the fresh fixture and write the canonical expected output:
+
+```bash
+uv run python -c "
+import json
+from pathlib import Path
+from harbor.agents.installed.bitfun_cli import BitfunCli
+from harbor.utils.trajectory_utils import format_trajectory_json
+root = Path('tests/golden/bitfun_cli/bitfun-golden-001')
+agent = BitfunCli(logs_dir=root, model_name='openai/gpt-5')
+agent._version = '0.0.1'
+session_dir = agent._get_session_dir()
+traj = agent._convert_events_to_trajectory(session_dir)
+(root / 'expected_trajectory.json').write_text(
+ format_trajectory_json(traj.to_json_dict())
+)
+"
+```
+
+**Manual review checklist for the generated `expected_trajectory.json`**:
+
+- `schema_version == "ATIF-v1.7"`.
+- `session_id == "bitfun-golden-001"`.
+- `agent.name == "bitfun-cli"`.
+- `agent.model_name == "openai/gpt-5"` (or `"default"` if metadata model name wasn't overridden — check both code path and fixture).
+- `steps[0].source == "user"`, message `"please help"`.
+- An agent step has `reasoning_content == "I should delegate."` and `message == "Delegating now."`.
+- A tool-call step has `tool_calls[0].function_name == "Task"` and an observation whose `subagent_trajectory_ref[0].trajectory_id == "bitfun-golden-001-sub"`.
+- A `source == "system"` step from the compaction turn, with `is_copied_context == true`.
+- `subagent_trajectories[0].trajectory_id == "bitfun-golden-001-sub"` and `agent.name == "Task"`.
+- `final_metrics.total_prompt_tokens == 120`, `total_completion_tokens == 80`, `total_cached_tokens == 10` (subagent record excluded from main totals; verify).
+
+If anything looks wrong, fix the source code (not the fixture). Re-run the generator after each code change so the golden file stays consistent with the conversion logic.
+
+- [ ] **Step 3: Write the failing golden test**
+
+Append the following test class to `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestGoldenIntegration:
+ GOLDEN_ROOT = _Path(__file__).resolve().parents[3] / "tests" / "golden" / "bitfun_cli" / "bitfun-golden-001"
+
+ def test_golden_session_converts_to_expected_trajectory(self, tmp_path):
+ # Copy the entire bitfun/ subtree into a fresh logs_dir so the agent
+ # finds it via _get_session_dir.
+ import shutil
+ shutil.copytree(
+ self.GOLDEN_ROOT / "bitfun", tmp_path / "bitfun",
+ )
+ agent = BitfunCli(logs_dir=tmp_path, model_name="openai/gpt-5")
+ agent._version = "0.0.1"
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+
+ produced = _json.loads((tmp_path / "trajectory.json").read_text())
+ expected = _json.loads(
+ (self.GOLDEN_ROOT / "expected_trajectory.json").read_text()
+ )
+ # Compare full structure.
+ assert produced == expected, (
+ "BitFun ATIF output drifted from golden fixture. Either fix the "
+ "conversion or regenerate expected_trajectory.json after a "
+ "review of the diff."
+ )
+```
+
+- [ ] **Step 4: Run the golden test to verify it passes**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestGoldenIntegration -v
+```
+
+Expected: PASS. If it fails, the produced trajectory has drifted from the expected one. Reconcile per the checklist above before regenerating the fixture.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add tests/golden/bitfun_cli/bitfun-golden-001 \
+ tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "test(bitfun-cli): add golden fixture covering thinking/tool/subagent/compaction"
+```
+
+---
+
+### Task 16: Update `AGENTS.md` and run global checks
+
+**Files:**
+
+- Modify: `AGENTS.md` (only the BitFun bullet)
+- Verify: full unit suite, `ruff`, `ty`.
+
+- [ ] **Step 1: Update the BitFun bullet in `AGENTS.md`**
+
+Locate the existing line:
+
+```markdown
+- **`bitfun-cli`**: BitFun CLI (`exec` mode; mount binary via `mounts_json`)
+```
+
+and replace with:
+
+```markdown
+- **`bitfun-cli`**: BitFun CLI (`exec` mode; mount binary via `mounts_json`); emits ATIF v1.7 trajectory with token usage and LiteLLM-derived cost.
+```
+
+- [ ] **Step 2: Run formatter and linter**
+
+```bash
+uv run ruff check --fix src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+uv run ruff format src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+```
+
+Expected: no remaining lint errors.
+
+- [ ] **Step 3: Run the type checker**
+
+```bash
+uv run ty check src/harbor/agents/installed/bitfun_cli.py
+```
+
+Expected: no errors. Fix any new diagnostics introduced by Tasks 1–15.
+
+- [ ] **Step 4: Run the full unit suite**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+uv run pytest tests/unit/ -q
+```
+
+Expected: all PASS. If anything else fails (e.g., a top-level lazy-import test), inspect the diff in `bitfun_cli.py` for accidental changes (missing imports, etc.).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add AGENTS.md src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "docs(bitfun-cli): note ATIF trajectory + token-usage support in AGENTS.md"
+```
+
+---
+
+## Out-of-scope reminders (do not address in this plan)
+
+- HOME-override approach — explicitly rejected in spec Q1. Do not refactor cp-back into a HOME mount in this plan.
+- Standalone offline `bitfun → ATIF` CLI — spec defers to future work (Q2). Conversion helpers stay private to `BitfunCli`.
+- Anthropic-vs-OpenAI prompt-token semantics swap — leave the "already inclusive" default; revisit only if a future Anthropic-backed golden run shows under-counting. (See spec section 4 caveat.)
+- Windows containers — `SUPPORTS_WINDOWS` stays `False`.
+
+## Self-review notes
+
+- **Spec coverage:** every section of the spec maps to a task: §1 (cp-back) → Task 14; §2 (event normalization) → Tasks 6, 7, 8, 9; §3 (ATIF mapping) → Tasks 6–9, 12; §4 (Metrics / cost / FinalMetrics) → Tasks 4, 5, 10, 11; §5 (test plan) → Tasks 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15; risks/follow-ups → out-of-scope reminders above; `SUPPORTS_ATIF=True` → Task 1.
+- **Name consistency:** `_get_session_dir`, `_load_token_records`, `_compute_cost_via_litellm`, `_convert_events_to_trajectory`, `_round_to_steps`, `_build_metrics_from_record`, `_allocate_records_to_steps`, `_embed_subagents`, `_build_final_metrics`, `_ts_iso`, `_user_text_from_message`, `_strip_user_query_wrapper`, `_load_turns`, `_parse_record_ts_ms` are used consistently across tasks.
+- **Schema version:** every produced `Trajectory` uses `_ATIF_SCHEMA_VERSION = "ATIF-v1.7"`, which is required for `subagent_trajectories[]` per `harbor.models.trajectories.trajectory.Trajectory`.
+- **No placeholders:** all steps include either complete code blocks, exact shell commands, or explicit "do X to file Y at location Z" edits with the surrounding context (replace…with…).
diff --git a/docs/superpowers/plans/2026-05-13-bitfun-cli-harbor.md b/docs/superpowers/plans/2026-05-13-bitfun-cli-harbor.md
new file mode 100644
index 00000000000..92f0d6ec1df
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-13-bitfun-cli-harbor.md
@@ -0,0 +1,441 @@
+# BitFun CLI (`bitfun-cli`) Harbor integration — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Register Harbor installed agent `bitfun-cli` that runs the host-mounted BitFun binary in `/testbed` for SWE-bench-style tasks, without ATIF (v1).
+
+**Architecture:** Add `BitfunCli` (`BaseInstalledAgent`) with `install()` verifying the bind-mounted binary and `run()` invoking ` exec --agent …` plus optional `--output-patch`, logging via `tee`. Register `AgentName.BITFUN_CLI` in the factory. Unit-test command shape, `cwd`, and env passthrough.
+
+**Tech stack:** Python 3.12+, Harbor `BaseInstalledAgent`, `pytest` + `AsyncMock`, existing `AgentFactory` / `AgentName` patterns (`Aider`, `Pi`).
+
+---
+
+## File map (create / modify)
+
+| File | Responsibility |
+|------|----------------|
+| `src/harbor/models/agent/name.py` | Add `BITFUN_CLI = "bitfun-cli"` |
+| `src/harbor/agents/installed/bitfun_cli.py` | **Create** — agent implementation |
+| `src/harbor/agents/factory.py` | Import `BitfunCli`, append to `_AGENTS` |
+| `tests/unit/agents/installed/test_bitfun_cli.py` | **Create** — unit tests |
+| `tests/unit/agents/installed/test_simple_agents.py` | Add `BitfunCli` to install parametrize lists |
+| `AGENTS.md` | Add `bitfun-cli` to built-in installed agents bullet list (keep alphabet reasonable) |
+
+**Spec reference:** `docs/superpowers/specs/2026-05-13-bitfun-cli-harbor-design.md`
+
+---
+
+### Task 1: Register agent name
+
+**Files:**
+
+- Modify: `src/harbor/models/agent/name.py`
+- Test: (covered in Task 4 via factory / `name()` assertion)
+
+- [ ] **Step 1: Add enum member**
+
+Insert after `AIDER = "aider"` (or in alphabetical place near other `*-cli` entries — here place after `AIDER` to minimize diff noise, or after `CODEX` if you prefer grouping; **use one line**):
+
+```python
+ BITFUN_CLI = "bitfun-cli"
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/harbor/models/agent/name.py
+git commit -m "feat(agents): add AgentName.BITFUN_CLI"
+```
+
+---
+
+### Task 2: Implement `BitfunCli` agent module
+
+**Files:**
+
+- Create: `src/harbor/agents/installed/bitfun_cli.py`
+- Test: Task 4
+
+- [ ] **Step 1: Add the new module (full file)**
+
+```python
+"""Harbor integration for BitFun's bitfun-cli (single-shot `exec` mode)."""
+
+from __future__ import annotations
+
+import os
+import shlex
+from pathlib import Path
+
+from harbor.agents.installed.base import BaseInstalledAgent, with_prompt_template
+from harbor.environments.base import BaseEnvironment
+from harbor.models.agent.context import AgentContext
+from harbor.models.agent.name import AgentName
+
+_DEFAULT_BINARY = "/usr/local/bin/bitfun-cli"
+_AGENT_LOG = "/logs/agent/bitfun.txt"
+
+# Copied into the container exec env when set on the Harbor host / orchestrator.
+_ENV_PASSTHROUGH: tuple[str, ...] = (
+ "OPENAI_API_KEY",
+ "OPENAI_BASE_URL",
+ "ANTHROPIC_API_KEY",
+ "ANTHROPIC_AUTH_TOKEN",
+ "ANTHROPIC_BASE_URL",
+ "GEMINI_API_KEY",
+ "GOOGLE_API_KEY",
+)
+
+
+class BitfunCli(BaseInstalledAgent):
+ """Run BitFun CLI in non-interactive `exec` mode (binary supplied via bind mount)."""
+
+ def __init__(
+ self,
+ logs_dir: Path,
+ binary_path: str = _DEFAULT_BINARY,
+ exec_agent: str = "agentic",
+ output_patch_path: str | None = "/logs/agent/bitfun.patch",
+ *args,
+ **kwargs,
+ ) -> None:
+ self._binary_path = binary_path
+ self._exec_agent = exec_agent
+ self._output_patch_path = output_patch_path
+ super().__init__(logs_dir, *args, **kwargs)
+
+ @staticmethod
+ def name() -> str:
+ return AgentName.BITFUN_CLI.value
+
+ def get_version_command(self) -> str | None:
+ return f"{shlex.quote(self._binary_path)} --version"
+
+ async def install(self, environment: BaseEnvironment) -> None:
+ quoted = shlex.quote(self._binary_path)
+ await self.exec_as_agent(
+ environment,
+ command=(
+ "set -euo pipefail; "
+ f"test -e {quoted}; "
+ f"chmod a+x {quoted} 2>/dev/null || true; "
+ f"{quoted} --version"
+ ),
+ )
+
+ def populate_context_post_run(self, context: AgentContext) -> None:
+ pass # ATIF / token metrics deferred.
+
+ def _env_for_run(self) -> dict[str, str]:
+ env: dict[str, str] = {}
+ for key in _ENV_PASSTHROUGH:
+ val = os.environ.get(key)
+ if val:
+ env[key] = val
+ for key, val in os.environ.items():
+ if key.startswith("BITFUN_") and val:
+ env[key] = val
+ return env
+
+ @with_prompt_template
+ async def run(
+ self,
+ instruction: str,
+ environment: BaseEnvironment,
+ context: AgentContext,
+ ) -> None:
+ _ = context
+ bp = shlex.quote(self._binary_path)
+ msg = shlex.quote(instruction)
+ agent_flag = shlex.quote(self._exec_agent)
+ patch_part = ""
+ if self._output_patch_path:
+ patch_part = f" --output-patch {shlex.quote(self._output_patch_path)}"
+ # Grading for SWE-bench Harbor tasks uses the git working tree under /testbed.
+ # --output-patch is only a convenience artifact; edits must land in the repo.
+ inner = (
+ f"{bp} exec {msg} --agent {agent_flag}{patch_part} "
+ f"2>&1 | stdbuf -oL tee {_AGENT_LOG}"
+ )
+ await self.exec_as_agent(
+ environment,
+ command=f"set -o pipefail; {inner}",
+ env=self._env_for_run(),
+ cwd="/testbed",
+ )
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py
+git commit -m "feat(agents): add BitfunCli installed agent"
+```
+
+---
+
+### Task 3: Wire `AgentFactory`
+
+**Files:**
+
+- Modify: `src/harbor/agents/factory.py`
+
+- [ ] **Step 1: Import and register**
+
+After `from harbor.agents.installed.aider import Aider`, add:
+
+```python
+from harbor.agents.installed.bitfun_cli import BitfunCli
+```
+
+In `_AGENTS`, add `BitfunCli` next to other installed CLIs (e.g. after `Aider`):
+
+```python
+ Aider,
+ BitfunCli,
+ ClineCli,
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/harbor/agents/factory.py
+git commit -m "feat(agents): register BitfunCli in AgentFactory"
+```
+
+---
+
+### Task 4: Unit tests — `test_bitfun_cli.py`
+
+**Files:**
+
+- Create: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Write tests**
+
+```python
+"""Unit tests for BitfunCli."""
+
+import os
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from harbor.agents.factory import AgentFactory
+from harbor.agents.installed.bitfun_cli import BitfunCli
+from harbor.models.agent.context import AgentContext
+from harbor.models.agent.name import AgentName
+
+
+@pytest.fixture
+def temp_dir(tmp_path):
+ return tmp_path
+
+
+class TestBitfunCliAgent:
+ def test_name(self):
+ assert BitfunCli.name() == AgentName.BITFUN_CLI.value
+
+ def test_registered_in_factory(self):
+ assert AgentName.BITFUN_CLI in AgentFactory._AGENT_MAP
+ assert AgentFactory._AGENT_MAP[AgentName.BITFUN_CLI] is BitfunCli
+
+ @pytest.mark.asyncio
+ async def test_install_verifies_binary(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/usr/local/bin/bitfun-cli")
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="bitfun 0.0.1\n", stderr="")
+ await agent.install(mock_env)
+ assert mock_env.exec.call_count == 1
+ cmd = mock_env.exec.call_args.kwargs["command"]
+ assert "/usr/local/bin/bitfun-cli" in cmd
+ assert "chmod a+x" in cmd
+ assert "--version" in cmd
+
+ @pytest.mark.asyncio
+ async def test_run_uses_testbed_cwd_and_exec(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/opt/bitfun-cli")
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-xx"}, clear=False):
+ await agent.run("Fix the issue", mock_env, AgentContext())
+
+ assert mock_env.exec.call_count == 1
+ call_kw = mock_env.exec.call_args.kwargs
+ assert call_kw["cwd"] == "/testbed"
+ cmd = call_kw["command"]
+ assert "/opt/bitfun-cli" in cmd
+ assert " exec " in cmd
+ assert "--agent " in cmd
+ assert "agentic" in cmd
+ assert "--output-patch " in cmd
+ assert "/logs/agent/bitfun.patch" in cmd
+ assert "tee /logs/agent/bitfun.txt" in cmd
+ assert call_kw["env"]["OPENAI_API_KEY"] == "sk-xx"
+
+ @pytest.mark.asyncio
+ async def test_run_without_output_patch(self, temp_dir):
+ agent = BitfunCli(
+ logs_dir=temp_dir,
+ binary_path="/bin/bitfun-cli",
+ output_patch_path=None,
+ )
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("Hello", mock_env, AgentContext())
+ cmd = mock_env.exec.call_args.kwargs["command"]
+ assert "--output-patch" not in cmd
+
+ @pytest.mark.asyncio
+ async def test_run_forwards_bitfun_prefixed_env(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ with patch.dict(
+ os.environ, {"BITFUN_DEBUG_LOG_PATH": "/tmp/x.log"}, clear=False
+ ):
+ await agent.run("Hi", mock_env, AgentContext())
+ env = mock_env.exec.call_args.kwargs["env"]
+ assert env["BITFUN_DEBUG_LOG_PATH"] == "/tmp/x.log"
+
+ def test_populate_context_post_run_noop(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ assert ctx.is_empty()
+```
+
+- [ ] **Step 2: Run tests (expect PASS)**
+
+```bash
+cd /home/djn/code/harbor && uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: all tests **PASSED**.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "test(agents): add BitfunCli unit tests"
+```
+
+---
+
+### Task 5: Extend `test_simple_agents.py`
+
+**Files:**
+
+- Modify: `tests/unit/agents/installed/test_simple_agents.py`
+
+- [ ] **Step 1: Import and parametrize**
+
+Add import:
+
+```python
+from harbor.agents.installed.bitfun_cli import BitfunCli
+```
+
+Add `BitfunCli` to **both** `@pytest.mark.parametrize("agent_class", [...])` lists in `TestSimpleAgentInstall` (after `Aider`):
+
+```python
+ Aider,
+ BitfunCli,
+ ClaudeCode,
+```
+
+- [ ] **Step 2: Run tests**
+
+```bash
+uv run pytest tests/unit/agents/installed/test_simple_agents.py -v
+```
+
+Expected: **PASSED**.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/unit/agents/installed/test_simple_agents.py
+git commit -m "test(agents): include BitfunCli in simple agent install tests"
+```
+
+---
+
+### Task 6: Documentation touch-up
+
+**Files:**
+
+- Modify: `AGENTS.md` (Built-in agents / Installed agents list)
+
+- [ ] **Step 1: Add bullet**
+
+In the “Installed agents” list (same section as `aider`, `codex`, …), add:
+
+```markdown
+- **`bitfun-cli`**: BitFun CLI (`exec` mode; mount binary via `mounts_json`)
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add AGENTS.md
+git commit -m "docs: list bitfun-cli in built-in agents"
+```
+
+---
+
+### Task 7: Repo verification (required before merge)
+
+- [ ] **Step 1: Unit suite (agents)**
+
+```bash
+uv run pytest tests/unit/agents/ -v --tb=short
+```
+
+Expected: **PASSED**.
+
+- [ ] **Step 2: Ruff + format + ty (per AGENTS.md)**
+
+```bash
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+```
+
+Expected: no errors.
+
+- [ ] **Step 3: Final commit** (only if formatting/lint fixes produced changes)
+
+```bash
+git add -u && git commit -m "chore: ruff format and ty fixes for bitfun-cli agent"
+```
+
+---
+
+## Plan self-review (completed)
+
+| Spec item | Task |
+|-----------|------|
+| `bitfun-cli` agent, no ATIF | Task 2 (`populate_context_post_run` noop), no trajectory writer |
+| Bind mount binary, `install()` verify | Task 2 `install()` |
+| `run()` in `/testbed`, `exec`, optional patch, `tee` | Task 2 + Task 4 |
+| Env passthrough | Task 2 `_env_for_run` + tests |
+| `AgentName` + factory | Tasks 1–3 |
+| Unit tests | Tasks 4–5 |
+| Docs | Task 6 |
+
+**Placeholder scan:** None — all shown code is complete.
+
+**Type/name consistency:** `AgentName.BITFUN_CLI.value` is `"bitfun-cli"`; CLI usage matches mounted filename convention from design doc.
+
+---
+
+## Execution handoff
+
+**Plan complete and saved to `docs/superpowers/plans/2026-05-13-bitfun-cli-harbor.md`. Two execution options:**
+
+**1. Subagent-Driven (recommended)** — Dispatch a fresh subagent per task, review between tasks, fast iteration.
+
+**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
+
+**Which approach?**
diff --git a/docs/superpowers/plans/2026-05-17-multi-provider-analyze.md b/docs/superpowers/plans/2026-05-17-multi-provider-analyze.md
new file mode 100644
index 00000000000..679aeb7db67
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-17-multi-provider-analyze.md
@@ -0,0 +1,769 @@
+# Multi-provider Analyze (Viewer + Claude Agent SDK env injection) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add Anthropic-compatible **analyze profiles** (non-secret TOML + secrets from `.env`), expose them via `GET /api/analyze/profiles`, extend trial/job summarize POST bodies to carry `profile_id` / `model_id`, and merge resolved `ANTHROPIC_*` overlays into **`ClaudeAgentOptions.env`** per invocation without touching global `os.environ`.
+
+**Architecture:** Introduce **`harbor.analyze.profiles`** to load TOML (or built-in defaults), validate with Pydantic, and **`resolve_summarize_invoke()`** producing `(api_model, sdk_env)`. **`create_app()`** parses profiles at startup (**fail-fast** if `HARBOR_ANALYZE_PROFILES` points to an invalid file). **`Analyzer`** accepts optional **`sdk_env_overlay`** reused by every `query_agent`/`query_llm` call inside a Viewer-driven job summary. Frontend fetches profiles, falls back to legacy three-option model UI on error.
+
+**Tech Stack:** Python 3.12 (`tomllib`, Pydantic v2), FastAPI, Claude Agent SDK (`ClaudeAgentOptions.env`), Viewer (React/TanStack Query/TypeScript), Typer CLI.
+
+---
+
+## File map (ownership)
+
+| File | Responsibility |
+|------|----------------|
+| **`src/harbor/analyze/profiles.py`** (new) | Pydantic types for profiles; `built_in_profiles()`; `load_profiles_from_file(Path)` → registry; `resolve_summarize_invoke(doc, profile_id \| None, logical_model_id: str)` (**logical model row id**, after HTTP-layer precedence); `profiles_for_public_api(registry)` serialization |
+| **`examples/config/analyze-profiles.example.toml`** (new) | Copy-paste example with two profiles (`anthropic` + proxy); no secrets |
+| **`src/harbor/analyze/backend.py`** (modify) | Add `sdk_env: dict[str, str] \| None = None` to `query_agent` and `query_llm`; merge into **`ClaudeAgentOptions(env={...})`**; keep existing `ANTHROPIC_API_KEY` guard only when **`sdk_env` does not define `ANTHROPIC_API_KEY`** (backward compatibility for callers that rely on ambient env) |
+| **`src/harbor/analyze/analyzer.py`** (modify) | `Analyzer(..., sdk_env_overlay: Mapping[str, str] \| None=None)` stored on instance; **`analyze_trial`**/`**analyze_job**`/ **`_aggregate`** pass overlay + model into **`query_*`** calls |
+| **`src/harbor/viewer/server.py`** (modify) | `create_app(..., analyze_profiles_file: Path \| None=None)` parses registry at app construction; **`GET /api/analyze/profiles`**; extend **`TrialSummarizeRequest`** / **`SummarizeRequest`**; wire **`summarize_trial`** and **`summarize_job`** resolver → **`Analyzer`** |
+| **`src/harbor/viewer/__init__.py`** (modify) | `create_app_from_env()` reads **`HARBOR_ANALYZE_PROFILES`** Optional path → pass into `create_app` |
+| **`src/harbor/cli/view.py`** (modify) | Option **`--analyze-profiles`** path → pass to **`create_app`** in production; set **`os.environ["HARBOR_ANALYZE_PROFILES"]`** in dev mode alongside existing viewer env so reload factory sees it (**or** pass via env inside `_run_dev_mode` before uvicorn.run — match existing `HARBOR_VIEWER_FOLDER` pattern) |
+| **`tests/unit/analyze/test_analyze_profiles.py`** (new) | TOML parse, uniqueness, resolver matrix, overlay dict construction (**no real SDK**) |
+| **`tests/unit/analyze/test_analyze_backend_env.py`** (new) | `query_agent` passes merged `sdk_env`; **never mutates global `os.environ`** (monkeypatch) |
+| **`apps/viewer/app/lib/api.ts`** (modify) | `fetchAnalyzeProfiles()`; extend **`summarizeTrial`**, **`summarizeJob`** payloads |
+| **`apps/viewer/app/routes/trial.tsx`** + **`apps/viewer/app/routes/job.tsx`** (modify) | Profile + cascading model **`Select`**; degraded legacy UI on **`GET`** failure |
+| **`CLAUDE.md`** (repo root) + **`apps/viewer/CLAUDE.md`** (modify short section) | `HARBOR_ANALYZE_PROFILES`, `.env`, new endpoint semantics |
+
+---
+
+## Spec coverage checklist (completed in self-review below)
+
+---
+
+### Task 1: Profile models + TOML loader + built-in fallback
+
+**Files:**
+- Create: `src/harbor/analyze/profiles.py`
+- Create: `examples/config/analyze-profiles.example.toml`
+- Create: `tests/unit/analyze/test_analyze_profiles.py`
+- Modify: *(none yet)*
+
+**Schema note:** Use TOML array-of-tables:
+
+```toml
+[[profile]]
+id = "anthropic"
+label = "Anthropic (direct)"
+api_key_env = "ANTHROPIC_API_KEY"
+base_url_env = "ANTHROPIC_BASE_URL"
+default_model = "haiku"
+
+[[profile.model]]
+id = "haiku"
+display_name = "Haiku (recommended)"
+api_model = "haiku"
+```
+
+Implement `AnalyzeModelEntry`, `AnalyzeProfileDoc`, top-level **`AnalyzeProfilesFile`** wrapping `profiles: list[AnalyzeProfileDoc]` (**rename from raw `profile` key** inside loader: after `tomllib.loads`, normalize `{"profile":[...]` → pydantic).
+
+- [ ] **Step 1: Write failing tests** — `tests/unit/analyze/test_analyze_profiles.py`
+
+```python
+import textwrap
+
+import pytest
+
+from harbor.analyze.profiles import (
+ ProfilesConfigurationError,
+ built_in_profiles,
+ load_profiles_from_file,
+)
+
+
+def test_built_in_has_three_models():
+ doc = built_in_profiles()
+ p = doc.require_profile("anthropic")
+ assert [m.id for m in p.models] == ["haiku", "sonnet", "opus"]
+
+
+def test_load_duplicate_profile_ids_raises(tmp_path):
+ cfg = tmp_path / "dup.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ '''
+ [[profile]]
+ id = "a"
+
+ [[profile.model]]
+ id = "one"
+ display_name = "One"
+ api_model = "m1"
+
+ [[profile]]
+ id = "a"
+
+ [[profile.model]]
+ id = "two"
+ display_name = "Two"
+ api_model = "m2"
+ '''
+ ).strip(),
+ encoding="utf-8",
+ )
+ with pytest.raises(ProfilesConfigurationError):
+ load_profiles_from_file(cfg)
+
+
+def test_resolve_logical_model_maps_to_builtin():
+ """Resolver receives the already-merged logical model row id."""
+ import os
+
+ from harbor.analyze.profiles import resolve_summarize_invoke
+
+ os.environ.setdefault("ANTHROPIC_API_KEY", "dummy-for-test")
+
+ doc = built_in_profiles()
+ api_model, sdk_env_instructions = resolve_summarize_invoke(
+ doc,
+ profile_id=None,
+ logical_model_id="sonnet",
+ )
+ assert api_model == "sonnet"
+ assert sdk_env_instructions.api_key_env == "ANTHROPIC_API_KEY"
+```
+
+Implement minimal **`resolve_summarize_invoke`** stub returning dataclass **`SdkEnvInstructions(api_key_env, base_url_env|None, inject:dict[str,str])`** in same module before running tests (**TDD tweak:** first add empty `raise NotImplementedError` then fill — here we inline full expected resolver in Step 3).
+
+- [ ] **Step 2: Run tests expecting failure**
+
+```bash
+uv run pytest tests/unit/analyze/test_analyze_profiles.py -q --tb=short
+```
+
+Expected: **`ImportError` / failures** until module exists.
+
+- [ ] **Step 3: Implement `profiles.py`** (resolver + loaders)
+
+```python
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+import tomllib
+
+from pydantic import BaseModel, Field
+
+
+class ProfilesConfigurationError(ValueError):
+ pass
+
+
+class AnalyzeModelRow(BaseModel):
+ id: str
+ display_name: str = ""
+ api_model: str
+
+
+class AnalyzeProfileDoc(BaseModel):
+ id: str
+ label: str
+ api_key_env: str = Field(..., min_length=1)
+ base_url_env: str | None = None
+ default_model: str
+ models: list[AnalyzeModelRow]
+
+
+class AnalyzeProfilesDocument(BaseModel):
+ profiles: list[AnalyzeProfileDoc]
+
+ def require_profile(self, profile_id: str) -> AnalyzeProfileDoc:
+ for p in self.profiles:
+ if p.id == profile_id:
+ return p
+ raise KeyError(profile_id)
+
+
+@dataclass(frozen=True)
+class SdkEnvInstructions:
+ api_key_env: str
+ base_url_env: str | None
+ inject: dict[str, str]
+
+
+def built_in_profiles() -> AnalyzeProfilesDocument:
+ anthropic_models = [
+ AnalyzeModelRow(
+ id="haiku",
+ display_name="Haiku (recommended)",
+ api_model="haiku",
+ ),
+ AnalyzeModelRow(id="sonnet", display_name="Sonnet", api_model="sonnet"),
+ AnalyzeModelRow(id="opus", display_name="Opus", api_model="opus"),
+ ]
+ return AnalyzeProfilesDocument(
+ profiles=[
+ AnalyzeProfileDoc(
+ id="anthropic",
+ label="Anthropic (direct)",
+ api_key_env="ANTHROPIC_API_KEY",
+ base_url_env="ANTHROPIC_BASE_URL",
+ default_model="haiku",
+ models=anthropic_models,
+ )
+ ]
+ )
+
+
+def load_profiles_from_file(path: Path) -> AnalyzeProfilesDocument:
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
+ rows = raw.get("profile") or raw.get("profiles")
+ if rows is None:
+ raise ProfilesConfigurationError("TOML must contain [[profile]] entries")
+ profs = []
+ seen: set[str] = set()
+ for block in rows:
+ models_raw = block.get("model") or []
+ pid = block["id"]
+ if pid in seen:
+ raise ProfilesConfigurationError(f"Duplicate profile id: {pid!r}")
+ seen.add(pid)
+ profs.append(
+ AnalyzeProfileDoc(
+ id=pid,
+ label=block.get("label", pid),
+ api_key_env=block["api_key_env"],
+ base_url_env=block.get("base_url_env"),
+ default_model=block["default_model"],
+ models=[
+ AnalyzeModelRow(
+ id=m["id"],
+ display_name=m.get("display_name", m["id"]),
+ api_model=m["api_model"],
+ )
+ for m in models_raw
+ ],
+ )
+ )
+ if not profs[-1].models:
+ raise ProfilesConfigurationError(f"profile {pid!r} has empty models")
+
+ doc = AnalyzeProfilesDocument(profiles=profs)
+
+ dup_model_ids = []
+ for p in doc.profiles:
+ ids = [m.id for m in p.models]
+ if len(ids) != len(set(ids)):
+ dup_model_ids.append(p.id)
+
+ if dup_model_ids:
+ raise ProfilesConfigurationError(
+ f"Duplicate model ids inside profiles: {dup_model_ids!r}"
+ )
+
+ return doc
+
+
+def profiles_for_public_api(doc: AnalyzeProfilesDocument) -> list[dict[str, object]]:
+ out = []
+ for p in doc.profiles:
+ item: dict[str, object] = {
+ "id": p.id,
+ "label": p.label,
+ "default_model": p.default_model,
+ "models": [
+ {"id": m.id, "display_name": m.display_name, "api_model": m.api_model}
+ for m in p.models
+ ],
+ "api_key_env": p.api_key_env,
+ }
+ if p.base_url_env:
+ item["base_url_env"] = p.base_url_env
+ out.append(item)
+ return out
+
+
+def _resolve_profile_id(profile_id: str | None, doc: AnalyzeProfilesDocument) -> str:
+ if profile_id:
+ return profile_id
+ return doc.profiles[0].id
+
+
+def _missing_env_message(name: str) -> str:
+ return (
+ f"Required environment variable {name!r} is not set or empty "
+ "(load credentials via .env or your process manager)."
+ )
+
+
+def resolve_summarize_invoke(
+ doc: AnalyzeProfilesDocument,
+ *,
+ profile_id: str | None,
+ logical_model_id: str,
+) -> tuple[str, SdkEnvInstructions]:
+ """Returns (api_model, instructions wired to ANT keys).
+
+ FastAPI MUST merge ``TrialSummarizeRequest`` / ``SummarizeRequest`` into a single
+ ``logical_model_id`` **before** calling this (critical because pydantic defaults
+ ``model=\"haiku\"`` even when omitted from JSON):
+
+ ```python
+ payload = req.model_dump(exclude_unset=True)
+ if "model_id" in payload:
+ logical = req.model_id # assumed non-null if key present (validate length)
+ elif "profile_id" in payload:
+ logical = doc.require_profile(req.profile_id).default_model
+ else:
+ logical = req.model
+ ```
+ """
+ pid = _resolve_profile_id(profile_id, doc)
+ profile = doc.require_profile(pid)
+
+ mid = logical_model_id
+ model_row = None
+ for m in profile.models:
+ if m.id == mid:
+ model_row = m
+ break
+ if model_row is None:
+ allowed = ", ".join(sorted(mm.id for mm in profile.models))
+ raise ProfilesConfigurationError(
+ f"Unknown model_id {mid!r} for profile {pid!r}; allowed: {allowed}"
+ )
+
+ api_model = model_row.api_model
+
+ inject: dict[str, str] = {}
+ key_val = os.getenv(profile.api_key_env)
+ if not key_val:
+ raise ProfilesConfigurationError(
+ _missing_env_message(profile.api_key_env),
+ )
+ inject["ANTHROPIC_API_KEY"] = key_val
+
+ base_url_env = profile.base_url_env
+ if base_url_env:
+ bu_val = os.getenv(base_url_env)
+ if not bu_val:
+ raise ProfilesConfigurationError(_missing_env_message(base_url_env))
+ inject["ANTHROPIC_BASE_URL"] = bu_val
+
+ instructions = SdkEnvInstructions(
+ api_key_env=profile.api_key_env,
+ base_url_env=profile.base_url_env,
+ inject=inject,
+ )
+ return api_model, instructions
+```
+
+- [ ] **Step 4: Add example `examples/config/analyze-profiles.example.toml`** — copy corp-proxy skeleton from **design doc §6**.
+
+- [ ] **Step 5: Run unit tests**
+
+```bash
+uv run pytest tests/unit/analyze/test_analyze_profiles.py -q --tb=short
+```
+
+Expected: **PASS**.
+
+- [ ] **Step 6: Commit** (omit if repo policy forbids unsolicited commits — otherwise)
+
+```bash
+git add src/harbor/analyze/profiles.py examples/config/analyze-profiles.example.toml tests/unit/analyze/test_analyze_profiles.py
+git commit -m "feat(analyze): add configurable analyze profiles loader"
+uv run ruff check --fix . && uv run ruff format .
+```
+
+---
+
+### Task 2: `query_agent` / `query_llm` sdk env injection (no global mutation)
+
+**Files:**
+- Modify: `src/harbor/analyze/backend.py`
+- Create: `tests/unit/analyze/test_analyze_backend_env.py`
+
+Contract:
+- **`sdk_env` dict** merges into **`ClaudeAgentOptions.env`** (**full dict replaces default factory `{}`**, per SDK typings — ensure only keys Harbor controls are supplied; omit clearing inherited process env responsibilities — **verify SDK merges with parent environ** inside Step 4 by reading **`claude_agent_sdk`** instantiation or experimenting in REPL).
+
+- **`ANTHROPIC_API_KEY` presence check:**
+ ```python
+ effective_key = (sdk_env or {}).get("ANTHROPIC_API_KEY") if sdk_env else None
+ if not effective_key:
+ if not os.environ.get("ANTHROPIC_API_KEY"):
+ raise RuntimeError("ANTHROPIC_API_KEY …")
+ ```
+
+- [ ] **Step 1: Failing tests** — **`tests/unit/analyze/test_analyze_backend_env.py`**
+
+```python
+import os
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+@pytest.mark.asyncio
+async def test_query_agent_sets_claude_agent_options_env(monkeypatch):
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
+
+ captured: dict[str, dict] = {}
+
+ class FakeOpts:
+ def __init__(self, **kw):
+ self.kw = kw
+
+ fake_query = AsyncMock()
+
+ async def fake_query_agent_import(prompt, options):
+ captured["kw"] = options.kw
+ if False:
+ yield # pragma: no cover
+
+ import harbor.analyze.backend as backend
+
+ with patch.object(backend, "ClaudeAgentOptions", FakeOpts), patch.object(backend, "query", fake_query):
+ overlay = {"ANTHROPIC_API_KEY": "sk-test", "ANTHROPIC_BASE_URL": "https://example.invalid"}
+ await backend.query_agent(prompt="hello", model="haiku", cwd="/tmp", sdk_env=overlay, tools=[], output_schema=None)
+
+ opts_env = captured["kw"]["env"]
+ assert opts_env["ANTHROPIC_API_KEY"] == "sk-test"
+ assert opts_env["ANTHROPIC_BASE_URL"] == "https://example.invalid"
+ assert os.environ.get("ANTHROPIC_API_KEY") is None
+```
+
+(Adjust patched symbols to match **`from claude_agent_sdk import ClaudeAgentOptions, query`** import style actually used — patch `backend.ClaudeAgentOptions` after import.)
+
+- [ ] **Step 2:** `pytest …` expecting failure.
+
+- [ ] **Step 3:** Implement `sdk_env` plumbing in **`query_agent`** and thread through **`query_llm`**.
+
+```python
+# inside query_agent, after constructing base options dict pieces
+inject = dict(sdk_env) if sdk_env else {}
+
+if (
+ inject.get("ANTHROPIC_API_KEY") is None
+ and not os.environ.get("ANTHROPIC_API_KEY")
+):
+ raise RuntimeError(
+ "ANTHROPIC_API_KEY …"
+ )
+
+options = ClaudeAgentOptions(
+ permission_mode="bypassPermissions",
+ allowed_tools=tools,
+ cwd=cwd,
+ model=normalize_model_name(model),
+ add_dirs=list(add_dirs) if add_dirs else [],
+ env=inject,
+)
+```
+
+- [ ] **Step 4:** Run tests PASS + **manual sanity:** `uv run python - <<'PY'` instantiates options with overlay (optional).
+
+- [ ] **Step 5:** Commit **`feat(analyze): pass sdk env overlays into Claude Agent SDK`**.
+
+---
+
+### Task 3: Analyzer carries default overlay across trials + aggregation
+
+**Files:**
+- Modify: `src/harbor/analyze/analyzer.py`
+
+- [ ] **Step 1:** Extend ctor `sdk_env_overlay: Mapping[str, str] | None = None` → `self._sdk_env_overlay = dict(sdk_env_overlay or {})`.
+
+- [ ] **Step 2:** `analyze_trial` **`query_agent(..., sdk_env=self._sdk_env_overlay)`**.
+
+- [ ] **Step 3:** `query_llm` signature change in **`backend`** → pass overlay from **`_aggregate`** using same dict.
+
+```python
+ job_summary = await query_llm(
+ prompt=prompt,
+ model=self._config.model,
+ sdk_env=self._sdk_env_overlay,
+ verbose=self._config.verbose,
+ )
+```
+
+- [ ] **Step 4:** Run existing analyze unit tests (**`tests/unit/cli/analyze/test_analyze.py`**) filtered if slow — at minimum:
+
+```bash
+uv run pytest tests/unit/cli/analyze/test_analyze.py tests/unit/analyze/ -q
+```
+
+- [ ] **Step 5:** Commit.
+
+---
+
+### Task 4: Viewer server wiring + summarize endpoints + bootstrap failure
+
+**Files:**
+- Modify: `src/harbor/viewer/server.py`
+- Modify: `src/harbor/viewer/__init__.py`
+- Modify: `src/harbor/cli/view.py`
+- Extend tests optionally: **`tests/unit/viewer`** if exists — else smoke via FastAPI **`TestClient`** in **`tests/unit/viewer/test_analyze_profiles_route.py`** (new)
+
+Definitions:
+
+```python
+class TrialSummarizeRequest(BaseModel):
+ model: str = "haiku" # legacy body field for old clients / fallback UI
+ profile_id: str | None = None
+ model_id: str | None = None
+
+
+class SummarizeRequest(BaseModel):
+ model: str = "haiku"
+ n_concurrent: int = 32
+ only_failed: bool = False
+ overwrite: bool = False
+ profile_id: str | None = None
+ model_id: str | None = None
+```
+
+`create_app` signature:
+
+```python
+def create_app(
+ folder: Path,
+ mode: str = "jobs",
+ static_dir: Path | None = None,
+ *,
+ analyze_profiles_file: Path | None = None,
+) -> FastAPI:
+```
+
+Bootstrap:
+
+```python
+def _bootstrap_profiles(analyze_profiles_file: Path | None) -> AnalyzeProfilesDocument:
+ if analyze_profiles_file is None:
+ return built_in_profiles()
+ path = analyze_profiles_file.expanduser().resolve()
+ if not path.is_file():
+ raise RuntimeError(f"HARBOR_ANALYZE_PROFILES points to missing file: {path}")
+ return load_profiles_from_file(path)
+```
+
+**HTTP mapping:** Translate **`ProfilesConfigurationError`** from resolver into **`HTTPException(status_code=422, detail=str(exc))`** (no secret values leak).
+
+Implement route:
+
+```python
+@app.get("/api/analyze/profiles")
+def analyze_profiles_endpoint() -> dict[str, Any]:
+ return {"profiles": profiles_for_public_api(analyze_profiles)}
+```
+
+Wire closure variable **`analyze_profiles = _bootstrap_profiles(...)`**.
+
+**Model precedence helper (implement in ``server.py`` beside request models):**
+
+```python
+def trial_summarize_model_resolution(
+ doc: AnalyzeProfilesDocument,
+ request: TrialSummarizeRequest | SummarizeRequest,
+) -> tuple[str | None, str]:
+ """Return (requested_profile_id, logical_model_row_id).
+
+ Uses ``model_dump(exclude_unset=True)`` so pydantic defaults (``model=\"haiku\"``)
+ do not mask omitted JSON keys.
+ """
+ data = request.model_dump(exclude_unset=True)
+
+ if "model_id" in data:
+ if not request.model_id:
+ raise HTTPException(status_code=422, detail="model_id cannot be empty")
+ return request.profile_id, request.model_id
+
+ if "profile_id" in data:
+ if not request.profile_id:
+ raise HTTPException(status_code=422, detail="profile_id cannot be empty")
+ profile = doc.require_profile(request.profile_id)
+ return request.profile_id, profile.default_model
+
+ return None, request.model
+
+
+# summarize_trial body:
+profile_id_hint, logical_model_id = trial_summarize_model_resolution(
+ analyze_profiles, request
+)
+
+try:
+ api_model, instructions = resolve_summarize_invoke(
+ analyze_profiles,
+ profile_id=profile_id_hint,
+ logical_model_id=logical_model_id,
+ )
+except KeyError:
+ raise HTTPException(status_code=422, detail="Unknown analyze profile") from None
+except ProfilesConfigurationError as e:
+ raise HTTPException(status_code=422, detail=str(e)) from e
+
+analyzer = Analyzer(model=api_model, sdk_env_overlay=instructions.inject)
+await analyzer.analyze_trial(trial_dir)
+```
+
+`**create_app_from_env**` augmentation:
+
+```python
+def create_app_from_env():
+ ...
+ ap_path = os.environ.get("HARBOR_ANALYZE_PROFILES")
+ ap = Path(ap_path).expanduser() if ap_path else None
+ return create_app(Path(folder), mode=mode, analyze_profiles_file=ap)
+```
+
+**CLI** Typer:**
+
+```python
+analyze_profiles: Annotated[
+ Path | None,
+ Option("--analyze-profiles", help="TOML file listing analyze profiles (non-secret metadata)"),
+] = None,
+...
+if analyze_profiles:
+ os.environ["HARBOR_ANALYZE_PROFILES"] = str(analyze_profiles.resolve())
+...
+app = create_app(..., analyze_profiles_file=analyze_profiles)
+```
+
+_Dev mode duplication:_ **Also** assign env var before **`uvicorn.run`** so reload child inherits path.
+
+**TestClient:**
+
+```python
+from fastapi.testclient import TestClient
+from pathlib import Path
+from harbor.viewer.server import create_app
+
+def test_analyze_profiles_endpoint_builtin():
+ app = create_app(Path("."), mode="tasks", analyze_profiles_file=None)
+ resp = TestClient(app).get("/api/analyze/profiles")
+ assert resp.status_code == 200
+ ids = [p["id"] for p in resp.json()["profiles"]]
+ assert "anthropic" in ids
+```
+
+(Use temp jobs folder if `.` violates scanner assumptions — **`tmp_path`** with minimal structure or monkeypatch scanners — simplest: **`mode='tasks'`** so job routes absent but **`/api/analyze/profiles`** still reachable — confirm `tasks` branch does not early-return before route registration.)
+
+**Inspect `server.py`:** If **`mode=='tasks'`** excludes job endpoints, **`GET /api/analyze/profiles`** must be declared **outside** jobs-only guards — attach near health/config.
+
+Tasks:
+
+- [ ] Implement + **`TestClient`** test file.
+- [ ] **`uv run pytest tests/unit/viewer/test_analyze_profiles_route.py tests/unit/analyze/`**
+- [ ] **`uv run ty check`** + **`ruff`**.
+- [ ] Commit.
+
+---
+
+### Task 5: Viewer SPA cascading selects + API wrappers
+
+**Files:**
+- Modify: `apps/viewer/app/lib/api.ts`
+- Modify: `apps/viewer/app/routes/trial.tsx`
+- Modify: `apps/viewer/app/routes/job.tsx`
+
+TypeScript additions:
+
+```ts
+export interface AnalyzeProfileModelRow {
+ id: string;
+ display_name: string;
+ api_model: string;
+}
+
+export interface AnalyzeProfileRow {
+ id: string;
+ label: string;
+ default_model: string;
+ models: AnalyzeProfileModelRow[];
+ api_key_env: string;
+ base_url_env?: string;
+}
+
+export async function fetchAnalyzeProfiles(): Promise<{ profiles: AnalyzeProfileRow[] }> {
+ const response = await fetch(`${API_BASE}/api/analyze/profiles`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch analyze profiles: ${response.statusText}`);
+ }
+ return response.json();
+}
+```
+
+Mutation bodies:
+
+```ts
+export async function summarizeTrial(jobName: string, trialName: string, req: SummarizeTrialRequest) ...
+
+export async function summarizeJob(jobName: string, req: SummarizeJobRequest) ...
+
+type SummarizeTrialRequest = {
+ model?: string; // omit when profiles mode
+ profile_id?: string;
+ model_id?: string;
+};
+
+type SummarizeJobRequest = {
+ model?: string;
+ n_concurrent: number;
+ only_failed: boolean;
+ profile_id?: string;
+ model_id?: string;
+};
+```
+
+**UI logic:**
+
+```tsx
+const { data: profs } = useQuery({ queryKey: ["analyze-profiles"], queryFn: fetchAnalyzeProfiles, retry: false });
+const degraded = Boolean(profs == null); // react-query exposes `isError` — use fallback when error
+```
+
+- When **`!error`**, render two ``: profile triggers default model (`useEffect` syncing `selectedModelId` when profile changes).
+
+- Mutation passes `{ profile_id, model_id }` (omit **`model`** to avoid divergence) — server treats missing **`legacy_model`** when **`model_id` present.
+
+- On error fallback, keep **`Select`** with haiku/sonnet/opus sending `{ model:"haiku" }` ONLY (omit profile fields) preserving old behavior.
+
+- [ ] `bun run typecheck`
+- [ ] Smoke `harbor view ./jobs --dev` flow (manual checklist)
+- [ ] Commit.
+
+---
+
+### Task 6: Documentation polish
+
+**Files:**
+- Modify: `CLAUDE.md` (Harbor repo root Operations / viewer section stub)
+- Modify: `apps/viewer/CLAUDE.md`
+
+Bullets explaining `HARBOR_ANALYZE_PROFILES`, **`--analyze-profiles`**, example path, **`GET`** shape, **`422`** env misses.
+
+- [ ] Commit `docs(viewer): document analyze profiles env + API`.
+
+---
+
+## Plan self-review (spec alignment)
+
+**Spec coverage:**
+- ✅ **§5 Profiles path**: `analyze_profiles_file` + env + `--analyze-profiles` + dev env injection.
+- ✅ **§7 `GET /api/analyze/profiles`**: `profiles_for_public_api`.
+- ✅ **§7 summarize extensions**: pydantic optional fields + resolver.
+- ✅ **§8 Analyzer propagation**: ctor overlay + **`query_llm`** path.
+- ✅ **Concurrency / no global mutation**: Task 2 test + never assign `os.environ` in summarize path.
+
+**Placeholder scan:** No `TBD` tokens left unresolved in tasks.
+
+**Type consistency:**
+- Resolver returns **`AnalyzeProfilesDocument`** + **`ProfilesConfigurationError`** consistently.
+- **HTTP 422** string detail matches **`ProfilesConfigurationError`** message surfaced to SPA.
+
+---
+
+## Execution handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-05-17-multi-provider-analyze.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — Dispatch a fresh subagent per Task, pause for review between tasks.
+
+**2. Inline Execution** — Execute tasks sequentially in one session using executing-plans with checkpoints after Tasks 2 and 4 (SDK/browser boundaries).
+
+Which approach?
diff --git a/docs/superpowers/plans/2026-05-19-job-analyze-aggregate-fallback.md b/docs/superpowers/plans/2026-05-19-job-analyze-aggregate-fallback.md
new file mode 100644
index 00000000000..cf67014d9ca
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-job-analyze-aggregate-fallback.md
@@ -0,0 +1,800 @@
+# Job Analyze Aggregate Transport Fallback Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Fix job-level `harbor analyze` / Viewer summarize failures on large jobs by transporting the full aggregation prompt via argv → SDK stream-json stdin → agent Read file, without changing model-visible content.
+
+**Architecture:** Add `AggregateTransportError` and transport helpers in `harbor.analyze`. Refactor `backend.py` so `query_llm(prompt, work_dir=job_dir)` proactively skips argv when `len(prompt.encode("utf-8")) > 120 * 1024`, then falls back on argv-length errors. Map the exception to HTTP 422 in `summarize_job`. Unit tests mock `query` / `query_agent` — no 282-trial integration fixture.
+
+**Tech Stack:** Python 3.12+, Claude Agent SDK (`query`, `ClaudeAgentOptions`), FastAPI, pytest (`@pytest.mark.unit`, `@pytest.mark.asyncio`).
+
+**Spec:** [2026-05-19-job-analyze-aggregate-fallback-design.md](../specs/2026-05-19-job-analyze-aggregate-fallback-design.md)
+
+---
+
+## File map (ownership)
+
+| File | Responsibility |
+|------|----------------|
+| **`src/harbor/analyze/errors.py`** (new) | `AggregateTransportError` with `to_dict()` for FastAPI `detail` |
+| **`src/harbor/analyze/backend.py`** (modify) | Constant `_AGGREGATE_ARGV_PROMPT_MAX_BYTES`; `_run_claude_query`; `_prompt_as_stream`; `_is_argv_transport_error`; `query_llm` fallback chain; `query_agent` delegates to `_run_claude_query` |
+| **`src/harbor/analyze/analyzer.py`** (modify) | `_aggregate` passes `work_dir=job_dir` into `query_llm` |
+| **`src/harbor/viewer/server.py`** (modify) | `except AggregateTransportError` → `HTTPException(422, detail=e.to_dict())` |
+| **`tests/unit/analyze/test_aggregate_transport_error.py`** (new) | Exception `to_dict()` shape |
+| **`tests/unit/analyze/test_query_llm_fallback.py`** (new) | Threshold routing, fallback chain, temp file cleanup |
+| **`tests/unit/viewer/test_summarize_job_aggregate_error.py`** (new) | `POST .../summarize` returns 422 + structured body |
+| **`tests/unit/cli/analyze/test_analyze.py`** (modify) | `mock_query_llm` accepts `work_dir` kwarg |
+
+---
+
+## Spec coverage checklist
+
+| Spec § | Task |
+|--------|------|
+| §5 proactive stdin when > threshold | Task 4 |
+| §5 argv failure → stdin → Read | Task 4 |
+| §5.1 stream-json stdin | Task 3–4 |
+| §5.2 timestamped temp file, cleanup | Task 4 |
+| §6 constant + comment | Task 4 |
+| §7 `AggregateTransportError` + 422 | Task 1, 5 |
+| §8 analyzer `work_dir` | Task 6 |
+| §9 tests | Tasks 1–5, 7 |
+
+---
+
+### Task 1: `AggregateTransportError`
+
+**Files:**
+- Create: `src/harbor/analyze/errors.py`
+- Create: `tests/unit/analyze/test_aggregate_transport_error.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/unit/analyze/test_aggregate_transport_error.py
+import pytest
+
+from harbor.analyze.errors import AggregateTransportError
+
+
+@pytest.mark.unit
+def test_to_dict_includes_required_fields():
+ err = AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=530_432,
+ attempts=["stdin", "agent_read"],
+ last_error="ProcessError: CLI exited",
+ prompt_file=".harbor-aggregate-prompt-1716123456789.txt",
+ )
+ d = err.to_dict()
+ assert d == {
+ "reason": "job_aggregate_failed",
+ "prompt_bytes": 530_432,
+ "attempts": ["stdin", "agent_read"],
+ "last_error": "ProcessError: CLI exited",
+ "prompt_file": ".harbor-aggregate-prompt-1716123456789.txt",
+ }
+
+
+@pytest.mark.unit
+def test_to_dict_omits_none_prompt_file():
+ err = AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=100,
+ attempts=["argv"],
+ last_error="OSError: [Errno 7]",
+ prompt_file=None,
+ )
+ assert err.to_dict()["prompt_file"] is None
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest tests/unit/analyze/test_aggregate_transport_error.py -v`
+Expected: FAIL — `ModuleNotFoundError: harbor.analyze.errors`
+
+- [ ] **Step 3: Implement exception**
+
+```python
+# src/harbor/analyze/errors.py
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class AggregateTransportError(Exception):
+ """All job-aggregate LLM transport attempts failed."""
+
+ reason: str
+ prompt_bytes: int
+ attempts: list[str]
+ last_error: str | None
+ prompt_file: str | None
+
+ def to_dict(self) -> dict[str, object]:
+ return {
+ "reason": self.reason,
+ "prompt_bytes": self.prompt_bytes,
+ "attempts": list(self.attempts),
+ "last_error": self.last_error,
+ "prompt_file": self.prompt_file,
+ }
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest tests/unit/analyze/test_aggregate_transport_error.py -v`
+Expected: PASS (2 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/analyze/errors.py tests/unit/analyze/test_aggregate_transport_error.py
+git commit -m "feat(analyze): add AggregateTransportError for job aggregate failures"
+```
+
+---
+
+### Task 2: Transport helpers and argv error classifier
+
+**Files:**
+- Modify: `src/harbor/analyze/backend.py`
+- Create: `tests/unit/analyze/test_query_llm_fallback.py` (helpers section only first)
+
+- [ ] **Step 1: Write failing tests for helpers**
+
+Append to `tests/unit/analyze/test_query_llm_fallback.py`:
+
+```python
+import pytest
+
+from harbor.analyze.backend import (
+ _AGGREGATE_ARGV_PROMPT_MAX_BYTES,
+ _is_argv_transport_error,
+ _prompt_byte_length,
+)
+
+
+@pytest.mark.unit
+def test_prompt_byte_length_utf8():
+ assert _prompt_byte_length("café") == 5
+
+
+@pytest.mark.unit
+def test_is_argv_transport_error_errno_7():
+ assert _is_argv_transport_error(OSError(7, "Argument list too long"))
+
+
+@pytest.mark.unit
+def test_is_argv_transport_error_message():
+ assert _is_argv_transport_error(RuntimeError("Argument list too long"))
+
+
+@pytest.mark.unit
+def test_is_argv_transport_error_other():
+ assert not _is_argv_transport_error(RuntimeError("connection reset"))
+
+
+@pytest.mark.unit
+def test_threshold_is_120_kib():
+ assert _AGGREGATE_ARGV_PROMPT_MAX_BYTES == 120 * 1024
+```
+
+- [ ] **Step 2: Run tests — expect FAIL**
+
+Run: `uv run pytest tests/unit/analyze/test_query_llm_fallback.py -v -k "prompt_byte or argv_transport or threshold"`
+Expected: FAIL — import errors
+
+- [ ] **Step 3: Add helpers at top of `backend.py` (after imports)**
+
+Add imports: `import time` and `from collections.abc import AsyncIterable` and `from pathlib import Path`.
+
+```python
+# Linux passes the full prompt as a single argv element after `--print --`.
+# Per-argument limit is ~128 KiB (MAX_ARG_STRLEN); oversize raises Errno 7 (E2BIG).
+# Leave headroom for CLI flags, model name, and env wrapper overhead.
+_AGGREGATE_ARGV_PROMPT_MAX_BYTES = 120 * 1024
+
+
+def _prompt_byte_length(prompt: str) -> int:
+ return len(prompt.encode("utf-8"))
+
+
+def _is_argv_transport_error(exc: BaseException) -> bool:
+ if isinstance(exc, OSError) and getattr(exc, "errno", None) == 7:
+ return True
+ msg = str(exc).lower()
+ return "argument list too long" in msg
+
+
+async def _prompt_as_stream(full_prompt: str):
+ yield {
+ "type": "user",
+ "message": {"role": "user", "content": full_prompt},
+ }
+
+
+def _write_aggregate_prompt_file(work_dir: Path, content: str) -> Path:
+ path = work_dir / f".harbor-aggregate-prompt-{int(time.time() * 1000)}.txt"
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+_READ_AGGREGATE_PROMPT_TEMPLATE = (
+ "Read the file at {path} using the Read tool. "
+ "It contains the complete job aggregation prompt (trial summaries and instructions). "
+ "Follow those instructions and produce the job-level summary as plain text."
+)
+```
+
+- [ ] **Step 4: Run helper tests — expect PASS**
+
+Run: `uv run pytest tests/unit/analyze/test_query_llm_fallback.py -v -k "prompt_byte or argv_transport or threshold"`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/analyze/backend.py tests/unit/analyze/test_query_llm_fallback.py
+git commit -m "feat(analyze): add aggregate prompt transport helpers"
+```
+
+---
+
+### Task 3: Refactor `query_agent` → internal `_run_claude_query`
+
+**Files:**
+- Modify: `src/harbor/analyze/backend.py`
+- Modify: `tests/unit/cli/analyze/test_backend.py` (should still pass — patch target remains `harbor.analyze.backend.query`)
+
+Extract the body of `query_agent` into `_run_claude_query` accepting `prompt: str | AsyncIterable[dict[str, Any]]`.
+
+- [ ] **Step 1: Add failing test — stream prompt uses non-string `query` prompt**
+
+Add to `tests/unit/analyze/test_query_llm_fallback.py`:
+
+```python
+from collections.abc import AsyncIterable
+from unittest.mock import patch
+
+import pytest
+
+from harbor.analyze.backend import _prompt_as_stream, _run_claude_query
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_run_claude_query_accepts_async_iterable_prompt(monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ captured: dict[str, object] = {}
+
+ async def fake_query(*, prompt, options):
+ captured["prompt_is_str"] = isinstance(prompt, str)
+ captured["prompt_type"] = type(prompt).__name__
+
+ async def _gen():
+ if False:
+ yield # pragma: no cover
+
+ return _gen()
+
+ with patch("harbor.analyze.backend.query", side_effect=fake_query):
+ await _run_claude_query(
+ prompt=_prompt_as_stream("x" * 200_000),
+ model="haiku",
+ cwd="/tmp",
+ tools=[],
+ output_schema=None,
+ )
+
+ assert captured["prompt_is_str"] is False
+```
+
+- [ ] **Step 2: Run test — expect FAIL**
+
+Run: `uv run pytest tests/unit/analyze/test_query_llm_fallback.py::test_run_claude_query_accepts_async_iterable_prompt -v`
+
+- [ ] **Step 3: Refactor `backend.py`**
+
+Rename the core loop to:
+
+```python
+async def _run_claude_query(
+ prompt: str | AsyncIterable[dict[str, Any]],
+ *,
+ model: str,
+ cwd: str,
+ tools: list[str] | None,
+ add_dirs: list[str] | None,
+ output_schema: dict[str, Any] | None,
+ verbose: bool,
+ sdk_env: dict[str, str] | None,
+) -> str | dict[str, Any]:
+ # Move existing query_agent body here; `async for message in query(prompt=prompt, options=options)`
+ ...
+```
+
+Change `query_agent` to:
+
+```python
+async def query_agent(
+ prompt: str,
+ model: str,
+ cwd: str,
+ ...
+) -> str | dict[str, Any]:
+ return await _run_claude_query(
+ prompt,
+ model=model,
+ cwd=cwd,
+ tools=tools,
+ add_dirs=add_dirs,
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ )
+```
+
+For verbose logging when `prompt` is not a `str`, log `"(stream prompt, N bytes)"` using a parameter or skip printing the full body.
+
+- [ ] **Step 4: Run existing + new backend tests**
+
+Run: `uv run pytest tests/unit/cli/analyze/test_backend.py tests/unit/analyze/test_query_llm_fallback.py::test_run_claude_query_accepts_async_iterable_prompt -v`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/analyze/backend.py tests/unit/analyze/test_query_llm_fallback.py
+git commit -m "refactor(analyze): extract _run_claude_query for str and stream prompts"
+```
+
+---
+
+### Task 4: `query_llm` fallback chain
+
+**Files:**
+- Modify: `src/harbor/analyze/backend.py`
+- Modify: `tests/unit/analyze/test_query_llm_fallback.py`
+
+- [ ] **Step 1: Write failing tests for routing**
+
+Add to `tests/unit/analyze/test_query_llm_fallback.py`:
+
+```python
+from pathlib import Path
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from harbor.analyze.backend import (
+ _AGGREGATE_ARGV_PROMPT_MAX_BYTES,
+ query_llm,
+)
+from harbor.analyze.errors import AggregateTransportError
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_small_prompt_uses_argv_only(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ prompt = "small"
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ new_callable=AsyncMock,
+ return_value="summary",
+ ) as mock_run:
+ result = await query_llm(
+ prompt=prompt,
+ model="haiku",
+ work_dir=work_dir,
+ )
+
+ assert result == "summary"
+ mock_run.assert_awaited_once()
+ assert mock_run.await_args.kwargs["prompt"] == prompt
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_large_prompt_skips_argv(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ prompt = "x" * (_AGGREGATE_ARGV_PROMPT_MAX_BYTES + 1)
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ new_callable=AsyncMock,
+ return_value="summary",
+ ) as mock_run:
+ await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ mock_run.assert_awaited_once()
+ sent = mock_run.await_args.kwargs["prompt"]
+ assert not isinstance(sent, str)
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_argv_failure_retries_stdin(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+ prompt = "small"
+
+ async def side_effect(*, prompt, **kwargs):
+ if isinstance(prompt, str):
+ raise OSError(7, "Argument list too long")
+ return "ok"
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ side_effect=side_effect,
+ ):
+ result = await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ assert result == "ok"
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_all_fail_raises_aggregate_error(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+ prompt = "x" * (_AGGREGATE_ARGV_PROMPT_MAX_BYTES + 1)
+
+ with (
+ patch(
+ "harbor.analyze.backend._run_claude_query",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("stdin failed"),
+ ),
+ patch(
+ "harbor.analyze.backend.query_agent",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("read failed"),
+ ),
+ ):
+ with pytest.raises(AggregateTransportError) as exc_info:
+ await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ err = exc_info.value
+ assert err.reason == "job_aggregate_failed"
+ assert err.prompt_bytes == len(prompt.encode("utf-8"))
+ assert err.attempts == ["stdin", "agent_read"]
+ assert err.prompt_file is not None
+ assert (work_dir / err.prompt_file).exists()
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_read_success_deletes_temp_file(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+ prompt = "x" * (_AGGREGATE_ARGV_PROMPT_MAX_BYTES + 1)
+
+ async def fail_stdin(**kwargs):
+ raise RuntimeError("stdin failed")
+
+ async def ok_read(**kwargs):
+ return "job summary"
+
+ with (
+ patch("harbor.analyze.backend._run_claude_query", side_effect=fail_stdin),
+ patch("harbor.analyze.backend.query_agent", side_effect=ok_read),
+ ):
+ result = await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ assert result == "job summary"
+ assert list(work_dir.glob(".harbor-aggregate-prompt-*.txt")) == []
+```
+
+- [ ] **Step 2: Run tests — expect FAIL**
+
+Run: `uv run pytest tests/unit/analyze/test_query_llm_fallback.py -v`
+
+- [ ] **Step 3: Implement `query_llm`**
+
+Replace `query_llm` body:
+
+```python
+async def query_llm(
+ prompt: str,
+ model: str,
+ *,
+ work_dir: Path,
+ output_schema: dict[str, Any] | None = None,
+ verbose: bool = False,
+ sdk_env: dict[str, str] | None = None,
+) -> str | dict[str, Any]:
+ prompt_bytes = _prompt_byte_length(prompt)
+ attempts: list[str] = []
+ last_error: str | None = None
+ prompt_file: str | None = None
+
+ async def _argv() -> str | dict[str, Any]:
+ return await _run_claude_query(
+ prompt,
+ model=model,
+ cwd=".",
+ tools=[],
+ add_dirs=None,
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ )
+
+ async def _stdin() -> str | dict[str, Any]:
+ return await _run_claude_query(
+ _prompt_as_stream(prompt),
+ model=model,
+ cwd=".",
+ tools=[],
+ add_dirs=None,
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ )
+
+ async def _agent_read() -> str | dict[str, Any]:
+ nonlocal prompt_file
+ path = _write_aggregate_prompt_file(work_dir, prompt)
+ prompt_file = path.name
+ short = _READ_AGGREGATE_PROMPT_TEMPLATE.format(path=path.resolve())
+ try:
+ return await query_agent(
+ prompt=short,
+ model=model,
+ cwd=str(work_dir),
+ tools=["Read"],
+ add_dirs=[str(work_dir)],
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ )
+ finally:
+ # Success path: delete in outer handler after return; on failure keep file.
+ pass
+
+ # Build attempt list
+ from collections.abc import Awaitable, Callable
+
+ steps: list[tuple[str, Callable[[], Awaitable[str | dict[str, Any]]]]] = []
+ if prompt_bytes <= _AGGREGATE_ARGV_PROMPT_MAX_BYTES:
+ steps.append(("argv", _argv))
+ steps.append(("stdin", _stdin))
+ steps.append(("agent_read", _agent_read))
+
+ for name, fn in steps:
+ attempts.append(name)
+ try:
+ result = await fn()
+ if name == "agent_read":
+ # success — remove temp file
+ p = work_dir / prompt_file if prompt_file else None
+ if p and p.exists():
+ p.unlink(missing_ok=True)
+ prompt_file = None
+ return result
+ except Exception as e:
+ last_error = f"{type(e).__name__}: {e}"
+ if name == "argv" and not _is_argv_transport_error(e):
+ raise
+ continue
+
+ raise AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=prompt_bytes,
+ attempts=attempts,
+ last_error=last_error,
+ prompt_file=prompt_file,
+ )
+```
+
+**Implementation note:** Adjust the success cleanup so `agent_read` deletes the file in a `try/finally` only when `query_agent` returns without raising (use a local `read_path` variable). Do not delete on failure.
+
+- [ ] **Step 4: Run fallback tests — expect PASS**
+
+Run: `uv run pytest tests/unit/analyze/test_query_llm_fallback.py -v`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/analyze/backend.py tests/unit/analyze/test_query_llm_fallback.py
+git commit -m "feat(analyze): add query_llm transport fallback for large job aggregates"
+```
+
+---
+
+### Task 5: Viewer `summarize_job` → 422
+
+**Files:**
+- Modify: `src/harbor/viewer/server.py`
+- Create: `tests/unit/viewer/test_summarize_job_aggregate_error.py`
+
+- [ ] **Step 1: Write failing test**
+
+```python
+# tests/unit/viewer/test_summarize_job_aggregate_error.py
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from harbor.analyze.errors import AggregateTransportError
+from harbor.viewer.server import create_app
+
+
+@pytest.mark.unit
+def test_summarize_job_aggregate_transport_error_returns_422(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "dummy-for-test")
+
+ jobs_root = tmp_path
+ job_dir = jobs_root / "my-job"
+ job_dir.mkdir()
+ (job_dir / "trial__a__0").mkdir()
+
+ app = create_app(jobs_root, mode="jobs", analyze_profiles_file=None)
+ client = TestClient(app)
+
+ err = AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=500_000,
+ attempts=["stdin", "agent_read"],
+ last_error="RuntimeError: fail",
+ prompt_file=".harbor-aggregate-prompt-1.txt",
+ )
+
+ with patch(
+ "harbor.analyze.analyzer.Analyzer.analyze_job",
+ new_callable=AsyncMock,
+ side_effect=err,
+ ):
+ resp = client.post(
+ "/api/jobs/my-job/summarize",
+ json={"model": "haiku", "overwrite": True},
+ )
+
+ assert resp.status_code == 422
+ detail = resp.json()["detail"]
+ assert detail["reason"] == "job_aggregate_failed"
+ assert detail["prompt_bytes"] == 500_000
+ assert detail["attempts"] == ["stdin", "agent_read"]
+```
+
+- [ ] **Step 2: Run test — expect FAIL** (no handler yet)
+
+Run: `uv run pytest tests/unit/viewer/test_summarize_job_aggregate_error.py -v`
+
+- [ ] **Step 3: Wire handler in `summarize_job`**
+
+After existing imports at top of handler block or file level:
+
+```python
+from harbor.analyze.errors import AggregateTransportError
+```
+
+Inside `try` around `analyzer.analyze_job`:
+
+```python
+ except AggregateTransportError as e:
+ raise HTTPException(status_code=422, detail=e.to_dict()) from e
+```
+
+Place **before** bare re-raise / after `ValueError` handler.
+
+- [ ] **Step 4: Run test — expect PASS**
+
+Run: `uv run pytest tests/unit/viewer/test_summarize_job_aggregate_error.py -v`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/viewer/server.py tests/unit/viewer/test_summarize_job_aggregate_error.py
+git commit -m "feat(viewer): return 422 detail when job aggregate transport fails"
+```
+
+---
+
+### Task 6: Wire `analyzer._aggregate`
+
+**Files:**
+- Modify: `src/harbor/analyze/analyzer.py`
+- Modify: `tests/unit/cli/analyze/test_analyze.py`
+
+- [ ] **Step 1: Update `_aggregate` call**
+
+```python
+ job_summary = await query_llm(
+ prompt=prompt,
+ model=self._config.model,
+ work_dir=job_dir,
+ verbose=self._config.verbose,
+ sdk_env=self._sdk_env_overlay,
+ )
+```
+
+- [ ] **Step 2: Fix `mock_query_llm` in `test_analyze.py`**
+
+```python
+ async def mock_query_llm(
+ prompt, model, work_dir, output_schema=None, verbose=False, sdk_env=None
+ ):
+ ...
+```
+
+- [ ] **Step 3: Run analyze unit tests**
+
+Run: `uv run pytest tests/unit/cli/analyze/test_analyze.py tests/unit/analyze/ -v`
+Expected: PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/harbor/analyze/analyzer.py tests/unit/cli/analyze/test_analyze.py
+git commit -m "feat(analyze): pass job_dir as work_dir for aggregate query_llm"
+```
+
+---
+
+### Task 7: Lint, typecheck, full unit slice
+
+**Files:** (verification only)
+
+- [ ] **Step 1: Format and lint**
+
+```bash
+uv run ruff check --fix src/harbor/analyze/ src/harbor/viewer/server.py tests/unit/analyze/ tests/unit/viewer/test_summarize_job_aggregate_error.py
+uv run ruff format src/harbor/analyze/ src/harbor/viewer/server.py tests/unit/analyze/ tests/unit/viewer/test_summarize_job_aggregate_error.py
+```
+
+- [ ] **Step 2: Typecheck**
+
+```bash
+uv run ty check
+```
+
+Fix any issues in modified files (e.g. `query` prompt union typing).
+
+- [ ] **Step 3: Run unit tests**
+
+```bash
+uv run pytest tests/unit/analyze/ tests/unit/cli/analyze/ tests/unit/viewer/test_summarize_job_aggregate_error.py -v --tb=short
+```
+
+Expected: all PASS
+
+- [ ] **Step 4: Commit** (only if lint/format produced changes)
+
+```bash
+git add -u
+git commit -m "chore: ruff/ty for aggregate transport fallback"
+```
+
+---
+
+## Plan self-review (completed)
+
+- **Spec coverage:** All §5–§9 requirements mapped in checklist; no gaps.
+- **Placeholder scan:** No TBD/TODO steps; each test includes runnable code.
+- **Type consistency:** `work_dir: Path` on `query_llm`; `AggregateTransportError.to_dict()` keys match Viewer test assertions.
+- **Risk:** Task 5 viewer test may need fixture tweaks after reading `create_app` / trial directory layout — engineer should copy `test_job_status.py` job tree pattern if 404 occurs.
+
+---
+
+## Execution handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-05-19-job-analyze-aggregate-fallback.md`.
+
+**Two execution options:**
+
+1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration
+2. **Inline Execution** — execute tasks in this session via executing-plans with checkpoints
+
+Which approach do you want?
diff --git a/docs/superpowers/plans/2026-05-21-bitfun-cli-harbor-integration-fixes.md b/docs/superpowers/plans/2026-05-21-bitfun-cli-harbor-integration-fixes.md
new file mode 100644
index 00000000000..9db829a4be2
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-21-bitfun-cli-harbor-integration-fixes.md
@@ -0,0 +1,634 @@
+# bitfun-cli Harbor integration debugging fixes — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make failed `bitfun-cli` trials debuggable by ensuring `/logs/agent` exists before `tee`, returning bitfun-cli’s true exit code (not `tee`’s), persisting full exec output to `trial/agent/bitfun.txt` on failure, merging `config.agent.env` into run/cp-back env, and logging cp-back gaps on the host.
+
+**Architecture:** Add module-level size constants and helpers on `BitfunCli`, extract `_build_run_shell()` for the main pipeline (`mkdir`, `PIPESTATUS`, `exit $rc`), override `_exec()` to call `environment.exec` once and persist merged stdout/stderr before raising `NonZeroAgentExitCodeError`, and add `_log_cp_back_gaps()` after cp-back in `run()`’s `finally`. No `base.py` changes.
+
+**Tech stack:** Python 3.12+, `BitfunCli` / `BaseInstalledAgent`, `pytest` + `AsyncMock`, `uv run ruff` / `ty check`.
+
+**Spec reference:** `docs/superpowers/specs/2026-05-21-bitfun-cli-harbor-integration-fixes-design.md`
+
+---
+
+## File map (modify only)
+
+| File | Responsibility |
+|------|----------------|
+| `src/harbor/agents/installed/bitfun_cli.py` | Constants, `_format_failure_log_text`, `_persist_failure_output`, `_build_run_shell`, `_env_for_run` update, `_exec` override, `_log_cp_back_gaps`, `run()` refactor |
+| `tests/unit/agents/installed/test_bitfun_cli.py` | New `TestRunShell`, `TestEnvForRun`, `TestExecFailurePersist`, update `TestBitfunCliAgent` / `TestRunCpBackFinally` expectations |
+
+---
+
+### Task 1: Failure log formatting helpers
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py` (after `_AGENT_LOG`, before `_STDOUT_TOKEN_STATS_RE`)
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add a new class near the top of the test file (after imports):
+
+```python
+class TestFailureLogFormatting:
+ def test_format_failure_log_returns_full_text_under_limit(self):
+ from harbor.agents.installed.bitfun_cli import _format_failure_log_text
+
+ text = "x" * 1000
+ assert _format_failure_log_text(text) == text
+
+ def test_format_failure_log_head_tail_over_limit(self):
+ from harbor.agents.installed.bitfun_cli import (
+ _FAILURE_LOG_HEAD_BYTES,
+ _FAILURE_LOG_MAX_BYTES,
+ _FAILURE_LOG_TAIL_BYTES,
+ _FAILURE_LOG_TRUNC_MARKER,
+ _format_failure_log_text,
+ )
+
+ text = "a" * (_FAILURE_LOG_MAX_BYTES + 1) + "TAIL_MARKER"
+ out = _format_failure_log_text(text)
+ assert out.startswith("a" * _FAILURE_LOG_HEAD_BYTES)
+ assert _FAILURE_LOG_TRUNC_MARKER in out
+ assert out.endswith("TAIL_MARKER")
+ assert len(out) < len(text)
+ assert len(out) == (
+ _FAILURE_LOG_HEAD_BYTES
+ + len(_FAILURE_LOG_TRUNC_MARKER)
+ + _FAILURE_LOG_TAIL_BYTES
+ )
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestFailureLogFormatting -v`
+
+Expected: FAIL — `ImportError` or `_format_failure_log_text` not defined
+
+- [ ] **Step 3: Add constants and helper to `bitfun_cli.py`**
+
+Insert after `_AGENT_LOG = "/logs/agent/bitfun.txt"`:
+
+```python
+_FAILURE_LOG_MAX_BYTES = 512 * 1024
+_FAILURE_LOG_HEAD_BYTES = 8 * 1024
+_FAILURE_LOG_TAIL_BYTES = 32 * 1024
+_FAILURE_LOG_TRUNC_MARKER = "\n...[truncated for host log]...\n"
+
+
+def _format_failure_log_text(text: str) -> str:
+ if len(text) <= _FAILURE_LOG_MAX_BYTES:
+ return text
+ return (
+ text[:_FAILURE_LOG_HEAD_BYTES]
+ + _FAILURE_LOG_TRUNC_MARKER
+ + text[-_FAILURE_LOG_TAIL_BYTES:]
+ )
+```
+
+Add method on `BitfunCli` (after `populate_context_post_run` or before `_cp_back_command`):
+
+```python
+ def _persist_failure_output(self, stdout: str | None, stderr: str | None) -> None:
+ parts: list[str] = []
+ if stdout:
+ parts.append(stdout)
+ if stderr:
+ if parts:
+ parts.append("\n--- stderr ---\n")
+ parts.append(stderr)
+ if not parts:
+ return
+ body = _format_failure_log_text("".join(parts))
+ path = self.logs_dir / "bitfun.txt"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(body, errors="replace")
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestFailureLogFormatting -v`
+
+Expected: PASS (2 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): add failure log head/tail formatting helper"
+```
+
+---
+
+### Task 2: `_build_run_shell()` with mkdir and PIPESTATUS
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py` (before `_cp_back_command`)
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+class TestBuildRunShell:
+ def test_includes_mkdir_agent_and_pipestatus(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/opt/bitfun-cli")
+ shell = agent._build_run_shell("Fix the bug")
+ assert "mkdir -p /logs/agent" in shell
+ assert "rc=${PIPESTATUS[0]}" in shell
+ assert "exit $rc" in shell
+ assert "/opt/bitfun-cli" in shell
+ assert " exec " in shell
+ assert "tee /logs/agent/bitfun.txt" in shell
+ assert "stdbuf -oL" in shell
+
+ def test_includes_patch_parent_mkdir_when_patch_enabled(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, output_patch_path="/logs/agent/bitfun.patch")
+ shell = agent._build_run_shell("Hi")
+ assert 'PATCH_PATH="/logs/agent/bitfun.patch"' in shell or "PATCH_PATH='/logs/agent/bitfun.patch'" in shell
+ assert 'mkdir -p "$(dirname "$PATCH_PATH")"' in shell
+ assert "--output-patch" in shell
+
+ def test_omits_patch_when_disabled(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, output_patch_path=None)
+ shell = agent._build_run_shell("Hi")
+ assert "PATCH_PATH=" not in shell
+ assert "--output-patch" not in shell
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBuildRunShell -v`
+
+Expected: FAIL — `AttributeError: _build_run_shell`
+
+- [ ] **Step 3: Implement `_build_run_shell`**
+
+Add to `BitfunCli` (replace inline shell assembly that will move out of `run()`):
+
+```python
+ def _build_run_shell(self, instruction: str) -> str:
+ bp = shlex.quote(self._binary_path)
+ msg = shlex.quote(instruction)
+ agent_flag = shlex.quote(self._exec_agent)
+ patch_part = ""
+ patch_setup = ""
+ if self._output_patch_path:
+ patch_q = shlex.quote(self._output_patch_path)
+ patch_part = f" --output-patch {patch_q}"
+ patch_setup = (
+ f"PATCH_PATH={patch_q}\n"
+ 'mkdir -p "$(dirname "$PATCH_PATH")" 2>/dev/null || true\n'
+ )
+ return (
+ "set -o pipefail\n"
+ "mkdir -p /logs/agent\n"
+ f"{patch_setup}"
+ f"{bp} exec {msg} --agent {agent_flag}{patch_part} "
+ f"2>&1 | stdbuf -oL tee {_AGENT_LOG}\n"
+ "rc=${PIPESTATUS[0]}\n"
+ "exit $rc"
+ )
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBuildRunShell -v`
+
+Expected: PASS (3 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): build run shell with mkdir and PIPESTATUS exit"
+```
+
+---
+
+### Task 3: `_env_for_run()` merges `_extra_env`
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py` — `_env_for_run`
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+class TestEnvForRun:
+ def test_merges_extra_env(self, temp_dir):
+ agent = BitfunCli(
+ logs_dir=temp_dir,
+ extra_env={"XDG_CONFIG_HOME": "/testbed/.config", "CUSTOM": "1"},
+ )
+ env = agent._env_for_run()
+ assert env["XDG_CONFIG_HOME"] == "/testbed/.config"
+ assert env["CUSTOM"] == "1"
+
+ def test_still_forwards_bitfun_prefixed_host_env(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ with patch.dict(os.environ, {"BITFUN_DEBUG_LOG_PATH": "/tmp/x.log"}, clear=False):
+ env = agent._env_for_run()
+ assert env["BITFUN_DEBUG_LOG_PATH"] == "/tmp/x.log"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestEnvForRun -v`
+
+Expected: FAIL — `XDG_CONFIG_HOME` not in env dict
+
+- [ ] **Step 3: Update `_env_for_run`**
+
+Change method body to end with:
+
+```python
+ env.update(self._extra_env)
+ return env
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestEnvForRun -v`
+
+Expected: PASS (2 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "fix(bitfun-cli): merge config agent env into _env_for_run"
+```
+
+---
+
+### Task 4: Override `_exec()` to persist output on failure
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py` — add `async def _exec` on `BitfunCli`
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+**Important:** Do **not** call `super()._exec()` on failure — that would re-run the container command. Copy the success/failure branches from `BaseInstalledAgent._exec`, inserting `_persist_failure_output` before `raise`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+class TestExecFailurePersist:
+ @pytest.mark.asyncio
+ async def test_persists_full_stdout_on_nonzero_exit(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(
+ return_code=1,
+ stdout="line\n" * 50 + "FINAL_ERROR_LINE",
+ stderr="",
+ )
+ with pytest.raises(NonZeroAgentExitCodeError) as exc_info:
+ await agent.exec_as_agent(mock_env, command="true")
+ assert "FINAL_ERROR_LINE" in (temp_dir / "bitfun.txt").read_text()
+ assert "exit 1" in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_persists_head_tail_when_output_huge(self, temp_dir):
+ from harbor.agents.installed.bitfun_cli import (
+ _FAILURE_LOG_HEAD_BYTES,
+ _FAILURE_LOG_MAX_BYTES,
+ _FAILURE_LOG_TAIL_BYTES,
+ _FAILURE_LOG_TRUNC_MARKER,
+ )
+
+ agent = BitfunCli(logs_dir=temp_dir)
+ marker = "ENDMARKER"
+ payload = ("a" * (_FAILURE_LOG_MAX_BYTES + 1)) + marker
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(
+ return_code=2, stdout=payload, stderr=""
+ )
+ with pytest.raises(NonZeroAgentExitCodeError):
+ await agent.exec_as_agent(mock_env, command="true")
+ text = (temp_dir / "bitfun.txt").read_text()
+ assert text.startswith("a" * _FAILURE_LOG_HEAD_BYTES)
+ assert _FAILURE_LOG_TRUNC_MARKER in text
+ assert text.endswith(marker)
+ assert len(text) == (
+ _FAILURE_LOG_HEAD_BYTES
+ + len(_FAILURE_LOG_TRUNC_MARKER)
+ + _FAILURE_LOG_TAIL_BYTES
+ )
+
+ @pytest.mark.asyncio
+ async def test_success_does_not_write_bitfun_txt(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(
+ return_code=0, stdout="ok", stderr=""
+ )
+ await agent.exec_as_agent(mock_env, command="true")
+ assert not (temp_dir / "bitfun.txt").exists()
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestExecFailurePersist -v`
+
+Expected: FAIL — no `bitfun.txt` written (base `_exec` used)
+
+- [ ] **Step 3: Implement `BitfunCli._exec`**
+
+Add import at top of `bitfun_cli.py` if missing: `from typing import Any` already present.
+
+Add method on `BitfunCli` (mirror `base.py` lines 287–342, with persist hook):
+
+```python
+ async def _exec(
+ self,
+ environment: BaseEnvironment,
+ command: str,
+ user: str | int | None = None,
+ env: dict[str, str] | None = None,
+ cwd: str | None = None,
+ timeout_sec: int | None = None,
+ ) -> Any:
+ merged_env = env
+ if self._extra_env:
+ merged_env = dict(env) if env else {}
+ merged_env.update(self._extra_env)
+
+ self.logger.debug(
+ f"Running command: {command}",
+ extra={"user": str(user), "env": merged_env or {}},
+ )
+
+ result = await environment.exec(
+ command=f"set -o pipefail; {command}",
+ user=user,
+ env=merged_env,
+ cwd=cwd,
+ timeout_sec=timeout_sec,
+ )
+ if result.return_code != 0:
+ self._persist_failure_output(result.stdout, result.stderr)
+ self.logger.debug(
+ "Command failed",
+ extra={
+ "return_code": result.return_code,
+ "stdout": self._truncate_output(result.stdout),
+ "stderr": self._truncate_output(result.stderr),
+ },
+ )
+ raise NonZeroAgentExitCodeError(
+ f"Command failed (exit {result.return_code}): {command}\n"
+ f"stdout: {self._truncate_output(result.stdout)}\n"
+ f"stderr: {self._truncate_output(result.stderr)}"
+ )
+
+ self.logger.debug(
+ "Command outputs captured",
+ extra={
+ "stdout": self._truncate_output(result.stdout),
+ "stderr": self._truncate_output(result.stderr),
+ },
+ )
+ return result
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestExecFailurePersist -v`
+
+Expected: PASS (3 tests)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "fix(bitfun-cli): persist exec output to bitfun.txt on failure"
+```
+
+---
+
+### Task 5: Refactor `run()` and cp-back gap logging
+
+**Files:**
+
+- Modify: `src/harbor/agents/installed/bitfun_cli.py` — `run()`, add `_log_cp_back_gaps`
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py` — update existing run tests
+
+- [ ] **Step 1: Update failing expectations in existing run tests**
+
+In `TestBitfunCliAgent.test_run_uses_testbed_cwd_and_exec`, change assertions:
+
+```python
+ cmd = call_kw["command"]
+ assert "mkdir -p /logs/agent" in cmd
+ assert "${PIPESTATUS[0]}" in cmd
+ assert "exit $rc" in cmd
+ assert "/opt/bitfun-cli" in cmd
+ assert " exec " in cmd
+ assert "tee /logs/agent/bitfun.txt" in cmd
+ assert call_kw["env"]["XDG_CONFIG_HOME"] == "/testbed/.config" # only if you add extra_env to this test
+```
+
+In `TestBitfunCliAgent.test_run_forwards_bitfun_prefixed_env`, also assert cp-back call uses same env:
+
+```python
+ cp_env = mock_env.exec.call_args_list[1].kwargs["env"]
+ assert cp_env["BITFUN_DEBUG_LOG_PATH"] == "/tmp/x.log"
+```
+
+- [ ] **Step 2: Implement `run()` refactor and `_log_cp_back_gaps`**
+
+Replace `run()` body:
+
+```python
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=self._build_run_shell(instruction),
+ env=self._env_for_run(),
+ cwd="/testbed",
+ )
+ finally:
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=self._cp_back_command(),
+ env=self._env_for_run(),
+ )
+ self._log_cp_back_gaps()
+ except Exception as exc:
+ self.logger.debug(f"BitFun cp-back failed (non-fatal): {exc}")
+```
+
+Add helper:
+
+```python
+ def _log_cp_back_gaps(self) -> None:
+ cli_log = self.logs_dir / _BITFUN_DATA_SUBDIR / "cli.log"
+ if not cli_log.is_file():
+ self.logger.debug("BitFun cp-back: missing cli.log at %s", cli_log)
+ sessions_root = self.logs_dir / _BITFUN_DATA_SUBDIR / "sessions"
+ if not sessions_root.is_dir():
+ self.logger.debug(
+ "BitFun cp-back: missing sessions directory at %s",
+ sessions_root,
+ )
+ return
+ session_dirs = [p for p in sessions_root.iterdir() if p.is_dir()]
+ if not session_dirs:
+ self.logger.debug(
+ "BitFun cp-back: no session subdirectories under %s",
+ sessions_root,
+ )
+```
+
+- [ ] **Step 3: Add test for cp-back gap logging**
+
+```python
+ @pytest.mark.asyncio
+ async def test_log_cp_back_gaps_debug_when_artifacts_missing(self, temp_dir, caplog):
+ import logging
+
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ with caplog.at_level(logging.DEBUG):
+ await agent.run("hi", mock_env, AgentContext())
+ assert any("missing cli.log" in r.message for r in caplog.records)
+ assert any(
+ "missing sessions" in r.message or "no session subdirectories" in r.message
+ for r in caplog.records
+ )
+```
+
+- [ ] **Step 4: Run all bitfun unit tests**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v`
+
+Expected: PASS (all tests in file)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "fix(bitfun-cli): wire run shell builder and cp-back gap logging"
+```
+
+---
+
+### Task 6: Verify cp-back shell unchanged and env on both execs
+
+**Files:**
+
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Add explicit cp-back content test (if not already covered)**
+
+```python
+ @pytest.mark.asyncio
+ async def test_run_passes_extra_env_to_main_and_cp_back(self, temp_dir):
+ agent = BitfunCli(
+ logs_dir=temp_dir,
+ extra_env={"XDG_CONFIG_HOME": "/testbed/.config"},
+ )
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ for call in mock_env.exec.call_args_list:
+ assert call.kwargs["env"]["XDG_CONFIG_HOME"] == "/testbed/.config"
+```
+
+Existing `TestRunCpBackFinally` tests should still pass — confirm `cli.log`, `token_usage`, `cp -R` remain in cp-back command (no change to `_CP_BACK_COMMAND` unless spec required; it does not).
+
+- [ ] **Step 2: Run targeted tests**
+
+Run: `uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally tests/unit/agents/installed/test_bitfun_cli.py::TestEnvForRun -v`
+
+Expected: PASS
+
+- [ ] **Step 3: Commit** (only if Step 1 added new test file hunk)
+
+```bash
+git add tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "test(bitfun-cli): assert extra_env on main and cp-back exec"
+```
+
+---
+
+### Task 7: Lint, typecheck, full unit gate
+
+**Files:** (verification only)
+
+- [ ] **Step 1: Format and lint**
+
+```bash
+uv run ruff check --fix .
+uv run ruff format .
+```
+
+Expected: no errors
+
+- [ ] **Step 2: Typecheck**
+
+```bash
+uv run ty check
+```
+
+Expected: no errors in `bitfun_cli.py`
+
+- [ ] **Step 3: Run bitfun unit tests**
+
+```bash
+uv run pytest tests/unit/ -k bitfun -v
+```
+
+Expected: all selected tests PASS
+
+- [ ] **Step 4: Commit** (only if ruff/format changed files)
+
+```bash
+git add -u
+git commit -m "chore: ruff format bitfun-cli integration fixes"
+```
+
+---
+
+## Spec coverage checklist (self-review)
+
+| Spec requirement | Task |
+|------------------|------|
+| `mkdir -p /logs/agent` before tee | Task 2 `_build_run_shell`, Task 5 `run()` |
+| Patch parent `mkdir` when `output_patch_path` set | Task 2 |
+| `${PIPESTATUS[0]}` / `exit $rc` | Task 2 |
+| `_env_for_run()` merges `_extra_env` | Task 3 |
+| Persist failure output to `logs_dir/bitfun.txt` | Task 1, Task 4 |
+| Head 8 KiB + tail 32 KiB when > 512 KiB | Task 1 |
+| Other agents unchanged (`base.py` untouched) | Task 4 override only on `BitfunCli` |
+| cp-back shell preserved | Task 5 (no `_CP_BACK_COMMAND` edit) |
+| Host debug when cli.log/sessions missing | Task 5 `_log_cp_back_gaps` |
+| Unit tests per spec | Tasks 1–6 |
+| `ruff` / `ty` / `pytest -k bitfun` | Task 7 |
+
+## Manual acceptance (post-implementation)
+
+1. Delete `/logs/agent` inside a trial container, rerun — tee should succeed after `mkdir`.
+2. Force `bitfun-cli` exit 1 — `trial/agent/bitfun.txt` contains tail of output; `exception.txt` may still truncate.
+3. Failed trial with bitfun session files — `agent/bitfun/cli.log` or `agent/bitfun/sessions/` present when cp-back sources exist.
+
+---
+
+## Execution handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-05-21-bitfun-cli-harbor-integration-fixes.md`.
+
+**Two execution options:**
+
+1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks, fast iteration
+2. **Inline Execution** — run tasks in this session with executing-plans, batch checkpoints
+
+**Which approach do you want?**
diff --git a/docs/superpowers/plans/2026-05-31-bitfun-tps-harbor-view.md b/docs/superpowers/plans/2026-05-31-bitfun-tps-harbor-view.md
new file mode 100644
index 00000000000..e7de7d94b93
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-31-bitfun-tps-harbor-view.md
@@ -0,0 +1,688 @@
+# BitFun TPS Harbor View Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Preserve BitFun CLI LLM latency/TPS metrics in Harbor ATIF trajectories and show them in Harbor view.
+
+**Architecture:** Keep ATIF schema unchanged and store BitFun-specific TPS data in `Metrics.extra` and `FinalMetrics.extra`. The viewer reads only `trajectory.json`, using small formatting/type-guard helpers in the existing trial route to display summary and step-level TPS.
+
+**Tech Stack:** Python 3.12, Pydantic v2 trajectory models, pytest, TypeScript/React Router viewer, Ruff, ty.
+
+---
+
+## File Structure
+
+- Modify `src/harbor/agents/installed/bitfun_cli.py`
+ - Add small helpers for parsing latency, calculating TPS extra fields, and merging TPS coverage.
+ - Extend `_build_metrics_from_record`, `_merge_metrics`, and `_build_final_metrics`.
+- Modify `tests/unit/agents/installed/test_bitfun_cli.py`
+ - Add focused unit tests for valid latency, missing latency, zero latency, merged metrics, and root-only main-session final TPS.
+- Modify `apps/viewer/app/lib/types.ts`
+ - Add `extra?: Record | null` to `StepMetrics` and `FinalMetrics`.
+- Modify `apps/viewer/app/routes/trial.tsx`
+ - Add local type guards and format helpers for TPS/latency.
+ - Extend the Tokens card and expanded step metric line.
+
+## Task 1: Backend Step-Level TPS Metrics
+
+**Files:**
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Add failing tests for valid, missing, and zero latency**
+
+Add this class after `TestComputeCostViaLitellm` in `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+```python
+class TestBitfunTpsStepMetrics:
+ def test_build_metrics_records_latency_and_tps(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ record = _make_token_record("m", "s", "t", 100, 25)
+ record["llm_latency_ms"] = 5000
+
+ metrics = agent._build_metrics_from_record(record)
+
+ assert metrics.extra is not None
+ assert metrics.extra["llm_latency_ms"] == 5000
+ assert metrics.extra["completion_tokens_per_second"] == 5.0
+ assert metrics.extra["tps_completion_tokens"] == 25
+ assert metrics.extra["tps_model_call_count"] == 1
+ assert metrics.extra["tps_latency_coverage"] == "complete"
+
+ def test_build_metrics_marks_missing_latency(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ record = _make_token_record("m", "s", "t", 100, 25)
+
+ metrics = agent._build_metrics_from_record(record)
+
+ assert metrics.extra is not None
+ assert "llm_latency_ms" not in metrics.extra
+ assert "completion_tokens_per_second" not in metrics.extra
+ assert metrics.extra["tps_unavailable_reason"] == "missing_latency"
+
+ def test_build_metrics_preserves_zero_latency_without_tps(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ record = _make_token_record("m", "s", "t", 100, 25)
+ record["llm_latency_ms"] = 0
+
+ metrics = agent._build_metrics_from_record(record)
+
+ assert metrics.extra is not None
+ assert metrics.extra["llm_latency_ms"] == 0
+ assert "completion_tokens_per_second" not in metrics.extra
+ assert metrics.extra["tps_unavailable_reason"] == "zero_latency"
+```
+
+- [ ] **Step 2: Run the new tests and verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunTpsStepMetrics -v
+```
+
+Expected: the first test fails because `llm_latency_ms` and `completion_tokens_per_second` are not in `metrics.extra`.
+
+- [ ] **Step 3: Add TPS helper methods**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add these static methods near `_parse_record_ts_ms`:
+
+```python
+ @staticmethod
+ def _parse_llm_latency_ms(record: dict[str, Any]) -> tuple[int | None, str | None]:
+ """Return usable non-negative latency and an unavailable reason, if any."""
+ raw = record.get("llm_latency_ms")
+ if raw is None:
+ return None, "missing_latency"
+ if isinstance(raw, bool):
+ return None, "missing_latency"
+ if isinstance(raw, int):
+ latency = raw
+ elif isinstance(raw, float) and raw.is_integer():
+ latency = int(raw)
+ else:
+ return None, "missing_latency"
+ if latency < 0:
+ return None, "missing_latency"
+ if latency == 0:
+ return 0, "zero_latency"
+ return latency, None
+
+ @staticmethod
+ def _build_tps_extra(
+ *,
+ completion_tokens: int,
+ llm_latency_ms: int | None,
+ unavailable_reason: str | None,
+ model_call_count: int = 1,
+ ) -> dict[str, Any]:
+ extra: dict[str, Any] = {}
+ if llm_latency_ms is not None:
+ extra["llm_latency_ms"] = llm_latency_ms
+ if llm_latency_ms and completion_tokens >= 0:
+ extra["completion_tokens_per_second"] = (
+ completion_tokens * 1000.0 / llm_latency_ms
+ )
+ extra["tps_completion_tokens"] = completion_tokens
+ extra["tps_model_call_count"] = model_call_count
+ extra["tps_latency_coverage"] = "complete"
+ elif unavailable_reason is not None:
+ extra["tps_unavailable_reason"] = unavailable_reason
+ return extra
+```
+
+- [ ] **Step 4: Extend `_build_metrics_from_record`**
+
+In `_build_metrics_from_record`, after computing `in_tok`, `out_tok`, and `cost`, parse latency and merge TPS fields into `extra`:
+
+```python
+ llm_latency_ms, tps_unavailable_reason = self._parse_llm_latency_ms(record)
+ extra = {
+ "token_details": record.get("token_details"),
+ "total_tokens": record.get("total_tokens"),
+ "cached_tokens_available": record.get("cached_tokens_available"),
+ "record_timestamp": record.get("timestamp"),
+ "record_model_id": model_id,
+ }
+ extra.update(
+ self._build_tps_extra(
+ completion_tokens=out_tok,
+ llm_latency_ms=llm_latency_ms,
+ unavailable_reason=tps_unavailable_reason,
+ )
+ )
+ extra = {k: v for k, v in extra.items() if v is not None} or None
+```
+
+Replace the existing `extra = { ... }` block in that method with this version.
+
+- [ ] **Step 5: Run step-level tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunTpsStepMetrics -v
+```
+
+Expected: all three tests pass.
+
+- [ ] **Step 6: Commit backend step metrics**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat: add bitfun step tps metrics"
+```
+
+## Task 2: Backend Merged and Final TPS Metrics
+
+**Files:**
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+- Test: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Add failing merge tests**
+
+Add these methods to `TestBitfunTpsStepMetrics`:
+
+```python
+ def test_merge_metrics_computes_weighted_tps(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ a_record = _make_token_record("m", "s", "t", 100, 20)
+ b_record = _make_token_record("m", "s", "t", 100, 40)
+ a_record["llm_latency_ms"] = 2000
+ b_record["llm_latency_ms"] = 8000
+
+ merged = agent._merge_metrics(
+ agent._build_metrics_from_record(a_record),
+ agent._build_metrics_from_record(b_record),
+ )
+
+ assert merged.extra is not None
+ assert merged.extra["llm_latency_ms"] == 10000
+ assert merged.extra["tps_completion_tokens"] == 60
+ assert merged.extra["tps_model_call_count"] == 2
+ assert merged.extra["tps_latency_coverage"] == "complete"
+ assert merged.extra["completion_tokens_per_second"] == 6.0
+
+ def test_merge_metrics_marks_partial_latency_coverage(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ with_latency = _make_token_record("m", "s", "t", 100, 20)
+ without_latency = _make_token_record("m", "s", "t", 100, 40)
+ with_latency["llm_latency_ms"] = 2000
+
+ merged = agent._merge_metrics(
+ agent._build_metrics_from_record(with_latency),
+ agent._build_metrics_from_record(without_latency),
+ )
+
+ assert merged.extra is not None
+ assert merged.completion_tokens == 60
+ assert merged.extra["llm_latency_ms"] == 2000
+ assert merged.extra["tps_completion_tokens"] == 20
+ assert merged.extra["tps_model_call_count"] == 1
+ assert merged.extra["tps_latency_coverage"] == "partial"
+ assert merged.extra["completion_tokens_per_second"] == 10.0
+```
+
+- [ ] **Step 2: Add failing final-metrics tests**
+
+Add this class after `TestConvertEventsToTrajectoryBasic`:
+
+```python
+class TestBitfunTpsFinalMetrics:
+ def test_final_metrics_tps_excludes_subagents(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ main_record = _make_token_record("m", "main", "t1", 100, 20)
+ main_record["llm_latency_ms"] = 4000
+ sub_record = _make_token_record("m", "sub", "t2", 100, 100)
+ sub_record["llm_latency_ms"] = 1000
+ sub_record["is_subagent"] = True
+
+ final_metrics = agent._build_final_metrics(
+ steps=[],
+ metadata={},
+ records_for_traj=[main_record],
+ all_records=[main_record, sub_record],
+ subagent_count=1,
+ )
+
+ assert final_metrics.extra is not None
+ assert final_metrics.extra["total_llm_latency_ms"] == 4000
+ assert final_metrics.extra["model_call_count"] == 1
+ assert final_metrics.extra["tps_completion_tokens"] == 20
+ assert final_metrics.extra["completion_tokens_per_second"] == 5.0
+ assert final_metrics.extra["tps_latency_coverage"] == "complete"
+ assert final_metrics.extra["subagent_total_tokens"] == sub_record["total_tokens"]
+
+ def test_final_metrics_marks_missing_latency(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ main_record = _make_token_record("m", "main", "t1", 100, 20)
+
+ final_metrics = agent._build_final_metrics(
+ steps=[],
+ metadata={},
+ records_for_traj=[main_record],
+ all_records=[main_record],
+ subagent_count=0,
+ )
+
+ assert final_metrics.extra is not None
+ assert "completion_tokens_per_second" not in final_metrics.extra
+ assert final_metrics.extra["tps_unavailable_reason"] == "missing_latency"
+```
+
+- [ ] **Step 3: Run the new backend tests and verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunTpsStepMetrics tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunTpsFinalMetrics -v
+```
+
+Expected: merge and final-metrics assertions fail because merged/final TPS aggregation is not implemented.
+
+- [ ] **Step 4: Add helper for combining TPS extras**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add this static method near `_build_tps_extra`:
+
+```python
+ @staticmethod
+ def _combine_tps_extras(
+ a_extra: dict[str, Any],
+ b_extra: dict[str, Any],
+ *,
+ total_completion_tokens: int,
+ ) -> dict[str, Any]:
+ covered_completion = int(a_extra.get("tps_completion_tokens") or 0) + int(
+ b_extra.get("tps_completion_tokens") or 0
+ )
+ covered_latency = int(a_extra.get("llm_latency_ms") or 0) + int(
+ b_extra.get("llm_latency_ms") or 0
+ )
+ covered_calls = int(a_extra.get("tps_model_call_count") or 0) + int(
+ b_extra.get("tps_model_call_count") or 0
+ )
+
+ combined: dict[str, Any] = {}
+ if covered_latency > 0:
+ combined["llm_latency_ms"] = covered_latency
+ combined["tps_completion_tokens"] = covered_completion
+ combined["tps_model_call_count"] = covered_calls
+ combined["completion_tokens_per_second"] = (
+ covered_completion * 1000.0 / covered_latency
+ )
+ combined["tps_latency_coverage"] = (
+ "complete"
+ if covered_completion == total_completion_tokens
+ else "partial"
+ )
+ elif a_extra.get("llm_latency_ms") == 0 or b_extra.get("llm_latency_ms") == 0:
+ combined["llm_latency_ms"] = 0
+ combined["tps_unavailable_reason"] = "zero_latency"
+ else:
+ combined["tps_unavailable_reason"] = "missing_latency"
+ return combined
+```
+
+- [ ] **Step 5: Update `_merge_metrics`**
+
+Replace the `extra = {**(a.extra or {}), **(b.extra or {})} or None` line in `_merge_metrics` with:
+
+```python
+ extra = {**(a.extra or {}), **(b.extra or {})}
+ extra.update(
+ self._combine_tps_extras(
+ a.extra or {},
+ b.extra or {},
+ total_completion_tokens=c,
+ )
+ )
+ extra = extra or None
+```
+
+Keep the existing return statement, passing the new `extra`.
+
+- [ ] **Step 6: Add helper for final TPS summary**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add this instance method near `_build_final_metrics`:
+
+```python
+ def _build_final_tps_extra(
+ self, records_for_traj: list[dict[str, Any]]
+ ) -> dict[str, Any]:
+ main_records = [r for r in records_for_traj if not r.get("is_subagent")]
+ if not main_records:
+ return {}
+
+ total_completion = 0
+ covered_completion = 0
+ total_latency = 0
+ covered_calls = 0
+ saw_zero_latency = False
+ saw_missing_latency = False
+
+ for record in main_records:
+ completion = int(record.get("output_tokens") or 0)
+ total_completion += completion
+ latency, reason = self._parse_llm_latency_ms(record)
+ if latency and latency > 0:
+ covered_completion += completion
+ total_latency += latency
+ covered_calls += 1
+ elif reason == "zero_latency":
+ saw_zero_latency = True
+ else:
+ saw_missing_latency = True
+
+ extra: dict[str, Any] = {}
+ if total_latency > 0:
+ extra["total_llm_latency_ms"] = total_latency
+ extra["model_call_count"] = covered_calls
+ extra["tps_completion_tokens"] = covered_completion
+ extra["completion_tokens_per_second"] = (
+ covered_completion * 1000.0 / total_latency
+ )
+ extra["tps_latency_coverage"] = (
+ "complete" if covered_completion == total_completion else "partial"
+ )
+ elif saw_zero_latency:
+ extra["total_llm_latency_ms"] = 0
+ extra["tps_unavailable_reason"] = "zero_latency"
+ elif saw_missing_latency:
+ extra["tps_unavailable_reason"] = "missing_latency"
+ return extra
+```
+
+- [ ] **Step 7: Include final TPS fields in `_build_final_metrics`**
+
+In `_build_final_metrics`, after constructing `extra_fields`, merge in the new TPS fields before filtering `None` values:
+
+```python
+ extra_fields.update(self._build_final_tps_extra(records_for_traj))
+ extra: dict[str, Any] | None = {
+ k: v for k, v in extra_fields.items() if v is not None
+ } or None
+```
+
+- [ ] **Step 8: Run backend TPS tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunTpsStepMetrics tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunTpsFinalMetrics -v
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 9: Run broader BitFun agent tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 10: Commit backend aggregation**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat: aggregate bitfun tps metrics"
+```
+
+## Task 3: Viewer Types and Formatting Helpers
+
+**Files:**
+- Modify: `apps/viewer/app/lib/types.ts`
+- Modify: `apps/viewer/app/routes/trial.tsx`
+
+- [ ] **Step 1: Extend trajectory metric types**
+
+In `apps/viewer/app/lib/types.ts`, update `StepMetrics` and `FinalMetrics`:
+
+```ts
+export interface StepMetrics {
+ prompt_tokens: number | null;
+ completion_tokens: number | null;
+ cached_tokens: number | null;
+ cost_usd: number | null;
+ extra?: Record | null;
+}
+```
+
+```ts
+export interface FinalMetrics {
+ total_prompt_tokens: number | null;
+ total_completion_tokens: number | null;
+ total_cached_tokens: number | null;
+ total_cost_usd: number | null;
+ total_steps: number | null;
+ extra?: Record | null;
+}
+```
+
+- [ ] **Step 2: Add local viewer helpers**
+
+In `apps/viewer/app/routes/trial.tsx`, add these helpers after `formatMs`:
+
+```tsx
+function getExtraNumber(
+ extra: Record | null | undefined,
+ key: string
+): number | null {
+ const value = extra?.[key];
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function getExtraString(
+ extra: Record | null | undefined,
+ key: string
+): string | null {
+ const value = extra?.[key];
+ return typeof value === "string" ? value : null;
+}
+
+function formatTps(value: number | null): string | null {
+ if (value === null) return null;
+ return `${value.toFixed(1)} tok/s`;
+}
+
+function formatLatencyMs(value: number | null): string | null {
+ if (value === null) return null;
+ if (value < 1000) return `${value.toFixed(0)}ms LLM`;
+ return `${formatMs(value)} LLM`;
+}
+```
+
+- [ ] **Step 3: Run viewer typecheck**
+
+Run:
+
+```bash
+cd apps/viewer && bun run typecheck
+```
+
+Expected: typecheck passes. If `bun` is unavailable, record that and rely on `uv run ty check` later for Python only; do not replace this with npm commands unless the repo already uses npm lockfiles.
+
+- [ ] **Step 4: Commit viewer types/helpers**
+
+Run:
+
+```bash
+git add apps/viewer/app/lib/types.ts apps/viewer/app/routes/trial.tsx
+git commit -m "feat: add viewer tps metric helpers"
+```
+
+## Task 4: Viewer TPS Display
+
+**Files:**
+- Modify: `apps/viewer/app/routes/trial.tsx`
+
+- [ ] **Step 1: Extend step metric line**
+
+In `StepContent`, replace the current `{step.metrics && (...)}` block with:
+
+```tsx
+ {step.metrics && (() => {
+ const tps = formatTps(
+ getExtraNumber(step.metrics.extra, "completion_tokens_per_second")
+ );
+ const latency = formatLatencyMs(
+ getExtraNumber(step.metrics.extra, "llm_latency_ms")
+ );
+ const cost =
+ step.metrics.cost_usd != null
+ ? `$${step.metrics.cost_usd.toFixed(2)}`
+ : null;
+ const parts = [
+ `${(step.metrics.prompt_tokens ?? 0).toLocaleString()} prompt`,
+ `${(step.metrics.completion_tokens ?? 0).toLocaleString()} completion`,
+ tps,
+ latency,
+ cost,
+ ].filter(Boolean);
+
+ return (
+
+ Tokens: {parts.join(" / ")}
+
+ );
+ })()}
+```
+
+- [ ] **Step 2: Add summary metric derivation in `TrialContent`**
+
+In `TrialContent`, after `const metrics = trajectory?.final_metrics;`, add:
+
+```tsx
+ const metricsExtra = metrics?.extra ?? null;
+ const summaryTps = getExtraNumber(
+ metricsExtra,
+ "completion_tokens_per_second"
+ );
+ const summaryLatencyMs = getExtraNumber(metricsExtra, "total_llm_latency_ms");
+ const summaryModelCalls = getExtraNumber(metricsExtra, "model_call_count");
+ const summaryCoverage = getExtraString(metricsExtra, "tps_latency_coverage");
+```
+
+- [ ] **Step 3: Render summary TPS under the token bar**
+
+In the Tokens card `CardContent`, immediately after the existing ` `, add:
+
+```tsx
+ {(summaryTps !== null ||
+ summaryLatencyMs !== null ||
+ summaryModelCalls !== null) && (
+
+ {summaryTps !== null && (
+
+ Output TPS: {summaryTps.toFixed(1)} tokens/s
+ {summaryCoverage === "partial" ? " (partial)" : ""}
+
+ )}
+ {summaryLatencyMs !== null && (
+ LLM latency: {formatMs(summaryLatencyMs)}
+ )}
+ {summaryModelCalls !== null && (
+ Model calls: {summaryModelCalls.toLocaleString()}
+ )}
+
+ )}
+```
+
+- [ ] **Step 4: Run viewer typecheck**
+
+Run:
+
+```bash
+cd apps/viewer && bun run typecheck
+```
+
+Expected: typecheck passes.
+
+- [ ] **Step 5: Commit viewer display**
+
+Run:
+
+```bash
+git add apps/viewer/app/routes/trial.tsx
+git commit -m "feat: show bitfun tps in viewer"
+```
+
+## Task 5: Full Verification
+
+**Files:**
+- No new code files.
+
+- [ ] **Step 1: Run Ruff check with fixes**
+
+Run:
+
+```bash
+uv run ruff check --fix .
+```
+
+Expected: exits 0. If it modifies files, review the diff and include them in the verification commit.
+
+- [ ] **Step 2: Run Ruff format**
+
+Run:
+
+```bash
+uv run ruff format .
+```
+
+Expected: exits 0. If it modifies files, review the diff and include them in the verification commit.
+
+- [ ] **Step 3: Run Python type check**
+
+Run:
+
+```bash
+uv run ty check
+```
+
+Expected: exits 0.
+
+- [ ] **Step 4: Run unit tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/
+```
+
+Expected: exits 0.
+
+- [ ] **Step 5: Run viewer typecheck**
+
+Run:
+
+```bash
+cd apps/viewer && bun run typecheck
+```
+
+Expected: exits 0.
+
+- [ ] **Step 6: Commit verification fixes if any**
+
+If formatting or linting changed files, run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py apps/viewer/app/lib/types.ts apps/viewer/app/routes/trial.tsx
+git commit -m "chore: apply bitfun tps verification fixes"
+```
+
+If there are no changes, do not create an empty commit.
+
+- [ ] **Step 7: Summarize implementation**
+
+Report:
+
+- Commits created.
+- Verification command results.
+- Any commands that could not run, with exact reason.
diff --git a/docs/superpowers/plans/2026-06-08-job-report-link.md b/docs/superpowers/plans/2026-06-08-job-report-link.md
new file mode 100644
index 00000000000..c52c5bd0b78
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-job-report-link.md
@@ -0,0 +1,611 @@
+# Job External Report Link Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add an optional `Report` entry beside `Results` and `Analysis` on Harbor Viewer job pages that opens an external report service at `{baseUrl}/{encodeURIComponent(jobName)}`.
+
+**Architecture:** Extend the existing analyze profiles TOML document with a top-level `external_job_report_base_url`, expose that value through the existing `/api/analyze/profiles` response, and let the job page render a tab-styled external anchor when configured. Keep URL construction in a small frontend helper so it can be tested without adding a frontend test framework.
+
+**Tech Stack:** Python 3.12, Pydantic v2, FastAPI/TestClient, TOML via `tomllib`, React Router, TanStack Query, TypeScript, Node built-in assertions.
+
+---
+
+## File Structure
+
+- Modify `src/harbor/analyze/profiles.py`: own parsing, validation, model storage, and public serialization for `external_job_report_base_url`.
+- Modify `tests/unit/analyze/test_analyze_profiles.py`: unit coverage for valid, empty, and non-HTTP(S) TOML values.
+- Modify `src/harbor/viewer/server.py`: return the richer analyze profiles public response from the existing route.
+- Modify `tests/unit/viewer/test_analyze_profiles_route.py`: route coverage for configured and unconfigured external report data.
+- Create `apps/viewer/app/lib/external-report.ts`: pure frontend URL helper.
+- Modify `apps/viewer/app/lib/api.ts`: add TypeScript response types for the new API shape.
+- Modify `apps/viewer/app/routes/job.tsx`: fetch analyze config at page load and render the `Report` external link beside the existing tabs.
+- Modify `examples/config/analyze-profiles.example.toml`: document the optional top-level field.
+
+## Scope Check
+
+This spec covers one coherent feature: a configured external job report link in Harbor Viewer. It touches backend config/API, frontend rendering, and docs, but each change is part of the same user-visible behavior and can be verified independently.
+
+### Task 1: Parse and Validate External Report Base URL
+
+**Files:**
+- Modify: `src/harbor/analyze/profiles.py`
+- Test: `tests/unit/analyze/test_analyze_profiles.py`
+
+- [ ] **Step 1: Add failing tests for TOML parsing and validation**
+
+Append these tests to `tests/unit/analyze/test_analyze_profiles.py`:
+
+```python
+def test_load_external_job_report_base_url(tmp_path):
+ cfg = tmp_path / "profiles.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ """
+ external_job_report_base_url = "http://reports.example.test:9000/"
+
+ [[profile]]
+ id = "a"
+ api_key_env = "KEY_A"
+ default_model = "one"
+
+ [[profile.model]]
+ id = "one"
+ display_name = "One"
+ api_model = "m1"
+ """
+ ).strip(),
+ encoding="utf-8",
+ )
+
+ doc = load_profiles_from_file(cfg)
+
+ assert doc.external_job_report_base_url == "http://reports.example.test:9000"
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ '""',
+ '"ftp://reports.example.test"',
+ '"reports.example.test"',
+ ],
+)
+def test_load_external_job_report_base_url_rejects_invalid_values(tmp_path, value):
+ cfg = tmp_path / "profiles.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ f"""
+ external_job_report_base_url = {value}
+
+ [[profile]]
+ id = "a"
+ api_key_env = "KEY_A"
+ default_model = "one"
+
+ [[profile.model]]
+ id = "one"
+ display_name = "One"
+ api_model = "m1"
+ """
+ ).strip(),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(ProfilesConfigurationError, match="external_job_report_base_url"):
+ load_profiles_from_file(cfg)
+```
+
+- [ ] **Step 2: Run the new tests and verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/analyze/test_analyze_profiles.py::test_load_external_job_report_base_url tests/unit/analyze/test_analyze_profiles.py::test_load_external_job_report_base_url_rejects_invalid_values -v
+```
+
+Expected: `test_load_external_job_report_base_url` fails with `AttributeError` or a Pydantic validation error because `external_job_report_base_url` is not modeled yet.
+
+- [ ] **Step 3: Implement parsing and validation**
+
+In `src/harbor/analyze/profiles.py`, add this import near the existing imports:
+
+```python
+from urllib.parse import urlparse
+```
+
+Update `AnalyzeProfilesDocument`:
+
+```python
+class AnalyzeProfilesDocument(BaseModel):
+ profiles: list[AnalyzeProfileDoc]
+ external_job_report_base_url: str | None = None
+```
+
+Add this helper after `_require_profile_key()`:
+
+```python
+def _external_job_report_base_url(raw: object) -> str | None:
+ if raw is None:
+ return None
+ if not isinstance(raw, str):
+ raise ProfilesConfigurationError(
+ "external_job_report_base_url must be a string"
+ )
+ base_url = raw.rstrip("/")
+ if not base_url:
+ raise ProfilesConfigurationError(
+ "external_job_report_base_url must be a non-empty HTTP or HTTPS URL"
+ )
+ parsed = urlparse(base_url)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ raise ProfilesConfigurationError(
+ "external_job_report_base_url must be a non-empty HTTP or HTTPS URL"
+ )
+ return base_url
+```
+
+In `load_profiles_from_file()`, immediately after `raw = tomllib.loads(...)`, add:
+
+```python
+ external_job_report_base_url = _external_job_report_base_url(
+ raw.get("external_job_report_base_url")
+ )
+```
+
+Replace:
+
+```python
+ doc = AnalyzeProfilesDocument(profiles=profs)
+```
+
+with:
+
+```python
+ doc = AnalyzeProfilesDocument(
+ profiles=profs,
+ external_job_report_base_url=external_job_report_base_url,
+ )
+```
+
+- [ ] **Step 4: Run the focused analyze profile tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/analyze/test_analyze_profiles.py -v
+```
+
+Expected: all tests in `tests/unit/analyze/test_analyze_profiles.py` pass.
+
+- [ ] **Step 5: Commit Task 1**
+
+Run:
+
+```bash
+git add src/harbor/analyze/profiles.py tests/unit/analyze/test_analyze_profiles.py
+git commit -m "feat(viewer): parse external job report config"
+```
+
+### Task 2: Expose External Report Config Through Analyze Profiles API
+
+**Files:**
+- Modify: `src/harbor/analyze/profiles.py`
+- Modify: `src/harbor/viewer/server.py`
+- Test: `tests/unit/viewer/test_analyze_profiles_route.py`
+
+- [ ] **Step 1: Add failing API route tests**
+
+Replace the contents of `tests/unit/viewer/test_analyze_profiles_route.py` with:
+
+```python
+import textwrap
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from harbor.viewer.server import create_app
+
+
+def test_analyze_profiles_endpoint_builtin(tmp_path: Path) -> None:
+ app = create_app(tmp_path, mode="tasks", analyze_profiles_file=None)
+ resp = TestClient(app).get("/api/analyze/profiles")
+ assert resp.status_code == 200
+ body = resp.json()
+ ids = [p["id"] for p in body["profiles"]]
+ assert "anthropic" in ids
+ assert "external_job_report" not in body
+
+
+def test_analyze_profiles_endpoint_includes_external_job_report(
+ tmp_path: Path,
+) -> None:
+ cfg = tmp_path / "profiles.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ """
+ external_job_report_base_url = "https://reports.example.test/base/"
+
+ [[profile]]
+ id = "corp"
+ label = "Corp"
+ api_key_env = "CORP_KEY"
+ default_model = "sonnet"
+
+ [[profile.model]]
+ id = "sonnet"
+ display_name = "Sonnet"
+ api_model = "anthropic/sonnet"
+ """
+ ).strip(),
+ encoding="utf-8",
+ )
+ app = create_app(tmp_path, mode="tasks", analyze_profiles_file=cfg)
+
+ resp = TestClient(app).get("/api/analyze/profiles")
+
+ assert resp.status_code == 200
+ assert resp.json()["external_job_report"] == {
+ "base_url": "https://reports.example.test/base"
+ }
+```
+
+- [ ] **Step 2: Run the API tests and verify the new route test fails**
+
+Run:
+
+```bash
+uv run pytest tests/unit/viewer/test_analyze_profiles_route.py -v
+```
+
+Expected: `test_analyze_profiles_endpoint_includes_external_job_report` fails because `/api/analyze/profiles` does not include `external_job_report`.
+
+- [ ] **Step 3: Add a public response serializer**
+
+In `src/harbor/analyze/profiles.py`, after `profiles_for_public_api()`, add:
+
+```python
+def profiles_document_for_public_api(
+ doc: AnalyzeProfilesDocument,
+) -> dict[str, object]:
+ out: dict[str, object] = {"profiles": profiles_for_public_api(doc)}
+ if doc.external_job_report_base_url:
+ out["external_job_report"] = {
+ "base_url": doc.external_job_report_base_url,
+ }
+ return out
+```
+
+- [ ] **Step 4: Use the serializer in the FastAPI route**
+
+In `src/harbor/viewer/server.py`, update the existing import from `harbor.analyze.profiles` to include `profiles_document_for_public_api` and remove `profiles_for_public_api`:
+
+```python
+from harbor.analyze.profiles import (
+ AnalyzeProfilesDocument,
+ ProfilesConfigurationError,
+ built_in_profiles,
+ load_profiles_from_file,
+ profiles_document_for_public_api,
+ resolve_summarize_invoke,
+)
+```
+
+Then replace the analyze profiles route body:
+
+```python
+ @app.get("/api/analyze/profiles")
+ def analyze_profiles_endpoint() -> dict[str, Any]:
+ return {"profiles": profiles_for_public_api(analyze_profiles)}
+```
+
+with:
+
+```python
+ @app.get("/api/analyze/profiles")
+ def analyze_profiles_endpoint() -> dict[str, Any]:
+ return profiles_document_for_public_api(analyze_profiles)
+```
+
+If `profiles_for_public_api` becomes unused in `src/harbor/viewer/server.py`, remove it from that import.
+
+- [ ] **Step 5: Run the focused API tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/viewer/test_analyze_profiles_route.py -v
+```
+
+Expected: both route tests pass.
+
+- [ ] **Step 6: Commit Task 2**
+
+Run:
+
+```bash
+git add src/harbor/analyze/profiles.py src/harbor/viewer/server.py tests/unit/viewer/test_analyze_profiles_route.py
+git commit -m "feat(viewer): expose external job report config"
+```
+
+### Task 3: Add Frontend URL Helper and API Types
+
+**Files:**
+- Create: `apps/viewer/app/lib/external-report.ts`
+- Modify: `apps/viewer/app/lib/api.ts`
+
+- [ ] **Step 1: Create the URL helper**
+
+Create `apps/viewer/app/lib/external-report.ts`:
+
+```typescript
+export function buildExternalJobReportUrl(
+ baseUrl: string,
+ jobName: string
+): string {
+ const trimmedBaseUrl = baseUrl.replace(/\/+$/, "");
+ return `${trimmedBaseUrl}/${encodeURIComponent(jobName)}`;
+}
+```
+
+- [ ] **Step 2: Verify helper behavior with a direct TypeScript compile and Node assertion**
+
+Run:
+
+```bash
+cd apps/viewer
+rm -rf /tmp/harbor-viewer-external-report-test
+./node_modules/.bin/tsc app/lib/external-report.ts --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir /tmp/harbor-viewer-external-report-test --skipLibCheck --strict
+node --input-type=module -e "import assert from 'node:assert/strict'; import { buildExternalJobReportUrl } from '/tmp/harbor-viewer-external-report-test/external-report.js'; assert.equal(buildExternalJobReportUrl('http://reports.example.test/', 'job name/1'), 'http://reports.example.test/job%20name%2F1'); assert.equal(buildExternalJobReportUrl('https://reports.example.test/base///', 'tb2-cc'), 'https://reports.example.test/base/tb2-cc');"
+```
+
+Expected: both commands exit with status `0`.
+
+- [ ] **Step 3: Update analyze profiles response types**
+
+In `apps/viewer/app/lib/api.ts`, replace:
+
+```typescript
+export async function fetchAnalyzeProfiles(): Promise<{
+ profiles: AnalyzeProfileRow[];
+}> {
+```
+
+with:
+
+```typescript
+export interface ExternalJobReportConfig {
+ base_url: string;
+}
+
+export interface AnalyzeProfilesResponse {
+ profiles: AnalyzeProfileRow[];
+ external_job_report?: ExternalJobReportConfig;
+}
+
+export async function fetchAnalyzeProfiles(): Promise {
+```
+
+- [ ] **Step 4: Run frontend typecheck**
+
+Run:
+
+```bash
+cd apps/viewer
+npm run typecheck
+```
+
+Expected: typecheck passes.
+
+- [ ] **Step 5: Commit Task 3**
+
+Run:
+
+```bash
+git add apps/viewer/app/lib/api.ts apps/viewer/app/lib/external-report.ts
+git commit -m "feat(viewer): add external report URL helper"
+```
+
+### Task 4: Render the Report Link on the Job Page
+
+**Files:**
+- Modify: `apps/viewer/app/routes/job.tsx`
+- Modify: `apps/viewer/app/lib/external-report.ts`
+
+- [ ] **Step 1: Add a reusable tab-link class export**
+
+Replace `apps/viewer/app/lib/external-report.ts` with:
+
+```typescript
+export const externalReportTabLinkClassName =
+ "inline-flex shrink-0 items-center justify-center whitespace-nowrap px-4 py-3 text-sm font-medium transition-all focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 border-b-2 border-transparent text-muted-foreground hover:text-foreground";
+
+export function buildExternalJobReportUrl(
+ baseUrl: string,
+ jobName: string
+): string {
+ const trimmedBaseUrl = baseUrl.replace(/\/+$/, "");
+ return `${trimmedBaseUrl}/${encodeURIComponent(jobName)}`;
+}
+```
+
+- [ ] **Step 2: Import the helper in the job route**
+
+In `apps/viewer/app/routes/job.tsx`, add this import with the other app imports:
+
+```typescript
+import {
+ buildExternalJobReportUrl,
+ externalReportTabLinkClassName,
+} from "~/lib/external-report";
+```
+
+- [ ] **Step 3: Fetch analyze profile config at job page load**
+
+Inside the main job route component, find the existing `useQuery` calls near the other job-level data queries. Add:
+
+```typescript
+ const { data: analyzeProfilesData } = useQuery({
+ queryKey: ["analyze-profiles"],
+ queryFn: fetchAnalyzeProfiles,
+ retry: false,
+ });
+```
+
+Then add this memo after `jobName` is available and before the JSX return:
+
+```typescript
+ const externalJobReportUrl = useMemo(() => {
+ const baseUrl = analyzeProfilesData?.external_job_report?.base_url;
+ if (!baseUrl || !jobName) return null;
+ return buildExternalJobReportUrl(baseUrl, jobName);
+ }, [analyzeProfilesData?.external_job_report?.base_url, jobName]);
+```
+
+- [ ] **Step 4: Render the external link beside the existing tabs**
+
+In `apps/viewer/app/routes/job.tsx`, replace:
+
+```tsx
+
+ Results
+ Analysis
+
+```
+
+with:
+
+```tsx
+
+ Results
+ Analysis
+ {externalJobReportUrl ? (
+
+ Report
+
+ ) : null}
+
+```
+
+- [ ] **Step 5: Run frontend typecheck**
+
+Run:
+
+```bash
+cd apps/viewer
+npm run typecheck
+```
+
+Expected: typecheck passes.
+
+- [ ] **Step 6: Build the viewer frontend**
+
+Run:
+
+```bash
+cd apps/viewer
+npm run build
+```
+
+Expected: build completes successfully.
+
+- [ ] **Step 7: Commit Task 4**
+
+Run:
+
+```bash
+git add apps/viewer/app/routes/job.tsx apps/viewer/app/lib/external-report.ts
+git commit -m "feat(viewer): show external job report link"
+```
+
+### Task 5: Document Configuration and Run Full Verification
+
+**Files:**
+- Modify: `examples/config/analyze-profiles.example.toml`
+
+- [ ] **Step 1: Document the optional top-level setting**
+
+Near the top of `examples/config/analyze-profiles.example.toml`, after the introductory comments and before the first `[[profile]]`, add:
+
+```toml
+# Optional: show a job-level "Report" link in Harbor Viewer.
+# The job page opens {external_job_report_base_url}/{job_name}.
+# external_job_report_base_url = "http://report-host:9000"
+```
+
+- [ ] **Step 2: Run backend focused tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/analyze/test_analyze_profiles.py tests/unit/viewer/test_analyze_profiles_route.py -v
+```
+
+Expected: all selected tests pass.
+
+- [ ] **Step 3: Run frontend helper regression check**
+
+Run:
+
+```bash
+cd apps/viewer
+rm -rf /tmp/harbor-viewer-external-report-test
+./node_modules/.bin/tsc app/lib/external-report.ts --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir /tmp/harbor-viewer-external-report-test --skipLibCheck --strict
+node --input-type=module -e "import assert from 'node:assert/strict'; import { buildExternalJobReportUrl } from '/tmp/harbor-viewer-external-report-test/external-report.js'; assert.equal(buildExternalJobReportUrl('http://reports.example.test/', 'job name/1'), 'http://reports.example.test/job%20name%2F1'); assert.equal(buildExternalJobReportUrl('https://reports.example.test/base///', 'tb2-cc'), 'https://reports.example.test/base/tb2-cc');"
+```
+
+Expected: both commands exit with status `0`.
+
+- [ ] **Step 4: Run required repository checks**
+
+Run:
+
+```bash
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+```
+
+Expected: all commands pass. If `ruff check --fix` or `ruff format` changes files, inspect the diff and include those formatting changes in the final commit.
+
+- [ ] **Step 5: Run frontend typecheck and build**
+
+Run:
+
+```bash
+cd apps/viewer
+npm run typecheck
+npm run build
+```
+
+Expected: both commands pass.
+
+- [ ] **Step 6: Inspect final diff**
+
+Run:
+
+```bash
+git diff --stat
+git diff -- src/harbor/analyze/profiles.py src/harbor/viewer/server.py tests/unit/analyze/test_analyze_profiles.py tests/unit/viewer/test_analyze_profiles_route.py apps/viewer/app/lib/api.ts apps/viewer/app/lib/external-report.ts apps/viewer/app/routes/job.tsx examples/config/analyze-profiles.example.toml
+```
+
+Expected: diff only includes the external report link feature, related tests, and example config comments.
+
+- [ ] **Step 7: Commit Task 5**
+
+Run:
+
+```bash
+git add src/harbor/analyze/profiles.py src/harbor/viewer/server.py tests/unit/analyze/test_analyze_profiles.py tests/unit/viewer/test_analyze_profiles_route.py apps/viewer/app/lib/api.ts apps/viewer/app/lib/external-report.ts apps/viewer/app/routes/job.tsx examples/config/analyze-profiles.example.toml
+git commit -m "docs(viewer): document external job report config"
+```
+
+If Task 5 has no code or formatting changes beyond `examples/config/analyze-profiles.example.toml`, the commit should still include only that example config file.
+
+## Self-Review
+
+- Spec coverage: Task 1 implements TOML configuration and validation. Task 2 exposes the setting through the existing analyze profiles API. Task 3 implements and verifies URL construction. Task 4 renders a same-level, tab-styled current-tab external link on job pages. Task 5 documents the setting and runs required verification. Non-goals are preserved because the plan adds no hosting, no new CLI flag, no URL templates, and no external service checks.
+- Incomplete-marker scan: The plan contains concrete file paths, code snippets, commands, and expected results for each task.
+- Type consistency: The backend field is consistently named `external_job_report_base_url`; the public API field is consistently named `external_job_report.base_url`; the frontend helper is consistently named `buildExternalJobReportUrl`.
diff --git a/docs/superpowers/plans/2026-06-08-standalone-html-report-viewer.md b/docs/superpowers/plans/2026-06-08-standalone-html-report-viewer.md
new file mode 100644
index 00000000000..f715aa88aee
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-08-standalone-html-report-viewer.md
@@ -0,0 +1,1012 @@
+# Standalone HTML Report Viewer Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build an independent FastAPI service under `report-viewer/` that serves job-level HTML reports, lets users upload or replace a single HTML file per job, and works with Harbor Viewer's `Report` link.
+
+**Architecture:** The service is a standalone Python project inside this repo, with its own `pyproject.toml`, tests, templates, and static assets. It stores reports on the local filesystem at `report-viewer/data/{job_name}/index.html`, serves `/{job_name}` as an app shell with a top-right upload button, and serves uploaded HTML inside an iframe from `/api/reports/{job_name}/html`. It does not register Harbor CLI commands and does not import `harbor.*`.
+
+**Tech Stack:** Python 3.12+, FastAPI, Uvicorn, Jinja2 templates, vanilla HTML/CSS/JS, pytest, FastAPI TestClient, local filesystem storage.
+
+---
+
+## File Structure
+
+- Create `report-viewer/pyproject.toml`: independent uv project metadata and dependencies.
+- Create `report-viewer/README.md`: run command, Harbor integration config, storage behavior, and security assumptions.
+- Create `report-viewer/.gitignore`: ignore uploaded report data while keeping `data/.gitkeep`.
+- Create `report-viewer/app/__init__.py`: package marker only.
+- Create `report-viewer/app/storage.py`: job name validation, path mapping, status lookup, and atomic HTML writes.
+- Create `report-viewer/app/main.py`: FastAPI app, routes, upload validation, template/static wiring.
+- Create `report-viewer/app/templates/report.html`: shell page with toolbar, upload input, empty state, and iframe.
+- Create `report-viewer/app/static/app.css`: standalone styling for shell UI.
+- Create `report-viewer/app/static/app.js`: fetch status, upload file, refresh iframe, display errors.
+- Create `report-viewer/data/.gitkeep`: keep the default storage root in git.
+- Create `report-viewer/tests/test_storage.py`: storage unit tests.
+- Create `report-viewer/tests/test_routes.py`: route tests through FastAPI TestClient.
+
+## Scope Check
+
+The design covers one coherent service: upload and display one HTML report per Harbor job. The plan intentionally excludes authentication, database persistence, zip upload, report sanitization, and Harbor CLI integration.
+
+### Task 1: Independent Project Skeleton and Storage RED Tests
+
+**Files:**
+- Create: `report-viewer/pyproject.toml`
+- Create: `report-viewer/.gitignore`
+- Create: `report-viewer/app/__init__.py`
+- Create: `report-viewer/tests/test_storage.py`
+- Create: `report-viewer/data/.gitkeep`
+
+- [ ] **Step 1: Create independent project config and package directories**
+
+Create `report-viewer/pyproject.toml`:
+
+```toml
+[project]
+name = "harbor-report-viewer"
+version = "0.1.0"
+description = "Standalone HTML report viewer for Harbor job reports."
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi>=0.128.0",
+ "httpx>=0.27.0",
+ "jinja2>=3.1.6",
+ "python-multipart>=0.0.20",
+ "uvicorn>=0.38.0",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.4.2",
+ "ruff>=0.15.4",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = ["-v", "--tb=short", "--strict-config"]
+
+[tool.ruff]
+line-length = 88
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B"]
+```
+
+Create `report-viewer/.gitignore`:
+
+```gitignore
+data/*
+!data/.gitkeep
+.venv/
+.pytest_cache/
+.ruff_cache/
+__pycache__/
+*.py[cod]
+```
+
+Create `report-viewer/app/__init__.py` as an empty file.
+
+Create `report-viewer/data/.gitkeep` as an empty file.
+
+- [ ] **Step 2: Write failing storage tests**
+
+Create `report-viewer/tests/test_storage.py`:
+
+```python
+from pathlib import Path
+
+import pytest
+
+from app.storage import (
+ InvalidJobNameError,
+ ReportStorage,
+ validate_job_name,
+)
+
+
+def test_validate_job_name_accepts_harbor_style_names() -> None:
+ assert (
+ validate_job_name("tb2-cc-ds-0003-rerun-run-1854e430d280")
+ == "tb2-cc-ds-0003-rerun-run-1854e430d280"
+ )
+ assert validate_job_name("job.name_123") == "job.name_123"
+
+
+@pytest.mark.parametrize(
+ "job_name",
+ [
+ "",
+ "../escape",
+ "nested/path",
+ "space name",
+ "name:colon",
+ "a" * 201,
+ ],
+)
+def test_validate_job_name_rejects_unsafe_names(job_name: str) -> None:
+ with pytest.raises(InvalidJobNameError):
+ validate_job_name(job_name)
+
+
+def test_report_path_maps_to_index_html_under_job_dir(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ assert storage.report_path("job-1") == tmp_path / "job-1" / "index.html"
+
+
+def test_missing_report_status(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ status = storage.status("job-1")
+
+ assert status == {
+ "job_name": "job-1",
+ "exists": False,
+ "size_bytes": None,
+ "updated_at": None,
+ }
+
+
+def test_save_html_creates_and_overwrites_report(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ first = storage.save_html("job-1", b"first")
+ second = storage.save_html("job-1", b"second")
+
+ assert first == tmp_path / "job-1" / "index.html"
+ assert second == tmp_path / "job-1" / "index.html"
+ assert second.read_text(encoding="utf-8") == "second"
+ assert storage.status("job-1")["exists"] is True
+ assert storage.status("job-1")["size_bytes"] == len(b"second")
+ assert storage.status("job-1")["updated_at"] is not None
+
+
+def test_save_html_rejects_empty_content(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ with pytest.raises(ValueError, match="empty"):
+ storage.save_html("job-1", b"")
+```
+
+- [ ] **Step 3: Run storage tests and verify they fail**
+
+Run:
+
+```bash
+cd report-viewer
+uv run pytest tests/test_storage.py -v
+```
+
+Expected: fail during import with `ModuleNotFoundError: No module named 'app.storage'`.
+
+- [ ] **Step 4: Commit skeleton and RED tests**
+
+Run:
+
+```bash
+git add report-viewer/pyproject.toml report-viewer/.gitignore report-viewer/app/__init__.py report-viewer/data/.gitkeep report-viewer/tests/test_storage.py
+git commit -m "test(report-viewer): add storage behavior tests"
+```
+
+### Task 2: Filesystem Storage Implementation
+
+**Files:**
+- Create: `report-viewer/app/storage.py`
+- Test: `report-viewer/tests/test_storage.py`
+
+- [ ] **Step 1: Implement minimal storage module**
+
+Create `report-viewer/app/storage.py`:
+
+```python
+from __future__ import annotations
+
+import os
+import re
+from datetime import UTC, datetime
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import TypedDict
+
+_JOB_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,200}$")
+
+
+class InvalidJobNameError(ValueError):
+ pass
+
+
+class ReportStatus(TypedDict):
+ job_name: str
+ exists: bool
+ size_bytes: int | None
+ updated_at: str | None
+
+
+def validate_job_name(job_name: str) -> str:
+ if not _JOB_NAME_RE.fullmatch(job_name):
+ raise InvalidJobNameError("invalid job name")
+ return job_name
+
+
+class ReportStorage:
+ def __init__(self, root: Path) -> None:
+ self.root = root
+
+ def report_path(self, job_name: str) -> Path:
+ safe_job_name = validate_job_name(job_name)
+ return self.root / safe_job_name / "index.html"
+
+ def status(self, job_name: str) -> ReportStatus:
+ path = self.report_path(job_name)
+ if not path.exists():
+ return {
+ "job_name": job_name,
+ "exists": False,
+ "size_bytes": None,
+ "updated_at": None,
+ }
+
+ stat = path.stat()
+ return {
+ "job_name": job_name,
+ "exists": True,
+ "size_bytes": stat.st_size,
+ "updated_at": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
+ }
+
+ def save_html(self, job_name: str, content: bytes) -> Path:
+ if not content:
+ raise ValueError("uploaded HTML is empty")
+
+ path = self.report_path(job_name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ with NamedTemporaryFile(delete=False, dir=path.parent) as tmp:
+ tmp.write(content)
+ tmp_path = Path(tmp.name)
+
+ os.replace(tmp_path, path)
+ return path
+```
+
+- [ ] **Step 2: Run storage tests and verify they pass**
+
+Run:
+
+```bash
+cd report-viewer
+uv run pytest tests/test_storage.py -v
+```
+
+Expected: all storage tests pass.
+
+- [ ] **Step 3: Commit storage implementation**
+
+Run:
+
+```bash
+git add report-viewer/app/storage.py report-viewer/tests/test_storage.py
+git commit -m "feat(report-viewer): add filesystem report storage"
+```
+
+### Task 3: FastAPI Route RED Tests
+
+**Files:**
+- Create: `report-viewer/tests/test_routes.py`
+
+- [ ] **Step 1: Write failing route tests**
+
+Create `report-viewer/tests/test_routes.py`:
+
+```python
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from app.main import create_app
+
+
+def test_health_returns_ok(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.get("/health")
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+
+def test_report_shell_returns_job_page(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.get("/job-1")
+
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "job-1" in response.text
+ assert "Upload" in response.text
+
+
+def test_invalid_job_name_route_returns_400(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.get("/api/reports/name:colon/status")
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "invalid job name"
+
+
+def test_status_reports_missing_and_present_report(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ missing = client.get("/api/reports/job-1/status")
+ uploaded = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.html", b"uploaded", "text/html")},
+ )
+ present = client.get("/api/reports/job-1/status")
+
+ assert missing.status_code == 200
+ assert missing.json() == {
+ "job_name": "job-1",
+ "exists": False,
+ "size_bytes": None,
+ "updated_at": None,
+ }
+ assert uploaded.status_code == 200
+ assert present.status_code == 200
+ assert present.json()["job_name"] == "job-1"
+ assert present.json()["exists"] is True
+ assert present.json()["size_bytes"] == len(b"uploaded")
+ assert present.json()["updated_at"] is not None
+
+
+def test_html_route_returns_404_before_upload_and_html_after_upload(
+ tmp_path: Path,
+) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ missing = client.get("/api/reports/job-1/html")
+ uploaded = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.html", b"uploaded", "text/html")},
+ )
+ html = client.get("/api/reports/job-1/html")
+
+ assert missing.status_code == 404
+ assert uploaded.status_code == 200
+ assert html.status_code == 200
+ assert "text/html" in html.headers["content-type"]
+ assert html.text == "uploaded"
+
+
+def test_upload_rejects_non_html_file(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.txt", b"not html", "text/plain")},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "only .html or .htm files are supported"
+
+
+def test_upload_rejects_empty_html_file(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.html", b"", "text/html")},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "uploaded HTML is empty"
+
+
+def test_upload_overwrites_existing_html(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ first = client.post(
+ "/api/reports/job-1",
+ files={"file": ("first.html", b"first", "text/html")},
+ )
+ second = client.post(
+ "/api/reports/job-1",
+ files={"file": ("second.html", b"second", "text/html")},
+ )
+ html = client.get("/api/reports/job-1/html")
+
+ assert first.status_code == 200
+ assert second.status_code == 200
+ assert second.json()["exists"] is True
+ assert html.text == "second"
+```
+
+- [ ] **Step 2: Run route tests and verify they fail**
+
+Run:
+
+```bash
+cd report-viewer
+uv run pytest tests/test_routes.py -v
+```
+
+Expected: fail during import with `ModuleNotFoundError: No module named 'app.main'`.
+
+- [ ] **Step 3: Commit RED route tests**
+
+Run:
+
+```bash
+git add report-viewer/tests/test_routes.py
+git commit -m "test(report-viewer): add HTTP route tests"
+```
+
+### Task 4: FastAPI Routes and Upload API
+
+**Files:**
+- Create: `report-viewer/app/main.py`
+- Create: `report-viewer/app/templates/report.html`
+- Create: `report-viewer/app/static/app.css`
+- Create: `report-viewer/app/static/app.js`
+- Test: `report-viewer/tests/test_routes.py`
+
+- [ ] **Step 1: Add minimal HTML shell template**
+
+Create `report-viewer/app/templates/report.html`:
+
+```html
+
+
+
+
+
+ Report {{ job_name }}
+
+
+
+
+
+
+
+ No report uploaded
+ Select an HTML file to upload and display it for this job.
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add minimal shell styling**
+
+Create `report-viewer/app/static/app.css`:
+
+```css
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: #111;
+ color: #f5f5f5;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+}
+
+.toolbar {
+ height: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 18px;
+ background: #181818;
+ border-bottom: 1px solid #2a2a2a;
+}
+
+.title {
+ min-width: 0;
+}
+
+.eyebrow {
+ display: block;
+ color: #a3a3a3;
+ font-size: 11px;
+ line-height: 1.2;
+ text-transform: uppercase;
+}
+
+h1 {
+ margin: 2px 0 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.upload-button {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 0 14px;
+ border: 1px solid #d4d4d4;
+ background: #f5f5f5;
+ color: #111;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.upload-button:hover {
+ background: #fff;
+}
+
+.upload-button input {
+ display: none;
+}
+
+.content {
+ height: calc(100vh - 64px);
+}
+
+#report-frame {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: #fff;
+}
+
+.empty-state,
+.error-state {
+ display: grid;
+ min-height: 100%;
+ place-content: center;
+ padding: 32px;
+ text-align: center;
+}
+
+.empty-state h2 {
+ margin: 0 0 8px;
+ font-size: 20px;
+}
+
+.empty-state p,
+.error-state {
+ color: #b8b8b8;
+}
+
+.error-state {
+ color: #ffb4b4;
+}
+```
+
+- [ ] **Step 3: Add browser behavior script**
+
+Create `report-viewer/app/static/app.js`:
+
+```javascript
+const content = document.querySelector(".content");
+const jobName = content.dataset.jobName;
+const input = document.getElementById("upload-input");
+const frame = document.getElementById("report-frame");
+const emptyState = document.getElementById("empty-state");
+const errorState = document.getElementById("error-state");
+
+function showError(message) {
+ errorState.textContent = message;
+ errorState.hidden = false;
+}
+
+function clearError() {
+ errorState.textContent = "";
+ errorState.hidden = true;
+}
+
+function showReport() {
+ emptyState.hidden = true;
+ frame.hidden = false;
+ frame.src = `/api/reports/${encodeURIComponent(jobName)}/html?t=${Date.now()}`;
+}
+
+function showEmpty() {
+ frame.hidden = true;
+ frame.removeAttribute("src");
+ emptyState.hidden = false;
+}
+
+async function refreshStatus() {
+ clearError();
+ const response = await fetch(`/api/reports/${encodeURIComponent(jobName)}/status`);
+ if (!response.ok) {
+ showError("Failed to load report status.");
+ showEmpty();
+ return;
+ }
+ const status = await response.json();
+ if (status.exists) {
+ showReport();
+ } else {
+ showEmpty();
+ }
+}
+
+async function uploadFile(file) {
+ clearError();
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const response = await fetch(`/api/reports/${encodeURIComponent(jobName)}`, {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!response.ok) {
+ let message = "Upload failed.";
+ try {
+ const data = await response.json();
+ if (data.detail) message = data.detail;
+ } catch {
+ message = response.statusText || message;
+ }
+ showError(message);
+ return;
+ }
+
+ await refreshStatus();
+}
+
+input.addEventListener("change", async () => {
+ const file = input.files && input.files[0];
+ input.value = "";
+ if (!file) return;
+ await uploadFile(file);
+});
+
+refreshStatus();
+```
+
+- [ ] **Step 4: Implement FastAPI app and routes**
+
+Create `report-viewer/app/main.py`:
+
+```python
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+from fastapi import FastAPI, File, HTTPException, Request, UploadFile
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+from app.storage import InvalidJobNameError, ReportStorage
+
+APP_DIR = Path(__file__).parent
+DEFAULT_DATA_ROOT = APP_DIR.parent / "data"
+
+
+def _storage_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, InvalidJobNameError):
+ return HTTPException(status_code=400, detail="invalid job name")
+ if isinstance(exc, ValueError):
+ return HTTPException(status_code=400, detail=str(exc))
+ return HTTPException(status_code=500, detail="failed to write report")
+
+
+def _is_html_filename(filename: str | None) -> bool:
+ if not filename:
+ return False
+ suffix = Path(filename).suffix.lower()
+ return suffix in {".html", ".htm"}
+
+
+def create_app(data_root: Path | None = None) -> FastAPI:
+ root = data_root or Path(os.environ.get("REPORT_VIEWER_DATA_ROOT", DEFAULT_DATA_ROOT))
+ storage = ReportStorage(root)
+ templates = Jinja2Templates(directory=str(APP_DIR / "templates"))
+
+ app = FastAPI(title="Harbor Report Viewer")
+ app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static")
+
+ @app.get("/health")
+ def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+ @app.get("/api/reports/{job_name}/status")
+ def report_status(job_name: str) -> dict[str, object]:
+ try:
+ return dict(storage.status(job_name))
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+
+ @app.get("/api/reports/{job_name}/html")
+ def report_html(job_name: str) -> FileResponse:
+ try:
+ path = storage.report_path(job_name)
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+ if not path.exists():
+ raise HTTPException(status_code=404, detail="report not found")
+ return FileResponse(path, media_type="text/html; charset=utf-8")
+
+ @app.post("/api/reports/{job_name}")
+ async def upload_report(
+ job_name: str,
+ file: UploadFile = File(...),
+ ) -> dict[str, object]:
+ if not _is_html_filename(file.filename):
+ raise HTTPException(
+ status_code=400,
+ detail="only .html or .htm files are supported",
+ )
+ content = await file.read()
+ try:
+ storage.save_html(job_name, content)
+ return dict(storage.status(job_name))
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+
+ @app.get("/{job_name}")
+ def report_page(request: Request, job_name: str):
+ try:
+ storage.report_path(job_name)
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+ return templates.TemplateResponse(
+ "report.html",
+ {"request": request, "job_name": job_name},
+ )
+
+ return app
+
+
+app = create_app()
+```
+
+- [ ] **Step 5: Run route tests and verify they pass**
+
+Run:
+
+```bash
+cd report-viewer
+uv run pytest tests/test_routes.py -v
+```
+
+Expected: all route tests pass.
+
+- [ ] **Step 6: Run all report-viewer tests**
+
+Run:
+
+```bash
+cd report-viewer
+uv run pytest -v
+```
+
+Expected: all report-viewer tests pass.
+
+- [ ] **Step 7: Commit routes and UI shell**
+
+Run:
+
+```bash
+git add report-viewer/app/main.py report-viewer/app/templates/report.html report-viewer/app/static/app.css report-viewer/app/static/app.js report-viewer/tests/test_routes.py
+git commit -m "feat(report-viewer): add report upload and display routes"
+```
+
+### Task 5: README and Harbor Integration Notes
+
+**Files:**
+- Create: `report-viewer/README.md`
+
+- [ ] **Step 1: Add service README**
+
+Create `report-viewer/README.md`:
+
+````markdown
+# Harbor Report Viewer
+
+Standalone HTML report service for Harbor job-level reports.
+
+This service lives in the Harbor repository for convenience, but it is not a Harbor CLI command and does not import Harbor internals.
+
+## Run
+
+```bash
+cd report-viewer
+uv run uvicorn app.main:app --host 0.0.0.0 --port 7397
+```
+
+Uploaded reports are stored under `data/{job_name}/index.html` by default.
+
+To use another storage root:
+
+```bash
+REPORT_VIEWER_DATA_ROOT=/path/to/report-data \
+uv run uvicorn app.main:app --host 0.0.0.0 --port 7397
+```
+
+## Harbor Viewer Integration
+
+Add the service base URL to the top level of the Harbor analyze profiles TOML file:
+
+```toml
+external_job_report_base_url = "http://111.119.196.110:7397"
+```
+
+Then restart Harbor Viewer. The Harbor job page `Report` button opens:
+
+```text
+{external_job_report_base_url}/{job_name}
+```
+
+## Upload Behavior
+
+- Open `/{job_name}`.
+- Click `Upload`.
+- Choose a `.html` or `.htm` file.
+- The upload replaces `data/{job_name}/index.html`.
+- The page refreshes the iframe after upload.
+
+The first version supports only one self-contained HTML file per job. It does not support zip uploads or additional assets.
+
+## Security
+
+This first version has no authentication. Run it only on a trusted network or behind external access control.
+
+Uploaded HTML is displayed in an iframe and is not sanitized. Treat uploaded reports as trusted internal content.
+````
+
+- [ ] **Step 2: Run README sanity check**
+
+Run:
+
+```bash
+sed -n '1,220p' report-viewer/README.md
+```
+
+Expected: output documents run command, Harbor integration, upload behavior, and security assumptions.
+
+- [ ] **Step 3: Commit README**
+
+Run:
+
+```bash
+git add report-viewer/README.md
+git commit -m "docs(report-viewer): document standalone service"
+```
+
+### Task 6: Final Verification and Manual Smoke Test
+
+**Files:**
+- No new files expected.
+
+- [ ] **Step 1: Run report-viewer tests**
+
+Run:
+
+```bash
+cd report-viewer
+uv run pytest -v
+```
+
+Expected: all report-viewer tests pass.
+
+- [ ] **Step 2: Run report-viewer lint**
+
+Run:
+
+```bash
+cd report-viewer
+uv run ruff check .
+uv run ruff format --check .
+```
+
+Expected: both commands pass.
+
+- [ ] **Step 3: Confirm service does not import Harbor**
+
+Run:
+
+```bash
+if rg -n "harbor\\." report-viewer; then
+ exit 1
+fi
+```
+
+Expected: command exits with status `0` and prints no matches.
+
+- [ ] **Step 4: Start service for a smoke test**
+
+Run:
+
+```bash
+cd report-viewer
+uv run uvicorn app.main:app --host 127.0.0.1 --port 7397
+```
+
+Expected: server logs show `Uvicorn running on http://127.0.0.1:7397`.
+
+- [ ] **Step 5: In another shell, exercise upload and display**
+
+Run:
+
+```bash
+cd report-viewer
+printf 'Smoke Report ' > /tmp/harbor-report-smoke.html
+curl -fsS http://127.0.0.1:7397/health
+curl -fsS http://127.0.0.1:7397/smoke-job | rg "Upload"
+curl -fsS http://127.0.0.1:7397/api/reports/smoke-job/status
+curl -fsS -F "file=@/tmp/harbor-report-smoke.html;type=text/html" http://127.0.0.1:7397/api/reports/smoke-job
+curl -fsS http://127.0.0.1:7397/api/reports/smoke-job/html | rg "Smoke Report"
+```
+
+Expected:
+
+- health response contains `{"status":"ok"}`
+- shell response contains `Upload`
+- initial status reports `"exists":false`
+- upload response reports `"exists":true`
+- HTML response contains `Smoke Report`
+
+- [ ] **Step 6: Stop smoke-test server**
+
+Press `Ctrl+C` in the uvicorn shell.
+
+Expected: uvicorn exits cleanly.
+
+- [ ] **Step 7: Inspect final diff**
+
+Run:
+
+```bash
+git status --short
+git diff --stat
+```
+
+Expected: no unstaged changes except possible smoke-test data under ignored `report-viewer/data/`.
+
+- [ ] **Step 8: Commit final verification changes only if needed**
+
+If lint or smoke testing changed tracked files, run:
+
+```bash
+git add report-viewer
+git commit -m "chore(report-viewer): apply final formatting"
+```
+
+Expected: commit contains only formatting or verification-driven tracked changes. If `git status --short` shows no tracked changes, do not create a commit.
+
+## Self-Review
+
+- Spec coverage: Task 1 and Task 2 implement filesystem storage, job validation, report status, and overwrite behavior. Task 3 and Task 4 implement the FastAPI routes, upload API, shell page, iframe display, and route error handling. Task 5 documents standalone operation and Harbor integration. Task 6 verifies tests, lint, lack of Harbor imports, and a real upload/display smoke path.
+- Incomplete-marker scan: The plan contains exact file paths, code, commands, and expected results. It does not leave implementation sections unspecified.
+- Type consistency: The storage class is consistently named `ReportStorage`; invalid names raise `InvalidJobNameError`; status keys are consistently `job_name`, `exists`, `size_bytes`, and `updated_at`; the upload form field is consistently `file`.
diff --git a/docs/superpowers/plans/2026-06-16-bitfun-cli-run-config.md b/docs/superpowers/plans/2026-06-16-bitfun-cli-run-config.md
new file mode 100644
index 00000000000..8d29118b34b
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-bitfun-cli-run-config.md
@@ -0,0 +1,427 @@
+# bitfun-cli Run Config Injection Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a `bitfun_config` agent kwarg that writes the provided BitFun app config root to BitFun's `config/app.json` before each `bitfun-cli exec` run.
+
+**Architecture:** `BitfunCli` owns a small setup-command builder that serializes the supplied config and overwrites BitFun's app config file inside the trial environment. `run()` executes that setup command before the main `bitfun-cli exec` command when `bitfun_config` is present, and keeps the existing cp-back `finally` behavior. The implementation follows OpenCode's "build config in memory, write before run, do not read existing config" pattern.
+
+**Tech Stack:** Python 3.12, Harbor installed-agent framework, pytest, unittest.mock `AsyncMock`, shell command construction with `json.dumps` and `shlex.quote`.
+
+---
+
+## Spec Reference
+
+- `docs/superpowers/specs/2026-06-16-bitfun-cli-run-config-design.md`
+
+## Scope Check
+
+The spec covers one subsystem: Harbor's built-in `bitfun-cli` agent. No separate plans are needed.
+
+## File Structure
+
+- Modify `src/harbor/agents/installed/bitfun_cli.py`
+ - Add `bitfun_config` constructor kwarg.
+ - Store validated config on `self._bitfun_config`.
+ - Add `_build_register_config_command()` to build the setup command.
+ - Call the setup command from `run()` before `_build_run_shell()`.
+- Modify `tests/unit/agents/installed/test_bitfun_cli.py`
+ - Add config-command unit tests near `TestBuildRunShell`.
+ - Add run-ordering and config-write-failure tests near existing `TestBitfunCliAgent` run tests.
+ - Add `shlex` import for shell-quoted JSON extraction.
+
+## Implementation Tasks
+
+### Task 1: Build the BitFun app config setup command
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add failing config-command tests**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, add `import shlex` after `import os`:
+
+```python
+import os
+import shlex
+import shutil
+```
+
+In the same file, insert this test class after `TestBuildRunShell` and before `TestBitfunCliAgent`:
+
+```python
+class TestRegisterConfigCommand:
+ def _parse_written_config(self, command: str) -> dict:
+ prefix = "printf '%s\\n' "
+ suffix = ' > "$BITFUN_CONFIG_ROOT/config/app.json"'
+ start = command.index(prefix) + len(prefix)
+ end = command.rindex(suffix)
+ quoted_json = command[start:end]
+ return _json.loads(shlex.split(f"cmd {quoted_json}")[1])
+
+ def test_no_bitfun_config_returns_none(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._build_register_config_command() is None
+
+ def test_builds_command_that_overwrites_app_json_with_exact_config(self, temp_dir):
+ bitfun_config = {
+ "app": {"language": "zh-CN"},
+ "ai": {
+ "models": [
+ {
+ "id": "deepseek-v4-pro",
+ "name": "deepseek-v4-pro",
+ "provider": "openai",
+ "model_name": "deepseek-v4-pro",
+ "base_url": "https://api.deepseek.com",
+ "api_key": "${DEEPSEEK_API_KEY}",
+ "enabled": True,
+ "context_window": 1048576,
+ "max_tokens": 65536,
+ "reasoning_mode": "enabled",
+ "reasoning_effort": "max",
+ }
+ ],
+ "default_models": {
+ "primary": "deepseek-v4-pro",
+ "fast": "deepseek-v4-pro",
+ },
+ },
+ "mcp_servers": {"example": {"command": "server --with 'quote'"}},
+ }
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config=bitfun_config)
+
+ command = agent._build_register_config_command()
+
+ assert command is not None
+ assert 'BITFUN_CONFIG_ROOT="${BITFUN_USER_ROOT:-}"' in command
+ assert 'BITFUN_CONFIG_ROOT="${BITFUN_E2E_USER_ROOT:-}"' in command
+ assert (
+ 'BITFUN_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"'
+ in command
+ )
+ assert 'mkdir -p "$BITFUN_CONFIG_ROOT/config"' in command
+ assert ' > "$BITFUN_CONFIG_ROOT/config/app.json"' in command
+ assert "config.toml" not in command
+ assert self._parse_written_config(command) == bitfun_config
+
+ def test_bitfun_config_must_be_dict(self, temp_dir):
+ kwargs = {"bitfun_config": ["not", "a", "dict"]}
+ with pytest.raises(ValueError, match="bitfun_config must be a dict"):
+ BitfunCli(logs_dir=temp_dir, **kwargs)
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRegisterConfigCommand -v
+```
+
+Expected: FAIL because `BitfunCli` has no `_build_register_config_command` method and does not validate `bitfun_config`.
+
+- [ ] **Step 3: Add `bitfun_config` constructor storage and validation**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, replace the current `__init__` method with:
+
+```python
+ def __init__(
+ self,
+ logs_dir: Path,
+ binary_path: str = _DEFAULT_BINARY,
+ exec_agent: str = "agentic",
+ output_patch_path: str | None = "/logs/agent/bitfun.patch",
+ bitfun_config: dict[str, Any] | None = None,
+ *args,
+ **kwargs,
+ ) -> None:
+ if bitfun_config is not None and not isinstance(bitfun_config, dict):
+ raise ValueError("bitfun_config must be a dict")
+ self._binary_path = binary_path
+ self._exec_agent = exec_agent
+ self._output_patch_path = output_patch_path
+ self._bitfun_config = bitfun_config
+ super().__init__(logs_dir, *args, **kwargs)
+```
+
+- [ ] **Step 4: Add the setup-command builder**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, insert this method after `_build_run_shell()` and before `_persist_failure_output()`:
+
+```python
+ def _build_register_config_command(self) -> str | None:
+ if self._bitfun_config is None:
+ return None
+
+ config_json = json.dumps(self._bitfun_config, indent=2)
+ escaped = shlex.quote(config_json)
+ return (
+ 'BITFUN_CONFIG_ROOT="${BITFUN_USER_ROOT:-}"\n'
+ 'if [ -z "$BITFUN_CONFIG_ROOT" ]; then\n'
+ ' BITFUN_CONFIG_ROOT="${BITFUN_E2E_USER_ROOT:-}"\n'
+ "fi\n"
+ 'if [ -z "$BITFUN_CONFIG_ROOT" ]; then\n'
+ ' BITFUN_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"\n'
+ ' BITFUN_CONFIG_ROOT="$BITFUN_XDG_CONFIG_HOME/bitfun"\n'
+ "fi\n"
+ 'mkdir -p "$BITFUN_CONFIG_ROOT/config"\n'
+ f"printf '%s\\n' {escaped} > \"$BITFUN_CONFIG_ROOT/config/app.json\""
+ )
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRegisterConfigCommand -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit Task 1**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): build run config setup command"
+```
+
+Expected: commit succeeds with only `bitfun_cli.py` and `test_bitfun_cli.py` staged.
+
+### Task 2: Execute the setup command before bitfun-cli exec
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add failing run-ordering tests**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, add these tests inside `class TestBitfunCliAgent`, after `test_run_forwards_bitfun_prefixed_env`:
+
+```python
+ @pytest.mark.asyncio
+ async def test_run_writes_bitfun_config_before_exec(self, temp_dir):
+ bitfun_config = {
+ "app": {"language": "zh-CN"},
+ "ai": {
+ "models": [],
+ "default_models": {
+ "primary": "deepseek-v4-pro",
+ "fast": "deepseek-v4-pro",
+ },
+ },
+ }
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config=bitfun_config)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+
+ await agent.run("Hi", mock_env, AgentContext())
+
+ assert mock_env.exec.call_count == 3
+ setup_cmd = mock_env.exec.call_args_list[0].kwargs["command"]
+ run_cmd = mock_env.exec.call_args_list[1].kwargs["command"]
+ cp_cmd = mock_env.exec.call_args_list[2].kwargs["command"]
+ assert "config/app.json" in setup_cmd
+ assert "deepseek-v4-pro" in setup_cmd
+ assert " exec " in run_cmd
+ assert "config/app.json" not in run_cmd
+ assert "/logs/agent/bitfun" in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_run_does_not_exec_main_when_config_write_fails(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config={"ai": {"models": []}})
+ mock_env = AsyncMock()
+ mock_env.exec.side_effect = [
+ AsyncMock(return_code=1, stdout="config failed", stderr=""),
+ AsyncMock(return_code=0, stdout="", stderr=""),
+ ]
+
+ with pytest.raises(NonZeroAgentExitCodeError):
+ await agent.run("Hi", mock_env, AgentContext())
+
+ assert mock_env.exec.call_count == 2
+ setup_cmd = mock_env.exec.call_args_list[0].kwargs["command"]
+ cp_cmd = mock_env.exec.call_args_list[1].kwargs["command"]
+ assert "config/app.json" in setup_cmd
+ assert " exec " not in cp_cmd
+ assert "/logs/agent/bitfun" in cp_cmd
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest \
+ tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_writes_bitfun_config_before_exec \
+ tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_does_not_exec_main_when_config_write_fails \
+ -v
+```
+
+Expected: FAIL because `run()` currently executes only the main command and cp-back.
+
+- [ ] **Step 3: Wire config setup into `run()`**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, replace the body of `run()` with:
+
+```python
+ _ = context
+ try:
+ config_command = self._build_register_config_command()
+ if config_command:
+ await self.exec_as_agent(
+ environment,
+ command=config_command,
+ env=self._env_for_run(),
+ )
+ await self.exec_as_agent(
+ environment,
+ command=self._build_run_shell(instruction),
+ env=self._env_for_run(),
+ )
+ finally:
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=self._cp_back_command(),
+ env=self._env_for_run(),
+ )
+ self._log_cp_back_gaps()
+ except Exception as exc:
+ self.logger.debug(f"BitFun cp-back failed (non-fatal): {exc}")
+```
+
+- [ ] **Step 4: Run focused run tests**
+
+Run:
+
+```bash
+uv run pytest \
+ tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_uses_container_workdir_and_exec \
+ tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_without_output_patch \
+ tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_forwards_bitfun_prefixed_env \
+ tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_writes_bitfun_config_before_exec \
+ tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_does_not_exec_main_when_config_write_fails \
+ -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run all bitfun-cli unit tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit Task 2**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): write app config before run"
+```
+
+Expected: commit succeeds with only `bitfun_cli.py` and `test_bitfun_cli.py` staged.
+
+### Task 3: Repository verification
+
+**Files:**
+- Verify: `src/harbor/agents/installed/bitfun_cli.py`
+- Verify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Run the project-required lint fix**
+
+Run:
+
+```bash
+uv run ruff check --fix .
+```
+
+Expected: command exits 0. If it modifies files, inspect `git diff`.
+
+- [ ] **Step 2: Run the project-required formatter**
+
+Run:
+
+```bash
+uv run ruff format .
+```
+
+Expected: command exits 0. If it modifies files, inspect `git diff`.
+
+- [ ] **Step 3: Run the project-required type check**
+
+Run:
+
+```bash
+uv run ty check
+```
+
+Expected: command exits 0.
+
+- [ ] **Step 4: Run the default unit verification**
+
+Run:
+
+```bash
+uv run pytest tests/unit/
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit formatting-only changes if any exist**
+
+Run:
+
+```bash
+git status --short
+```
+
+If `ruff` changed files after Task 2's commit, run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "chore: format bitfun-cli run config injection"
+```
+
+Expected: either no changes remain, or the formatting-only commit succeeds.
+
+- [ ] **Step 6: Confirm final status**
+
+Run:
+
+```bash
+git status --short
+```
+
+Expected: no modified tracked files from this implementation remain. Pre-existing untracked files, such as `test.yaml`, may still appear and should not be modified unless the user asks.
+
+## Self-Review
+
+Spec coverage:
+
+- `bitfun_config` kwarg is implemented in Task 1.
+- Complete app config root is written exactly as provided in Task 1 tests.
+- No read, merge, wrapping, or preservation of existing `app.json` is enforced by the command builder behavior in Task 1.
+- Run-before-exec ordering and failure behavior are implemented and tested in Task 2.
+- Existing no-config behavior is preserved by focused run tests in Task 2.
+- Required project verification commands are listed in Task 3.
+
+Type consistency:
+
+- The kwarg is consistently named `bitfun_config`.
+- The stored instance field is consistently named `_bitfun_config`.
+- The setup helper is consistently named `_build_register_config_command`.
+
+Placeholder scan:
+
+- The plan contains no `TBD`, `TODO`, or deferred implementation steps.
diff --git a/docs/superpowers/plans/2026-06-17-bitfun-cli-redacted-final-config-capture.md b/docs/superpowers/plans/2026-06-17-bitfun-cli-redacted-final-config-capture.md
new file mode 100644
index 00000000000..816d96ee54a
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-17-bitfun-cli-redacted-final-config-capture.md
@@ -0,0 +1,986 @@
+# bitfun-cli Redacted Final Config Capture Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Save the final BitFun app config after each `bitfun-cli` run as a redacted agent-log artifact without persisting raw secrets.
+
+**Architecture:** `BitfunCli.run()` keeps its existing pre-run config write, main exec, and cp-back behavior, then performs a BitFun-only post-run config capture while the environment is still alive. The container only probes paths and creates directories; Harbor downloads the raw config to a private host temp file, redacts it with Python stdlib JSON handling, uploads only `app.redacted.json` back to `/logs/agent`, updates the cp-back manifest, and deletes all temp files in `finally`.
+
+**Tech Stack:** Python 3.12, Harbor installed-agent framework, `BaseEnvironment.download_file()` / `upload_file()`, pytest, `AsyncMock`, Python stdlib `json`, `tempfile`, shell command construction.
+
+---
+
+## Spec Reference
+
+- `docs/superpowers/specs/2026-06-17-bitfun-cli-save-final-config-design.md`
+
+## Scope Check
+
+The spec covers one subsystem: Harbor's built-in `bitfun-cli` agent. No separate plans are needed.
+
+## File Structure
+
+- Modify `src/harbor/agents/installed/bitfun_cli.py`
+ - Add constants for the redacted artifact path, remote config directory, remote manifest path, redaction marker, and sensitive key names.
+ - Add a shared BitFun config-root shell snippet used by both the pre-run config writer and the post-run probe.
+ - Add `_build_app_config_probe_command()` for container-side path probing without JSON processing.
+ - Add host-side recursive redaction helpers.
+ - Add host-side final config capture helpers that use `download_file()` and `upload_file()`.
+ - Call final config capture from `run()` after existing cp-back.
+ - Add the redacted config artifact to BitFun context metadata when present.
+- Modify `tests/unit/agents/installed/test_bitfun_cli.py`
+ - Add probe command tests.
+ - Add redaction helper tests.
+ - Add final config capture success, absent-source, invalid-JSON, and cleanup tests.
+ - Update existing run/cp-back tests for the extra post-run probe command.
+
+## Implementation Tasks
+
+### Task 1: Add a shared config-root snippet and app-config probe command
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add failing probe-command tests**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, insert this class after `TestRegisterConfigCommand`:
+
+```python
+class TestAppConfigProbeCommand:
+ def test_probe_uses_same_config_root_resolution_as_config_writer(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config={"ai": {"models": []}})
+
+ setup_command = agent._build_register_config_command()
+ probe_command = agent._build_app_config_probe_command()
+
+ assert setup_command is not None
+ for snippet in (
+ 'BITFUN_CONFIG_ROOT="${BITFUN_USER_ROOT:-}"',
+ 'BITFUN_CONFIG_ROOT="${BITFUN_E2E_USER_ROOT:-}"',
+ 'BITFUN_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
+ 'BITFUN_CONFIG_ROOT="$BITFUN_XDG_CONFIG_HOME/bitfun"',
+ ):
+ assert snippet in setup_command
+ assert snippet in probe_command
+
+ def test_probe_reports_source_exists_and_size_without_json_tooling(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ command = agent._build_app_config_probe_command()
+
+ assert 'APP_CONFIG_SRC="$BITFUN_CONFIG_ROOT/config/app.json"' in command
+ assert "source=%s" in command
+ assert "exists=true" in command
+ assert "exists=false" in command
+ assert "size_bytes=%s" in command
+ assert "jq" not in command
+ assert "python" not in command.lower()
+ assert "node" not in command.lower()
+ assert "/root/.config/bitfun" not in command
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestAppConfigProbeCommand -v
+```
+
+Expected: FAIL because `_build_app_config_probe_command()` does not exist.
+
+- [ ] **Step 3: Add path constants and shared shell snippet**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add `import tempfile` beside the existing imports:
+
+```python
+import shlex
+import tempfile
+```
+
+Add these constants after `_BITFUN_DATA_SUBDIR`:
+
+```python
+_REMOTE_BITFUN_CONFIG_DIR = "/logs/agent/bitfun/config"
+_REMOTE_APP_CONFIG_REDACTED_PATH = (
+ f"{_REMOTE_BITFUN_CONFIG_DIR}/app.redacted.json"
+)
+_APP_CONFIG_REDACTED_ARTIFACT_PATH = "agent/bitfun/config/app.redacted.json"
+_REMOTE_CP_BACK_MANIFEST_PATH = "/logs/agent/bitfun/cp-back-manifest.json"
+_REDACTED_CONFIG_VALUE = "[REDACTED]"
+```
+
+Add this module-level helper after `_CP_BACK_COMMAND`:
+
+```python
+def _bitfun_config_root_shell() -> str:
+ return (
+ 'BITFUN_CONFIG_ROOT="${BITFUN_USER_ROOT:-}"\n'
+ 'if [ -z "$BITFUN_CONFIG_ROOT" ]; then\n'
+ ' BITFUN_CONFIG_ROOT="${BITFUN_E2E_USER_ROOT:-}"\n'
+ "fi\n"
+ 'if [ -z "$BITFUN_CONFIG_ROOT" ]; then\n'
+ ' BITFUN_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"\n'
+ ' BITFUN_CONFIG_ROOT="$BITFUN_XDG_CONFIG_HOME/bitfun"\n'
+ "fi\n"
+ )
+```
+
+- [ ] **Step 4: Reuse the snippet in the pre-run config writer**
+
+In `BitfunCli._build_register_config_command()`, replace the duplicated config-root shell text with:
+
+```python
+ return (
+ _bitfun_config_root_shell()
+ + 'mkdir -p "$BITFUN_CONFIG_ROOT/config"\n'
+ + f"printf '%s\\n' {escaped} > \"$BITFUN_CONFIG_ROOT/config/app.json\""
+ )
+```
+
+- [ ] **Step 5: Add the probe command builder**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add this method after `_build_register_config_command()`:
+
+```python
+ def _build_app_config_probe_command(self) -> str:
+ return (
+ _bitfun_config_root_shell()
+ + 'APP_CONFIG_SRC="$BITFUN_CONFIG_ROOT/config/app.json"\n'
+ + 'printf "source=%s\\n" "$APP_CONFIG_SRC"\n'
+ + 'if [ -f "$APP_CONFIG_SRC" ]; then\n'
+ + ' printf "exists=true\\n"\n'
+ + ' printf "size_bytes=%s\\n" "$(wc -c < "$APP_CONFIG_SRC" 2>/dev/null || printf 0)"\n'
+ + "else\n"
+ + ' printf "exists=false\\n"\n'
+ + ' printf "size_bytes=0\\n"\n'
+ + "fi\n"
+ )
+```
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRegisterConfigCommand tests/unit/agents/installed/test_bitfun_cli.py::TestAppConfigProbeCommand -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 7: Commit Task 1**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): probe final app config path"
+```
+
+Expected: commit succeeds with only `bitfun_cli.py` and `test_bitfun_cli.py` staged.
+
+### Task 2: Add host-side recursive redaction helpers
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add failing redaction tests**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, insert this class after `TestAppConfigProbeCommand`:
+
+```python
+class TestBitfunConfigRedaction:
+ def test_redacts_sensitive_keys_recursively_outside_ai(self, temp_dir):
+ config = {
+ "ai": {
+ "models": [
+ {
+ "id": "deepseek-v4-pro",
+ "api_key": "sk-secret",
+ "max_tokens": 65536,
+ }
+ ]
+ },
+ "mcp_servers": {
+ "private": {
+ "command": "server",
+ "env": {
+ "ACCESS_TOKEN": "token-secret",
+ "client-secret": "client-secret-value",
+ },
+ }
+ },
+ "auth": {
+ "Authorization": "Bearer secret",
+ "private_key": "-----BEGIN PRIVATE KEY-----",
+ "password": "p@ss",
+ },
+ }
+
+ redacted = BitfunCli._redact_config_secrets(config)
+
+ assert redacted["ai"]["models"][0]["api_key"] == "[REDACTED]"
+ assert redacted["ai"]["models"][0]["max_tokens"] == 65536
+ assert (
+ redacted["mcp_servers"]["private"]["env"]["ACCESS_TOKEN"]
+ == "[REDACTED]"
+ )
+ assert (
+ redacted["mcp_servers"]["private"]["env"]["client-secret"]
+ == "[REDACTED]"
+ )
+ assert redacted["auth"]["Authorization"] == "[REDACTED]"
+ assert redacted["auth"]["private_key"] == "[REDACTED]"
+ assert redacted["auth"]["password"] == "[REDACTED]"
+ assert config["ai"]["models"][0]["api_key"] == "sk-secret"
+
+ def test_does_not_redact_non_secret_token_or_model_fields(self, temp_dir):
+ config = {
+ "ai": {
+ "models": [
+ {
+ "id": "openai/gpt-5",
+ "model_name": "gpt-5",
+ "context_window": 1048576,
+ "max_tokens": 65536,
+ }
+ ],
+ "token_usage": {"records": 3},
+ }
+ }
+
+ redacted = BitfunCli._redact_config_secrets(config)
+
+ model = redacted["ai"]["models"][0]
+ assert model["id"] == "openai/gpt-5"
+ assert model["model_name"] == "gpt-5"
+ assert model["context_window"] == 1048576
+ assert model["max_tokens"] == 65536
+ assert redacted["ai"]["token_usage"] == {"records": 3}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunConfigRedaction -v
+```
+
+Expected: FAIL because `_redact_config_secrets()` does not exist.
+
+- [ ] **Step 3: Add sensitive key constants**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add these constants after `_REDACTED_CONFIG_VALUE`:
+
+```python
+_SENSITIVE_CONFIG_KEYS = frozenset(
+ {
+ "api_key",
+ "apikey",
+ "access_token",
+ "refresh_token",
+ "id_token",
+ "auth_token",
+ "bearer_token",
+ "authorization",
+ "password",
+ "passphrase",
+ "secret",
+ "client_secret",
+ "private_key",
+ "credential",
+ "credentials",
+ }
+)
+_SENSITIVE_CONFIG_SUFFIXES = ("_secret", "_password", "_private_key")
+```
+
+- [ ] **Step 4: Add recursive redaction helpers**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add these static methods inside `class BitfunCli`, after `_build_app_config_probe_command()`:
+
+```python
+ @staticmethod
+ def _is_sensitive_config_key(key: str) -> bool:
+ normalized = key.lower().replace("-", "_").replace(" ", "_")
+ return normalized in _SENSITIVE_CONFIG_KEYS or normalized.endswith(
+ _SENSITIVE_CONFIG_SUFFIXES
+ )
+
+ @classmethod
+ def _redact_config_secrets(cls, value: Any) -> Any:
+ if isinstance(value, dict):
+ return {
+ key: _REDACTED_CONFIG_VALUE
+ if isinstance(key, str) and cls._is_sensitive_config_key(key)
+ else cls._redact_config_secrets(child)
+ for key, child in value.items()
+ }
+ if isinstance(value, list):
+ return [cls._redact_config_secrets(item) for item in value]
+ return value
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunConfigRedaction -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit Task 2**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): redact captured app config"
+```
+
+Expected: commit succeeds with only `bitfun_cli.py` and `test_bitfun_cli.py` staged.
+
+### Task 3: Capture, redact, upload, and manifest final app config
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add test helpers for capture tests**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, add this import near the existing imports:
+
+```python
+from types import SimpleNamespace
+```
+
+Add these helpers after `_write_session()`:
+
+```python
+class _CaptureEnv:
+ def __init__(
+ self,
+ *,
+ raw_config_text: str | None,
+ probe_stdout: str,
+ existing_manifest: dict | None = None,
+ upload_raises: Exception | None = None,
+ ) -> None:
+ self.raw_config_text = raw_config_text
+ self.probe_stdout = probe_stdout
+ self.existing_manifest = existing_manifest or {}
+ self.upload_raises = upload_raises
+ self.exec_calls: list[dict] = []
+ self.downloads: list[tuple[str, _Path]] = []
+ self.uploads: dict[str, str] = {}
+
+ async def exec(self, **kwargs):
+ self.exec_calls.append(kwargs)
+ command = kwargs["command"]
+ if "APP_CONFIG_SRC" in command:
+ return SimpleNamespace(return_code=0, stdout=self.probe_stdout, stderr="")
+ return SimpleNamespace(return_code=0, stdout="", stderr="")
+
+ async def download_file(self, source_path, target_path):
+ target = _Path(target_path)
+ self.downloads.append((source_path, target))
+ if source_path == "/logs/agent/bitfun/cp-back-manifest.json":
+ target.write_text(_json.dumps(self.existing_manifest))
+ return
+ if self.raw_config_text is None:
+ raise FileNotFoundError(source_path)
+ target.write_text(self.raw_config_text)
+
+ async def upload_file(self, source_path, target_path):
+ if self.upload_raises is not None:
+ raise self.upload_raises
+ self.uploads[target_path] = _Path(source_path).read_text()
+```
+
+- [ ] **Step 2: Add failing successful-capture test**
+
+Add this class after `TestBitfunConfigRedaction`:
+
+```python
+class TestFinalAppConfigCapture:
+ @pytest.mark.asyncio
+ async def test_capture_uploads_only_redacted_config_and_updates_manifest(
+ self, temp_dir
+ ):
+ raw_config = {
+ "ai": {
+ "models": [
+ {
+ "id": "deepseek-v4-pro",
+ "api_key": "sk-secret",
+ "max_tokens": 65536,
+ }
+ ]
+ },
+ "mcp_servers": {
+ "private": {"env": {"ACCESS_TOKEN": "token-secret"}}
+ },
+ }
+ env = _CaptureEnv(
+ raw_config_text=_json.dumps(raw_config),
+ probe_stdout=(
+ "source=/home/agent/.config/bitfun/config/app.json\n"
+ "exists=true\n"
+ "size_bytes=160\n"
+ ),
+ existing_manifest={"cli_log": {"exists": True}},
+ )
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ await agent._capture_final_app_config(env)
+
+ assert "/logs/agent/bitfun/config/app.redacted.json" in env.uploads
+ assert "/logs/agent/bitfun/config/app.json" not in env.uploads
+ redacted = _json.loads(
+ env.uploads["/logs/agent/bitfun/config/app.redacted.json"]
+ )
+ assert redacted["ai"]["models"][0]["api_key"] == "[REDACTED]"
+ assert redacted["ai"]["models"][0]["max_tokens"] == 65536
+ assert (
+ redacted["mcp_servers"]["private"]["env"]["ACCESS_TOKEN"]
+ == "[REDACTED]"
+ )
+
+ manifest = _json.loads(
+ env.uploads["/logs/agent/bitfun/cp-back-manifest.json"]
+ )
+ assert manifest["cli_log"] == {"exists": True}
+ assert manifest["app_config"] == {
+ "source": "/home/agent/.config/bitfun/config/app.json",
+ "exists": True,
+ "size_bytes": 160,
+ "target": "agent/bitfun/config/app.redacted.json",
+ "redacted": True,
+ "raw_saved": False,
+ "capture_error": None,
+ }
+ assert not list(temp_dir.parent.glob(".bitfun-app-config-*.raw.json"))
+ assert not list(temp_dir.parent.glob(".bitfun-app-config-*.redacted.json"))
+
+ @pytest.mark.asyncio
+ async def test_capture_records_absent_source_without_downloading_config(
+ self, temp_dir
+ ):
+ env = _CaptureEnv(
+ raw_config_text=None,
+ probe_stdout=(
+ "source=/home/agent/.config/bitfun/config/app.json\n"
+ "exists=false\n"
+ "size_bytes=0\n"
+ ),
+ )
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ await agent._capture_final_app_config(env)
+
+ assert "/logs/agent/bitfun/config/app.redacted.json" not in env.uploads
+ assert all(
+ source == "/logs/agent/bitfun/cp-back-manifest.json"
+ for source, _target in env.downloads
+ )
+ manifest = _json.loads(
+ env.uploads["/logs/agent/bitfun/cp-back-manifest.json"]
+ )
+ assert manifest["app_config"] == {
+ "source": "/home/agent/.config/bitfun/config/app.json",
+ "exists": False,
+ "size_bytes": 0,
+ "target": None,
+ "redacted": False,
+ "raw_saved": False,
+ "capture_error": None,
+ }
+
+ @pytest.mark.asyncio
+ async def test_capture_invalid_json_does_not_upload_raw_or_redacted_config(
+ self, temp_dir
+ ):
+ env = _CaptureEnv(
+ raw_config_text="{not json",
+ probe_stdout=(
+ "source=/home/agent/.config/bitfun/config/app.json\n"
+ "exists=true\n"
+ "size_bytes=9\n"
+ ),
+ )
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ await agent._capture_final_app_config(env)
+
+ assert "/logs/agent/bitfun/config/app.redacted.json" not in env.uploads
+ assert "/logs/agent/bitfun/config/app.json" not in env.uploads
+ manifest = _json.loads(
+ env.uploads["/logs/agent/bitfun/cp-back-manifest.json"]
+ )
+ assert manifest["app_config"]["exists"] is True
+ assert manifest["app_config"]["redacted"] is False
+ assert manifest["app_config"]["raw_saved"] is False
+ assert manifest["app_config"]["capture_error"] == "invalid JSON"
+ assert not list(temp_dir.parent.glob(".bitfun-app-config-*.raw.json"))
+```
+
+- [ ] **Step 3: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestFinalAppConfigCapture -v
+```
+
+Expected: FAIL because `_capture_final_app_config()` does not exist.
+
+- [ ] **Step 4: Add probe parsing and temp-file helpers**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add these methods after `_redact_config_secrets()`:
+
+```python
+ @staticmethod
+ def _parse_app_config_probe_output(stdout: str | None) -> dict[str, str]:
+ parsed: dict[str, str] = {}
+ for line in (stdout or "").splitlines():
+ key, sep, value = line.partition("=")
+ if sep:
+ parsed[key.strip()] = value
+ return parsed
+
+ @staticmethod
+ def _probe_size_bytes(probe: dict[str, str]) -> int:
+ try:
+ return int(probe.get("size_bytes") or 0)
+ except ValueError:
+ return 0
+
+ def _new_app_config_capture_temp_path(self, suffix: str) -> Path:
+ self.logs_dir.parent.mkdir(parents=True, exist_ok=True)
+ fd, path = tempfile.mkstemp(
+ prefix=".bitfun-app-config-",
+ suffix=suffix,
+ dir=self.logs_dir.parent,
+ )
+ os.close(fd)
+ return Path(path)
+```
+
+- [ ] **Step 5: Add manifest upload helper**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add this method after `_new_app_config_capture_temp_path()`:
+
+```python
+ async def _upload_app_config_capture_manifest(
+ self,
+ environment: BaseEnvironment,
+ app_config: dict[str, Any],
+ temp_paths: list[Path],
+ ) -> None:
+ current_manifest = self._new_app_config_capture_temp_path(".manifest.json")
+ updated_manifest = self._new_app_config_capture_temp_path(
+ ".manifest.updated.json"
+ )
+ temp_paths.extend([current_manifest, updated_manifest])
+
+ manifest: dict[str, Any] = {}
+ try:
+ await environment.download_file(
+ _REMOTE_CP_BACK_MANIFEST_PATH,
+ current_manifest,
+ )
+ loaded = json.loads(current_manifest.read_text())
+ if isinstance(loaded, dict):
+ manifest = loaded
+ except Exception as exc:
+ self.logger.debug(
+ "BitFun final app config: could not load existing manifest: %s",
+ exc,
+ )
+
+ manifest["app_config"] = app_config
+ updated_manifest.write_text(json.dumps(manifest, indent=2) + "\n")
+ await environment.upload_file(updated_manifest, _REMOTE_CP_BACK_MANIFEST_PATH)
+```
+
+- [ ] **Step 6: Add final app config capture helper**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, add this method after `_upload_app_config_capture_manifest()`:
+
+```python
+ async def _capture_final_app_config(self, environment: BaseEnvironment) -> None:
+ app_config: dict[str, Any] = {
+ "source": None,
+ "exists": False,
+ "size_bytes": 0,
+ "target": None,
+ "redacted": False,
+ "raw_saved": False,
+ "capture_error": None,
+ }
+ temp_paths: list[Path] = []
+
+ try:
+ probe_result = await environment.exec(
+ command=f"set -o pipefail; {self._build_app_config_probe_command()}",
+ env=self._env_for_run(),
+ )
+ if probe_result.return_code != 0:
+ raise RuntimeError(
+ f"probe failed with exit {probe_result.return_code}"
+ )
+
+ probe = self._parse_app_config_probe_output(probe_result.stdout)
+ source = probe.get("source") or None
+ exists = probe.get("exists") == "true"
+ app_config.update(
+ {
+ "source": source,
+ "exists": exists,
+ "size_bytes": self._probe_size_bytes(probe),
+ }
+ )
+
+ if exists:
+ if source is None:
+ raise RuntimeError("app config probe did not return source")
+
+ raw_path = self._new_app_config_capture_temp_path(".raw.json")
+ redacted_path = self._new_app_config_capture_temp_path(
+ ".redacted.json"
+ )
+ temp_paths.extend([raw_path, redacted_path])
+
+ await environment.download_file(source, raw_path)
+ try:
+ raw_config = json.loads(raw_path.read_text())
+ except json.JSONDecodeError:
+ app_config["capture_error"] = "invalid JSON"
+ else:
+ redacted_config = self._redact_config_secrets(raw_config)
+ redacted_path.write_text(
+ json.dumps(redacted_config, indent=2) + "\n"
+ )
+ mkdir_result = await environment.exec(
+ command=f"mkdir -p {shlex.quote(_REMOTE_BITFUN_CONFIG_DIR)}",
+ env=self._env_for_run(),
+ )
+ if mkdir_result.return_code != 0:
+ raise RuntimeError(
+ f"mkdir failed with exit {mkdir_result.return_code}"
+ )
+ await environment.upload_file(
+ redacted_path,
+ _REMOTE_APP_CONFIG_REDACTED_PATH,
+ )
+ app_config.update(
+ {
+ "target": _APP_CONFIG_REDACTED_ARTIFACT_PATH,
+ "redacted": True,
+ }
+ )
+ except Exception as exc:
+ if app_config["capture_error"] is None:
+ app_config["capture_error"] = str(exc)
+ self.logger.debug("BitFun final app config capture failed: %s", exc)
+ finally:
+ try:
+ await self._upload_app_config_capture_manifest(
+ environment,
+ app_config,
+ temp_paths,
+ )
+ except Exception as exc:
+ self.logger.debug(
+ "BitFun final app config manifest update failed: %s",
+ exc,
+ )
+ for path in temp_paths:
+ try:
+ path.unlink(missing_ok=True)
+ except OSError as exc:
+ self.logger.debug(
+ "BitFun final app config temp cleanup failed for %s: %s",
+ path,
+ exc,
+ )
+```
+
+- [ ] **Step 7: Run tests to verify they pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestFinalAppConfigCapture -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Commit Task 3**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): capture redacted final app config"
+```
+
+Expected: commit succeeds with only `bitfun_cli.py` and `test_bitfun_cli.py` staged.
+
+### Task 4: Wire capture into run and expose artifact metadata
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add failing run integration test**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, add this test inside `class TestBitfunCliAgent`, after `test_run_writes_bitfun_config_before_exec`:
+
+```python
+ @pytest.mark.asyncio
+ async def test_run_attempts_final_app_config_capture_after_cp_back(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+
+ await agent.run("Hi", mock_env, AgentContext())
+
+ commands = [call.kwargs["command"] for call in mock_env.exec.call_args_list]
+ assert "bitfun-cli exec" in commands[0]
+ assert "cp-back-manifest.json" in commands[1]
+ assert "APP_CONFIG_SRC" in commands[2]
+```
+
+- [ ] **Step 2: Update existing run/cp-back tests for the extra probe command**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, make these exact assertion updates:
+
+```python
+# In test_run_uses_container_workdir_and_exec:
+assert mock_env.exec.call_count == 3
+
+# In test_run_writes_bitfun_config_before_exec:
+assert mock_env.exec.call_count == 4
+probe_cmd = mock_env.exec.call_args_list[3].kwargs["command"]
+assert "APP_CONFIG_SRC" in probe_cmd
+
+# In test_run_invokes_cp_back_in_finally:
+assert mock_env.exec.call_count == 3
+
+# In test_cp_back_failures_do_not_propagate:
+assert mock_env.exec.call_count == 3
+
+# In test_main_exec_failure_still_runs_cp_back:
+assert call_idx["n"] == 3
+```
+
+If a test already fetches `cp_cmd = mock_env.exec.call_args_list[1].kwargs["command"]`, keep that index unchanged. The cp-back command remains before the probe.
+
+- [ ] **Step 3: Add failing metadata test**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, find the existing `populate_context_post_run` metadata test that asserts `cp_back_manifest_path`. Add this setup before calling `populate_context_post_run(ctx)`:
+
+```python
+ (temp_dir / "bitfun" / "config").mkdir(parents=True)
+ (temp_dir / "bitfun" / "config" / "app.redacted.json").write_text("{}")
+```
+
+Add this assertion beside the other BitFun metadata path assertions:
+
+```python
+ assert (
+ ctx.metadata["bitfun"]["final_app_config_path"]
+ == "agent/bitfun/config/app.redacted.json"
+ )
+```
+
+- [ ] **Step 4: Run tests to verify they fail**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_attempts_final_app_config_capture_after_cp_back tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_run_invokes_cp_back_in_finally -v
+```
+
+Expected: FAIL because `run()` does not call `_capture_final_app_config()` yet.
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -k final_app_config_path -v
+```
+
+Expected: FAIL because context metadata does not include `final_app_config_path` yet.
+
+- [ ] **Step 5: Call final config capture from `run()`**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, replace the current `finally` block in `run()` with:
+
+```python
+ finally:
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=self._cp_back_command(),
+ env=self._env_for_run(),
+ )
+ self._log_cp_back_gaps()
+ except Exception as exc:
+ self.logger.debug(f"BitFun cp-back failed (non-fatal): {exc}")
+ try:
+ await self._capture_final_app_config(environment)
+ except Exception as exc:
+ self.logger.debug(
+ f"BitFun final app config capture failed (non-fatal): {exc}"
+ )
+```
+
+- [ ] **Step 6: Add final app config path to context metadata**
+
+In `BitfunCli.populate_context_post_run()`, extend `artifact_paths` with:
+
+```python
+ "final_app_config_path": (
+ self.logs_dir
+ / _BITFUN_DATA_SUBDIR
+ / "config"
+ / "app.redacted.json",
+ _APP_CONFIG_REDACTED_ARTIFACT_PATH,
+ ),
+```
+
+Place it near `cp_back_manifest_path`, since both are BitFun auxiliary artifacts.
+
+- [ ] **Step 7: Run targeted tests to verify they pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestBitfunCliAgent::test_run_attempts_final_app_config_capture_after_cp_back tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_run_invokes_cp_back_in_finally tests/unit/agents/installed/test_bitfun_cli.py -k final_app_config_path -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 8: Run the full BitFun unit test file**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 9: Commit Task 4**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): save redacted final config after run"
+```
+
+Expected: commit succeeds with only `bitfun_cli.py` and `test_bitfun_cli.py` staged.
+
+### Task 5: Final verification and formatting
+
+**Files:**
+- Modify: any files changed by formatters
+
+- [ ] **Step 1: Run Ruff check with fixes**
+
+Run:
+
+```bash
+uv run ruff check --fix .
+```
+
+Expected: PASS. If Ruff modifies files, inspect `git diff` and keep only relevant formatting changes.
+
+- [ ] **Step 2: Run Ruff format**
+
+Run:
+
+```bash
+uv run ruff format .
+```
+
+Expected: PASS. If this fails on an unrelated permission issue in `src/harbor/viewer/server.py`, run:
+
+```bash
+uv run ruff format --check src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+```
+
+Expected: PASS for the files touched by this feature.
+
+- [ ] **Step 3: Run type check**
+
+Run:
+
+```bash
+uv run ty check
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Run unit tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Inspect final diff**
+
+Run:
+
+```bash
+git status --short
+git diff --stat
+git diff -- src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+```
+
+Expected: only intended BitFun implementation/test changes plus any expected formatting changes are present. Existing unrelated `test.yaml` remains untracked and unstaged.
+
+- [ ] **Step 6: Commit verification fixes if any**
+
+If Task 5 formatting or type-check fixes changed files, run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "chore: format bitfun final config capture"
+```
+
+Expected: commit succeeds only if there are new relevant changes.
+
+## Self-Review
+
+- Spec coverage:
+ - BitFun-only behavior: Task 4 wires only `BitfunCli.run()`.
+ - No container JSON dependency: Task 1 probe is shell-only and tests reject `jq`, `python`, and `node`.
+ - Same config-root resolution as pre-run writer: Task 1 extracts and tests a shared shell snippet.
+ - External redaction: Tasks 2 and 3 parse/redact with host-side Python before upload.
+ - No raw artifact: Task 3 tests no upload to `/logs/agent/bitfun/config/app.json`; security is preserved by temp cleanup.
+ - Non-mounted environments: Task 3 uploads redacted output back to `/logs/agent` so normal trial log download collects it.
+ - Manifest metadata: Task 3 merges and uploads `app_config` into `cp-back-manifest.json`.
+ - Non-fatal behavior: Task 3 swallows capture failures and records manifest errors; Task 4 keeps main/cp-back failure semantics.
+- Placeholder scan: no TBD/TODO/fill-in placeholders remain.
+- Type consistency: constants, method names, artifact paths, and test expectations match across tasks.
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-06-17-bitfun-cli-redacted-final-config-capture.md`. Two execution options:
+
+1. Subagent-Driven (recommended) - dispatch a fresh subagent per task, review between tasks, fast iteration
+2. Inline Execution - execute tasks in this session using executing-plans, batch execution with checkpoints
+
+Which approach?
diff --git a/docs/superpowers/plans/2026-06-17-bitfun-cli-request-traces-retention.md b/docs/superpowers/plans/2026-06-17-bitfun-cli-request-traces-retention.md
new file mode 100644
index 00000000000..c6df8ae020c
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-17-bitfun-cli-request-traces-retention.md
@@ -0,0 +1,507 @@
+# bitfun-cli Request Traces Retention Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Preserve BitFun's project-level `request-traces` directory in Harbor trial artifacts and expose it in BitFun metadata.
+
+**Architecture:** Refactor the existing BitFun cp-back shell to select a BitFun project root, then copy both `sessions` and sibling `request-traces` from that root into `/logs/agent/bitfun/`. Extend the cp-back manifest, host-side artifact metadata, and gap debug logging without parsing request traces or changing ATIF trajectory conversion.
+
+**Tech Stack:** Python 3.12, pytest/pytest-asyncio, Bash cp-back shell embedded in `src/harbor/agents/installed/bitfun_cli.py`, Ruff, ty.
+
+---
+
+## Scope Check
+
+The approved spec covers one subsystem: Harbor's built-in `bitfun-cli` agent artifact retention path. It does not require viewer changes, ATIF conversion changes, or request trace parsing.
+
+## File Structure
+
+- Modify `src/harbor/agents/installed/bitfun_cli.py`
+ - `_CP_BACK_COMMAND`: select a project root, copy `sessions` and `request-traces`, and add `request_traces` to the manifest.
+ - `populate_context_post_run()`: expose `request_traces_path` when the copied directory exists.
+ - `_log_cp_back_gaps()`: log missing `request-traces` at debug level.
+- Modify `tests/unit/agents/installed/test_bitfun_cli.py`
+ - Add focused unit coverage for cp-back command text, metadata path exposure, and missing artifact debug logging.
+- Do not add unrelated files. Leave existing untracked `test.yaml` untouched.
+
+## Reference Spec
+
+- `docs/superpowers/specs/2026-06-17-bitfun-cli-request-traces-design.md`
+
+### Task 1: Preserve request-traces in the cp-back shell
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add the failing cp-back command test**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, add this test inside `class TestRunCpBackFinally`, after `test_cp_back_command_copies_cli_logs_directory`:
+
+```python
+ @pytest.mark.asyncio
+ async def test_cp_back_command_copies_project_request_traces(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+
+ await agent.run("hi", mock_env, AgentContext())
+
+ cp_cmd = mock_env.exec.call_args_list[1].kwargs["command"]
+ assert 'REQUEST_TRACES_SRC="$PROJECT_PATH/request-traces"' in cp_cmd
+ assert 'if [ -d "$REQUEST_TRACES_SRC" ]; then' in cp_cmd
+ assert "mkdir -p /logs/agent/bitfun/request-traces" in cp_cmd
+ assert (
+ 'cp -R "$REQUEST_TRACES_SRC"/. /logs/agent/bitfun/request-traces/'
+ in cp_cmd
+ )
+ assert '"request_traces":{"source":%s,"exists":%s}' in cp_cmd
+```
+
+Update `test_cp_back_command_has_slug_first_then_mtime_fallback` in the same class by replacing the first two assertions after `cp_cmd = ...` with these assertions:
+
+```python
+ assert "$HOME/.bitfun/projects/testbed" in cp_cmd
+ assert "$HOME/.bitfun/projects/-testbed" in cp_cmd
+ assert '[ -d "$d/sessions" ] && PROJECT_PATH="$d" && break' in cp_cmd
+ assert "LATEST_SESSIONS=$(ls -dt" in cp_cmd
+ assert 'PROJECT_PATH=$(dirname "${LATEST_SESSIONS%/}")' in cp_cmd
+```
+
+- [ ] **Step 2: Run the focused cp-back command tests and verify failure**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_cp_back_command_copies_project_request_traces tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_cp_back_command_has_slug_first_then_mtime_fallback -v
+```
+
+Expected: FAIL. The new test should fail because `_CP_BACK_COMMAND` does not define `REQUEST_TRACES_SRC`, does not copy `/logs/agent/bitfun/request-traces`, and does not include `request_traces` in the manifest.
+
+- [ ] **Step 3: Replace `_CP_BACK_COMMAND` with project-root based cp-back**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, replace the current `_CP_BACK_COMMAND = """\` block with this complete block:
+
+```python
+_CP_BACK_COMMAND = """\
+set +e
+PROJECT_PATH=""
+if [ -d "$HOME/.bitfun/projects" ]; then
+ for d in "$HOME/.bitfun/projects/testbed" \\
+ "$HOME/.bitfun/projects/-testbed"; do
+ [ -d "$d/sessions" ] && PROJECT_PATH="$d" && break
+ done
+fi
+if [ -z "$PROJECT_PATH" ]; then
+ LATEST_SESSIONS=$(ls -dt "$HOME"/.bitfun/projects/*/sessions/ 2>/dev/null | head -1)
+ [ -n "$LATEST_SESSIONS" ] && PROJECT_PATH=$(dirname "${LATEST_SESSIONS%/}")
+fi
+SESSIONS_SRC=""
+REQUEST_TRACES_SRC=""
+if [ -n "$PROJECT_PATH" ]; then
+ SESSIONS_SRC="$PROJECT_PATH/sessions"
+ REQUEST_TRACES_SRC="$PROJECT_PATH/request-traces"
+fi
+mkdir -p /logs/agent/bitfun/sessions
+if [ -n "$SESSIONS_SRC" ]; then
+ cp -R "$SESSIONS_SRC"/. /logs/agent/bitfun/sessions/ 2>/dev/null || true
+fi
+if [ -d "$REQUEST_TRACES_SRC" ]; then
+ mkdir -p /logs/agent/bitfun/request-traces
+ cp -R "$REQUEST_TRACES_SRC"/. /logs/agent/bitfun/request-traces/ 2>/dev/null || true
+fi
+BITFUN_CONFIG_DIR="$HOME/.config/bitfun"
+TOKEN_USAGE_SRC="$BITFUN_CONFIG_DIR/data/token_usage"
+CLI_LOGS_SRC="$BITFUN_CONFIG_DIR/cli-logs"
+CLI_LOG_SRC="$BITFUN_CONFIG_DIR/logs/bitfun-cli.log"
+AI_AUDIT_SRC="$BITFUN_CONFIG_DIR/logs/ai-request-audit.jsonl"
+MANIFEST=/logs/agent/bitfun/cp-back-manifest.json
+json_string() {
+ printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"
+}
+if [ -d "$TOKEN_USAGE_SRC" ]; then
+ cp -R "$TOKEN_USAGE_SRC" /logs/agent/bitfun/ 2>/dev/null || true
+fi
+if [ -d "$CLI_LOGS_SRC" ]; then
+ cp -R "$CLI_LOGS_SRC" /logs/agent/bitfun/ 2>/dev/null || true
+fi
+if [ -f "$CLI_LOG_SRC" ]; then
+ cp "$CLI_LOG_SRC" /logs/agent/bitfun/cli.log 2>/dev/null || true
+fi
+if [ -f "$AI_AUDIT_SRC" ]; then
+ cp "$AI_AUDIT_SRC" /logs/agent/bitfun/ai-request-audit.jsonl 2>/dev/null || true
+fi
+printf '{"bitfun_config_dir":%s,"sessions":{"source":%s,"exists":%s},"request_traces":{"source":%s,"exists":%s},"token_usage":{"source":%s,"exists":%s},"cli_logs":{"source":%s,"exists":%s},"cli_log":{"source":%s,"exists":%s,"size_bytes":%s},"ai_request_audit":{"source":%s,"exists":%s,"size_bytes":%s}}\n' \
+ "$(json_string "$BITFUN_CONFIG_DIR")" \
+ "$(json_string "${SESSIONS_SRC:-}")" \
+ "$([ -n "$SESSIONS_SRC" ] && [ -d "$SESSIONS_SRC" ] && printf true || printf false)" \
+ "$(json_string "${REQUEST_TRACES_SRC:-}")" \
+ "$([ -n "$REQUEST_TRACES_SRC" ] && [ -d "$REQUEST_TRACES_SRC" ] && printf true || printf false)" \
+ "$(json_string "$TOKEN_USAGE_SRC")" \
+ "$([ -d "$TOKEN_USAGE_SRC" ] && printf true || printf false)" \
+ "$(json_string "$CLI_LOGS_SRC")" \
+ "$([ -d "$CLI_LOGS_SRC" ] && printf true || printf false)" \
+ "$(json_string "$CLI_LOG_SRC")" \
+ "$([ -f "$CLI_LOG_SRC" ] && printf true || printf false)" \
+ "$([ -f "$CLI_LOG_SRC" ] && wc -c < "$CLI_LOG_SRC" 2>/dev/null || printf 0)" \
+ "$(json_string "$AI_AUDIT_SRC")" \
+ "$([ -f "$AI_AUDIT_SRC" ] && printf true || printf false)" \
+ "$([ -f "$AI_AUDIT_SRC" ] && wc -c < "$AI_AUDIT_SRC" 2>/dev/null || printf 0)" \
+ > "$MANIFEST" 2>/dev/null || true
+"""
+```
+
+- [ ] **Step 4: Run the focused cp-back command tests and verify pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_cp_back_command_copies_project_request_traces tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_cp_back_command_has_slug_first_then_mtime_fallback -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run all BitFun cp-back tests touched by this task**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit the cp-back shell change**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): preserve request traces in cp-back"
+```
+
+Expected: commit succeeds. Do not add `test.yaml`.
+
+### Task 2: Expose request_traces_path in BitFun metadata
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add the failing metadata assertion**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, update `TestPopulateContextPostRun.test_populates_context_artifact_paths_when_present`.
+
+Add this setup line after the existing `cli-logs` directory setup:
+
+```python
+ (temp_dir / "bitfun" / "request-traces" / "trace-0001").mkdir(parents=True)
+```
+
+Add this assertion after the existing `cli_logs_path` assertion:
+
+```python
+ assert (
+ ctx.metadata["bitfun"]["request_traces_path"]
+ == "agent/bitfun/request-traces"
+ )
+```
+
+The edited part of the test should read:
+
+```python
+ (temp_dir / "bitfun" / "cli-logs" / "20260604T172854").mkdir(parents=True)
+ (temp_dir / "bitfun" / "request-traces" / "trace-0001").mkdir(parents=True)
+ (temp_dir / "bitfun" / "cp-back-manifest.json").write_text("{}\n")
+ (temp_dir / "bitfun" / "config").mkdir(parents=True)
+ (temp_dir / "bitfun" / "config" / "app.redacted.json").write_text("{}")
+
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+
+ assert ctx.metadata is not None
+ assert ctx.metadata["bitfun"]["bitfun_data_path"] == "agent/bitfun"
+ assert ctx.metadata["bitfun"]["cli_log_path"] == "agent/bitfun/cli.log"
+ assert (
+ ctx.metadata["bitfun"]["ai_request_audit_path"]
+ == "agent/bitfun/ai-request-audit.jsonl"
+ )
+ assert ctx.metadata["bitfun"]["cli_logs_path"] == "agent/bitfun/cli-logs"
+ assert (
+ ctx.metadata["bitfun"]["request_traces_path"]
+ == "agent/bitfun/request-traces"
+ )
+ assert (
+ ctx.metadata["bitfun"]["cp_back_manifest_path"]
+ == "agent/bitfun/cp-back-manifest.json"
+ )
+```
+
+- [ ] **Step 2: Run the focused metadata test and verify failure**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestPopulateContextPostRun::test_populates_context_artifact_paths_when_present -v
+```
+
+Expected: FAIL with `KeyError: 'request_traces_path'`.
+
+- [ ] **Step 3: Add request_traces_path to `artifact_paths`**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, update the `artifact_paths` dict inside `populate_context_post_run()` by inserting this entry after `cli_logs_path`:
+
+```python
+ "request_traces_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "request-traces",
+ "agent/bitfun/request-traces",
+ ),
+```
+
+The relevant part should read:
+
+```python
+ "cli_logs_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "cli-logs",
+ "agent/bitfun/cli-logs",
+ ),
+ "request_traces_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "request-traces",
+ "agent/bitfun/request-traces",
+ ),
+ "cp_back_manifest_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "cp-back-manifest.json",
+ "agent/bitfun/cp-back-manifest.json",
+ ),
+```
+
+- [ ] **Step 4: Run the focused metadata test and verify pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestPopulateContextPostRun::test_populates_context_artifact_paths_when_present -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Commit the metadata change**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "feat(bitfun-cli): expose request traces artifact path"
+```
+
+Expected: commit succeeds. Do not add `test.yaml`.
+
+### Task 3: Log missing request-traces after cp-back
+
+**Files:**
+- Modify: `tests/unit/agents/installed/test_bitfun_cli.py`
+- Modify: `src/harbor/agents/installed/bitfun_cli.py`
+
+- [ ] **Step 1: Add the failing gap-log assertion**
+
+In `tests/unit/agents/installed/test_bitfun_cli.py`, update `TestRunCpBackFinally.test_log_cp_back_gaps_debug_when_artifacts_missing`.
+
+Add this assertion after the existing sessions assertion:
+
+```python
+ assert any("missing request-traces" in m for m in messages)
+```
+
+The final assertions in the test should read:
+
+```python
+ assert any("missing cli.log" in m for m in messages)
+ assert any(
+ "missing sessions" in m or "no session subdirectories" in m
+ for m in messages
+ )
+ assert any("missing request-traces" in m for m in messages)
+```
+
+- [ ] **Step 2: Run the focused gap-log test and verify failure**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_log_cp_back_gaps_debug_when_artifacts_missing -v
+```
+
+Expected: FAIL because no debug log mentions `missing request-traces`.
+
+- [ ] **Step 3: Add request-traces gap logging**
+
+In `src/harbor/agents/installed/bitfun_cli.py`, update `_log_cp_back_gaps()` by inserting this block after the `ai-request-audit.jsonl` check and before the `sessions_root` check:
+
+```python
+ request_traces_root = (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "request-traces"
+ )
+ if not request_traces_root.is_dir():
+ self.logger.debug(
+ "BitFun cp-back: missing request-traces directory at %s",
+ request_traces_root,
+ )
+```
+
+The top of `_log_cp_back_gaps()` should read:
+
+```python
+ def _log_cp_back_gaps(self) -> None:
+ cli_log = self.logs_dir / _BITFUN_DATA_SUBDIR / "cli.log"
+ if not cli_log.is_file():
+ self.logger.debug("BitFun cp-back: missing cli.log at %s", cli_log)
+ elif cli_log.stat().st_size == 0:
+ self.logger.debug("BitFun cp-back: empty cli.log at %s", cli_log)
+ audit_log = self.logs_dir / _BITFUN_DATA_SUBDIR / "ai-request-audit.jsonl"
+ if not audit_log.is_file():
+ self.logger.debug(
+ "BitFun cp-back: missing ai-request-audit.jsonl at %s",
+ audit_log,
+ )
+ request_traces_root = (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "request-traces"
+ )
+ if not request_traces_root.is_dir():
+ self.logger.debug(
+ "BitFun cp-back: missing request-traces directory at %s",
+ request_traces_root,
+ )
+ sessions_root = self.logs_dir / _BITFUN_DATA_SUBDIR / "sessions"
+```
+
+- [ ] **Step 4: Run the focused gap-log test and verify pass**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally::test_log_cp_back_gaps_debug_when_artifacts_missing -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Run the cp-back test class again**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py::TestRunCpBackFinally -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit the gap logging change**
+
+Run:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "chore(bitfun-cli): log missing request traces artifacts"
+```
+
+Expected: commit succeeds. Do not add `test.yaml`.
+
+### Task 4: Final verification
+
+**Files:**
+- Verify: `src/harbor/agents/installed/bitfun_cli.py`
+- Verify: `tests/unit/agents/installed/test_bitfun_cli.py`
+
+- [ ] **Step 1: Run all BitFun unit tests**
+
+Run:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run the repository unit test suite**
+
+Run:
+
+```bash
+uv run pytest tests/unit/
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run lint fix across the repo**
+
+Run:
+
+```bash
+uv run ruff check --fix .
+```
+
+Expected: exits 0. If Ruff modifies tracked files, inspect them with:
+
+```bash
+git diff -- src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+```
+
+- [ ] **Step 4: Run formatting across the repo**
+
+Run:
+
+```bash
+uv run ruff format .
+```
+
+Expected: exits 0. If Ruff formats tracked files, inspect them with:
+
+```bash
+git diff -- src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+```
+
+- [ ] **Step 5: Run type checking**
+
+Run:
+
+```bash
+uv run ty check
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Commit formatter or lint changes when present**
+
+Run:
+
+```bash
+git status --short
+```
+
+If the output includes modified tracked files from Ruff, commit only those tracked files:
+
+```bash
+git add src/harbor/agents/installed/bitfun_cli.py tests/unit/agents/installed/test_bitfun_cli.py
+git commit -m "chore: format bitfun request traces retention"
+```
+
+If `git status --short` only shows `?? test.yaml`, do not commit.
+
+- [ ] **Step 7: Record final verification status**
+
+Summarize these command results in the final handoff:
+
+```text
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+uv run pytest tests/unit/
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+```
+
+Expected: all passed, or any failures are listed with the failing command and the first actionable error.
+
+## Self-Review
+
+- Spec coverage: Task 1 covers cp-back project-root selection, `request-traces` copy, and `request_traces` manifest fields. Task 2 covers `request_traces_path` metadata. Task 3 covers best-effort missing artifact debug logging. Task 4 covers required verification.
+- Scope: The plan does not parse request traces, does not change ATIF conversion, and does not change viewer behavior.
+- Type and key consistency: The artifact directory name is `request-traces`; the manifest key is `request_traces`; the metadata key is `request_traces_path`.
diff --git a/docs/superpowers/specs/2026-05-13-bitfun-cli-atif-adapter-design.md b/docs/superpowers/specs/2026-05-13-bitfun-cli-atif-adapter-design.md
new file mode 100644
index 00000000000..7796f51bbb8
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-13-bitfun-cli-atif-adapter-design.md
@@ -0,0 +1,468 @@
+# Design: BitFun CLI → Harbor ATIF Trajectory Adapter
+
+**Status:** Approved for specification (2026-05-13).
+**Scope:** Implement `populate_context_post_run` for the existing `bitfun-cli` Harbor agent so it converts BitFun's native session/trace/metrics data into the ATIF trajectory format that Harbor already expects from first-party agents like `claude-code` and `codex`.
+**Predecessor:** `docs/superpowers/specs/2026-05-13-bitfun-cli-harbor-design.md` (the Harbor integration; explicitly defers ATIF as "future work"). This spec is that follow-up.
+
+## Goals
+
+- Set `SUPPORTS_ATIF = True` on `BitfunCli`.
+- Convert BitFun's per-turn `DialogTurnData` JSON files (`~/.bitfun/projects//sessions//turns/turn-*.json`) and per-session `metadata.json` into an ATIF **v1.7** `Trajectory`.
+- Pull token usage from BitFun's global token-usage ledger (`~/.config/bitfun/data/token_usage/records/YYYY-MM-DD.json`) and attach per-step `Metrics` + aggregate `FinalMetrics`.
+- Compute USD cost via LiteLLM's pricing table (BitFun itself does not report cost).
+- Emit subagent runs as **embedded** `subagent_trajectories[]` per ATIF v1.7.
+- Write `trajectory.json` to `self.logs_dir` and populate `AgentContext.cost_usd / n_input_tokens / n_cache_tokens / n_output_tokens`.
+- Keep the implementation structurally aligned with `src/harbor/agents/installed/claude_code.py` (`_get_session_dir` / `_convert_events_to_trajectory` / `populate_context_post_run`) so reviewers and future agent authors recognize the pattern.
+
+## Non-goals
+
+- Modifying BitFun's data model or storage layout.
+- Adding a standalone CLI for offline BitFun-session → ATIF conversion (could be a future tool reusing the same conversion functions).
+- Supporting BitFun's interactive (non-`exec`) mode.
+- Windows containers (`SUPPORTS_WINDOWS` stays `False`; BitFun-CLI on Harbor today is Docker/Linux-only).
+
+## Background
+
+### BitFun's on-disk schema (read from `BitFun/src/crates/services-core/src/session/types.rs`)
+
+Per BitFun session, three groups of files exist:
+
+- **`~/.bitfun/projects//sessions//`**
+ - `metadata.json` — `SessionMetadata` (sessionId, agentType, sessionKind: `standard|subagent`, modelName, turnCount, messageCount, toolCallCount, workspacePath, …).
+ - `state.json` — runtime config (max_context_tokens, compression flags, …). Not required for ATIF.
+ - `turns/turn-NNNN.json` — `DialogTurnData`:
+ - `turnId`, `turnIndex`, `sessionId`, `timestamp`, `kind` (`user_dialog`/`manual_compaction`/`local_command`), `status` (`in_progress|completed|error|cancelled`), `startTime`/`endTime`/`durationMs`.
+ - `userMessage`: `{ id, content, timestamp, metadata: { original_text?, … } }`.
+ - `modelRounds[]`: each round has its own `id`, `roundIndex`, `timestamp`, `status`, `providerId`, `modelId`, `modelAlias`, `firstChunkMs`, `firstVisibleOutputMs`, `streamDurationMs`, `durationMs`, `attemptCount`, `failureCategory`, `tokenDetails`, and three ordered arrays:
+ - `textItems[]` (`content`, `isMarkdown`, `orderIndex`, `status`, optional subagent markers)
+ - `thinkingItems[]` (`content`, `orderIndex`, `isCollapsed`, …)
+ - `toolItems[]` (`toolName`, `toolCall: {id, input}`, `toolResult: {result, success, resultForAssistant?, error?, durationMs?}`, `aiIntent`, runtime spans `queueWaitMs/preflightMs/confirmationWaitMs/executionMs`, `orderIndex`, `status`, `interruptionReason`, subagent markers `isSubagentItem`/`parentTaskToolId`/`subagentSessionId`/`subagentModelId`)
+ - `snapshots/context-NNNN.json` — cumulative LLM-message view (User/Assistant Mixed with `reasoning_content` + `tool_calls`). **Not used for ATIF** because `turn-*.json` is strictly richer.
+- **`~/.config/bitfun/data/token_usage/records/YYYY-MM-DD.json`** — `{ "records": [ TokenUsageRecord, … ] }`, one record per LLM call (`model_id`, `session_id`, `turn_id`, `timestamp`, `input_tokens`, `output_tokens`, `cached_tokens`, `cached_tokens_available`, `total_tokens`, `token_details`, `is_subagent`). Token data is **not** stored inside `turns/*.json`.
+- **`~/.config/bitfun/logs/bitfun-cli.log`** — diagnostic log. Optional copy-back.
+
+### Project slug rule (from `path_manager.rs:build_project_runtime_slug`)
+
+1. Each char of the canonical workspace path: ASCII-alphanumeric → lowercase, else → `-`.
+2. Trim leading/trailing `-`. Empty → `"workspace"`.
+3. If length > 120 → suffix with `-{sha256[:12]}`.
+
+Examples:
+- `/testbed` → `testbed`.
+- `/home/djn/code/harbor/BitFun/target/release` → `home-djn-code-harbor-bitfun-target-release`.
+
+### Subagent storage
+
+BitFun stores subagent sessions as **sibling session directories under the same project** with `metadata.json.sessionKind == "subagent"`. The parent's `turn-*.json` references the child via `toolItems[].subagentSessionId`. So copying the entire project's `sessions/` directory back picks up subagents automatically; we differentiate by reading each `metadata.json`.
+
+### Cost
+
+BitFun records token counts only, never `cost_usd`. Harbor users still expect a populated `AgentContext.cost_usd`, so we estimate it via LiteLLM's `model_cost` table (same approach used by `Codex._compute_cost_from_pricing`). When the table has no entry for the model, `cost_usd` stays `None` (non-blocking).
+
+## Architecture
+
+The implementation is contained to two files:
+
+- **Modify:** `src/harbor/agents/installed/bitfun_cli.py`
+- **Extend:** `tests/unit/agents/installed/test_bitfun_cli.py`
+- **Add (test fixture):** `tests/golden/bitfun_cli//…` (sanitized real session)
+
+### Section 1: Container-side data landing (cp-back, not env redirection)
+
+BitFun does **not** expose an environment variable to redirect its data directory (the `bitfun_home_override` field is test-only). Unlike `claude-code` which can set `CLAUDE_CONFIG_DIR` to a mount path, we keep BitFun's default HOME and **copy data back at the end of `run()`** (Codex-style finally block).
+
+In `BitfunCli.run()`, wrap the existing `bitfun exec …` invocation with `try/finally`. The finally block runs a small shell snippet inside the container:
+
+```bash
+set +e
+# Strategy C: try the precise slug-based path first, fall back to mtime scan.
+SLUG_PATH=""
+if [ -d "$HOME/.bitfun/projects" ]; then
+ for d in "$HOME/.bitfun/projects/testbed/sessions" \
+ "$HOME/.bitfun/projects/-testbed/sessions"; do
+ [ -d "$d" ] && SLUG_PATH="$d" && break
+ done
+fi
+# Fallback: pick the most recently modified session across all projects.
+if [ -z "$SLUG_PATH" ]; then
+ LATEST=$(ls -dt "$HOME"/.bitfun/projects/*/sessions/ 2>/dev/null | head -1)
+ [ -n "$LATEST" ] && SLUG_PATH="$LATEST"
+fi
+mkdir -p /logs/agent/bitfun/sessions
+if [ -n "$SLUG_PATH" ]; then
+ cp -R "$SLUG_PATH"/. /logs/agent/bitfun/sessions/ 2>/dev/null || true
+fi
+if [ -d "$HOME/.config/bitfun/data/token_usage" ]; then
+ cp -R "$HOME/.config/bitfun/data/token_usage" /logs/agent/bitfun/ 2>/dev/null || true
+fi
+[ -f "$HOME/.config/bitfun/logs/bitfun-cli.log" ] && \
+ cp "$HOME/.config/bitfun/logs/bitfun-cli.log" /logs/agent/bitfun/cli.log 2>/dev/null || true
+exit 0
+```
+
+`cwd` for BitFun is `/testbed`, so the canonical slug is `testbed`. We try both `testbed` and the literal `-testbed` (BitFun's slug code strips leading `-`, but if BitFun ever canonicalizes `/testbed` to something else due to symlinks, the mtime fallback covers us — hence **Strategy C: slug-precise first, mtime fallback**, per design decision Q1).
+
+The cp-back is **best-effort**: failures (e.g., bitfun didn't create any session because of a startup error) must not propagate, so the main exec's exit status is preserved.
+
+Resulting host-side layout under `/agent/` (= `self.logs_dir`):
+
+```
+/agent/
+├── bitfun.txt # existing: stdout tee
+├── bitfun.patch # existing: --output-patch artifact
+├── trajectory.json # NEW: ATIF output
+└── bitfun/ # NEW: raw bitfun data
+ ├── sessions/
+ │ ├── /
+ │ │ ├── metadata.json ({sessionKind: "standard"})
+ │ │ ├── state.json
+ │ │ ├── turns/turn-NNNN.json
+ │ │ └── snapshots/context-NNNN.json
+ │ └── /
+ │ └── … (sessionKind: "subagent")
+ ├── token_usage/
+ │ ├── model_stats.json
+ │ └── records/YYYY-MM-DD.json
+ └── cli.log # optional diagnostic
+```
+
+### Section 2: Event normalization (`bitfun turns → normalized events`)
+
+Same two-stage approach as `claude_code._convert_events_to_trajectory`: first flatten BitFun's per-turn structure into a list of `normalized_events`, then map 1:1 to ATIF `Step`s.
+
+```python
+def _convert_events_to_trajectory(session_dir: Path, *, is_subagent: bool = False) -> Trajectory | None:
+ # 1. Read metadata.json. If missing, return None.
+ # 2. Read turns/turn-*.json sorted by turnIndex ascending.
+ # 3. For each turn, dispatch on turn.kind:
+ # user_dialog → emit user step + walk modelRounds
+ # manual_compaction → emit a synthetic system step with is_copied_context=True
+ # local_command → skip silently (BitFun marks these as not model-visible)
+ # 4. Inside a round, merge thinkingItems + textItems + toolItems by orderIndex.
+ # Accumulate thinking content into a pending_reasoning buffer that is attached
+ # to the next assistant text or tool_call step in the same round.
+ # 5. After draining all turns, walk subagent sessions (sessionKind == "subagent")
+ # referenced via toolItems[].subagentSessionId; recursively convert each into
+ # its own Trajectory with trajectory_id = .
+```
+
+**Key normalization rules:**
+
+- **User step source text:** prefer `userMessage.metadata.original_text` (BitFun stores the unwrapped user input here); fall back to stripping the `… ` wrapper from `userMessage.content` if absent.
+- **Empty rounds** (no text/tool/thinking items): emit a single placeholder `source="agent"` step with `message=""` so round-level metrics and status are preserved on `step.extra` (per design decision Q3).
+- **`manual_compaction` turns:** emit one `source="system"` step with `message=""` and `is_copied_context=True` (per design decision Q3). These are valid ATIF v1.5+ markers indicating "do not include in training data". `local_command` turns are dropped (BitFun's own `is_model_visible()` returns false; they're shell snippets that never reach the model).
+- **Tool item ordering:** within a round, `orderIndex` is authoritative. Thinking accumulates **forward**: when we see a thinking item, append to `pending_reasoning`; when we see a textItem/toolItem, attach the buffered reasoning and clear it.
+
+### Section 3: ATIF field mapping
+
+#### Trajectory (root)
+
+| ATIF field | Source |
+|---|---|
+| `schema_version` | `"ATIF-v1.7"` (required for `subagent_trajectories`) |
+| `session_id` | `metadata.json["sessionId"]` |
+| `trajectory_id` | `None` on root; `` on embedded subagents |
+| `agent.name` | `"bitfun-cli"` |
+| `agent.version` | `self.version()` or `"unknown"` |
+| `agent.model_name` | `metadata.json["modelName"]` (falls back to "default") |
+| `agent.extra` | `{ "agent_type", "session_kind", "workspace_path", "schema_version" }` from metadata |
+| `steps` | see below |
+| `final_metrics` | see Section 4 |
+| `subagent_trajectories` | one per unique `subagentSessionId` (deduped); each is a recursively-built `Trajectory` with `trajectory_id` set |
+| `notes` | optional audit string (e.g., `"Skipped N local_command turns."`) |
+
+#### Step variants
+
+**User step** (from `turn.userMessage`)
+
+```
+source="user"
+timestamp=ISO8601(userMessage.timestamp / 1000)
+message=userMessage.metadata.original_text || strip_user_query_wrapper(userMessage.content)
+extra={ turn_id, turn_index, turn_kind, user_message_id }
+```
+
+**Assistant text step** (from `modelRound.textItems[i]`)
+
+```
+source="agent"
+timestamp=ISO8601(textItem.timestamp / 1000)
+message=textItem.content
+model_name=modelRound.modelId (fallback metadata.modelName)
+reasoning_content=joined thinking content with orderIndex < textItem.orderIndex in same round
+metrics=Metrics(...) # only on the FIRST assistant-source step per round (Section 4)
+extra={ turn_id, round_id, round_index, model_alias, provider_id,
+ status, round_status, attempt_count, failure_category }
+```
+
+**Tool-call step** (from `modelRound.toolItems[i]`)
+
+```
+source="agent"
+timestamp=ISO8601(toolItem.startTime / 1000)
+message=toolItem.aiIntent || f"Executed {toolItem.toolName}"
+model_name=modelRound.modelId
+reasoning_content=joined thinking before this toolItem in same round
+tool_calls=[ ToolCall(
+ tool_call_id=toolItem.toolCall.id,
+ function_name=toolItem.toolName,
+ arguments=toolItem.toolCall.input if isinstance(dict) else {"input": …},
+ extra={ tool_item_id, queue_wait_ms, preflight_ms, confirmation_wait_ms,
+ execution_ms, interruption_reason } ) ]
+observation=Observation(results=[ ObservationResult(
+ source_call_id=toolItem.toolCall.id,
+ content=toolItem.toolResult.resultForAssistant # preferred per Q4
+ or json.dumps(toolItem.toolResult.result),
+ subagent_trajectory_ref=[SubagentTrajectoryRef(trajectory_id=, session_id=)]
+ if toolItem.subagentSessionId else None,
+ extra={ raw_result, success, error, tool_duration_ms } # raw_result kept here per Q4
+) ])
+extra={ turn_id, round_id, tool_status, is_subagent_dispatch }
+```
+
+**Compaction system step** (from `turn.kind == "manual_compaction"`)
+
+```
+source="system"
+timestamp=ISO8601(turn.timestamp / 1000)
+message=""
+is_copied_context=True
+extra={ turn_id, turn_index, turn_kind: "manual_compaction" }
+```
+
+#### Subagent embedding (ATIF v1.7)
+
+For each unique `subagentSessionId` encountered while walking the main session:
+
+1. Locate `logs_dir/bitfun/sessions//` (must have `metadata.json.sessionKind == "subagent"`).
+2. Recursively call `_convert_events_to_trajectory(sub_dir, is_subagent=True)`.
+3. On the resulting `Trajectory`:
+ - Set `trajectory_id = ` (ATIF v1.7 mandates a unique non-null `trajectory_id` on every embedded subagent).
+ - Override `agent.name` with the dispatch tool name (e.g., `"Task"`, `"Explore"`).
+ - Override `agent.model_name` with `toolItem.subagentModelId` if present.
+ - Add `agent.extra["parent_task_tool_id"] = toolItem.id`.
+4. Append to `root.subagent_trajectories[]`.
+5. On the parent tool-call step's `observation.results[0].subagent_trajectory_ref`, append `SubagentTrajectoryRef(trajectory_id=, session_id=)`.
+
+Deduplicate by ``: if the same subagent is referenced from multiple tool items (uncommon but possible), embed once and reference many times.
+
+#### ATIF v1.7 validation invariants the code must respect
+
+- `step_id` sequential from 1 (`Trajectory.validate_step_ids`). The conversion assigns `step_id` after all events are normalized.
+- Observation `source_call_id` must reference an actual `tool_call_id` within the same step (`Trajectory.validate_tool_call_references`). Since we build the tool call and observation together for the same step, this is enforced by construction.
+- Embedded subagent `trajectory_id` must be non-null and unique within `subagent_trajectories[]`. Enforced by deduping via the sub-sid set before appending.
+
+### Section 4: Metrics, Cost, FinalMetrics
+
+#### Token-record loading
+
+```python
+def _load_token_records(self) -> list[dict]:
+ records_dir = self.logs_dir / "bitfun" / "token_usage" / "records"
+ out = []
+ if not records_dir.is_dir():
+ return out
+ for jf in records_dir.glob("*.json"):
+ try:
+ batch = json.loads(jf.read_text())
+ out.extend(batch.get("records", []))
+ except (OSError, json.JSONDecodeError):
+ continue
+ return out
+```
+
+#### Step-level allocation (per design decision Q5: nearest-timestamp matching)
+
+```python
+# Pseudocode for one (sub)trajectory:
+records_for_traj = [r for r in all_records
+ if r["session_id"] == sid and r["is_subagent"] == is_subagent]
+records_by_turn = group_by(records_for_traj, key="turn_id")
+
+for turn in turns:
+ turn_records = records_by_turn.get(turn.turnId, [])
+ rounds = list(turn.modelRounds)
+
+ # Match each record to the round whose timestamp is closest.
+ # Records without an assignable round (e.g., extra retries) are appended
+ # to the last assigned round of this turn.
+ round_metrics_idx = nearest_neighbor_match(
+ sources=[parse_iso(r["timestamp"]) for r in turn_records],
+ targets=[r.timestamp for r in rounds],
+ )
+ for record, ridx in zip(turn_records, round_metrics_idx):
+ step = first_assistant_step_of(rounds[ridx]) # or last assistant-source step if no text exists
+ step.metrics = Metrics(
+ prompt_tokens = record["input_tokens"],
+ completion_tokens = record["output_tokens"],
+ cached_tokens = record["cached_tokens"],
+ cost_usd = compute_cost(record["model_id"], record), # see below
+ extra = { "token_details", "total_tokens",
+ "cached_tokens_available", "record_timestamp" },
+ )
+```
+
+**Caveat: `prompt_tokens` and `cached_tokens` semantics.** BitFun transparently forwards each provider's `prompt_token_count`. For OpenAI/Gemini that value already includes cached input; for Anthropic the field is exclusive. The conversion assumes "already inclusive" by default (matching `codex.py` / `cursor_cli.py` behavior). If golden tests show double-counting on Anthropic, swap to `claude_code.py`'s additive formula. This caveat is noted inline in the implementation.
+
+#### Cost computation (per design decision Q4: LiteLLM)
+
+```python
+def _compute_cost_via_litellm(model_id, prompt_tokens, cached_tokens, completion_tokens):
+ """Lift-and-shift of Codex._compute_cost_from_pricing.
+
+ Returns None when the model isn't in litellm.model_cost (non-blocking).
+ Cached tokens billed at cache_read_input_token_cost when available, otherwise
+ at input_cost_per_token.
+ """
+```
+
+#### FinalMetrics
+
+```
+FinalMetrics(
+ total_prompt_tokens = sum(step.metrics.prompt_tokens for step in steps if step.metrics)
+ total_completion_tokens = sum(step.metrics.completion_tokens for step in steps if step.metrics)
+ total_cached_tokens = sum(step.metrics.cached_tokens for step in steps if step.metrics)
+ total_cost_usd = sum(step.metrics.cost_usd) if every step.metrics.cost_usd is non-None
+ else None
+ total_steps = len(steps)
+ extra = {
+ "main_session_tool_calls": metadata.toolCallCount,
+ "main_session_turn_count": metadata.turnCount,
+ "main_session_duration_ms": metadata.lastActiveAt - metadata.createdAt,
+ "models_used": sorted({ r["model_id"] for r in trajectory-scoped records }),
+ "subagent_session_count": len(unique set),
+ "subagent_total_tokens": sum(records with is_subagent=True),
+ }
+)
+```
+
+#### `populate_context_post_run`
+
+```python
+def populate_context_post_run(self, context):
+ session_dir = self._get_session_dir()
+ if not session_dir:
+ self.logger.debug("No BitFun session directory found")
+ return
+ try:
+ trajectory = self._convert_events_to_trajectory(session_dir)
+ except Exception:
+ self.logger.exception("Failed to convert BitFun events to trajectory")
+ return
+ if not trajectory:
+ return
+
+ trajectory_path = self.logs_dir / "trajectory.json"
+ try:
+ trajectory_path.write_text(format_trajectory_json(trajectory.to_json_dict()))
+ self.logger.debug(f"Wrote BitFun trajectory to {trajectory_path}")
+ except OSError as exc:
+ self.logger.debug(f"Failed to write trajectory file {trajectory_path}: {exc}")
+
+ if trajectory.final_metrics:
+ fm = trajectory.final_metrics
+ context.cost_usd = fm.total_cost_usd
+ context.n_input_tokens = fm.total_prompt_tokens or 0
+ context.n_cache_tokens = fm.total_cached_tokens or 0
+ context.n_output_tokens = fm.total_completion_tokens or 0
+```
+
+### Section 5: Test plan
+
+Test file: `tests/unit/agents/installed/test_bitfun_cli.py` (extends the existing one).
+Test fixtures and golden data: `tests/golden/bitfun_cli//`.
+
+#### Fixture builders
+
+Helpers that construct minimal valid BitFun JSON dicts:
+
+- `_make_metadata(session_id, *, kind="standard", model="default", workspace="/testbed")`
+- `_make_turn(turn_index, turn_id, *, kind="user_dialog", user_text="hi", model_rounds=None)`
+- `_make_round(round_id, *, text_items=None, tool_items=None, thinking_items=None, **runtime_kwargs)`
+- `_make_tool_item(tool_id, tool_name, input_args, *, result_text=None, raw_result=None, subagent_sid=None, error=None)`
+- `_make_token_record(model_id, session_id, turn_id, in_tok, out_tok, *, cached=0, is_sub=False, ts=...)`
+- `_write_session(logs_dir, sid, *, metadata, turns, token_records=None)` — drops files at `logs_dir/bitfun/sessions//{metadata.json,turns/turn-NNNN.json}` and `logs_dir/bitfun/token_usage/records/.json`.
+
+#### Existing tests (keep, lightly adjust)
+
+- `test_name`, `test_registered_in_factory`, `test_install_*`, `test_run_*`: keep.
+- `test_populate_context_post_run_noop` → rename `test_populate_context_post_run_returns_when_no_session_dir`.
+
+#### `TestGetSessionDir`
+
+- `test_picks_unique_standard_session`: write 1 standard + 2 subagent dirs; assert main is the standard one.
+- `test_no_bitfun_dir_returns_none`.
+- `test_falls_back_to_mtime_when_no_standard_metadata`: write 2 dirs without `sessionKind` set, assert mtime-latest is picked.
+
+#### `TestConvertEventsToTrajectory`
+
+- `test_basic_user_assistant_pair`: 1 turn / 1 round / 1 text → 2 steps.
+- `test_user_query_wrapper_is_stripped_when_metadata_missing`.
+- `test_thinking_blocks_join_with_double_newline_into_reasoning_content`.
+- `test_tool_call_uses_result_for_assistant_as_content`.
+- `test_tool_call_falls_back_to_json_dumps_when_result_for_assistant_absent`.
+- `test_tool_call_preserves_raw_result_in_observation_extra` (verifies the Q4-chosen dual emission).
+- `test_tool_error_propagates_to_observation_extra_and_does_not_crash_when_result_for_assistant_missing`.
+- `test_order_index_orders_mixed_items_within_round` (thinking-A → text-B → tool-C produces 1 assistant text step with reasoning + 1 tool step in order).
+- `test_empty_round_emits_placeholder_agent_step_with_round_metrics_on_extra`.
+- `test_manual_compaction_turn_emits_system_step_with_is_copied_context_true`.
+- `test_local_command_turn_is_silently_skipped`.
+- `test_subagent_trajectory_is_embedded_with_trajectory_id_and_referenced_from_parent_observation`.
+- `test_duplicate_subagent_session_id_is_embedded_only_once`.
+- `test_step_ids_are_sequential_from_1`.
+- `test_schema_version_is_atif_v1_7`.
+
+#### `TestTokenAndCostAllocation`
+
+- `test_metrics_assigned_to_step_by_nearest_record_timestamp`.
+- `test_step_metrics_missing_when_no_records_match_turn`.
+- `test_subagent_records_only_count_toward_subagent_trajectory_final_metrics`.
+- `test_main_trajectory_final_metrics_excludes_is_subagent_records`.
+- `test_cost_computed_via_litellm_pricing_table` (patch `litellm.model_cost` with a known model entry).
+- `test_cost_is_none_when_model_unknown_to_litellm`.
+
+#### `TestPopulateContextPostRun`
+
+- `test_writes_trajectory_json_to_logs_dir`.
+- `test_populates_context_token_counts_from_final_metrics`.
+- `test_silently_returns_when_session_dir_absent`.
+- `test_swallows_conversion_errors_and_logs_debug` (force a malformed turn file, ensure no crash).
+
+#### `TestRunCpBackFinally`
+
+- `test_run_invokes_cp_back_in_finally`: mock `exec_as_agent`, assert there is a second call whose command contains `cp -R` and targets `/logs/agent/bitfun`.
+- `test_cp_back_includes_slug_first_then_mtime_fallback`: the command string contains both the slug-based candidate(s) and the `ls -dt` mtime-fallback fragment.
+- `test_cp_back_failures_do_not_propagate`: configure the second exec to raise; `run()` still completes (the existing `try/except: pass` around codex's analogue is the model).
+
+#### Golden integration test (per design decision Q6)
+
+- Sanitize the real `/home/djn/.bitfun/projects/home-djn-code-harbor-bitfun-target-release/sessions/4d8cc46e-070a-45bb-b341-be5d6f6a5b79/` by:
+ - Replacing user-identifying content with placeholders.
+ - Trimming long fields and any sensitive paths.
+ - Adding a small synthetic token-usage records file (since the real session has no tool calls or token entries) — or use a different real session that exercises tool calls + token records.
+- Drop under `tests/golden/bitfun_cli//` with `expected_trajectory.json` alongside.
+- `test_golden_session_converts_to_expected_atif`: build a `BitfunCli` with `logs_dir=tmp_path`, copy the golden session into `tmp_path/bitfun/sessions//`, call `populate_context_post_run`, then assert `tmp_path/trajectory.json` matches the expected JSON (modulo timestamp normalization if needed).
+
+## Risks and follow-ups
+
+- **Provider-specific token semantics:** Anthropic's `input_tokens` excludes cached; OpenAI's `prompt_tokens` includes cached. BitFun passes the upstream field through unchanged. The default conversion treats the value as inclusive (matches codex/cursor_cli/most of the harbor agent ecosystem). If golden-test data from real Anthropic-backed BitFun runs shows under-counting, swap to additive (`prompt = input + cached + creation`).
+- **Slug drift:** If a future BitFun release changes `build_project_runtime_slug` or starts canonicalizing `/testbed`, the mtime fallback continues to work; only the slug-precise fast path needs updating.
+- **Subagent session not copied:** A subagent referenced in a tool item but with no matching `/metadata.json` on disk indicates an incomplete bitfun cp-back. We log debug and embed an empty placeholder trajectory (or omit the embed — implementation chooses the safer "omit and only keep the `subagent_trajectory_ref.session_id` for forensics"). Decision: **omit the embed**, set `subagent_trajectory_ref` to `None`, and surface the issue in `notes`.
+- **`metadata.modelName == "default"`:** BitFun sometimes records a generic model alias. We rely on each token record's `model_id` (which is the concrete provider/model string emitted at LLM-call time) for pricing lookups; `agent.model_name` may stay as "default" — acceptable, mirrors what the user actually configured.
+
+## Alternatives considered
+
+- **HOME-override approach** (set `HOME=/logs/agent/bitfun-home` so BitFun writes directly to the mount). Rejected per Q1: HOME redirection risks side effects on shell initialization (`~/.bashrc`, `~/.profile`) and other host integrations BitFun may depend on. cp-back is more surgical.
+- **Standalone CLI tool** for offline BitFun → ATIF conversion. Rejected per Q2: scope kept narrow to `populate_context_post_run` for parity with `claude_code` / `codex`. The conversion helpers will be importable in the future to power such a CLI without redesign.
+- **Slug-only or mtime-only directory resolution.** Rejected per Q1c: each strategy alone has a failure mode (slug drift; reused containers). Doing both, slug-first with mtime fallback, costs ~10 lines of shell and eliminates both classes.
+- **Subagent as flat steps with `extra["is_subagent"]`** (claude-code's sidechain pattern). Rejected per Q2 in favor of true ATIF v1.7 `subagent_trajectories[]` embedding; the trajectory format already supports it, and BitFun's data model has clear parent/child session linkage that maps cleanly.
+- **Tool result content strategy** of "result only" or "resultForAssistant only". Rejected per Q4 in favor of both: `resultForAssistant` as the LLM-visible content and `raw_result` retained in `observation_result.extra` for debugging/training-data-mining.
+- **Empty rounds: skip silently.** Rejected per Q3: emitting a placeholder preserves round-level metrics (`durationMs`, `attemptCount`, `failureCategory`) that are useful for debugging.
+- **Compaction turns: skip silently or note-only.** Rejected per Q3 in favor of "emit system step with `is_copied_context=True`" so the trajectory is faithful but training-data builders auto-skip these.
+
+## Approval
+
+- Designed in collaboration on 2026-05-13.
+- All design decisions captured under the **per design decision Q*** annotations above.
+- Pending user review of this spec file before writing the implementation plan.
diff --git a/docs/superpowers/specs/2026-05-13-bitfun-cli-harbor-design.md b/docs/superpowers/specs/2026-05-13-bitfun-cli-harbor-design.md
new file mode 100644
index 00000000000..a4d063e75d4
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-13-bitfun-cli-harbor-design.md
@@ -0,0 +1,102 @@
+# Design: Harbor integration for BitFun CLI (`bitfun-cli`)
+
+**Status:** Approved for specification (2026-05-13).
+**Scope:** First-party coding agent `bitfun-cli` for SWE-bench Verified-style tasks via Harbor, **local Docker only** for binary delivery. **No ATIF** in this phase.
+
+## Goals
+
+- Register a Harbor **installed agent** that runs BitFun’s CLI in task containers.
+- Support **host-built** static binary injection via **Docker bind mount** (`mounts_json`).
+- Align with **SWE-bench Verified** Harbor tasks: agent must leave correct changes in **`/testbed`** git working tree; optional patch file for human/debug artifacts only.
+
+## Non-goals
+
+- ATIF trajectory export (future work).
+- Non-Docker environments (Daytona, Modal, etc.) and generic artifact hosting for the binary.
+- Building `bitfun-cli` inside the container during `install()`.
+
+## Background
+
+- BitFun source lives under repo-relative `BitFun/`; release binary: `BitFun/target/release/bitfun-cli`.
+- CLI exposes `bitfun exec ` with `--output-patch` (optional path) for SWE-style patch output; non-interactive runs should avoid `--confirm`.
+- Harbor SWE-bench adapter tasks expect the model to modify **`/testbed`**; verifier script applies the SWE **test** patch and runs repo tests (`adapters/swebench/src/swebench_adapter/utils.py`).
+
+## Architecture
+
+### New components
+
+1. **`BitfunCli` agent class** (Python symbol; Harbor agent id / CLI string **`bitfun-cli`**).
+ - Subclass **`BaseInstalledAgent`** (same pattern as `Aider`: no ATIF).
+ - **`SUPPORTS_ATIF`:** `False` (implicit default or explicit).
+ - **`populate_context_post_run`:** no-op (no `trajectory.json`).
+
+2. **Registration**
+ - Add **`AgentName.BITFUN_CLI`** (or equivalent) in `src/harbor/models/agent/name.py`.
+ - Register in **`AgentFactory`** (`src/harbor/agents/factory.py`).
+
+### Binary delivery (chosen: bind mount)
+
+- Users add a **`bind`** volume in **`EnvironmentConfig.mounts_json`**:
+ - `source`: absolute host path to `bitfun-cli` binary.
+ - `target`: e.g. `/usr/local/bin/bitfun-cli`.
+ - `read_only`: `true`.
+- **Agent `install()`** does **not** download BitFun. It should:
+ - Verify the mounted binary exists and is executable (`chmod +x` if needed).
+ - Optionally document glibc/musl compatibility if SWE images differ (out of scope unless issues arise).
+
+### `run()` behavior
+
+- **Working directory:** `/testbed` (SWE-bench task layout).
+- **Command:** invoke BitFun exec mode, e.g.
+ `bitfun exec --output-patch /logs/agent/bitfun.patch`
+ (exact path flags configurable via agent kwargs if useful).
+- **Logging:** pipe stdout/stderr through `tee` to `/logs/agent/bitfun.txt` (or similar).
+- **Prompt template:** use `@with_prompt_template` like other installed agents.
+- **Semantics:** The **authoritative** state for grading is the **git working tree under `/testbed`**. `--output-patch` is **supplementary**; if the CLI only wrote a patch file without applying edits, the task would still fail—document this in code comments.
+
+### Environment variables
+
+- Pass through API/auth variables required by BitFun’s global config.
+- Harbor already forwards agent env via **`--ae` / `agent.env`**; map to the exact names BitFun expects (explicit mapping in the agent if names differ from common `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` conventions).
+
+### Configuration knobs (recommended)
+
+- **`binary_path`** (agent kwarg): default `/usr/local/bin/bitfun-cli`, must match `mounts_json` target.
+- Optional: toggle `--output-patch` path or disable patch file.
+
+## Example: local Docker `mounts_json`
+
+```json
+[
+ {
+ "type": "bind",
+ "source": "/ABS/PATH/TO/harbor/BitFun/target/release/bitfun-cli",
+ "target": "/usr/local/bin/bitfun-cli",
+ "read_only": true
+ }
+]
+```
+
+Wire into job YAML / CLI `environment` section per Harbor docs for `mounts_json`.
+
+## Testing
+
+- **Unit tests** only (per project norms): mock `BaseEnvironment.exec`, assert:
+ - Correct `cwd` (`/testbed`).
+ - Command includes `exec` and expected flags.
+ - Env merge behavior for keys under test.
+- No ATIF golden tests in this phase.
+
+## Risks and follow-ups
+
+- **Image vs binary ABI:** SWE-bench images are typically glibc-based; musl static builds may still be preferred for portability—validate on one real task image.
+- **Remote sandboxes:** If usage expands beyond local Docker, binary distribution must be redesigned (not in this spec).
+
+## Alternatives considered (summary)
+
+- **Copy binary into `trial_dir`:** extra step; deferred.
+- **Build in `install()`:** slow and contradicts host-build workflow; rejected.
+
+## Approval
+
+- Product/approach approved in design thread: bind mount + thin agent, local Docker, no ATIF v1.
diff --git a/docs/superpowers/specs/2026-05-17-multi-provider-analyze-design.md b/docs/superpowers/specs/2026-05-17-multi-provider-analyze-design.md
new file mode 100644
index 00000000000..cfa59397a65
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-17-multi-provider-analyze-design.md
@@ -0,0 +1,205 @@
+# Multi-provider Analyze (Anthropic-compatible) — Design
+
+**Date:** 2026-05-17
+**Status:** Approved for specification (brainstorm complete)
+**Scope:** Viewer-triggered trial/job analysis (`harbor analyze` integration via `Analyzer` → `query_agent`)
+
+## 1. Context
+
+Trial and job analysis in Harbor uses `harbor/analyze/backend.py`, which invokes the Claude Agent SDK (`query`) with tools (`Read`, `Glob`, `Grep`). Today the viewer calls `POST /api/jobs/.../trials/.../summarize` with a short model alias (`haiku` | `sonnet` | `opus`) and implicitly relies on **`ANTHROPIC_API_KEY`** (and optionally process-wide `ANTHROPIC_*`).
+
+The codebase already mirrors Anthropic-compatible env patterns elsewhere (e.g. `agents/installed/claude_code.py` passes `ANTHROPIC_API_KEY`, `ANTHROPIC_BASE_URL`, `ANTHROPIC_MODEL`). The Claude Agent SDK exposes **`ClaudeAgentOptions.env`**, enabling per-invocation overlays without mutating global `os.environ`.
+
+## 2. Goals
+
+- Support **multiple providers** that expose an **Anthropic Messages–compatible API** (including official Anthropic and common proxies/gateways).
+- Store **non-secret** provider + model definitions in server-side configuration; resolve **`api_key` and `base_url` only from environment variables** (typically `.env` loaded by the user’s shell or process manager — **credentials are not persisted in profiles files**).
+- Allow the Viewer UI to choose **provider (profile)** and **model**, then POST that choice to existing analyze endpoints (extended payload).
+- Pass resolved credentials and routing hints into the SDK via **`base_url + api_key + model`** as environment variables wired through **`ClaudeAgentOptions.env`** (plus **`options.model`** for the selected API model ID), respecting **thread/concurrency safety** (no global `os.environ` mutation for per-request overrides).
+
+## 3. Non-goals
+
+- Replacing Claude Agent SDK with LiteLLM or a generic HTTP client for this analysis path.
+- Persisting encrypted secrets in-repo or prompting users to paste API keys into the profiles file.
+- Unifying **`harbor analyze` CLI** with profiles in phase 1 (optional phase 2: read the same profiles file / env lookup).
+- Guaranteeing behavioral parity of non-Anthropic proxies (only **API compatibility** is assumed).
+
+## 4. Threat model / security
+
+- **Secrets live in environment** (`export` / `.env` / deployment secret injection). Profiles reference **environment variable names** only.
+- **Anyone who can attach to or read** the Viewer server process environment (or dumped env in misconfigured deployments) **can observe keys**, same as today’s single-key model.
+- The **`GET .../profiles` response may include env var names** (`api_key_env`, `base_url_env`) **but never values**. This aids debugging when operators control the deployment.
+- Responses on misconfiguration MUST NOT echo secret values; errors reference **unset variable names** only.
+
+## 5. Configuration location and precedence
+
+### 5.1 Profiles file path
+
+- Primary: **`HARBOR_ANALYZE_PROFILES`** — absolute or relative path to a TOML file containing one or more profiles.
+- Optional CLI parity: **`harbor view`** gains **`--analyze-profiles PATH`**, equivalent to setting the env var for that process lifetime.
+- If **unset or file missing/unreadable**: use a **built-in default profile set** equivalent to today’s UX (single logical provider “Anthropic direct”, models `haiku` / `sonnet` / `opus`, credentials from standard `ANTHROPIC_*` env with no profile file required).
+
+### 5.2 Example file in repo
+
+- Add **`examples/config/analyze-profiles.example.toml`** (documented-only; operators copy/adapt locally and point `HARBOR_ANALYZE_PROFILES` to their copy).
+
+## 6. Profiles file schema (TOML)
+
+Logical model (exact key names finalized at implementation):
+
+```toml
+# Optional file-level defaults
+schema_version = 1
+
+[[profile]]
+id = "anthropic"
+label = "Anthropic (direct)"
+# Env var NAMES — values loaded at runtime via os.getenv(); never literals for secrets/base URLs
+api_key_env = "ANTHROPIC_API_KEY"
+base_url_env = "ANTHROPIC_BASE_URL" # omit or empty if official endpoint default is acceptable via SDK/parent env
+default_model = "haiku"
+
+ [[profile.model]]
+ id = "haiku" # Stable id for UI + API payloads
+ display_name = "Haiku (recommended)"
+ api_model = "haiku" # Passed to Analyzer / ClaudeAgentOptions.model (after normalization rules)
+
+ [[profile.model]]
+ id = "sonnet"
+ display_name = "Sonnet"
+ api_model = "sonnet"
+
+ [[profile.model]]
+ id = "opus"
+ display_name = "Opus"
+ api_model = "opus"
+
+[[profile]]
+id = "corp-proxy"
+label = "Corporate Anthropic-compatible gateway"
+api_key_env = "CORP_ANTHROPIC_API_KEY"
+base_url_env = "CORP_ANTHROPIC_BASE_URL"
+default_model = "default"
+
+ [[profile.model]]
+ id = "default"
+ display_name = "Gateway default model"
+ api_model = "claude-sonnet-4-20250514" # example only
+```
+
+Validation rules:
+
+- **`id`** unique among profiles.
+- **`api_key_env` required** (string, non-empty).
+- **`base_url_env` optional**; if present, must resolve to a non-empty string at request time **or** the request fails with a typed error listing the missing name (same as unset `api_key_env`).
+- **`profile.model`** at least one per profile for phase 1; **`api_model`** is the verbatim model string for the SDK (subject to existing `normalize_model_name` stripping `anthropic/` prefix).
+
+## 7. Viewer / API behavior
+
+### 7.1 `GET /api/analyze/profiles`
+
+Returns a JSON list safe for caching in the SPA:
+
+```json
+{
+ "profiles": [
+ {
+ "id": "anthropic",
+ "label": "Anthropic (direct)",
+ "default_model": "haiku",
+ "models": [
+ { "id": "haiku", "display_name": "Haiku (recommended)", "api_model": "haiku" }
+ ],
+ "api_key_env": "ANTHROPIC_API_KEY",
+ "base_url_env": "ANTHROPIC_BASE_URL"
+ }
+ ]
+}
+```
+
+If `base_url_env` is omitted at file level, **omit field** or return `null` in JSON consistently.
+
+Errors: malformed TOML / duplicate ids → Viewer startup SHOULD fail fast OR log loudly and expose `GET` error (`500`) with sanitized message (`"profiles_invalid"`); implementation chooses **fail-fast on server bootstrap** preferred for operator clarity.
+
+### 7.2 `POST .../summarize` (trial) and job-level analyze
+
+Extend request body (`TrialSummarizeRequest` sibling fields):
+
+```json
+{
+ "profile_id": "corp-proxy",
+ "model_id": "default"
+}
+```
+
+Semantics:
+
+- **`profile_id`** optional — default built-in `"anthropic"` behavior when omitted (backward compatible).
+- **`model_id`** optional — when omitted use profile’s **`default_model`**.
+- Server resolves **`api_model`** from `(profile_id, model_id)`.
+- Builds `inject_env: dict[str, str]`:
+
+ - `inject_env["ANTHROPIC_API_KEY"] = os.getenv(profile.api_key_env) or abort`
+ - If `profile.base_url_env` configured:
+ `inject_env["ANTHROPIC_BASE_URL"] = os.getenv(profile.base_url_env) or abort`
+ (omit key entirely when no `base_url_env` configured — inherits subprocess defaults).
+ - Optionally align with `claude_code` behavior by also setting **`ANTHROPIC_MODEL`** to **`api_model`** when using custom base URLs; exact mirroring finalized in implementation to avoid **double sources of truth**. **Single source recommendation:** **`ClaudeAgentOptions.model = api_model`**, env injection limited to **`ANTHROPIC_API_KEY`** / **`ANTHROPIC_BASE_URL`** unless SDK/docs require additional keys — document decision in code comments.
+
+Concurrency: **`query_agent`** MUST merge overlays into **`ClaudeAgentOptions.env`** passed to **`query`** and MUST NOT assign to **`os.environ`** for request-specific overrides.
+
+## 8. Analyzer / SDK integration
+
+Changes centered on **`harbor/analyze/backend.py`**:
+
+- New helper (conceptual): `resolve_profile(...) -> ResolvedProfile` cached at startup TOML parse + invalidated on reload if hot-reload ever added — phase 1: **parse once at startup**.
+- `query_agent(..., profile: ResolvedProfile | None = None, api_model: str | None = None)` merges:
+
+ ```python
+ ClaudeAgentOptions(
+ ...,
+ model=normalize_model_name(api_model or model),
+ env={**explicit_inject_env},
+ )
+ ```
+
+- **`Analyzer.__init__` / `analyze_trial`**: propagate optional **`profile_id` + `model_id`** from viewer into `query_agent`.
+
+Backward compatibility:
+
+- Omitting `profile_id`/`model_id` retains current aliases and ambient key behavior.
+
+## 9. Frontend (Viewer SPA)
+
+- On Analysis dialog open / app shell: **`useQuery`** on **`GET /api/analyze/profiles`** when feature flag not needed — always-on.
+- UI: cascading select **profile** → **model** (populate from nested `models`).
+- POST payloads include **`profile_id`** and **`model_id`** alongside legacy fields only if compatibility layer maps old tri-state to **`model_id`** when profiles load fails (implementation detail).
+- Loading/error UX: fallback to legacy three-option model picker if **`GET /profiles`** errors (explicit user-visible degraded mode).
+
+## 10. Documentation
+
+- `CLAUDE.md` / Viewer `CLAUDE.md`: document **`HARBOR_ANALYZE_PROFILES`**, `.env` expectations, profile schema, **`GET /api/analyze/profiles`**.
+- Security note: do not commit real profiles with literal secrets; operators use `.env` for values.
+
+## 11. Testing (phase 1)
+
+- Unit tests:
+
+ - TOML parse success/failure, duplicate IDs, missing required fields.
+ - Resolution of env injection dict with **mocked os.environ**.
+ - `query_agent` receives merged `env` without mutating `os.environ` (monkeypatch `query`/`ClaudeAgentOptions` assertion).
+
+## 12. Implementation phases
+
+| Phase | Deliverable |
+|-------|--------------|
+| 1 | Profiles TOML + bootstrap parse + `/api/analyze/profiles` + extend summarize endpoints + `query_agent` env merge + Viewer UI |
+| 2 | `harbor analyze` CLI consumes same resolver; optional `--profile`/`--model` |
+
+---
+
+## Spec self-review (checklist)
+
+- **Placeholders:** None intentional; **`api_model` examples** are illustrative only in this doc — real defaults stay `haiku`/`sonnet`/`opus` for built-in profile.
+- **Consistency:** Profiles drive viewer; env injection limited to **`ClaudeAgentOptions.env`** + explicit **`model` field`; no global mutation.
+- **Scope:** Viewer + summarize endpoints first; CLI extension deferred.
+- **Ambiguity closure:** **`GET /profiles` exposes `api_key_env` / `base_url_env` names (not values)** — approved by stakeholders.
diff --git a/docs/superpowers/specs/2026-05-19-job-analyze-aggregate-fallback-design.md b/docs/superpowers/specs/2026-05-19-job-analyze-aggregate-fallback-design.md
new file mode 100644
index 00000000000..348f34adabc
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-job-analyze-aggregate-fallback-design.md
@@ -0,0 +1,174 @@
+# Job Analyze Aggregate Transport Fallback — Design
+
+**Date:** 2026-05-19
+**Status:** Approved (brainstorm complete)
+**Scope:** Job-level aggregation in `Analyzer._aggregate` via `query_llm` (`harbor/analyze/backend.py`); Viewer `POST /api/jobs/{job_name}/summarize` error surface
+**Related:** [job-analyze-aggregate-fallback-summary.md](../../job-analyze-aggregate-fallback-summary.md), [2026-05-17-multi-provider-analyze-design.md](./2026-05-17-multi-provider-analyze-design.md)
+
+## 1. Context
+
+### 1.1 Problem
+
+Job-level analysis aggregates per-trial summaries into one prompt and calls `query_llm` → `query_agent` with `tools=[]`. The Claude Agent SDK passes string prompts to the CLI as `--print -- ` (a single argv element).
+
+For large jobs (e.g. 282 trials, ~518KB prompt), Linux raises **`OSError: [Errno 7] Argument list too long`** because per-argument size is capped at ~128KiB (`MAX_ARG_STRLEN`). Trial-level analysis completes and is cacheable via `analysis.json`; failure occurs only in **`_aggregate`**, so `analysis.md` is never written.
+
+### 1.2 Design principles (from product discussion)
+
+- **Do not** reduce or rewrite model-visible content (no batched map-reduce).
+- **Only** change how the full prompt is **transported** to the model: argv → stdin (stream-json) → agent Read file.
+- **Proactive** routing for large prompts: skip the argv path when over a fixed byte threshold.
+- On total failure, return **HTTP 422** with **structured** `detail` (not a bare 500).
+
+## 2. Goals
+
+- Job aggregation succeeds for prompts that exceed argv limits when stdin or Read transport works.
+- Single implementation in **`backend.py`** used by CLI (`harbor analyze` on job dirs) and Viewer (`summarize_job`).
+- Preserve existing trial-level behavior and `analysis.json` caching.
+- Clear, structured API errors for operators and Viewer UI.
+
+## 3. Non-goals
+
+- Batched map-reduce or summarizing trials in chunks to fit context.
+- Replacing Claude Agent SDK / LiteLLM dual path for analyze.
+- Changing `_aggregate` prompt template or trial summary formatting.
+- Serializing concurrent `summarize` requests on the same job (timestamped temp files avoid overwrite; locking is out of scope).
+- Configurable threshold via env or profiles TOML (fixed constant only for this phase).
+
+## 4. Approach (selected)
+
+**Centralized `query_llm_with_fallback` in `backend.py`** (recommended over logic-only-in-`_aggregate` or raw subprocess CLI).
+
+Rejected alternatives:
+
+- **Analyzer-only fallback** — duplicates transport logic; future `query_llm` callers remain broken.
+- **Direct subprocess to `claude` CLI** — duplicates SDK behavior and upgrade path.
+
+## 5. Transport decision tree
+
+Measure **`prompt_bytes = len(prompt.encode("utf-8"))`** against a fixed constant (see §6).
+
+| Condition | Transport | Mechanism |
+|-----------|-----------|-----------|
+| `prompt_bytes ≤ threshold` | **argv (current)** | String `prompt` to `query()` → `--print -- ` |
+| `prompt_bytes > threshold` | **stdin** | `prompt` as `AsyncIterable` → CLI `--input-format stream-json`, content on subprocess stdin |
+| stdin fails (or small argv fails with argv-length error) | **agent Read** | Write full prompt to timestamped file under job dir; short instruction prompt; `query_agent` with `tools=["Read"]` |
+| all attempts fail | **error** | Raise `AggregateTransportError` → Viewer **422** |
+
+**Small-prompt argv failure:** If the string path fails with an error classified as argv / process spawn length (e.g. `OSError` errno 7, message containing `Argument list too long`), retry stdin then Read (same as large-prompt chain minus the initial argv attempt).
+
+### 5.1 Stdin path (SDK-native)
+
+The installed `claude_agent_sdk` already uses stream-json when `prompt` is not a `str`:
+
+```python
+async def _prompt_as_stream(full_prompt: str):
+ yield {
+ "type": "user",
+ "message": {"role": "user", "content": full_prompt},
+ }
+```
+
+Reuse existing `query_agent` message collection (structured output, verbose, `sdk_env`). No fork of `SubprocessCLITransport` required.
+
+### 5.2 Agent Read path
+
+1. Write **`{work_dir}/.harbor-aggregate-prompt-{unix_ms}.txt`** (or short uuid suffix) containing the **exact** same bytes as the stdin/argv prompt would have sent.
+2. Send a **short** user prompt referencing the absolute path, instructing the model to read the file and produce the job summary per instructions inside it.
+3. `query_agent(..., cwd=str(work_dir), tools=["Read"], add_dirs=[work_dir])`.
+4. **Cleanup:** On success, `unlink` the temp file in `finally`. On failure, **retain** the file and set `prompt_file` on the exception (basename or path relative to job dir).
+
+`work_dir` is the job directory (`job_dir` from `_aggregate`).
+
+## 6. Constants
+
+In `harbor/analyze/backend.py`:
+
+```python
+# Linux passes the full prompt as a single argv element after `--print --`.
+# Per-argument limit is ~128 KiB (MAX_ARG_STRLEN); oversize raises Errno 7 (E2BIG).
+# Leave headroom for CLI flags, model name, and env wrapper overhead.
+_AGGREGATE_ARGV_PROMPT_MAX_BYTES = 120 * 1024
+```
+
+**Proactive rule:** If `len(prompt.encode("utf-8")) > _AGGREGATE_ARGV_PROMPT_MAX_BYTES`, do **not** invoke the string/argv path first; start at stdin.
+
+## 7. API and errors
+
+### 7.1 Exception
+
+New type, e.g. `harbor.analyze.errors.AggregateTransportError`:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `reason` | `str` | Stable code, e.g. `"job_aggregate_failed"` |
+| `prompt_bytes` | `int` | UTF-8 byte length of full aggregation prompt |
+| `attempts` | `list[str]` | Ordered transports tried, e.g. `["argv", "stdin", "agent_read"]` or `["stdin", "agent_read"]` |
+| `last_error` | `str \| None` | Exception type + short message; no secrets |
+| `prompt_file` | `str \| None` | Temp file path if retained after failure |
+
+Provide `to_dict()` for HTTP `detail`.
+
+### 7.2 Viewer `summarize_job`
+
+In `src/harbor/viewer/server.py`:
+
+```python
+except AggregateTransportError as e:
+ raise HTTPException(status_code=422, detail=e.to_dict()) from e
+```
+
+Example `detail`:
+
+```json
+{
+ "reason": "job_aggregate_failed",
+ "prompt_bytes": 530432,
+ "attempts": ["stdin", "agent_read"],
+ "last_error": "ProcessError: ...",
+ "prompt_file": ".harbor-aggregate-prompt-1716123456789.txt"
+}
+```
+
+Do not include API keys or full stderr in `detail`. Log full trace at `logger.debug` if needed.
+
+## 8. Code changes (implementation map)
+
+| File | Change |
+|------|--------|
+| `src/harbor/analyze/backend.py` | Extend `query_llm(..., work_dir: Path)` with internal fallback chain (stdin helper + Read path); `_aggregate` is the sole caller and always passes `job_dir` |
+| `src/harbor/analyze/errors.py` (new) | `AggregateTransportError` |
+| `src/harbor/analyze/analyzer.py` | `_aggregate`: pass `job_dir` as `work_dir` to fallback entrypoint |
+| `src/harbor/viewer/server.py` | Map `AggregateTransportError` → 422 |
+| `tests/unit/analyze/` | Threshold routing, exception shape, cleanup behavior, server 422 (mock) |
+
+**Unchanged:** `_aggregate` template substitution; trial `analyze_trial` / caching; multi-provider `sdk_env_overlay` propagation into each transport attempt.
+
+## 9. Testing
+
+| Test | Assert |
+|------|--------|
+| Prompt ≤ threshold | Mock transport: string/`--print` path used |
+| Prompt > threshold | Mock: stream-json / no giant argv |
+| Stdin + Read both fail | `AggregateTransportError` fields populated; `prompt_file` set |
+| Read success | Temp file removed |
+| Read failure | Temp file exists |
+| `summarize_job` | 422 + `detail` keys |
+
+Prefer unit tests with mocked `query` / `query_agent`; no requirement for a 282-trial integration job in CI.
+
+## 10. Limitations
+
+- Stdin fixes **argv only**; ~500KB+ prompts may still hit **model context** limits; Read does not guarantee bypass.
+- Read path is slower and may emit tool/Bash-related stderr in verbose mode.
+- Concurrent summarizes on one job produce multiple timestamped files if failures occur; success paths delete their own file only.
+
+## 11. Brainstorm decisions log
+
+| Question | Choice |
+|----------|--------|
+| Fallback trigger | **B** — proactive skip of argv when over threshold |
+| Threshold config | **A** — fixed 120KB + explanatory comment |
+| Read fallback file | **B** — timestamped temp under job dir |
+| Temp file cleanup | **A** — delete on success, keep on failure |
+| API error shape | **B** — structured 422 `detail` |
diff --git a/docs/superpowers/specs/2026-05-21-bitfun-cli-harbor-integration-fixes-design.md b/docs/superpowers/specs/2026-05-21-bitfun-cli-harbor-integration-fixes-design.md
new file mode 100644
index 00000000000..3cb0a3f7fe3
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-21-bitfun-cli-harbor-integration-fixes-design.md
@@ -0,0 +1,164 @@
+# Design: bitfun-cli Harbor integration debugging fixes
+
+**Status:** Approved (2026-05-21).
+**Scope:** Harbor-side changes only — `src/harbor/agents/installed/bitfun_cli.py`, focused unit tests. No BitFun CLI source changes.
+
+**Related prompt:** `prompts/bitfun-cli-harbor-integration-fixes.md`
+**Background job:** `2026-05-13__19-41-05` — 91× `NonZeroAgentExitCodeError` with `tee /logs/agent/bitfun.txt` ENOENT, truncated exceptions, empty failed-trial `agent/` trees.
+
+## Problem
+
+Failed bitfun-cli trials are hard to diagnose because:
+
+1. `/logs/agent` may not exist before the main pipeline runs → `tee` fails with ENOENT.
+2. `set -o pipefail` makes a `tee` failure return exit 1, masking bitfun-cli’s real exit code.
+3. `BaseInstalledAgent._truncate_output(max_len=1000)` drops the tail of stdout in `exception.txt`.
+4. cp-back runs in `finally` but cannot recover artifacts when the main command never created log paths; env for cp-back may be unclear vs `config.agent.env`.
+
+Successful trials under the same job config have full `agent/bitfun.txt`, sessions, and patch files — failures are often infrastructure/setup, not model quality.
+
+## Goals
+
+- Distinguish **infrastructure failures** (missing dirs, tee errors) from **bitfun-cli exit 1** using the CLI’s true exit code in errors.
+- Ensure failed trials retain **actionable logs** on the host under `{trial}/agent/` (bind-mounted `logs_dir`).
+- Keep cp-back best-effort and enrich host-side debug when expected artifacts are missing.
+- Do not change default truncation behavior for other installed agents.
+
+## Non-goals
+
+- BitFun-side exec file logging, `BITFUN_EXIT`, or `--diagnostics-dir` (see `BitFun/prompts/harbor-integration-fixes.md`).
+- Changing `BaseInstalledAgent._truncate_output` globally.
+- New cloud/binary delivery paths.
+
+## Decisions (locked)
+
+| Topic | Decision |
+|-------|----------|
+| Failure log source | **A** — persist from `ExecResult.stdout` / `stderr` in Python before raising `NonZeroAgentExitCodeError`. Container `tee` still runs on success path; when tee never wrote, exec capture is the only source. |
+| Implementation shape | **Shell builder + `BitfunCli._exec` override** — extract `_build_run_shell()`, keep `_cp_back_command()`; override `_exec` only in `BitfunCli`. |
+| Large output | If combined stdout+stderr length **> 512 KiB**, write **head 8 KiB** + marker + **tail 32 KiB** to `logs_dir/bitfun.txt`; otherwise write full text. `exception.txt` may stay truncated at 1000 chars. |
+| Exit code | Final shell exit = `${PIPESTATUS[0]}` (bitfun-cli only). Error message cites this code; optional note if output suggests tee failure. |
+
+## Architecture
+
+### Components touched
+
+- **`BitfunCli`** (`bitfun_cli.py`) — all behavioral changes.
+- **`tests/unit/agents/installed/test_bitfun_cli.py`** — command-string and failure-persist tests.
+
+No factory, trial, or environment changes required.
+
+### Data flow (main run)
+
+```mermaid
+sequenceDiagram
+ participant Trial
+ participant BitfunCli
+ participant Env as Container exec
+ participant Host as trial/agent (logs_dir)
+
+ Trial->>BitfunCli: run(instruction)
+ BitfunCli->>Env: exec_as_agent(_build_run_shell(), env=_env_for_run())
+ Note over Env: mkdir -p /logs/agent; bitfun | tee; exit PIPESTATUS[0]
+ alt return_code != 0
+ BitfunCli->>Host: write bitfun.txt from ExecResult (full or head+tail)
+ BitfunCli-->>Trial: NonZeroAgentExitCodeError (truncated msg)
+ end
+ BitfunCli->>Env: exec_as_agent(cp-back, same env)
+ BitfunCli->>Host: debug log missing cli.log / sessions
+```
+
+## Detailed behavior
+
+### 1. `_build_run_shell(instruction) -> str`
+
+Single shell script fragment (merged into one `exec_as_agent` call, same user/cwd as today):
+
+```bash
+set -o pipefail
+mkdir -p /logs/agent
+# when output_patch_path set:
+mkdir -p "$(dirname "$PATCH_PATH")" 2>/dev/null || true
+ exec --agent [--output-patch ...] \
+ 2>&1 | stdbuf -oL tee /logs/agent/bitfun.txt
+rc=${PIPESTATUS[0]}
+exit $rc
+```
+
+Requirements:
+
+- `mkdir -p /logs/agent` runs as agent user with `cwd=/testbed` (unchanged).
+- Patch parent dir uses the same pattern as cp-back (`dirname` of `_output_patch_path`).
+- `base._exec` still prefixes `set -o pipefail;` — acceptable double prefix; final process exit is explicit `exit $rc`.
+
+### 2. `_env_for_run() -> dict[str, str]`
+
+- Collect `_ENV_PASSTHROUGH`, `BITFUN_*` from host `os.environ` (unchanged).
+- **`env.update(self._extra_env)`** so `config.agent.env` (e.g. `XDG_CONFIG_HOME=/testbed/.config`) is visible in the dict passed to both main exec and cp-back.
+- `_exec` may still merge `_extra_env` again; duplication is harmless.
+
+### 3. `BitfunCli._exec` override
+
+On `result.return_code != 0`:
+
+1. Merge `result.stdout` and `result.stderr` (stderr appended after stdout if non-empty).
+2. Apply size policy → write `self.logs_dir / "bitfun.txt"` (create parent dirs if needed).
+3. Delegate to `super()._exec(...)` **or** replicate raise path so `NonZeroAgentExitCodeError` message still uses `_truncate_output` for the exception string only.
+
+On success: no extra write (tee + bind mount already populate `bitfun.txt` when applicable).
+
+### 4. cp-back enhancements
+
+Keep `_CP_BACK_COMMAND` behavior (sessions slug paths, mtime fallback, token_usage, cli.log, patch placeholder).
+
+Add after cp-back completes in `run()` `finally` (host-side, no extra container exec):
+
+- `logger.debug` if `logs_dir/bitfun/cli.log` missing.
+- `logger.debug` if `logs_dir/bitfun/sessions` missing or has no session subdirs.
+- Do not raise; preserve finally semantics.
+
+Shell cp-back already starts with `mkdir -p /logs/agent/bitfun/sessions`; copy failures remain `|| true`.
+
+### 5. `run()` structure
+
+```python
+try:
+ await self.exec_as_agent(..., command=self._build_run_shell(...), env=self._env_for_run(), cwd="/testbed")
+finally:
+ try:
+ await self.exec_as_agent(..., command=self._cp_back_command(), env=self._env_for_run())
+ self._log_cp_back_gaps() # new host-side helper
+ except Exception as exc:
+ self.logger.debug(...)
+```
+
+## Testing
+
+Add/update in `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+1. `_build_run_shell` output contains `mkdir -p /logs/agent` and `${PIPESTATUS[0]}` / `exit $rc`.
+2. With `output_patch_path`, shell contains patch parent `mkdir`.
+3. Mock `environment.exec` failure with long stdout → `logs_dir/bitfun.txt` exists and respects head+tail when over threshold.
+4. cp-back command still includes `cli.log`, sessions `cp -R`, `token_usage`.
+5. `_env_for_run()` includes keys from `extra_env` when agent constructed with `_extra_env`.
+
+Run:
+
+```bash
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+uv run pytest tests/unit/ -k bitfun
+```
+
+## Acceptance (manual)
+
+- Delete `/logs/agent` in container, rerun trial → mkdir prevents tee ENOENT.
+- Force bitfun-cli exit 1 → `exception.txt` truncated but `trial/agent/bitfun.txt` contains failure tail (or head+tail if huge).
+- When bitfun wrote cli.log/sessions, failed trial’s `agent/bitfun/cli.log` or `agent/bitfun/sessions/` present after cp-back.
+
+## Risks / notes
+
+- **Double pipefail:** benign; explicit `exit $rc` is authoritative.
+- **Stdout size from environment providers:** if an environment truncates exec capture below 512 KiB, head+tail policy applies to captured bytes only (documented limitation).
+- **Success path:** no change to ATIF conversion or `populate_context_post_run`.
diff --git a/docs/superpowers/specs/2026-05-31-bitfun-tps-harbor-view-design.md b/docs/superpowers/specs/2026-05-31-bitfun-tps-harbor-view-design.md
new file mode 100644
index 00000000000..3c4fad6971d
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-31-bitfun-tps-harbor-view-design.md
@@ -0,0 +1,200 @@
+# Design: BitFun TPS Metrics in Harbor Trajectory and Viewer
+
+**Status:** Approved for specification on 2026-05-31.
+**Scope:** Carry BitFun CLI's LLM tokens-per-second observation into Harbor's ATIF trajectory output and display it in Harbor view.
+
+## Goals
+
+- Preserve BitFun's LLM latency and output TPS observations in Harbor trajectory data without changing the ATIF schema.
+- Show TPS in Harbor view at both trajectory-summary and step levels.
+- Keep the metric semantics aligned with BitFun's current calculation: completion tokens divided by LLM latency, excluding tool execution time.
+- Distinguish missing latency from zero latency so consumers do not confuse "not observed" with "observed but unusable for division."
+
+## Non-goals
+
+- Changing BitFun's Rust data model or storage layout.
+- Adding first-class ATIF fields for latency or TPS.
+- Making Harbor view parse BitFun raw artifacts directly.
+- Showing an all-in parent-plus-subagent TPS in the UI.
+
+## Background
+
+BitFun commit `66ca5cd0` added per-LLM-call `llm_latency_ms` to token usage records and logs a turn-level `llm_tps` value computed as:
+
+```text
+completion_tokens * 1000.0 / llm_duration_ms
+```
+
+The logged TPS is per dialog turn. Parent turns do not include subagent model calls in their turn-level TPS. Subagents run as hidden sessions with their own dialog turns and token records marked `is_subagent = true`.
+
+Harbor's current `bitfun-cli` integration already copies BitFun token usage records into `agent/bitfun/token_usage/records/*.json` and converts those records into ATIF `Metrics` on trajectory steps. ATIF's `Metrics.extra` and `FinalMetrics.extra` are the right compatibility-preserving place for this provider-specific metric.
+
+## Data Model
+
+Do not change ATIF schema models. Use existing `extra` dictionaries.
+
+### Step Metrics
+
+For each BitFun token usage record allocated to a Harbor step, add latency and TPS to `step.metrics.extra` when available.
+
+Example:
+
+```json
+{
+ "metrics": {
+ "prompt_tokens": 1000,
+ "completion_tokens": 120,
+ "cached_tokens": 300,
+ "cost_usd": 0.01,
+ "extra": {
+ "llm_latency_ms": 4800,
+ "completion_tokens_per_second": 25.0,
+ "token_details": {},
+ "total_tokens": 1120,
+ "cached_tokens_available": true,
+ "record_timestamp": "2026-05-31T10:00:00Z",
+ "record_model_id": "example-model"
+ }
+ }
+}
+```
+
+Use BitFun's formula:
+
+```text
+completion_tokens_per_second = completion_tokens * 1000.0 / llm_latency_ms
+```
+
+When multiple token records merge onto one step, compute TPS from summed covered values:
+
+```text
+sum(completion_tokens from records with usable latency) * 1000.0
+/ sum(llm_latency_ms from records with usable latency)
+```
+
+Do not average per-record TPS values.
+
+### Missing vs Zero Latency
+
+Handle unavailable TPS explicitly:
+
+- Missing or null latency: do not write `llm_latency_ms`; do not write `completion_tokens_per_second`; write `tps_unavailable_reason: "missing_latency"`.
+- Zero latency: write `llm_latency_ms: 0`; do not write `completion_tokens_per_second`; write `tps_unavailable_reason: "zero_latency"`.
+- Invalid latency, including non-numeric or negative values: treat as missing latency and log at debug level.
+
+When merged step metrics include a mix of usable and missing latency records, compute TPS from the usable subset and write:
+
+- `tps_model_call_count`: count of records with usable latency.
+- `tps_completion_tokens`: completion tokens covered by usable latency.
+- `tps_latency_coverage: "partial"`.
+
+If all merged records with token data have usable latency, write `tps_latency_coverage: "complete"`.
+
+### Final Metrics
+
+Add summary TPS data to `trajectory.final_metrics.extra`, using main-session records only. Records with `is_subagent == true` do not participate in the main trajectory TPS.
+
+Example:
+
+```json
+{
+ "final_metrics": {
+ "total_prompt_tokens": 5000,
+ "total_completion_tokens": 600,
+ "total_cached_tokens": 1200,
+ "total_cost_usd": 0.05,
+ "extra": {
+ "total_llm_latency_ms": 24000,
+ "model_call_count": 5,
+ "completion_tokens_per_second": 25.0,
+ "tps_completion_tokens": 600,
+ "tps_latency_coverage": "complete",
+ "subagent_session_count": 1,
+ "subagent_total_tokens": 800
+ }
+ }
+}
+```
+
+Partial and unavailable semantics match step metrics:
+
+- All main-session records have usable latency: `tps_latency_coverage: "complete"`.
+- Some main-session records have usable latency: compute TPS from the usable subset and write `tps_latency_coverage: "partial"`.
+- No usable latency because latency is missing: do not write TPS and write `tps_unavailable_reason: "missing_latency"`.
+- No usable latency because latency sums to zero: write `total_llm_latency_ms: 0`, do not write TPS, and write `tps_unavailable_reason: "zero_latency"`.
+
+## Viewer Design
+
+Harbor view reads TPS only from `trajectory.json`. It does not parse `agent/bitfun/token_usage/records/*.json`.
+
+### Summary Display
+
+On the trial page, extend the existing Tokens card with compact summary metrics below the token bar:
+
+- `Output TPS: 25.0 tokens/s`
+- `LLM latency: 24.0s`
+- `Model calls: 5`
+
+If `tps_latency_coverage` is `"partial"`, display `Output TPS: 25.0 tokens/s (partial)`.
+
+If TPS is unavailable, omit the TPS line. If latency is present but zero, the UI may still show `LLM latency: 0ms`; it should not show `0 TPS`.
+
+### Step Display
+
+When a step is expanded, extend the existing step token line from:
+
+```text
+Tokens: 1,000 prompt / 120 completion / $0.01
+```
+
+to:
+
+```text
+Tokens: 1,000 prompt / 120 completion / 25.0 tok/s / 4.8s LLM / $0.01
+```
+
+If step TPS is unavailable, omit the `tok/s` segment. If latency is present, still show the latency segment.
+
+### Type Safety
+
+Update viewer trajectory types so `StepMetrics` and `FinalMetrics` include:
+
+```ts
+extra?: Record | null;
+```
+
+Read values from `extra` through type guards. Do not assume fields are present or numeric.
+
+## Error Handling
+
+TPS is observational metadata and must not affect trial success or trajectory generation.
+
+- Invalid latency values are ignored for TPS and logged at debug level.
+- Missing output tokens prevent TPS calculation for that record but do not affect existing token and cost fields.
+- Existing token totals, cached token totals, and cost behavior remain unchanged.
+- Subagent token records remain represented in embedded subagent trajectories as they are today; they are excluded only from the root trajectory's main-session summary TPS.
+
+## Testing
+
+Add focused unit coverage in `tests/unit/agents/installed/test_bitfun_cli.py` and viewer tests where helpers are introduced.
+
+Required cases:
+
+- `_build_metrics_from_record` writes `llm_latency_ms` and `completion_tokens_per_second` for a valid latency record.
+- Missing latency writes `tps_unavailable_reason: "missing_latency"` and no TPS.
+- Zero latency preserves `llm_latency_ms: 0`, writes `tps_unavailable_reason: "zero_latency"`, and no TPS.
+- `_merge_metrics` computes merged TPS using summed completion tokens and summed latency, not average TPS.
+- `_merge_metrics` marks mixed usable/missing latency as `tps_latency_coverage: "partial"`.
+- `_build_final_metrics` computes root summary TPS from main-session records only and excludes `is_subagent: true`.
+- Viewer helpers safely read numeric TPS and latency from `extra`, and omit display when values are unavailable.
+
+## Verification
+
+After implementation, run:
+
+```bash
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+uv run pytest tests/unit/
+```
diff --git a/docs/superpowers/specs/2026-06-08-job-report-link-design.md b/docs/superpowers/specs/2026-06-08-job-report-link-design.md
new file mode 100644
index 00000000000..785b168e280
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-08-job-report-link-design.md
@@ -0,0 +1,109 @@
+# Job External Report Link Design
+
+## Goal
+
+Add an optional job-level external report entry to Harbor Viewer. Some jobs have agent-generated HTML reports hosted by a separate HTTP service. From each Harbor Viewer job page, users should be able to open the corresponding external report with one click.
+
+## Requirements
+
+- Show the entry at the same hierarchy as the existing `Results` and `Analysis` job page tabs.
+- Match the existing tab visual style closely.
+- Clicks navigate to an external HTTP service page.
+- The external service is deployed separately from Harbor.
+- The page path is the Harbor job name.
+- Configure the external service base URL in the existing analyze profiles TOML file.
+- Keep Harbor changes small and avoid adding a new standalone viewer configuration system.
+- Do not require Harbor Viewer to probe or validate the external service at runtime.
+
+## Chosen Approach
+
+Extend the existing analyze profiles TOML document with one optional top-level field:
+
+```toml
+external_job_report_base_url = "http://report-host:9000"
+
+[[profile]]
+id = "anthropic"
+label = "Anthropic (direct)"
+api_key_env = "ANTHROPIC_API_KEY"
+base_url_env = "ANTHROPIC_BASE_URL"
+default_model = "haiku"
+```
+
+The field is top-level because the external report service is a Viewer/job navigation concern, not a model provider setting. Keeping it in the analyze profiles file satisfies the deployment preference without tying the link to a specific analyze profile row.
+
+## URL Construction
+
+For job name `tb2-cc-ds-0003-rerun-run-1854e430d280` and base URL `http://report-host:9000`, the final URL is:
+
+```text
+http://report-host:9000/tb2-cc-ds-0003-rerun-run-1854e430d280
+```
+
+Implementation should trim trailing slashes from the configured base URL and append `encodeURIComponent(jobName)`.
+
+## Backend Design
+
+Update `src/harbor/analyze/profiles.py`:
+
+- Add an optional `external_job_report_base_url` field to `AnalyzeProfilesDocument`.
+- Parse `external_job_report_base_url` from the TOML top level in `load_profiles_from_file()`.
+- Keep `built_in_profiles()` returning no external report configuration.
+- Validate that the value, when present, is a non-empty HTTP or HTTPS URL.
+- Extend the public API serialization to include:
+
+```json
+{
+ "profiles": [],
+ "external_job_report": {
+ "base_url": "http://report-host:9000"
+ }
+}
+```
+
+The existing `/api/analyze/profiles` route can keep returning one document; no new endpoint is required.
+
+## Frontend Design
+
+Update the Viewer job page in `apps/viewer/app/routes/job.tsx`:
+
+- Fetch analyze profiles/config on the job page, not only when the Generate Analysis dialog is opened.
+- If `external_job_report.base_url` exists, render an external link beside `Results` and `Analysis`.
+- Label the entry `Report`.
+- Style the link to match `TabsTrigger` spacing, typography, border, and hover behavior.
+- Use a normal anchor with `href` set to the constructed external report URL.
+- Navigate in the current browser tab.
+
+The entry is intentionally an external link, not a Radix tabs trigger, because Harbor Viewer does not render the report content and should not add an empty tab panel.
+
+## Error Handling
+
+- If no external report base URL is configured, do not show the `Report` entry.
+- If the configured URL is empty or not HTTP/HTTPS, fail profile loading with a clear configuration error.
+- If the external service is down or a job report is missing, let the external service/browser show the resulting error. Harbor Viewer should not add service health checks or per-job availability checks.
+
+## Testing
+
+Add focused tests:
+
+- TOML loading accepts a valid `external_job_report_base_url`.
+- TOML loading rejects empty or non-HTTP(S) values.
+- `/api/analyze/profiles` includes `external_job_report` when configured and omits it when not configured.
+- Frontend URL construction trims trailing slashes and URL-encodes the job name.
+
+After implementation, run the repository-required checks:
+
+```bash
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+```
+
+Also run the relevant unit tests, including analyze profile and viewer route tests.
+
+## Non-Goals
+
+- Hosting or generating the external HTML reports inside Harbor.
+- Adding a new Harbor Viewer configuration file or CLI flag.
+- Supporting per-profile or per-job custom report URL templates.
+- Checking external report existence before rendering the link.
diff --git a/docs/superpowers/specs/2026-06-08-standalone-html-report-viewer-design.md b/docs/superpowers/specs/2026-06-08-standalone-html-report-viewer-design.md
new file mode 100644
index 00000000000..78db97ca79c
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-08-standalone-html-report-viewer-design.md
@@ -0,0 +1,166 @@
+# Standalone HTML Report Viewer Design
+
+## Goal
+
+Build a small HTTP service for job-level HTML reports. Harbor Viewer links to this service through the existing job-level `Report` button, but the service runs independently from Harbor and is not a Harbor CLI subcommand.
+
+## Requirements
+
+- Code lives in this repository in a standalone directory.
+- Runtime architecture is independent from Harbor.
+- The service must not register a Harbor subcommand or import Harbor business modules.
+- Harbor Viewer integration uses `external_job_report_base_url/{job_name}`.
+- `GET /{job_name}` opens a report page for that Harbor job.
+- The page has a top-right `Upload` button.
+- If a report was uploaded before, display that HTML report.
+- If no report was uploaded, show an empty state and allow upload.
+- Uploading a local HTML file overwrites any previous HTML for that job.
+- No authentication in the first version.
+- Store reports on the local filesystem.
+- Support only a single self-contained HTML file per job in the first version.
+
+## Chosen Approach
+
+Create a standalone FastAPI application under `report-viewer/`.
+
+Example runtime command:
+
+```bash
+cd /home/djn/code/harbor/report-viewer
+uv run uvicorn app.main:app --host 0.0.0.0 --port 7397
+```
+
+Harbor integration is only configuration:
+
+```toml
+external_job_report_base_url = "http://111.119.196.110:7397"
+```
+
+When a Harbor job page links to `http://111.119.196.110:7397/tb2-cc-ds-0003-rerun-run-1854e430d280`, this service renders the report shell for that job.
+
+## File Layout
+
+```text
+report-viewer/
+ pyproject.toml
+ README.md
+ app/
+ __init__.py
+ main.py
+ storage.py
+ templates/
+ report.html
+ static/
+ app.css
+ app.js
+ tests/
+ test_storage.py
+ test_routes.py
+ data/
+ .gitkeep
+```
+
+`data/` is the default local report storage root and should be gitignored except for `.gitkeep`.
+
+## Data Model
+
+Each job stores one uploaded HTML file:
+
+```text
+report-viewer/data/{job_name}/index.html
+```
+
+The service does not maintain a database. The filesystem is the source of truth:
+
+- report exists if `data/{job_name}/index.html` exists
+- upload time can be derived from file mtime
+- uploaded size can be derived from file size
+
+## Routes
+
+```text
+GET /health
+GET /{job_name}
+GET /api/reports/{job_name}/status
+GET /api/reports/{job_name}/html
+POST /api/reports/{job_name}
+```
+
+Route behavior:
+
+- `GET /health` returns `{"status": "ok"}`.
+- `GET /{job_name}` returns the viewer shell with toolbar, upload control, empty state, and iframe.
+- `GET /api/reports/{job_name}/status` returns whether the job has an uploaded report, plus size and updated time when available.
+- `GET /api/reports/{job_name}/html` returns the uploaded `index.html`, or 404 if none exists.
+- `POST /api/reports/{job_name}` accepts multipart form field `file`, validates it as `.html` or `.htm`, and atomically overwrites `data/{job_name}/index.html`.
+
+## Job Name Validation
+
+Use a conservative allowlist for `job_name`:
+
+- allowed characters: `A-Z`, `a-z`, `0-9`, `.`, `_`, `-`
+- reject empty names
+- reject names longer than 200 characters
+- reject path separators and path traversal by construction
+
+This supports current Harbor job names while keeping the filesystem mapping simple and safe.
+
+## Page Behavior
+
+The report page is an app shell:
+
+- top toolbar shows the job name and an `Upload` button on the right
+- the upload button opens a local file picker
+- accepted file types are `.html` and `.htm`
+- if status says a report exists, iframe loads `/api/reports/{job_name}/html`
+- if status says no report exists, show an empty state
+- after successful upload, refresh status and reload the iframe
+- upload errors are shown inline in the shell
+
+The uploaded HTML is not rewritten. The first version assumes uploaded reports are self-contained single HTML files.
+
+## Security Model
+
+The first version has no service-level authentication because the service is expected to run in a trusted environment or behind external access control.
+
+Uploaded HTML is displayed in an iframe. The first version should use an iframe `sandbox` attribute to reduce accidental page-level impact while still allowing typical generated reports to work:
+
+```html
+sandbox="allow-scripts allow-forms allow-popups allow-downloads"
+```
+
+The service does not sanitize uploaded HTML. Operators should treat uploaded reports as trusted internal content.
+
+## Error Handling
+
+- Invalid job names return 400.
+- Missing report HTML returns 404 from the HTML API.
+- Unsupported upload extension returns 400.
+- Empty upload returns 400.
+- Filesystem write failures return 500 with a short message.
+- The UI displays upload failures without navigating away.
+
+## Testing
+
+Add focused tests for:
+
+- valid job name maps to `data/{job_name}/index.html`
+- invalid job names are rejected
+- report existence detection
+- uploading a file creates `index.html`
+- uploading again overwrites `index.html`
+- `GET /health` returns ok
+- `GET /{job_name}` returns the shell
+- `GET /api/reports/{job_name}/status` reports missing and present states
+- `GET /api/reports/{job_name}/html` returns 404 before upload and HTML after upload
+- `POST /api/reports/{job_name}` rejects non-HTML files and accepts HTML files
+
+## Non-Goals
+
+- No Harbor CLI integration.
+- No dependency on Harbor internals.
+- No database.
+- No authentication in the first version.
+- No zip upload or asset bundle support.
+- No sanitization or rewriting of uploaded HTML.
+- No list-all-jobs page.
diff --git a/docs/superpowers/specs/2026-06-16-bitfun-cli-run-config-design.md b/docs/superpowers/specs/2026-06-16-bitfun-cli-run-config-design.md
new file mode 100644
index 00000000000..b2e1dfcc473
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-16-bitfun-cli-run-config-design.md
@@ -0,0 +1,141 @@
+# Design: bitfun-cli run-time app config injection
+
+**Scope:** Harbor-side change for `src/harbor/agents/installed/bitfun_cli.py` and focused unit tests. No BitFun source changes.
+
+## Problem
+
+Harbor's `bitfun-cli` agent needs a first-class way to make a supplied BitFun app configuration effective before each agent run, similar to how OpenCode writes `~/.config/opencode/opencode.json` before `opencode run`.
+
+The configuration is not limited to model settings. The input may include `ai`, `app`, `mcp_servers`, or any other BitFun app config root fields. Harbor must not assume that BitFun's app config file already exists in a fresh trial container.
+
+## Context Found
+
+BitFun has two relevant config files:
+
+- `~/.config/bitfun/config.toml` is CLI-specific UI and behavior config.
+- `~/.config/bitfun/config/app.json` is the global app config used by `ConfigManager`; it contains `ai.models`, `ai.default_models`, and other app-level fields.
+
+On Linux, BitFun's `PathManager` resolves the user config root from `BITFUN_USER_ROOT` or `BITFUN_E2E_USER_ROOT` when set; otherwise it uses the platform config directory joined with `bitfun`. The app config file is `/config/app.json`.
+
+OpenCode's Harbor integration does not read an existing config file. It builds the config in memory and writes `~/.config/opencode/opencode.json` before the main run command.
+
+## Decision
+
+Add a `bitfun_config` agent kwarg. When present, Harbor treats it as the complete BitFun app config root and writes it directly to BitFun's app config file before `bitfun-cli exec`.
+
+Harbor will not read an existing `app.json`, merge with it, wrap the value under `ai`, or preserve old fields. This avoids stale `ai.agent_models`, `ai.func_agent_models`, old model entries, or other prior config from influencing the trial. It also makes the behavior identical whether the file existed before the run or not.
+
+## User-Facing Configuration
+
+Example job agent config:
+
+```yaml
+agents:
+ - name: bitfun-cli
+ kwargs:
+ bitfun_config:
+ ai:
+ models:
+ - id: deepseek-v4-pro
+ name: deepseek-v4-pro
+ provider: openai
+ model_name: deepseek-v4-pro
+ base_url: https://api.deepseek.com
+ api_key: ${DEEPSEEK_API_KEY}
+ enabled: true
+ context_window: 1048576
+ max_tokens: 65536
+ reasoning_mode: enabled
+ reasoning_effort: max
+ default_models:
+ primary: deepseek-v4-pro
+ fast: deepseek-v4-pro
+```
+
+The value above is written as the root of `app.json` without wrapping or field filtering:
+
+```json
+{
+ "ai": {
+ "models": [
+ {
+ "id": "deepseek-v4-pro",
+ "name": "deepseek-v4-pro",
+ "provider": "openai",
+ "model_name": "deepseek-v4-pro",
+ "base_url": "https://api.deepseek.com",
+ "api_key": "${DEEPSEEK_API_KEY}",
+ "enabled": true,
+ "context_window": 1048576,
+ "max_tokens": 65536,
+ "reasoning_mode": "enabled",
+ "reasoning_effort": "max"
+ }
+ ],
+ "default_models": {
+ "primary": "deepseek-v4-pro",
+ "fast": "deepseek-v4-pro"
+ }
+ }
+}
+```
+
+The JSON above is illustrative only; the implementation writes the full provided object. Credentials should be passed through Harbor config/environment templating rather than committed as literal secrets.
+
+## Behavior
+
+`BitfunCli.__init__` accepts:
+
+```python
+bitfun_config: dict[str, Any] | None = None
+```
+
+If `bitfun_config` is `None`, `BitfunCli.run()` keeps its current behavior: main run command, then best-effort cp-back.
+
+If `bitfun_config` is provided:
+
+1. Serialize it with `json.dumps(..., indent=2)`.
+2. Build a setup command that resolves BitFun's app config path:
+ - `BITFUN_USER_ROOT` if set.
+ - Else `BITFUN_E2E_USER_ROOT` if set.
+ - Otherwise `${XDG_CONFIG_HOME:-$HOME/.config}/bitfun`.
+3. Create the config directory.
+4. Overwrite `/config/app.json` with the serialized JSON.
+5. Run the existing `bitfun-cli exec` command.
+6. Run the existing cp-back command in `finally`.
+
+No pre-run BitFun initialization command is required. BitFun's `GlobalConfig` uses serde defaults, so missing app config root fields are supplied by BitFun when it loads the file.
+
+## Validation And Errors
+
+`bitfun_config` must be a dict. Passing a non-dict raises `ValueError` during agent construction.
+
+If the config write command fails, the failure propagates through `exec_as_agent` and the main `bitfun-cli exec` command does not run. The cp-back behavior remains the existing `finally` block behavior from `run()`.
+
+The setup command includes the serialized JSON in the shell command, matching the existing OpenCode pattern. This is simple and consistent, but it means debug logs that include full commands can contain config values. This is an existing class of risk for config-file setup commands; a future hardening pass can switch these setup writes to stdin/heredoc handling with redacted logging.
+
+## Tests
+
+Add focused unit coverage in `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+- `_build_register_config_command()` returns `None` when no `bitfun_config` is provided.
+- `_build_register_config_command()` writes to BitFun's `config/app.json` path and contains the exact serialized config when `bitfun_config` is provided.
+- `run()` with `bitfun_config` executes config setup before the main run command and still executes cp-back afterwards.
+- `run()` without `bitfun_config` preserves the current two-exec behavior.
+- Non-dict `bitfun_config` raises `ValueError`.
+
+Verification after implementation:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+```
+
+## Non-Goals
+
+- Do not modify BitFun's config schema or CLI commands.
+- Do not implement partial `ai` merging.
+- Do not preserve an existing `app.json`.
+- Do not add model-specific Harbor shorthands in this change.
diff --git a/docs/superpowers/specs/2026-06-17-bitfun-cli-request-traces-design.md b/docs/superpowers/specs/2026-06-17-bitfun-cli-request-traces-design.md
new file mode 100644
index 00000000000..1a3b1e0402a
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-17-bitfun-cli-request-traces-design.md
@@ -0,0 +1,139 @@
+# bitfun-cli request-traces retention design
+
+## Goal
+
+Preserve BitFun's raw `request-traces` artifact after a Harbor `bitfun-cli` run.
+
+BitFun stores `request-traces` as a project-level directory next to `sessions`:
+
+```text
+~/.bitfun/projects//
+ request-traces/
+ sessions/
+```
+
+Harbor already copies `sessions` back from the selected BitFun project into
+`agent/bitfun/sessions`. This change extends the same cp-back path to preserve
+`request-traces` without parsing or transforming its contents.
+
+## Non-Goals
+
+- Do not parse `request-traces` into ATIF steps.
+- Do not change BitFun trajectory conversion.
+- Do not change which BitFun project is selected, except as needed to express
+ the existing selection as a project root instead of only a `sessions` path.
+- Do not make missing `request-traces` fatal.
+
+## Current Behavior
+
+`BitfunCli._CP_BACK_COMMAND` locates a BitFun sessions directory using:
+
+1. Preferred slug paths:
+ - `$HOME/.bitfun/projects/testbed/sessions`
+ - `$HOME/.bitfun/projects/-testbed/sessions`
+2. Fallback: the most recently modified `$HOME/.bitfun/projects/*/sessions/`.
+
+It copies the selected sessions directory into `/logs/agent/bitfun/sessions`.
+The command also copies config-level artifacts such as token usage, CLI logs,
+the single `cli.log`, and `ai-request-audit.jsonl`, then writes
+`/logs/agent/bitfun/cp-back-manifest.json`.
+
+`populate_context_post_run()` exposes several copied artifacts in
+`context.metadata["bitfun"]` when they exist, but there is currently no explicit
+`request_traces_path`.
+
+## Proposed Behavior
+
+The cp-back command should locate the BitFun project root and copy both project
+artifacts from that same root:
+
+```text
+/sessions -> /logs/agent/bitfun/sessions
+/request-traces -> /logs/agent/bitfun/request-traces
+```
+
+The project selection should stay equivalent to the current sessions selection:
+
+1. Prefer `$HOME/.bitfun/projects/testbed` when it has a `sessions` directory.
+2. Prefer `$HOME/.bitfun/projects/-testbed` when it has a `sessions` directory.
+3. Otherwise pick the project whose `sessions/` directory is most recently
+ modified.
+
+This keeps all existing trajectory behavior tied to sessions while making the
+neighboring `request-traces` directory available for debugging and downstream
+analysis.
+
+## Manifest
+
+Extend `/logs/agent/bitfun/cp-back-manifest.json` with a `request_traces`
+entry:
+
+```json
+{
+ "request_traces": {
+ "source": "/home/agent/.bitfun/projects//request-traces",
+ "exists": true
+ }
+}
+```
+
+When no project is selected, `source` should be an empty string and `exists`
+should be `false`. When a project is selected but `request-traces` is missing,
+`source` should still point to the expected sibling path and `exists` should be
+`false`.
+
+The existing `sessions` manifest entry should continue to describe the selected
+sessions source and whether it exists.
+
+## Metadata
+
+When `logs_dir/bitfun/request-traces` exists after cp-back,
+`populate_context_post_run()` should add:
+
+```json
+{
+ "request_traces_path": "agent/bitfun/request-traces"
+}
+```
+
+to `context.metadata["bitfun"]`.
+
+This mirrors existing artifact path metadata such as `cli_logs_path`,
+`ai_request_audit_path`, `cp_back_manifest_path`, and `final_app_config_path`.
+
+## Error Handling
+
+The behavior remains best-effort:
+
+- Copy failures are swallowed with `|| true`, matching existing cp-back
+ behavior.
+- Missing `request-traces` does not fail the run.
+- `_log_cp_back_gaps()` should emit a debug log when
+ `logs_dir/bitfun/request-traces` is missing, similar to the current sessions
+ and log checks.
+- Existing main-exec, cp-back, and final app config capture failure semantics
+ remain unchanged.
+
+## Testing
+
+Update focused BitFun unit tests:
+
+- cp-back command includes `request-traces`, copies it to
+ `/logs/agent/bitfun/request-traces`, and writes a `request_traces` manifest
+ entry.
+- cp-back command still includes the existing sessions slug preference and mtime
+ fallback behavior.
+- `populate_context_post_run()` adds `request_traces_path` when the copied
+ directory exists.
+- `_log_cp_back_gaps()` logs a debug message when `request-traces` is missing.
+
+Verification after implementation should follow repository guidance:
+
+```bash
+uv run pytest tests/unit/agents/installed/test_bitfun_cli.py -v
+uv run pytest tests/unit/
+uv run ruff check --fix .
+uv run ruff format .
+uv run ty check
+```
+
diff --git a/docs/superpowers/specs/2026-06-17-bitfun-cli-save-final-config-design.md b/docs/superpowers/specs/2026-06-17-bitfun-cli-save-final-config-design.md
new file mode 100644
index 00000000000..231d4f4afec
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-17-bitfun-cli-save-final-config-design.md
@@ -0,0 +1,183 @@
+# Design: bitfun-cli post-run app config capture
+
+**Scope:** Harbor-side change for `src/harbor/agents/installed/bitfun_cli.py` and focused unit tests. No BitFun source changes.
+
+## Problem
+
+Harbor's `bitfun-cli` agent can now write a supplied `bitfun_config` to BitFun's app config before each run. After a run completes, Harbor should also save the final BitFun app config that the agent actually used.
+
+This matters because the file in the trial environment is the authoritative post-run state. It may differ from the input `bitfun_config` if BitFun writes defaults, migrations, or runtime changes during the run.
+
+The final config may contain API keys, tokens, credentials, or other secrets. Harbor must not persist a raw copy in the trial artifacts.
+
+## Decision
+
+Capture the final BitFun app config only for the `bitfun-cli` agent, redact it on the Harbor host side, and persist only the redacted artifact.
+
+The container must not perform JSON parsing or redaction. Some benchmark containers do not have `jq`, Python, Node, or other JSON tooling available. Container-side work is limited to shell path probing and artifact directory creation.
+
+The source path must not be hard-coded as `/root/.config/bitfun/config/app.json`. It must use the same config-root resolution as the pre-run config writer:
+
+1. `BITFUN_USER_ROOT`
+2. `BITFUN_E2E_USER_ROOT`
+3. `${XDG_CONFIG_HOME:-$HOME/.config}/bitfun`
+
+The final source file is:
+
+```text
+$BITFUN_CONFIG_ROOT/config/app.json
+```
+
+The persisted artifact path is:
+
+```text
+/logs/agent/bitfun/config/app.redacted.json
+```
+
+There is no persisted raw `app.json` artifact.
+
+## Data Flow
+
+`BitfunCli.run()` already executes `_cp_back_command()` in a `finally` block after the main `bitfun-cli exec` command. Final config capture runs from the same `finally` flow after the existing cp-back command, while the environment is still alive.
+
+1. Run the existing cp-back command for sessions, token usage, CLI logs, patch metadata, and the current cp-back manifest.
+2. Run a lightweight app-config probe command in the container using the same environment as the BitFun run.
+3. The probe command resolves `BITFUN_CONFIG_ROOT`, computes `APP_CONFIG_SRC="$BITFUN_CONFIG_ROOT/config/app.json"`, and prints line-oriented metadata such as `source=...`, `exists=true|false`, and `size_bytes=...`.
+4. If the source file exists, Harbor downloads it with `environment.download_file()` to a private temporary file outside `/logs/agent`.
+5. Harbor parses and redacts the temporary raw JSON using Python standard-library `json`.
+6. Harbor writes the redacted JSON to another private temporary file.
+7. Harbor creates `/logs/agent/bitfun/config` in the environment and uploads the redacted file to `/logs/agent/bitfun/config/app.redacted.json` with `environment.upload_file()`.
+8. Harbor updates `/logs/agent/bitfun/cp-back-manifest.json` with final config capture metadata, using host-side JSON parsing and `environment.upload_file()`.
+9. Harbor deletes all temporary raw, redacted, and manifest files in a `finally` block.
+
+Uploading the redacted file back into `/logs/agent` is intentional. For mounted environments it becomes visible in the host logs immediately; for non-mounted environments the normal trial log download later collects it with the rest of `/logs/agent`.
+
+The pre-run writer and post-run probe should use equivalent config-root resolution snippets. This avoids drift between "where Harbor writes config" and "where Harbor reads final config back".
+
+## Redaction
+
+Redaction is recursive and schema-agnostic. It must work for `ai` config and for unrelated config sections such as MCP servers, provider credentials, auth settings, or future BitFun app fields.
+
+For dictionaries, if a key is considered sensitive, replace the whole value with:
+
+```json
+"[REDACTED]"
+```
+
+Key matching is case-insensitive and should cover high-confidence secret names such as:
+
+- `api_key`, `apikey`
+- `access_token`, `refresh_token`, `id_token`, `auth_token`, `bearer_token`
+- `authorization`
+- `password`, `passphrase`
+- `secret`, `client_secret`
+- `private_key`
+- `credential`, `credentials`
+
+Avoid broad matching that would redact non-secret configuration such as `max_tokens`, `context_window`, or model names.
+
+If the final app config is not valid JSON, Harbor does not persist the raw file. It records a capture error in the manifest and deletes the temporary raw file.
+
+## Manifest
+
+Extend `/logs/agent/bitfun/cp-back-manifest.json` with an `app_config` entry. The update is performed on the Harbor host side and uploaded back to `/logs/agent`.
+
+Successful capture:
+
+```json
+{
+ "app_config": {
+ "source": "$BITFUN_CONFIG_ROOT/config/app.json",
+ "exists": true,
+ "size_bytes": 1234,
+ "target": "agent/bitfun/config/app.redacted.json",
+ "redacted": true,
+ "raw_saved": false,
+ "capture_error": null
+ }
+}
+```
+
+Absent source file:
+
+```json
+{
+ "app_config": {
+ "source": "$BITFUN_CONFIG_ROOT/config/app.json",
+ "exists": false,
+ "size_bytes": 0,
+ "target": null,
+ "redacted": false,
+ "raw_saved": false,
+ "capture_error": null
+ }
+}
+```
+
+Capture failure:
+
+```json
+{
+ "app_config": {
+ "source": "$BITFUN_CONFIG_ROOT/config/app.json",
+ "exists": true,
+ "size_bytes": 1234,
+ "target": null,
+ "redacted": false,
+ "raw_saved": false,
+ "capture_error": "invalid JSON"
+ }
+}
+```
+
+The manifest entry is useful even when the file is absent. It distinguishes "BitFun did not create an app config file" from "Harbor did not know where to look".
+
+## Error Handling
+
+The existing cp-back semantics remain non-fatal:
+
+- If `app.json` does not exist, no exception is raised.
+- If downloading, parsing, redacting, uploading, or manifest update fails, the raw file is still deleted and the agent result is not changed.
+- If the main `bitfun-cli exec` command fails, `run()` still attempts cp-back and final config capture, then preserves the main command error.
+- If config capture fails unexpectedly, Harbor logs the failure at debug level and attempts to record `app_config.capture_error` in the manifest.
+
+## Security
+
+- Raw `app.json` is never written under `/logs/agent` or any final artifact directory.
+- Raw `app.json` may exist briefly as a private host-side temporary file solely to allow external redaction without container JSON dependencies.
+- The temporary raw file is deleted in `finally`, including parse or upload failures.
+- The final persisted artifact is `app.redacted.json` only.
+- There is no first-version opt-in flag to persist raw config.
+
+## Out of Scope
+
+- Saving the input `bitfun_config` separately from the final app config.
+- Persisting raw final config in trial artifacts.
+- Adding new non-BitFun agent behavior.
+- Changing how `bitfun_config` is written before the run.
+- Changing `ConfigManager`, BitFun path resolution, or BitFun app config schema.
+- Adding configurable redaction policies.
+
+## Tests
+
+Add focused tests in `tests/unit/agents/installed/test_bitfun_cli.py`:
+
+- The post-run probe resolves `BITFUN_CONFIG_ROOT` using `BITFUN_USER_ROOT`, `BITFUN_E2E_USER_ROOT`, then `${XDG_CONFIG_HOME:-$HOME/.config}/bitfun`.
+- The post-run probe does not contain a hard-coded `/root/.config/bitfun` source path.
+- Final config capture downloads `$BITFUN_CONFIG_ROOT/config/app.json` to a private temporary file, redacts it on the host side, and uploads only `/logs/agent/bitfun/config/app.redacted.json`.
+- Raw `app.json` is not copied to `/logs/agent/bitfun/config/app.json`.
+- Temporary raw files are deleted after success and after parse/upload failures.
+- Redaction covers sensitive keys outside the `ai` section.
+- Redaction does not redact non-secret keys such as `max_tokens`.
+- The manifest includes `app_config.source`, `app_config.exists`, `app_config.size_bytes`, `app_config.target`, `app_config.redacted`, `app_config.raw_saved`, and `app_config.capture_error`.
+- Existing run-finally behavior remains covered: cp-back and final config capture still run when the main command fails, and failures remain non-fatal.
+
+## User-Facing Result
+
+For a `bitfun-cli` trial, the final app config is available in the trial's agent logs under:
+
+```text
+bitfun/config/app.redacted.json
+```
+
+The cp-back manifest records where the file came from, whether it existed, whether redaction succeeded, and confirms that no raw config was saved.
diff --git a/examples/config/README.md b/examples/config/README.md
new file mode 100644
index 00000000000..c8573f1e4e1
--- /dev/null
+++ b/examples/config/README.md
@@ -0,0 +1,102 @@
+# Analyze profiles (Viewer)
+
+Configure multiple Anthropic-compatible providers for **Analyze** in the Harbor Viewer (job/trial summarization). Profiles hold **non-secret metadata** (provider labels, model mappings, env var *names*). API keys and base URLs come from the server process environment or `.env`.
+
+See [`analyze-profiles.example.toml`](./analyze-profiles.example.toml) for a full example.
+
+## Quick start
+
+1. Copy the example and edit profiles as needed:
+
+```bash
+cp examples/config/analyze-profiles.example.toml ~/harbor-analyze-profiles.toml
+```
+
+2. Set credentials in the environment (never in the TOML file):
+
+```bash
+export ANTHROPIC_API_KEY=sk-ant-...
+# optional
+export ANTHROPIC_BASE_URL=https://api.anthropic.com
+```
+
+3. Start the Viewer with the profiles file:
+
+```bash
+harbor view ./jobs --analyze-profiles ~/harbor-analyze-profiles.toml
+```
+
+Dev mode (`--dev`) sets `HARBOR_ANALYZE_PROFILES` for reload workers automatically when you pass `--analyze-profiles`.
+
+Alternatively, set the env var yourself:
+
+```bash
+export HARBOR_ANALYZE_PROFILES=~/harbor-analyze-profiles.toml
+harbor view ./jobs
+```
+
+## TOML schema
+
+Each profile is a `[[profile]]` table with one or more `[[profile.model]]` rows:
+
+| Field | Required | Description |
+| --- | --- | --- |
+| `id` | yes | Stable profile id (used in API/UI) |
+| `label` | no | Display name in the Viewer (defaults to `id`) |
+| `api_key_env` | yes | Env var name holding the API key |
+| `base_url_env` | no | Env var name for an Anthropic-compatible base URL |
+| `default_model` | yes | Logical model id used when only `profile_id` is sent |
+| `[[profile.model]].id` | yes | Logical model id in the UI |
+| `[[profile.model]].display_name` | no | Label in the UI |
+| `[[profile.model]].api_model` | yes | Model string passed to the analyze backend |
+
+Rules:
+
+- Profile `id` values must be unique.
+- Model `id` values must be unique within each profile.
+- Each profile must have at least one model.
+- `default_model` must match a model `id` in that profile.
+
+Without `--analyze-profiles` / `HARBOR_ANALYZE_PROFILES`, the Viewer uses a built-in **Anthropic (direct)** profile (`haiku` / `sonnet` / `opus`).
+
+## Viewer UI
+
+On job and trial **Analyze** dialogs, the UI calls `GET /api/analyze/profiles` and shows **Profile** + **Model** dropdowns when profiles are available. If the endpoint fails, it falls back to the legacy Haiku / Sonnet / Opus picker.
+
+## API
+
+**List profiles**
+
+```
+GET /api/analyze/profiles
+→ { "profiles": [ { "id", "label", "default_model", "models", "api_key_env", "base_url_env?" } ] }
+```
+
+**Summarize** (job or trial)
+
+Send either the legacy field or profile-aware fields:
+
+```json
+{ "model": "haiku" }
+```
+
+```json
+{ "profile_id": "corp-proxy", "model_id": "sonnet" }
+```
+
+If only `profile_id` is set, the profile’s `default_model` is used.
+
+Missing or invalid config returns **422** with a string `detail` (no secret values).
+
+## Corporate / proxy providers
+
+Add a second profile pointing at your proxy’s env vars (see the `corp-proxy` block in the example). Keys and URLs are mapped into `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` per request via the Claude Agent SDK env overlay—global `os.environ` is not modified.
+
+## Troubleshooting
+
+| Symptom | Likely cause |
+| --- | --- |
+| Startup error: missing file | `HARBOR_ANALYZE_PROFILES` path does not exist |
+| 422: env var not set | Export the `api_key_env` (and `base_url_env` if configured) before starting the Viewer |
+| 422: Unknown model_id | `model_id` not listed under that profile in TOML |
+| Legacy model UI only | Profiles endpoint failed; check server logs and TOML syntax |
diff --git a/examples/config/analyze-profiles.example.toml b/examples/config/analyze-profiles.example.toml
new file mode 100644
index 00000000000..8db97e03b4d
--- /dev/null
+++ b/examples/config/analyze-profiles.example.toml
@@ -0,0 +1,48 @@
+# Harbor Viewer analyze profiles (non-secret metadata).
+# Copy to your deployment and set HARBOR_ANALYZE_PROFILES to this path.
+# Provide API keys and base URLs via process environment or .env (never commit secrets).
+
+# Optional: show a job-level "Report" link in Harbor Viewer.
+# The job page opens {external_job_report_base_url}/{job_name}.
+# external_job_report_base_url = "http://report-host:9000"
+
+[[profile]]
+id = "anthropic"
+label = "Anthropic (direct)"
+api_key_env = "ANTHROPIC_API_KEY"
+base_url_env = "ANTHROPIC_BASE_URL"
+default_model = "haiku"
+
+[[profile.model]]
+id = "haiku"
+display_name = "Haiku (recommended)"
+api_model = "haiku"
+
+[[profile.model]]
+id = "sonnet"
+display_name = "Sonnet"
+api_model = "sonnet"
+
+[[profile.model]]
+id = "opus"
+display_name = "Opus"
+api_model = "opus"
+
+# Example: corporate proxy that exposes an Anthropic-compatible endpoint.
+# Set CORP_ANTHROPIC_API_KEY and CORP_ANTHROPIC_BASE_URL in the environment.
+[[profile]]
+id = "corp-proxy"
+label = "Corp proxy (Anthropic-compatible)"
+api_key_env = "CORP_ANTHROPIC_API_KEY"
+base_url_env = "CORP_ANTHROPIC_BASE_URL"
+default_model = "haiku"
+
+[[profile.model]]
+id = "haiku"
+display_name = "Haiku"
+api_model = "haiku"
+
+[[profile.model]]
+id = "sonnet"
+display_name = "Sonnet"
+api_model = "sonnet"
diff --git a/examples/configs/README.md b/examples/configs/README.md
index db774d3df7c..ce3c78d658a 100644
--- a/examples/configs/README.md
+++ b/examples/configs/README.md
@@ -4,3 +4,17 @@
- `environments/`: configs that demonstrate environment providers or runtimes.
- `features/`: configs that demonstrate job, trial, artifact, and model backend features.
- `tests/`: task-partition configs for exercising groups of example tasks.
+
+## bitfun-cli hello-world
+
+The hello-world task image installs `ca-certificates` so bitfun-cli can reach HTTPS
+APIs from Docker. Render a runnable job config from `.env` (API key is embedded in
+`bitfun_config`; Harbor does not expand `${OPENAI_API_KEY}` there):
+
+```bash
+set -a && source .env && set +a
+uv run python scripts/render-bitfun-hello-world-job.py > /tmp/bitfun-hello-world.yaml
+uv run harbor run -c /tmp/bitfun-hello-world.yaml -y
+```
+
+Requires `BitFun/target/release/bitfun-cli` on the host (bind-mounted into trials).
diff --git a/examples/configs/codeagent-job.yaml b/examples/configs/codeagent-job.yaml
new file mode 100644
index 00000000000..120485bc9e2
--- /dev/null
+++ b/examples/configs/codeagent-job.yaml
@@ -0,0 +1,30 @@
+jobs_dir: jobs
+n_attempts: 1
+timeout_multiplier: 1.0
+orchestrator:
+ type: local
+ n_concurrent_trials: 1
+ quiet: false
+environment:
+ type: docker
+ force_build: true
+ delete: true
+ env:
+ - ENTERPRISE_API_BASE_URL=${ENTERPRISE_API_BASE_URL}
+ - ENTERPRISE_API_KEY=${ENTERPRISE_API_KEY}
+ - ENTERPRISE_MAIN_MODEL=${ENTERPRISE_MAIN_MODEL}
+agents:
+ - name: codeagent
+ kwargs:
+ install_mode: binary
+ binary_path: /abs/path/to/codeagentcli
+ instruction_mode: inline
+ # thinking: adaptive
+ # reasoning_effort: max
+ # max_thinking_tokens: 32000
+ # task_budget: 200000
+ # max_output_tokens: 65536
+ # max_tokens: 65536
+ # context_window: 1048576
+datasets:
+ - path: examples/tasks
diff --git a/examples/tasks/hello-world/environment/Dockerfile b/examples/tasks/hello-world/environment/Dockerfile
index 59b6d903b1c..3448288257b 100644
--- a/examples/tasks/hello-world/environment/Dockerfile
+++ b/examples/tasks/hello-world/environment/Dockerfile
@@ -1,3 +1,7 @@
FROM ubuntu:24.04
-WORKDIR /app
\ No newline at end of file
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+
+WORKDIR /app
diff --git a/report-viewer/.gitignore b/report-viewer/.gitignore
new file mode 100644
index 00000000000..9b21bde4780
--- /dev/null
+++ b/report-viewer/.gitignore
@@ -0,0 +1,7 @@
+data/*
+!data/.gitkeep
+.venv/
+.pytest_cache/
+.ruff_cache/
+__pycache__/
+*.py[cod]
diff --git a/report-viewer/README.md b/report-viewer/README.md
new file mode 100644
index 00000000000..813a5c335c9
--- /dev/null
+++ b/report-viewer/README.md
@@ -0,0 +1,56 @@
+# Harbor Report Viewer
+
+Standalone HTML report service for Harbor job-level reports.
+
+This service lives in the Harbor repository for convenience, but it is not a
+Harbor CLI command and does not import Harbor internals.
+
+## Run
+
+```bash
+cd report-viewer
+uv run uvicorn app.main:app --host 0.0.0.0 --port 7397
+```
+
+Uploaded reports are stored under `data/{job_name}/index.html` by default.
+
+To use another storage root:
+
+```bash
+REPORT_VIEWER_DATA_ROOT=/path/to/report-data \
+uv run uvicorn app.main:app --host 0.0.0.0 --port 7397
+```
+
+## Harbor Viewer Integration
+
+Add the service base URL to the top level of the Harbor analyze profiles TOML
+file:
+
+```toml
+external_job_report_base_url = "http://111.119.196.110:7397"
+```
+
+Then restart Harbor Viewer. The Harbor job page `Report` button opens:
+
+```text
+{external_job_report_base_url}/{job_name}
+```
+
+## Upload Behavior
+
+- Open `/{job_name}`.
+- Click `Upload`.
+- Choose a `.html` or `.htm` file.
+- The upload replaces `data/{job_name}/index.html`.
+- The page refreshes the iframe after upload.
+
+The first version supports only one self-contained HTML file per job. It does
+not support zip uploads or additional assets.
+
+## Security
+
+This first version has no authentication. Run it only on a trusted network or
+behind external access control.
+
+Uploaded HTML is displayed in an iframe and is not sanitized. Treat uploaded
+reports as trusted internal content.
diff --git a/report-viewer/app/__init__.py b/report-viewer/app/__init__.py
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/report-viewer/app/__init__.py
@@ -0,0 +1 @@
+
diff --git a/report-viewer/app/main.py b/report-viewer/app/main.py
new file mode 100644
index 00000000000..d66e0a48a8e
--- /dev/null
+++ b/report-viewer/app/main.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import Annotated
+
+from fastapi import FastAPI, File, HTTPException, Request, UploadFile
+from fastapi.responses import FileResponse
+from fastapi.staticfiles import StaticFiles
+from fastapi.templating import Jinja2Templates
+
+from app.storage import InvalidJobNameError, ReportStorage
+
+APP_DIR = Path(__file__).parent
+DEFAULT_DATA_ROOT = APP_DIR.parent / "data"
+
+
+def _storage_error(exc: Exception) -> HTTPException:
+ if isinstance(exc, InvalidJobNameError):
+ return HTTPException(status_code=400, detail="invalid job name")
+ if isinstance(exc, ValueError):
+ return HTTPException(status_code=400, detail=str(exc))
+ return HTTPException(status_code=500, detail="failed to write report")
+
+
+def _is_html_filename(filename: str | None) -> bool:
+ if not filename:
+ return False
+ suffix = Path(filename).suffix.lower()
+ return suffix in {".html", ".htm"}
+
+
+def create_app(data_root: Path | None = None) -> FastAPI:
+ root = data_root or Path(
+ os.environ.get("REPORT_VIEWER_DATA_ROOT", DEFAULT_DATA_ROOT)
+ )
+ storage = ReportStorage(root)
+ templates = Jinja2Templates(directory=str(APP_DIR / "templates"))
+
+ app = FastAPI(title="Harbor Report Viewer")
+ app.mount("/static", StaticFiles(directory=str(APP_DIR / "static")), name="static")
+
+ @app.get("/health")
+ def health() -> dict[str, str]:
+ return {"status": "ok"}
+
+ @app.get("/api/reports/{job_name}/status")
+ def report_status(job_name: str) -> dict[str, object]:
+ try:
+ return dict(storage.status(job_name))
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+
+ @app.get("/api/reports/{job_name}/html")
+ def report_html(job_name: str) -> FileResponse:
+ try:
+ path = storage.report_path(job_name)
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+ if not path.exists():
+ raise HTTPException(status_code=404, detail="report not found")
+ return FileResponse(path, media_type="text/html; charset=utf-8")
+
+ @app.post("/api/reports/{job_name}")
+ async def upload_report(
+ job_name: str,
+ file: Annotated[UploadFile, File()],
+ ) -> dict[str, object]:
+ if not _is_html_filename(file.filename):
+ raise HTTPException(
+ status_code=400,
+ detail="only .html or .htm files are supported",
+ )
+ content = await file.read()
+ try:
+ storage.save_html(job_name, content)
+ return dict(storage.status(job_name))
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+
+ @app.get("/{job_name}")
+ def report_page(request: Request, job_name: str):
+ try:
+ storage.report_path(job_name)
+ except Exception as exc:
+ raise _storage_error(exc) from exc
+ return templates.TemplateResponse(
+ request,
+ "report.html",
+ {"job_name": job_name},
+ )
+
+ return app
+
+
+app = create_app()
diff --git a/report-viewer/app/static/app.css b/report-viewer/app/static/app.css
new file mode 100644
index 00000000000..904c32ca6a7
--- /dev/null
+++ b/report-viewer/app/static/app.css
@@ -0,0 +1,113 @@
+* {
+ box-sizing: border-box;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: #111;
+ color: #f5f5f5;
+ font-family:
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
+ sans-serif;
+}
+
+.toolbar {
+ height: 64px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 18px;
+ background: #181818;
+ border-bottom: 1px solid #2a2a2a;
+}
+
+.title {
+ min-width: 0;
+}
+
+.eyebrow {
+ display: block;
+ color: #a3a3a3;
+ font-size: 11px;
+ line-height: 1.2;
+ text-transform: uppercase;
+}
+
+h1 {
+ margin: 2px 0 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 18px;
+ font-weight: 600;
+}
+
+.upload-button {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 36px;
+ padding: 0 14px;
+ border: 1px solid #d4d4d4;
+ background: #f5f5f5;
+ color: #111;
+ font-size: 14px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.upload-button:hover {
+ background: #fff;
+}
+
+.upload-input {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ border: 0;
+}
+
+.content {
+ height: calc(100vh - 64px);
+}
+
+#report-frame {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ background: #fff;
+}
+
+.empty-state,
+.error-state {
+ display: grid;
+ min-height: 100%;
+ place-content: center;
+ padding: 32px;
+ text-align: center;
+}
+
+.empty-state h2 {
+ margin: 0 0 8px;
+ font-size: 20px;
+}
+
+.empty-state p,
+.error-state {
+ color: #b8b8b8;
+}
+
+.error-state {
+ color: #ffb4b4;
+}
diff --git a/report-viewer/app/static/app.js b/report-viewer/app/static/app.js
new file mode 100644
index 00000000000..5fd3d4f0380
--- /dev/null
+++ b/report-viewer/app/static/app.js
@@ -0,0 +1,83 @@
+const content = document.querySelector(".content");
+const jobName = content.dataset.jobName;
+const uploadButton = document.getElementById("upload-button");
+const input = document.getElementById("upload-input");
+const frame = document.getElementById("report-frame");
+const emptyState = document.getElementById("empty-state");
+const errorState = document.getElementById("error-state");
+
+function showError(message) {
+ errorState.textContent = message;
+ errorState.hidden = false;
+}
+
+function clearError() {
+ errorState.textContent = "";
+ errorState.hidden = true;
+}
+
+function showReport() {
+ emptyState.hidden = true;
+ frame.hidden = false;
+ frame.src = `/api/reports/${encodeURIComponent(jobName)}/html?t=${Date.now()}`;
+}
+
+function showEmpty() {
+ frame.hidden = true;
+ frame.removeAttribute("src");
+ emptyState.hidden = false;
+}
+
+async function refreshStatus() {
+ clearError();
+ const response = await fetch(`/api/reports/${encodeURIComponent(jobName)}/status`);
+ if (!response.ok) {
+ showError("Failed to load report status.");
+ showEmpty();
+ return;
+ }
+ const status = await response.json();
+ if (status.exists) {
+ showReport();
+ } else {
+ showEmpty();
+ }
+}
+
+async function uploadFile(file) {
+ clearError();
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const response = await fetch(`/api/reports/${encodeURIComponent(jobName)}`, {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!response.ok) {
+ let message = "Upload failed.";
+ try {
+ const data = await response.json();
+ if (data.detail) message = data.detail;
+ } catch {
+ message = response.statusText || message;
+ }
+ showError(message);
+ return;
+ }
+
+ await refreshStatus();
+}
+
+input.addEventListener("change", async () => {
+ const file = input.files && input.files[0];
+ input.value = "";
+ if (!file) return;
+ await uploadFile(file);
+});
+
+uploadButton.addEventListener("click", () => {
+ input.click();
+});
+
+refreshStatus();
diff --git a/report-viewer/app/storage.py b/report-viewer/app/storage.py
new file mode 100644
index 00000000000..927f839031c
--- /dev/null
+++ b/report-viewer/app/storage.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import os
+import re
+from datetime import UTC, datetime
+from pathlib import Path
+from tempfile import NamedTemporaryFile
+from typing import TypedDict
+
+_JOB_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,200}$")
+
+
+class InvalidJobNameError(ValueError):
+ pass
+
+
+class ReportStatus(TypedDict):
+ job_name: str
+ exists: bool
+ size_bytes: int | None
+ updated_at: str | None
+
+
+def validate_job_name(job_name: str) -> str:
+ if not _JOB_NAME_RE.fullmatch(job_name):
+ raise InvalidJobNameError("invalid job name")
+ return job_name
+
+
+class ReportStorage:
+ def __init__(self, root: Path) -> None:
+ self.root = root
+
+ def report_path(self, job_name: str) -> Path:
+ safe_job_name = validate_job_name(job_name)
+ return self.root / safe_job_name / "index.html"
+
+ def status(self, job_name: str) -> ReportStatus:
+ path = self.report_path(job_name)
+ if not path.exists():
+ return {
+ "job_name": job_name,
+ "exists": False,
+ "size_bytes": None,
+ "updated_at": None,
+ }
+
+ stat = path.stat()
+ return {
+ "job_name": job_name,
+ "exists": True,
+ "size_bytes": stat.st_size,
+ "updated_at": datetime.fromtimestamp(stat.st_mtime, UTC).isoformat(),
+ }
+
+ def save_html(self, job_name: str, content: bytes) -> Path:
+ if not content:
+ raise ValueError("uploaded HTML is empty")
+
+ path = self.report_path(job_name)
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ with NamedTemporaryFile(delete=False, dir=path.parent) as tmp:
+ tmp.write(content)
+ tmp_path = Path(tmp.name)
+
+ os.replace(tmp_path, path)
+ return path
diff --git a/report-viewer/app/templates/report.html b/report-viewer/app/templates/report.html
new file mode 100644
index 00000000000..f0006915d2e
--- /dev/null
+++ b/report-viewer/app/templates/report.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Report {{ job_name }}
+
+
+
+
+
+
+
+ No report uploaded
+ Select an HTML file to upload and display it for this job.
+
+
+
+
+
+
+
+
diff --git a/report-viewer/data/.gitkeep b/report-viewer/data/.gitkeep
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/report-viewer/data/.gitkeep
@@ -0,0 +1 @@
+
diff --git a/report-viewer/pyproject.toml b/report-viewer/pyproject.toml
new file mode 100644
index 00000000000..dd1d0d64d8e
--- /dev/null
+++ b/report-viewer/pyproject.toml
@@ -0,0 +1,34 @@
+[project]
+name = "harbor-report-viewer"
+version = "0.1.0"
+description = "Standalone HTML report viewer for Harbor job reports."
+requires-python = ">=3.12"
+dependencies = [
+ "fastapi>=0.128.0",
+ "httpx>=0.27.0",
+ "jinja2>=3.1.6",
+ "python-multipart>=0.0.20",
+ "uvicorn>=0.38.0",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.4.2",
+ "ruff>=0.15.4",
+]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_functions = ["test_*"]
+addopts = ["-v", "--tb=short", "--strict-config"]
+pythonpath = ["."]
+filterwarnings = [
+ "ignore:Using `httpx` with `starlette.testclient` is deprecated:starlette.exceptions.StarletteDeprecationWarning",
+]
+
+[tool.ruff]
+line-length = 88
+
+[tool.ruff.lint]
+select = ["E", "F", "I", "UP", "B"]
diff --git a/report-viewer/tests/test_routes.py b/report-viewer/tests/test_routes.py
new file mode 100644
index 00000000000..42c9d7bdb45
--- /dev/null
+++ b/report-viewer/tests/test_routes.py
@@ -0,0 +1,133 @@
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from app.main import create_app
+
+
+def test_health_returns_ok(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.get("/health")
+
+ assert response.status_code == 200
+ assert response.json() == {"status": "ok"}
+
+
+def test_report_shell_returns_job_page(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.get("/job-1")
+
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
+ assert "job-1" in response.text
+ assert "Upload" in response.text
+
+
+def test_report_shell_uses_button_to_trigger_file_input(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.get("/job-1")
+
+ assert response.status_code == 200
+ assert 'id="upload-button"' in response.text
+ assert 'type="button"' in response.text
+ assert 'id="upload-input"' in response.text
+ assert 'class="upload-input"' in response.text
+
+
+def test_invalid_job_name_route_returns_400(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.get("/api/reports/name:colon/status")
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "invalid job name"
+
+
+def test_status_reports_missing_and_present_report(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ missing = client.get("/api/reports/job-1/status")
+ uploaded = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.html", b"uploaded", "text/html")},
+ )
+ present = client.get("/api/reports/job-1/status")
+
+ assert missing.status_code == 200
+ assert missing.json() == {
+ "job_name": "job-1",
+ "exists": False,
+ "size_bytes": None,
+ "updated_at": None,
+ }
+ assert uploaded.status_code == 200
+ assert present.status_code == 200
+ assert present.json()["job_name"] == "job-1"
+ assert present.json()["exists"] is True
+ assert present.json()["size_bytes"] == len(b"uploaded")
+ assert present.json()["updated_at"] is not None
+
+
+def test_html_route_returns_404_before_upload_and_html_after_upload(
+ tmp_path: Path,
+) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ missing = client.get("/api/reports/job-1/html")
+ uploaded = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.html", b"uploaded", "text/html")},
+ )
+ html = client.get("/api/reports/job-1/html")
+
+ assert missing.status_code == 404
+ assert uploaded.status_code == 200
+ assert html.status_code == 200
+ assert "text/html" in html.headers["content-type"]
+ assert html.text == "uploaded"
+
+
+def test_upload_rejects_non_html_file(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.txt", b"not html", "text/plain")},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "only .html or .htm files are supported"
+
+
+def test_upload_rejects_empty_html_file(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ response = client.post(
+ "/api/reports/job-1",
+ files={"file": ("report.html", b"", "text/html")},
+ )
+
+ assert response.status_code == 400
+ assert response.json()["detail"] == "uploaded HTML is empty"
+
+
+def test_upload_overwrites_existing_html(tmp_path: Path) -> None:
+ client = TestClient(create_app(data_root=tmp_path))
+
+ first = client.post(
+ "/api/reports/job-1",
+ files={"file": ("first.html", b"first", "text/html")},
+ )
+ second = client.post(
+ "/api/reports/job-1",
+ files={"file": ("second.html", b"second", "text/html")},
+ )
+ html = client.get("/api/reports/job-1/html")
+
+ assert first.status_code == 200
+ assert second.status_code == 200
+ assert second.json()["exists"] is True
+ assert html.text == "second"
diff --git a/report-viewer/tests/test_static_assets.py b/report-viewer/tests/test_static_assets.py
new file mode 100644
index 00000000000..78407616057
--- /dev/null
+++ b/report-viewer/tests/test_static_assets.py
@@ -0,0 +1,17 @@
+from pathlib import Path
+
+APP_CSS = Path("app/static/app.css")
+
+
+def test_hidden_attribute_remains_hidden_for_shell_states() -> None:
+ css = APP_CSS.read_text(encoding="utf-8")
+
+ assert "[hidden]" in css
+ assert "display: none !important" in css
+
+
+def test_upload_file_input_is_visually_hidden_not_display_none() -> None:
+ css = APP_CSS.read_text(encoding="utf-8")
+
+ assert ".upload-input" in css
+ assert ".upload-button input" not in css
diff --git a/report-viewer/tests/test_storage.py b/report-viewer/tests/test_storage.py
new file mode 100644
index 00000000000..4d20d8dc9e5
--- /dev/null
+++ b/report-viewer/tests/test_storage.py
@@ -0,0 +1,73 @@
+from pathlib import Path
+
+import pytest
+
+from app.storage import (
+ InvalidJobNameError,
+ ReportStorage,
+ validate_job_name,
+)
+
+
+def test_validate_job_name_accepts_harbor_style_names() -> None:
+ assert (
+ validate_job_name("tb2-cc-ds-0003-rerun-run-1854e430d280")
+ == "tb2-cc-ds-0003-rerun-run-1854e430d280"
+ )
+ assert validate_job_name("job.name_123") == "job.name_123"
+
+
+@pytest.mark.parametrize(
+ "job_name",
+ [
+ "",
+ "../escape",
+ "nested/path",
+ "space name",
+ "name:colon",
+ "a" * 201,
+ ],
+)
+def test_validate_job_name_rejects_unsafe_names(job_name: str) -> None:
+ with pytest.raises(InvalidJobNameError):
+ validate_job_name(job_name)
+
+
+def test_report_path_maps_to_index_html_under_job_dir(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ assert storage.report_path("job-1") == tmp_path / "job-1" / "index.html"
+
+
+def test_missing_report_status(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ status = storage.status("job-1")
+
+ assert status == {
+ "job_name": "job-1",
+ "exists": False,
+ "size_bytes": None,
+ "updated_at": None,
+ }
+
+
+def test_save_html_creates_and_overwrites_report(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ first = storage.save_html("job-1", b"first")
+ second = storage.save_html("job-1", b"second")
+
+ assert first == tmp_path / "job-1" / "index.html"
+ assert second == tmp_path / "job-1" / "index.html"
+ assert second.read_text(encoding="utf-8") == "second"
+ assert storage.status("job-1")["exists"] is True
+ assert storage.status("job-1")["size_bytes"] == len(b"second")
+ assert storage.status("job-1")["updated_at"] is not None
+
+
+def test_save_html_rejects_empty_content(tmp_path: Path) -> None:
+ storage = ReportStorage(tmp_path)
+
+ with pytest.raises(ValueError, match="empty"):
+ storage.save_html("job-1", b"")
diff --git a/report-viewer/uv.lock b/report-viewer/uv.lock
new file mode 100644
index 00000000000..7aac50c0d4e
--- /dev/null
+++ b/report-viewer/uv.lock
@@ -0,0 +1,457 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "annotated-doc"
+version = "0.0.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.13.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.5.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.136.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "harbor-report-viewer"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "fastapi" },
+ { name = "httpx" },
+ { name = "jinja2" },
+ { name = "python-multipart" },
+ { name = "uvicorn" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "fastapi", specifier = ">=0.128.0" },
+ { name = "httpx", specifier = ">=0.27.0" },
+ { name = "jinja2", specifier = ">=3.1.6" },
+ { name = "python-multipart", specifier = ">=0.0.20" },
+ { name = "uvicorn", specifier = ">=0.38.0" },
+]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=8.4.2" },
+ { name = "ruff", specifier = ">=0.15.4" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "certifi" },
+ { name = "httpcore" },
+ { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+ { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+ { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+ { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
+ { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
+ { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
+ { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
+ { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
+ { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
+ { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
+ { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
+ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-types" },
+ { name = "pydantic-core" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
+ { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
+ { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
+ { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
+ { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
+ { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
+ { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
+ { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
+ { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
+ { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
+ { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
+ { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
+ { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
+ { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
+ { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
+ { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
+ { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
+ { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
+ { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
+ { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
+ { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
+ { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.16"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/5f7ec371001337d8fa61701c186ff8b613ecac1651848c5950f4c4d5f2e9/ruff-0.15.16.tar.gz", hash = "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", size = 4714267, upload-time = "2026-06-04T16:33:09.974Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/42/53ef1c3953f157956db9bf7861e3bc50b9b887ce93300aa48cdba8336fe6/ruff-0.15.16-py3-none-linux_armv6l.whl", hash = "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", size = 10709025, upload-time = "2026-06-04T16:32:51.935Z" },
+ { url = "https://files.pythonhosted.org/packages/93/9a/a79159346f19134a956607754e57d8d128f7a4c00f4ad2f7514d224c172c/ruff-0.15.16-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", size = 11063550, upload-time = "2026-06-04T16:32:42.24Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/72/3ce2ac000a5299ec238e01f51397b3b653c93b077d9b1bfe8715bb895f20/ruff-0.15.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", size = 10421345, upload-time = "2026-06-04T16:32:37.251Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/c2/cc7fad3ec9169373f5b6a18f1917b91080feec40c3f9658334a1d28e2f03/ruff-0.15.16-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", size = 10757217, upload-time = "2026-06-04T16:32:54.722Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d2/3474009eaa0a65b31fa7152a2fad5e2f050c640ceb1e6b02ee6922e94c82/ruff-0.15.16-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", size = 10507035, upload-time = "2026-06-04T16:33:05.343Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/81/b7ae6ccbd11f0c8dc3d5d67fc4be9b57ff57ca86ba56152021378e1277f2/ruff-0.15.16-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", size = 11255291, upload-time = "2026-06-04T16:32:49.49Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/e1/46e526f1a7cc90857ce6ddf25fbb77eb6568651ac38d71b033af07076dd5/ruff-0.15.16-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", size = 12124922, upload-time = "2026-06-04T16:33:07.821Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/da/5c791b088b596b24d0deb967fa28ae02ad751a140c0b9ea81c5ab915d6c0/ruff-0.15.16-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4", size = 11332186, upload-time = "2026-06-04T16:33:02.971Z" },
+ { url = "https://files.pythonhosted.org/packages/72/11/5da87abe20047c8962361473923ebb2f62b595250126aadfad8c20649c1e/ruff-0.15.16-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", size = 11373541, upload-time = "2026-06-04T16:32:47.007Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/2a/8554754c23a854ae3fd6b507e36ad61ddb121e298c6d5d617dec94ed0f14/ruff-0.15.16-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", size = 11353014, upload-time = "2026-06-04T16:32:34.795Z" },
+ { url = "https://files.pythonhosted.org/packages/62/25/62ea41529ec89f742ea3fed9cb1059c72877ec7cf9b9e99ac9cf3294d1d9/ruff-0.15.16-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", size = 10737467, upload-time = "2026-06-04T16:32:26.348Z" },
+ { url = "https://files.pythonhosted.org/packages/90/17/334d3ad9de4d40f9dd58fdd09e35ce64553bb501e2f19a839e2fb6be14fc/ruff-0.15.16-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", size = 10521910, upload-time = "2026-06-04T16:32:32.54Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/bd/3ac7c6ae77a885c1004b3dda2446ea401768d24f851c14b4ad4b24f6639c/ruff-0.15.16-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", size = 10979190, upload-time = "2026-06-04T16:32:57.492Z" },
+ { url = "https://files.pythonhosted.org/packages/33/d7/609546e6a413c3f216fbf2a50c928f97c80939154f6a0503114094a86191/ruff-0.15.16-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", size = 11477014, upload-time = "2026-06-04T16:32:44.687Z" },
+ { url = "https://files.pythonhosted.org/packages/74/0d/f2cd247ad32633a5c36e97141a2c21b11c6279f7957bc2ff360b1e08fddd/ruff-0.15.16-py3-none-win32.whl", hash = "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", size = 10735541, upload-time = "2026-06-04T16:32:30.145Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" },
+ { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "anyio" },
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/25/44/ec35f1b6e83094b997da438a02c8c9b0ade2b1e84cfc48bd4656780760a6/starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6", size = 2701854, upload-time = "2026-05-31T01:07:51.847Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/54/196d0c1db10af76baa4f64894448505d60d3cdf70ef92cbb35f46a4e4c71/starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89", size = 73350, upload-time = "2026-05-31T01:07:50.09Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.49.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" },
+]
diff --git a/scripts/publish-fork-pr.sh b/scripts/publish-fork-pr.sh
new file mode 100755
index 00000000000..15407e5473d
--- /dev/null
+++ b/scripts/publish-fork-pr.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# Push a branch to your fork and open a PR into JinnanDuan/bitfun-harbor.
+#
+# Usage:
+# export GITHUB_TOKEN=ghp_xxxx
+# # or: echo ghp_xxxx > ~/.github-token && chmod 600 ~/.github-token
+# ./scripts/publish-fork-pr.sh
+#
+# Optional env:
+# GITHUB_USER=Messimeimei
+# UPSTREAM_OWNER=JinnanDuan
+# UPSTREAM_REPO=bitfun-harbor
+# BRANCH=fix/post-cherry-pick-regressions
+# BASE_BRANCH=dev
+
+set -euo pipefail
+
+GITHUB_USER="${GITHUB_USER:-Messimeimei}"
+UPSTREAM_OWNER="${UPSTREAM_OWNER:-JinnanDuan}"
+UPSTREAM_REPO="${UPSTREAM_REPO:-bitfun-harbor}"
+BRANCH="${BRANCH:-fix/post-cherry-pick-regressions}"
+BASE_BRANCH="${BASE_BRANCH:-dev}"
+
+TOKEN="${GITHUB_TOKEN:-${GH_TOKEN:-}}"
+if [[ -z "$TOKEN" && -f "${HOME}/.github-token" ]]; then
+ TOKEN="$(tr -d '[:space:]' < "${HOME}/.github-token")"
+fi
+repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
+if [[ -z "$TOKEN" && -f "${repo_root}/.github-token.local" ]]; then
+ TOKEN="$(tr -d '[:space:]' < "${repo_root}/.github-token.local")"
+fi
+
+if [[ -z "$TOKEN" ]]; then
+ echo "Error: set GITHUB_TOKEN (or GH_TOKEN), or write PAT to ~/.github-token" >&2
+ exit 1
+fi
+
+api() {
+ curl -sS -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/vnd.github+json" "$@"
+}
+
+auth_user="$(api https://api.github.com/user | python3 -c 'import json,sys; print(json.load(sys.stdin).get("login",""))')"
+if [[ -z "$auth_user" || "$auth_user" == "None" ]]; then
+ echo "Error: invalid GITHUB_TOKEN (could not read authenticated user)." >&2
+ exit 1
+fi
+echo "Authenticated as: ${auth_user}"
+
+cd "$repo_root"
+git checkout "$BRANCH"
+
+if ! git remote get-url mine &>/dev/null; then
+ git remote add mine "https://github.com/${GITHUB_USER}/${UPSTREAM_REPO}.git"
+fi
+
+echo "Pushing ${BRANCH} to mine ..."
+git push "https://oauth2:${TOKEN}@github.com/${GITHUB_USER}/${UPSTREAM_REPO}.git" "${BRANCH}:${BRANCH}"
+
+existing_pr="$(api "https://api.github.com/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/pulls?head=${GITHUB_USER}:${BRANCH}&base=${BASE_BRANCH}&state=open" \
+ | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d[0]["html_url"] if d else "")')"
+
+if [[ -n "$existing_pr" ]]; then
+ echo "Open PR already exists: ${existing_pr}"
+ exit 0
+fi
+
+export GITHUB_USER BRANCH BASE_BRANCH
+
+payload="$(python3 - <<'PY'
+import json
+import os
+
+body = """## Summary
+- Restore upstream `docker.py` and re-apply Windows agent setup in `base.py`, fixing `harbor run` failures (`COMPOSE_BASE_PATH` import error) after cherry-pick conflict resolution.
+- Fix viewer analyze/summarize: handle `AggregateTransportError` as 422, and update summarize tests for multi-provider analyze (`ANTHROPIC_API_KEY`, updated job summarize response fields).
+- Align stale fork-only unit tests with upstream implementations (OpenCode, Codex MCP env isolation); full unit suite passes (4874 passed).
+
+## Test plan
+- [x] `uv run pytest tests/unit/` — 4874 passed, 13 skipped
+- [x] `uv run harbor run -p examples/tasks/hello-world -a oracle -e docker -n 1 -y` — Mean 1.000
+"""
+
+print(
+ json.dumps(
+ {
+ "title": "Fix viewer analyze and Docker regressions after fork merge",
+ "head": f"{os.environ['GITHUB_USER']}:{os.environ['BRANCH']}",
+ "base": os.environ["BASE_BRANCH"],
+ "body": body,
+ }
+ )
+)
+PY
+)"
+
+pr_url="$(api -X POST "https://api.github.com/repos/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/pulls" -d "$payload" \
+ | python3 -c 'import json,sys; r=json.load(sys.stdin); print(r.get("html_url","")); sys.exit(0 if r.get("html_url") else 1)' \
+ || true)"
+
+if [[ -n "$pr_url" ]]; then
+ echo "PR created: ${pr_url}"
+else
+ echo "Push succeeded. Open PR manually:"
+ echo "https://github.com/${UPSTREAM_OWNER}/${UPSTREAM_REPO}/compare/${BASE_BRANCH}...${GITHUB_USER}:${BRANCH}?expand=1"
+fi
diff --git a/scripts/render-bitfun-hello-world-job.py b/scripts/render-bitfun-hello-world-job.py
new file mode 100755
index 00000000000..6ad81ff9760
--- /dev/null
+++ b/scripts/render-bitfun-hello-world-job.py
@@ -0,0 +1,109 @@
+#!/usr/bin/env python3
+"""Render a Harbor job YAML for bitfun-cli on hello-world.
+
+Reads OPENAI_API_KEY and OPENAI_BASE_URL from the environment (or a .env file)
+and prints a job config to stdout. bitfun-cli needs the API key embedded in
+bitfun_config; ${OPENAI_API_KEY} placeholders are not expanded by Harbor.
+
+Usage:
+ set -a && source .env && set +a
+ uv run python scripts/render-bitfun-hello-world-job.py > /tmp/bitfun-hello-world.yaml
+ uv run harbor run -c /tmp/bitfun-hello-world.yaml -y
+"""
+
+from __future__ import annotations
+
+import os
+import sys
+from pathlib import Path
+
+import yaml
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+BITFUN_CLI = REPO_ROOT / "BitFun" / "target" / "release" / "bitfun-cli"
+DEFAULT_BASE_URL = "https://api.openbitfun.com/v1"
+DEFAULT_MODEL = "deepseek-v4-pro"
+
+
+def _load_dotenv(path: Path) -> None:
+ if not path.is_file():
+ return
+ for line in path.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ key, _, value = line.partition("=")
+ os.environ.setdefault(key.strip(), value.strip())
+
+
+def main() -> int:
+ _load_dotenv(REPO_ROOT / ".env")
+ api_key = os.environ.get("OPENAI_API_KEY")
+ if not api_key:
+ print(
+ "OPENAI_API_KEY is required (export it or add it to .env).", file=sys.stderr
+ )
+ return 1
+ if not BITFUN_CLI.is_file():
+ print(
+ f"bitfun-cli binary not found at {BITFUN_CLI}. "
+ "Build BitFun first or adjust the path in this script.",
+ file=sys.stderr,
+ )
+ return 1
+
+ base_url = os.environ.get("OPENAI_BASE_URL", DEFAULT_BASE_URL)
+ model_id = os.environ.get("BITFUN_MODEL", DEFAULT_MODEL)
+
+ job = {
+ "jobs_dir": "jobs",
+ "n_attempts": 1,
+ "n_concurrent_trials": 1,
+ "environment": {
+ "type": "docker",
+ "force_build": True,
+ "delete": True,
+ "mounts": [
+ {
+ "type": "bind",
+ "source": str(BITFUN_CLI),
+ "target": "/usr/local/bin/bitfun-cli",
+ "read_only": True,
+ }
+ ],
+ },
+ "agents": [
+ {
+ "name": "bitfun-cli",
+ "kwargs": {
+ "bitfun_config": {
+ "app": {"language": "zh-CN"},
+ "ai": {
+ "models": [
+ {
+ "id": model_id,
+ "name": model_id,
+ "provider": "openai",
+ "model_name": model_id,
+ "base_url": base_url,
+ "api_key": api_key,
+ "enabled": True,
+ }
+ ],
+ "default_models": {
+ "primary": model_id,
+ "fast": model_id,
+ },
+ },
+ }
+ },
+ }
+ ],
+ "tasks": [{"path": "examples/tasks/hello-world"}],
+ }
+ yaml.safe_dump(job, sys.stdout, sort_keys=False)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/harbor/agents/factory.py b/src/harbor/agents/factory.py
index 48beddf8c81..96a926a816d 100644
--- a/src/harbor/agents/factory.py
+++ b/src/harbor/agents/factory.py
@@ -30,6 +30,7 @@ class AgentFactory:
AgentName.CLAUDE_CODE: "harbor.agents.installed.claude_code:ClaudeCode",
AgentName.COPILOT_CLI: "harbor.agents.installed.copilot_cli:CopilotCli",
AgentName.AIDER: "harbor.agents.installed.aider:Aider",
+ AgentName.BITFUN_CLI: "harbor.agents.installed.bitfun_cli:BitfunCli",
AgentName.CLINE_CLI: "harbor.agents.installed.cline:ClineCli",
AgentName.CODEX: "harbor.agents.installed.codex:Codex",
AgentName.CURSOR_CLI: "harbor.agents.installed.cursor_cli:CursorCli",
@@ -56,6 +57,7 @@ class AgentFactory:
AgentName.QWEN_CODE: "harbor.agents.installed.qwen_code:QwenCode",
AgentName.DEVIN: "harbor.agents.installed.devin:Devin",
AgentName.TRAE_AGENT: "harbor.agents.installed.trae_agent:TraeAgent",
+ AgentName.CODEAGENT: "harbor.agents.installed.codeagent:CodeAgent",
AgentName.COMPUTER_1: "harbor.agents.computer_1:Computer1",
AgentName.EVE: "harbor.agents.installed.eve:Eve",
AgentName.DSPY_RLM: "harbor.agents.dspy_rlm:DspyRlmAgent",
diff --git a/src/harbor/agents/installed/base.py b/src/harbor/agents/installed/base.py
index 4e96e56f4bc..9655eeddcc1 100644
--- a/src/harbor/agents/installed/base.py
+++ b/src/harbor/agents/installed/base.py
@@ -8,6 +8,7 @@
from harbor.agents.base import BaseAgent
from harbor.environments.base import BaseEnvironment
+from harbor.models.task.config import TaskOS
from harbor.utils.env import parse_bool_env_value
from harbor.utils.templating import render_prompt_template
@@ -75,6 +76,19 @@ class UnknownApiError(ApiError):
pass
+class AgentSafetyRefusalError(ApiError):
+ """Raised when the model provider blocks a request on safety grounds (e.g.
+ Anthropic's Cyber Verification Program safeguard on cybersecurity content).
+
+ A deterministic, request-level decision -- unlike a transient
+ ``UnknownApiError`` it will not succeed on retry, so it is excluded from
+ retries by default. The distinct type also keeps a legitimate model refusal
+ (a real ``reward 0`` outcome) from reading as an unknown/flaky API error.
+ """
+
+ pass
+
+
class NetworkConnectionError(NonZeroAgentExitCodeError):
"""Raised when a failed command's output indicates a network or TLS
transport failure (DNS, connection refused, SSL handshake, curl errors).
@@ -234,6 +248,11 @@ class BaseInstalledAgent(BaseAgent, ABC):
r"API Error: Connection closed mid-response",
ApiConnectionClosedError,
),
+ # Must precede the generic "API Error" catch-all below.
+ ErrorPattern(
+ r"safety measures that flagged|Cyber Verification Program",
+ AgentSafetyRefusalError,
+ ),
ErrorPattern(r"API Error", UnknownApiError),
ErrorPattern(r"SSL_ERROR_SYSCALL", NetworkConnectionError),
ErrorPattern(r"SSL_connect", NetworkConnectionError),
@@ -495,10 +514,13 @@ async def install(self, environment: BaseEnvironment) -> None:
@override
async def setup(self, environment: BaseEnvironment) -> None:
- await environment.exec(
- command="[ -d /installed-agent ] || mkdir -p /installed-agent",
- user="root",
- )
+ if environment.os == TaskOS.WINDOWS:
+ await environment.ensure_dirs(["C:/installed-agent"], chmod=False)
+ else:
+ await environment.exec(
+ command="[ -d /installed-agent ] || mkdir -p /installed-agent",
+ user="root",
+ )
setup_dir = self.logs_dir / "setup"
setup_dir.mkdir(parents=True, exist_ok=True)
diff --git a/src/harbor/agents/installed/bitfun_cli.py b/src/harbor/agents/installed/bitfun_cli.py
new file mode 100644
index 00000000000..5acc949942e
--- /dev/null
+++ b/src/harbor/agents/installed/bitfun_cli.py
@@ -0,0 +1,2668 @@
+"""Harbor integration for BitFun's bitfun-cli (single-shot `exec` mode)."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import shlex
+import tempfile
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+from harbor.agents.installed.base import (
+ BaseInstalledAgent,
+ NonZeroAgentExitCodeError,
+ with_prompt_template,
+)
+from harbor.environments.base import BaseEnvironment
+from harbor.models.agent.context import AgentContext
+from harbor.models.agent.name import AgentName
+from harbor.models.trajectories import (
+ Agent,
+ FinalMetrics,
+ Metrics,
+ Observation,
+ ObservationResult,
+ Step,
+ SubagentTrajectoryRef,
+ ToolCall,
+ Trajectory,
+)
+from harbor.models.task.config import TaskOS
+from harbor.models.trial.paths import EnvironmentPaths
+from harbor.utils.scripts import quote_shell_arg
+from harbor.utils.trajectory_utils import format_trajectory_json
+
+_DEFAULT_BINARY = "/usr/local/bin/bitfun-cli"
+_WINDOWS_DEFAULT_BINARY = "C:/bitfun/bitfun-cli.exe"
+_AGENT_LOG = "/logs/agent/bitfun.txt"
+_WINDOWS_AGENT_LOG_NAME = "bitfun.txt"
+_FAILURE_LOG_MAX_BYTES = 512 * 1024
+_FAILURE_LOG_HEAD_BYTES = 8 * 1024
+_FAILURE_LOG_TAIL_BYTES = 32 * 1024
+_FAILURE_LOG_TRUNC_MARKER = "\n...[truncated for host log]...\n"
+_ATIF_SCHEMA_VERSION = "ATIF-v1.7"
+_BITFUN_DATA_SUBDIR = "bitfun" # under self.logs_dir
+PATCH_ARTIFACTS_SUBDIR = "patch"
+_DEFAULT_OUTPUT_PATCH_PATH = "/logs/agent/bitfun.patch"
+_REMOTE_BITFUN_CONFIG_DIR = "/logs/agent/bitfun/config"
+_REMOTE_APP_CONFIG_REDACTED_PATH = f"{_REMOTE_BITFUN_CONFIG_DIR}/app.redacted.json"
+_APP_CONFIG_REDACTED_ARTIFACT_PATH = "agent/bitfun/config/app.redacted.json"
+_REMOTE_CP_BACK_MANIFEST_PATH = "/logs/agent/bitfun/cp-back-manifest.json"
+_WINDOWS_PROMPT_FILE_NAME = "bitfun-prompt.txt"
+_WINDOWS_RUN_SCRIPT_NAME = "bitfun-run.bat"
+_WINDOWS_BITFUN_USER_ROOT = "C:/bitfun-user"
+_WINDOWS_BITFUN_HOME = "C:/bitfun-home"
+_REDACTED_CONFIG_VALUE = "[REDACTED]"
+_SENSITIVE_CONFIG_KEYS = frozenset(
+ {
+ "api_key",
+ "apikey",
+ "access_token",
+ "refresh_token",
+ "id_token",
+ "auth_token",
+ "bearer_token",
+ "authorization",
+ "password",
+ "passphrase",
+ "secret",
+ "client_secret",
+ "private_key",
+ "credential",
+ "credentials",
+ }
+)
+_SENSITIVE_CONFIG_SUFFIXES = ("_secret", "_password", "_private_key")
+
+
+def build_repo_baseline_capture_script(log_dir: str) -> str:
+ return f"""set -eu
+LOG_DIR={shlex.quote(log_dir)}
+mkdir -p "$LOG_DIR"
+if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
+ echo "not-a-git-repository" > "$LOG_DIR/repo-capture.error.txt"
+ exit 0
+fi
+export GIT_AUTHOR_NAME="Harbor BitFun"
+export GIT_AUTHOR_EMAIL="bitfun-cli@harbor.invalid"
+export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
+export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+echo "$REPO_ROOT" > "$LOG_DIR/repo-root.txt"
+git rev-parse HEAD > "$LOG_DIR/git-head.before.txt" 2>/dev/null || true
+git status --porcelain=v1 > "$LOG_DIR/git-status.before.txt" 2>/dev/null || true
+git log --oneline --decorate -n 20 > "$LOG_DIR/git-log.before.txt" 2>/dev/null || true
+TMP_INDEX="$(mktemp)"
+trap 'rm -f "$TMP_INDEX"' EXIT
+rm -f "$TMP_INDEX"
+GIT_INDEX_FILE="$TMP_INDEX" git read-tree -m HEAD
+GIT_INDEX_FILE="$TMP_INDEX" git add -A
+BASE_TREE="$(GIT_INDEX_FILE="$TMP_INDEX" git write-tree)"
+BASE_COMMIT="$(printf 'harbor-bitfun-baseline\\n' | git commit-tree "$BASE_TREE")"
+echo "$BASE_COMMIT" > "$LOG_DIR/git-baseline-commit.txt"
+"""
+
+
+def build_repo_final_capture_script(log_dir: str) -> str:
+ return f"""set -eu
+LOG_DIR={shlex.quote(log_dir)}
+if [ ! -f "$LOG_DIR/repo-root.txt" ] || [ ! -f "$LOG_DIR/git-baseline-commit.txt" ]; then
+ echo "missing-baseline" > "$LOG_DIR/fix-patch.error.txt"
+ exit 0
+fi
+export GIT_AUTHOR_NAME="Harbor BitFun"
+export GIT_AUTHOR_EMAIL="bitfun-cli@harbor.invalid"
+export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
+export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
+REPO_ROOT="$(cat "$LOG_DIR/repo-root.txt")"
+BASE_COMMIT="$(cat "$LOG_DIR/git-baseline-commit.txt")"
+cd "$REPO_ROOT"
+git rev-parse HEAD > "$LOG_DIR/git-head.after.txt" 2>/dev/null || true
+git status --porcelain=v1 > "$LOG_DIR/git-status.after.txt" 2>/dev/null || true
+git log --oneline --decorate -n 20 > "$LOG_DIR/git-log.after.txt" 2>/dev/null || true
+TMP_INDEX="$(mktemp)"
+trap 'rm -f "$TMP_INDEX"' EXIT
+rm -f "$TMP_INDEX"
+GIT_INDEX_FILE="$TMP_INDEX" git read-tree -m HEAD
+GIT_INDEX_FILE="$TMP_INDEX" git add -A
+FINAL_TREE="$(GIT_INDEX_FILE="$TMP_INDEX" git write-tree)"
+FINAL_COMMIT="$(printf 'harbor-bitfun-final\\n' | git commit-tree "$FINAL_TREE")"
+echo "$FINAL_COMMIT" > "$LOG_DIR/git-final-commit.txt"
+git diff --binary "$BASE_COMMIT" "$FINAL_COMMIT" > "$LOG_DIR/fix.patch" 2>/dev/null || true
+git diff --stat "$BASE_COMMIT" "$FINAL_COMMIT" > "$LOG_DIR/fix.stat.txt" 2>/dev/null || true
+git diff --name-status "$BASE_COMMIT" "$FINAL_COMMIT" > "$LOG_DIR/fix.name-status.txt" 2>/dev/null || true
+find "$LOG_DIR" -maxdepth 4 -type f | sort > "$LOG_DIR/artifacts.index.txt" 2>/dev/null || true
+"""
+
+
+def _format_failure_log_text(text: str) -> str:
+ if len(text) <= _FAILURE_LOG_MAX_BYTES:
+ return text
+ return (
+ text[:_FAILURE_LOG_HEAD_BYTES]
+ + _FAILURE_LOG_TRUNC_MARKER
+ + text[-_FAILURE_LOG_TAIL_BYTES:]
+ )
+
+
+_STDOUT_TOKEN_STATS_RE = re.compile(
+ r"Dialog turn completed - Token stats:.*?"
+ r"prompt_tokens=(?P\d+),\s*"
+ r"completion_tokens=(?P\d+),\s*"
+ r"total_tokens=(?P\d+)"
+ r"(?:,\s*cached_tokens=(?P\d+))?"
+ r"(?:,\s*cached_tokens_available=(?Ptrue|false|partial))?"
+)
+
+_CP_BACK_COMMAND = """\
+set +e
+PROJECT_PATH=""
+if [ -d "$HOME/.bitfun/projects" ]; then
+ for d in "$HOME/.bitfun/projects/testbed" \\
+ "$HOME/.bitfun/projects/-testbed"; do
+ [ -d "$d/sessions" ] && PROJECT_PATH="$d" && break
+ done
+fi
+if [ -z "$PROJECT_PATH" ]; then
+ LATEST_SESSIONS=$(ls -dt "$HOME"/.bitfun/projects/*/sessions/ 2>/dev/null | head -1)
+ [ -n "$LATEST_SESSIONS" ] && PROJECT_PATH=$(dirname "${LATEST_SESSIONS%/}")
+fi
+SESSIONS_SRC=""
+REQUEST_TRACES_SRC=""
+if [ -n "$PROJECT_PATH" ]; then
+ SESSIONS_SRC="$PROJECT_PATH/sessions"
+ REQUEST_TRACES_SRC="$PROJECT_PATH/request-traces"
+fi
+mkdir -p /logs/agent/bitfun/sessions
+if [ -n "$SESSIONS_SRC" ]; then
+ cp -R "$SESSIONS_SRC"/. /logs/agent/bitfun/sessions/ 2>/dev/null || true
+fi
+if [ -d "$REQUEST_TRACES_SRC" ]; then
+ mkdir -p /logs/agent/bitfun/request-traces
+ cp -R "$REQUEST_TRACES_SRC"/. /logs/agent/bitfun/request-traces/ 2>/dev/null || true
+fi
+BITFUN_CONFIG_DIR="$HOME/.config/bitfun"
+TOKEN_USAGE_SRC="$BITFUN_CONFIG_DIR/data/token_usage"
+CLI_LOGS_SRC="$BITFUN_CONFIG_DIR/cli-logs"
+CLI_LOG_SRC="$BITFUN_CONFIG_DIR/logs/bitfun-cli.log"
+AI_AUDIT_SRC="$BITFUN_CONFIG_DIR/logs/ai-request-audit.jsonl"
+MANIFEST=/logs/agent/bitfun/cp-back-manifest.json
+json_string() {
+ printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"
+}
+if [ -d "$TOKEN_USAGE_SRC" ]; then
+ cp -R "$TOKEN_USAGE_SRC" /logs/agent/bitfun/ 2>/dev/null || true
+fi
+if [ -d "$CLI_LOGS_SRC" ]; then
+ cp -R "$CLI_LOGS_SRC" /logs/agent/bitfun/ 2>/dev/null || true
+fi
+if [ -f "$CLI_LOG_SRC" ]; then
+ cp "$CLI_LOG_SRC" /logs/agent/bitfun/cli.log 2>/dev/null || true
+fi
+if [ -f "$AI_AUDIT_SRC" ]; then
+ cp "$AI_AUDIT_SRC" /logs/agent/bitfun/ai-request-audit.jsonl 2>/dev/null || true
+fi
+printf '{"bitfun_config_dir":%s,"sessions":{"source":%s,"exists":%s},"request_traces":{"source":%s,"exists":%s},"token_usage":{"source":%s,"exists":%s},"cli_logs":{"source":%s,"exists":%s},"cli_log":{"source":%s,"exists":%s,"size_bytes":%s},"ai_request_audit":{"source":%s,"exists":%s,"size_bytes":%s}}\n' \
+ "$(json_string "$BITFUN_CONFIG_DIR")" \
+ "$(json_string "${SESSIONS_SRC:-}")" \
+ "$([ -n "$SESSIONS_SRC" ] && [ -d "$SESSIONS_SRC" ] && printf true || printf false)" \
+ "$(json_string "${REQUEST_TRACES_SRC:-}")" \
+ "$([ -n "$REQUEST_TRACES_SRC" ] && [ -d "$REQUEST_TRACES_SRC" ] && printf true || printf false)" \
+ "$(json_string "$TOKEN_USAGE_SRC")" \
+ "$([ -d "$TOKEN_USAGE_SRC" ] && printf true || printf false)" \
+ "$(json_string "$CLI_LOGS_SRC")" \
+ "$([ -d "$CLI_LOGS_SRC" ] && printf true || printf false)" \
+ "$(json_string "$CLI_LOG_SRC")" \
+ "$([ -f "$CLI_LOG_SRC" ] && printf true || printf false)" \
+ "$([ -f "$CLI_LOG_SRC" ] && wc -c < "$CLI_LOG_SRC" 2>/dev/null || printf 0)" \
+ "$(json_string "$AI_AUDIT_SRC")" \
+ "$([ -f "$AI_AUDIT_SRC" ] && printf true || printf false)" \
+ "$([ -f "$AI_AUDIT_SRC" ] && wc -c < "$AI_AUDIT_SRC" 2>/dev/null || printf 0)" \
+ > "$MANIFEST" 2>/dev/null || true
+"""
+
+
+def _bitfun_config_root_shell() -> str:
+ return (
+ 'BITFUN_CONFIG_ROOT="${BITFUN_USER_ROOT:-}"\n'
+ 'if [ -z "$BITFUN_CONFIG_ROOT" ]; then\n'
+ ' BITFUN_CONFIG_ROOT="${BITFUN_E2E_USER_ROOT:-}"\n'
+ "fi\n"
+ 'if [ -z "$BITFUN_CONFIG_ROOT" ]; then\n'
+ ' BITFUN_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"\n'
+ ' BITFUN_CONFIG_ROOT="$BITFUN_XDG_CONFIG_HOME/bitfun"\n'
+ "fi\n"
+ )
+
+
+# Copied into the container exec env when set on the Harbor host / orchestrator.
+_ENV_PASSTHROUGH: tuple[str, ...] = (
+ "OPENAI_API_KEY",
+ "OPENAI_BASE_URL",
+ "ANTHROPIC_API_KEY",
+ "ANTHROPIC_AUTH_TOKEN",
+ "ANTHROPIC_BASE_URL",
+ "GEMINI_API_KEY",
+ "GOOGLE_API_KEY",
+)
+
+
+class BitfunCli(BaseInstalledAgent):
+ """Run BitFun CLI in non-interactive `exec` mode (binary supplied via bind mount)."""
+
+ SUPPORTS_ATIF: bool = True
+ SUPPORTS_WINDOWS: bool = True
+
+ def __init__(
+ self,
+ logs_dir: Path,
+ binary_path: str = _DEFAULT_BINARY,
+ exec_agent: str = "agentic",
+ output_patch_path: str | None = _DEFAULT_OUTPUT_PATCH_PATH,
+ bitfun_config: dict[str, Any] | None = None,
+ *args,
+ **kwargs,
+ ) -> None:
+ if bitfun_config is not None and not isinstance(bitfun_config, dict):
+ raise ValueError("bitfun_config must be a dict")
+ self._binary_path = binary_path
+ self._exec_agent = exec_agent
+ self._output_patch_path = output_patch_path
+ self._bitfun_config = bitfun_config
+ super().__init__(logs_dir, *args, **kwargs)
+
+ @property
+ def _patch_logs_dir(self) -> Path:
+ return self.logs_dir / PATCH_ARTIFACTS_SUBDIR
+
+ @staticmethod
+ def _task_os(environment: BaseEnvironment) -> TaskOS:
+ return getattr(environment, "os", TaskOS.LINUX)
+
+ @classmethod
+ def _env_paths(cls, environment: BaseEnvironment) -> EnvironmentPaths:
+ return EnvironmentPaths.for_os(cls._task_os(environment))
+
+ @classmethod
+ def _agent_log_path(cls, environment: BaseEnvironment) -> str:
+ env_paths = cls._env_paths(environment)
+ if cls._task_os(environment) == TaskOS.WINDOWS:
+ return str(env_paths.agent_dir / _WINDOWS_AGENT_LOG_NAME)
+ return _AGENT_LOG
+
+ @classmethod
+ def _prompt_path(cls, environment: BaseEnvironment) -> str:
+ return str(cls._env_paths(environment).agent_dir / _WINDOWS_PROMPT_FILE_NAME)
+
+ @classmethod
+ def _run_script_path(cls, environment: BaseEnvironment) -> str:
+ return str(cls._env_paths(environment).agent_dir / _WINDOWS_RUN_SCRIPT_NAME)
+
+ @classmethod
+ def _patch_logs_dir_in_env(cls, environment: BaseEnvironment) -> str:
+ return str(cls._env_paths(environment).agent_dir / PATCH_ARTIFACTS_SUBDIR)
+
+ @classmethod
+ def _remote_bitfun_config_dir(cls, environment: BaseEnvironment) -> str:
+ if cls._task_os(environment) == TaskOS.WINDOWS:
+ return str(cls._env_paths(environment).agent_dir / "bitfun/config")
+ return _REMOTE_BITFUN_CONFIG_DIR
+
+ @classmethod
+ def _remote_app_config_redacted_path(cls, environment: BaseEnvironment) -> str:
+ if cls._task_os(environment) == TaskOS.WINDOWS:
+ return f"{cls._remote_bitfun_config_dir(environment)}/app.redacted.json"
+ return _REMOTE_APP_CONFIG_REDACTED_PATH
+
+ @classmethod
+ def _remote_cp_back_manifest_path(cls, environment: BaseEnvironment) -> str:
+ if cls._task_os(environment) == TaskOS.WINDOWS:
+ return str(
+ cls._env_paths(environment).agent_dir / "bitfun/cp-back-manifest.json"
+ )
+ return _REMOTE_CP_BACK_MANIFEST_PATH
+
+ def _output_patch_path_for(self, environment: BaseEnvironment) -> str | None:
+ if self._output_patch_path is None:
+ return None
+ if (
+ self._task_os(environment) == TaskOS.WINDOWS
+ and self._output_patch_path == _DEFAULT_OUTPUT_PATCH_PATH
+ ):
+ return str(self._env_paths(environment).agent_dir / "bitfun.patch")
+ return self._output_patch_path
+
+ @staticmethod
+ def _windows_cmd_path(path: str) -> str:
+ return path.replace("/", "\\").rstrip("\\")
+
+ def _windows_user_root_for(self, environment: BaseEnvironment) -> str:
+ user_root = self._env_for_run(environment).get(
+ "BITFUN_USER_ROOT", _WINDOWS_BITFUN_USER_ROOT
+ )
+ return self._windows_cmd_path(user_root)
+
+ def _windows_home_for(self, environment: BaseEnvironment) -> str:
+ home_root = self._env_for_run(environment).get(
+ "BITFUN_HOME", _WINDOWS_BITFUN_HOME
+ )
+ return self._windows_cmd_path(home_root)
+
+ @staticmethod
+ def name() -> str:
+ return AgentName.BITFUN_CLI.value
+
+ def _binary_path_for(self, environment: BaseEnvironment) -> str:
+ if (
+ self._task_os(environment) == TaskOS.WINDOWS
+ and self._binary_path == _DEFAULT_BINARY
+ ):
+ return _WINDOWS_DEFAULT_BINARY
+ return self._binary_path
+
+ def get_version_command(self) -> str | None:
+ return f"{shlex.quote(self._binary_path)} --version"
+
+ def _version_command_for(self, environment: BaseEnvironment) -> str | None:
+ binary_path = self._binary_path_for(environment)
+ if self._task_os(environment) == TaskOS.WINDOWS:
+ return f"{quote_shell_arg(binary_path, environment.os)} --version"
+ return self.get_version_command()
+
+ async def setup(self, environment: BaseEnvironment) -> None:
+ if self._task_os(environment) != TaskOS.WINDOWS:
+ await super().setup(environment)
+ return
+
+ await environment.ensure_dirs(["C:/installed-agent"], chmod=False)
+
+ setup_dir = self.logs_dir / "setup"
+ setup_dir.mkdir(parents=True, exist_ok=True)
+
+ try:
+ await self.install(environment)
+ except RuntimeError:
+ raise
+ except Exception as exc:
+ raise RuntimeError(f"Agent install failed: {exc}") from exc
+
+ if self._version is None:
+ version_cmd = self._version_command_for(environment)
+ if version_cmd:
+ try:
+ version_result = await environment.exec(command=version_cmd)
+ if version_result.return_code == 0 and version_result.stdout:
+ self._version = self.parse_version(version_result.stdout)
+ except Exception:
+ pass # Version detection is best-effort
+
+ async def install(self, environment: BaseEnvironment) -> None:
+ binary_path = self._binary_path_for(environment)
+ if environment.os == TaskOS.WINDOWS:
+ quoted = quote_shell_arg(binary_path, environment.os)
+ await self.exec_as_agent(
+ environment,
+ command=(
+ f"if not exist {quoted} "
+ f"(echo BitFun CLI binary not found: {quoted} & exit /b 1) "
+ f"& {quoted} --version"
+ ),
+ )
+ return
+
+ quoted = shlex.quote(binary_path)
+ await self.exec_as_agent(
+ environment,
+ command=(
+ "set -euo pipefail; "
+ f"test -e {quoted}; "
+ f"chmod a+x {quoted} 2>/dev/null || true; "
+ f"{quoted} --version"
+ ),
+ )
+
+ def _get_session_dir(self) -> Path | None:
+ """Locate the main BitFun *standard* session directory under self.logs_dir.
+
+ Layout (populated by the cp-back finally block in `run()`)::
+
+ /bitfun/sessions//metadata.json
+ /bitfun/sessions//turns/turn-*.json
+
+ Filters out subagent sessions (`sessionKind == "subagent"`). Returns the
+ unique standard session when exactly one is present; otherwise picks the
+ most recently modified standard session (mtime fallback). Returns
+ ``None`` when no readable standard session exists.
+ """
+ sessions_root = self.logs_dir / _BITFUN_DATA_SUBDIR / "sessions"
+ if not sessions_root.is_dir():
+ return None
+
+ candidates: list[Path] = []
+ for entry in sessions_root.iterdir():
+ if not entry.is_dir():
+ continue
+ meta_path = entry / "metadata.json"
+ if not meta_path.is_file():
+ continue
+ try:
+ meta = json.loads(meta_path.read_text())
+ except (OSError, json.JSONDecodeError):
+ continue
+ if meta.get("sessionKind", "standard") == "subagent":
+ continue
+ candidates.append(entry)
+
+ if not candidates:
+ return None
+ if len(candidates) == 1:
+ return candidates[0]
+
+ self.logger.debug(
+ "Multiple BitFun standard sessions found; falling back to mtime",
+ )
+ return max(candidates, key=lambda p: p.stat().st_mtime)
+
+ def _load_token_records(self) -> list[dict[str, Any]]:
+ """Aggregate all BitFun TokenUsageRecord entries from records/*.json files.
+
+ Malformed JSON or unreadable files are skipped silently with a debug log.
+ Returns an empty list when the records directory does not exist.
+ """
+ records_dir = self.logs_dir / _BITFUN_DATA_SUBDIR / "token_usage" / "records"
+ if not records_dir.is_dir():
+ return []
+
+ out: list[dict[str, Any]] = []
+ for jf in sorted(records_dir.glob("*.json")):
+ try:
+ batch = json.loads(jf.read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ self.logger.debug(f"Skipping malformed token-record file {jf}: {exc}")
+ continue
+ if not isinstance(batch, dict):
+ continue
+ recs = batch.get("records")
+ if isinstance(recs, list):
+ out.extend(r for r in recs if isinstance(r, dict))
+ return out
+
+ def _load_stdout_token_stats(self) -> dict[str, Any] | None:
+ """Parse aggregate token totals from BitFun stdout when records are absent.
+
+ Older/non-server `bitfun-cli exec` runs may not persist
+ `token_usage/records`, but they still log per-turn aggregate totals like:
+
+ Dialog turn completed - Token stats: ..., prompt_tokens=10,
+ completion_tokens=2, total_tokens=12
+
+ This is less detailed than TokenUsageRecord files, so it is only used as
+ a fallback for final metrics.
+ """
+ log_path = self.logs_dir / "bitfun.txt"
+ if not log_path.is_file():
+ return None
+ try:
+ text = log_path.read_text(errors="replace")
+ except OSError as exc:
+ self.logger.debug(f"Failed to read BitFun stdout log {log_path}: {exc}")
+ return None
+
+ prompt = 0
+ completion = 0
+ total = 0
+ cached = 0
+ count = 0
+ cached_count = 0
+ saw_partial_cache = False
+ saw_unavailable_cache = False
+ for match in _STDOUT_TOKEN_STATS_RE.finditer(text):
+ prompt += int(match.group("prompt"))
+ completion += int(match.group("completion"))
+ total += int(match.group("total"))
+ count += 1
+ cached_value = match.group("cached")
+ if cached_value is not None:
+ cached += int(cached_value)
+ cached_count += 1
+ cache_coverage = match.group("cache_coverage")
+ if cache_coverage == "partial":
+ saw_partial_cache = True
+ elif cache_coverage == "false":
+ saw_unavailable_cache = True
+
+ if count == 0:
+ return None
+ cache_coverage = "false"
+ cached_tokens: int | None = None
+ if saw_partial_cache:
+ cache_coverage = "partial"
+ cached_tokens = cached if cached_count > 0 else None
+ elif cached_count == count:
+ cache_coverage = "true"
+ cached_tokens = cached
+ elif cached_count > 0:
+ cache_coverage = "partial"
+ cached_tokens = cached
+ elif saw_unavailable_cache:
+ cache_coverage = "false"
+ return {
+ "prompt_tokens": prompt,
+ "completion_tokens": completion,
+ "cached_tokens": cached_tokens,
+ "total_tokens": total,
+ "record_count": count,
+ "cached_tokens_available": cache_coverage == "true",
+ "cached_tokens_coverage": cache_coverage,
+ }
+
+ def _compute_cost_via_litellm(
+ self,
+ model_id: str | None,
+ prompt_tokens: int | None,
+ cached_tokens: int | None,
+ completion_tokens: int | None,
+ ) -> float | None:
+ """Compute USD cost for a token record via litellm.model_cost.
+
+ BitFun records token counts only; cost must be derived. Returns None
+ when the model is not in litellm.model_cost so callers can leave
+ `cost_usd` unset rather than report a misleading $0.
+
+ Mirrors Codex._compute_cost_from_pricing: cached input tokens are
+ billed at `cache_read_input_token_cost` when present, otherwise at
+ `input_cost_per_token`.
+ """
+ lookup = model_id or self.model_name
+ if not lookup:
+ return None
+
+ try:
+ import litellm
+ except ImportError:
+ self.logger.debug("litellm not available; bitfun cost_usd will be None")
+ return None
+
+ pricing: dict[str, Any] | None = None
+ for key in (lookup, lookup.split("/", 1)[-1]):
+ entry = litellm.model_cost.get(key)
+ if entry:
+ pricing = entry
+ break
+
+ if pricing is None:
+ self.logger.debug(
+ "No LiteLLM pricing for model %r; bitfun cost_usd will be None",
+ lookup,
+ )
+ return None
+
+ input_rate = pricing.get("input_cost_per_token") or 0.0
+ output_rate = pricing.get("output_cost_per_token") or 0.0
+ cache_read_rate = pricing.get("cache_read_input_token_cost", input_rate)
+ if cache_read_rate is None:
+ cache_read_rate = input_rate
+
+ uncached_input = max(0, (prompt_tokens or 0) - (cached_tokens or 0))
+ cached = cached_tokens or 0
+ output = completion_tokens or 0
+
+ return (
+ uncached_input * input_rate
+ + cached * cache_read_rate
+ + output * output_rate
+ )
+
+ @staticmethod
+ def _ts_iso(ms: int | None) -> str | None:
+ """Convert BitFun's u64 epoch-ms timestamp to ISO-8601 UTC."""
+ if ms is None:
+ return None
+ return (
+ datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ )
+
+ @staticmethod
+ def _strip_user_query_wrapper(content: str) -> str:
+ """BitFun wraps exec input in … ; strip if present."""
+ text = content.strip()
+ if text.startswith("") and text.endswith(" "):
+ inner = text[len("") : -len(" ")]
+ return inner.strip()
+ return text
+
+ @classmethod
+ def _user_text_from_message(cls, user_message: dict[str, Any]) -> str:
+ meta = user_message.get("metadata") or {}
+ original = meta.get("original_text")
+ if isinstance(original, str) and original:
+ return original
+ return cls._strip_user_query_wrapper(user_message.get("content") or "")
+
+ def _load_turns(self, session_dir: Path) -> list[dict[str, Any]]:
+ """Read all turn-*.json files sorted by turnIndex ascending; skip malformed."""
+ turns_dir = session_dir / "turns"
+ if not turns_dir.is_dir():
+ return []
+ turns: list[dict[str, Any]] = []
+ for jf in sorted(turns_dir.glob("turn-*.json")):
+ try:
+ turns.append(json.loads(jf.read_text()))
+ except (OSError, json.JSONDecodeError) as exc:
+ self.logger.debug(f"Skipping malformed turn file {jf}: {exc}")
+ turns.sort(key=lambda t: t.get("turnIndex", 0))
+ return turns
+
+ @staticmethod
+ def _snapshot_ts_ms(ts: Any) -> int | None:
+ """Convert BitFun's snapshot `{secs_since_epoch, nanos_since_epoch}` to epoch ms."""
+ if not isinstance(ts, dict):
+ return None
+ secs = ts.get("secs_since_epoch")
+ nanos = ts.get("nanos_since_epoch") or 0
+ if not isinstance(secs, (int, float)):
+ return None
+ return int(secs * 1000 + int(nanos) // 1_000_000)
+
+ def _synthesize_turns_from_snapshot(
+ self, session_dir: Path
+ ) -> list[dict[str, Any]] | None:
+ """Reconstruct turn-shaped dicts from `snapshots/context-*.json`.
+
+ BitFun's ``exec`` mode (observed in 0.2.7) sometimes only persists a
+ single synthetic ``-final-round`` to ``turns/turn-*.json`` with no
+ ``toolItems``/``thinkingItems``, while the *complete* message history
+ is preserved in ``snapshots/context-NNNN.json``. We rebuild a
+ turn-file-shaped structure from the snapshot so the existing
+ ``_round_to_steps()`` pipeline can produce a complete trajectory.
+
+ Returns None when no readable snapshot or no turn-keyed messages exist.
+ """
+ snapshots_dir = session_dir / "snapshots"
+ if not snapshots_dir.is_dir():
+ return None
+ candidates = sorted(snapshots_dir.glob("context-*.json"))
+ if not candidates:
+ return None
+
+ latest = candidates[-1]
+ try:
+ snapshot = json.loads(latest.read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ self.logger.debug(f"Skipping malformed snapshot {latest}: {exc}")
+ return None
+
+ messages = snapshot.get("messages")
+ if not isinstance(messages, list):
+ return None
+
+ turn_order: list[str] = []
+ by_turn: dict[str, list[dict[str, Any]]] = {}
+ for msg in messages:
+ meta = msg.get("metadata") or {}
+ turn_id = meta.get("turn_id")
+ if not isinstance(turn_id, str):
+ continue
+ if turn_id not in by_turn:
+ turn_order.append(turn_id)
+ by_turn[turn_id] = []
+ by_turn[turn_id].append(msg)
+
+ if not by_turn:
+ return None
+
+ # tool_id -> Tool result message (across all turns; tool_ids are unique)
+ tool_results_by_id: dict[str, dict[str, Any]] = {}
+ for msg in messages:
+ if msg.get("role") != "Tool":
+ continue
+ tr = (msg.get("content") or {}).get("ToolResult") or {}
+ tid = tr.get("tool_id")
+ if isinstance(tid, str):
+ tool_results_by_id[tid] = msg
+
+ synthesized: list[dict[str, Any]] = []
+ session_id = snapshot.get("session_id")
+ for turn_idx, turn_id in enumerate(turn_order):
+ msgs = by_turn[turn_id]
+ user_msg = next((m for m in msgs if m.get("role") == "User"), None)
+ if user_msg is None:
+ continue
+
+ user_text = ""
+ user_content_obj = user_msg.get("content")
+ if isinstance(user_content_obj, dict):
+ t = user_content_obj.get("Text")
+ if isinstance(t, str):
+ user_text = t
+ user_ts_ms = self._snapshot_ts_ms(user_msg.get("timestamp"))
+
+ round_order: list[str] = []
+ by_round: dict[str, list[dict[str, Any]]] = {}
+ for m in msgs:
+ if m.get("role") == "User":
+ continue
+ meta = m.get("metadata") or {}
+ rid = meta.get("round_id")
+ if not isinstance(rid, str):
+ continue
+ if rid not in by_round:
+ round_order.append(rid)
+ by_round[rid] = []
+ by_round[rid].append(m)
+
+ model_rounds: list[dict[str, Any]] = []
+ for round_idx, rid in enumerate(round_order):
+ text_items: list[dict[str, Any]] = []
+ tool_items: list[dict[str, Any]] = []
+ thinking_items: list[dict[str, Any]] = []
+ round_ts_ms: int | None = None
+ order_idx = 0
+
+ for m in by_round[rid]:
+ if m.get("role") != "Assistant":
+ continue
+ m_ts = self._snapshot_ts_ms(m.get("timestamp"))
+ if round_ts_ms is None and m_ts is not None:
+ round_ts_ms = m_ts
+
+ content = m.get("content") or {}
+ mixed = content.get("Mixed") if isinstance(content, dict) else None
+ if not isinstance(mixed, dict):
+ continue
+
+ reasoning = mixed.get("reasoning_content")
+ if isinstance(reasoning, str) and reasoning:
+ thinking_items.append(
+ {
+ "id": f"{m.get('id')}-th",
+ "content": reasoning,
+ "timestamp": m_ts,
+ "orderIndex": order_idx,
+ }
+ )
+ order_idx += 1
+
+ text = mixed.get("text")
+ if isinstance(text, str) and text:
+ text_items.append(
+ {
+ "id": f"{m.get('id')}-text",
+ "content": text,
+ "timestamp": m_ts,
+ "orderIndex": order_idx,
+ "status": "completed",
+ "isMarkdown": True,
+ }
+ )
+ order_idx += 1
+
+ for tc in mixed.get("tool_calls") or []:
+ tool_id = tc.get("tool_id") or ""
+ tool_name = tc.get("tool_name") or ""
+ args = tc.get("arguments")
+ if not isinstance(args, dict):
+ args = {"input": args} if args is not None else {}
+ tool_item: dict[str, Any] = {
+ "id": tool_id or f"{m.get('id')}-tc{order_idx}",
+ "toolName": tool_name,
+ "toolCall": {"id": tool_id, "input": args},
+ "timestamp": m_ts,
+ "orderIndex": order_idx,
+ "status": "completed",
+ }
+ tr_msg = tool_results_by_id.get(tool_id) if tool_id else None
+ if tr_msg is not None:
+ tr = (tr_msg.get("content") or {}).get("ToolResult") or {}
+ is_error = bool(tr.get("is_error"))
+ tool_result: dict[str, Any] = {
+ "result": tr.get("result"),
+ "resultForAssistant": tr.get("result_for_assistant"),
+ "success": not is_error,
+ }
+ if is_error:
+ tool_result["error"] = (
+ tr.get("result_for_assistant") or "tool error"
+ )
+ tool_item["toolResult"] = tool_result
+ tool_items.append(tool_item)
+ order_idx += 1
+
+ model_rounds.append(
+ {
+ "id": rid,
+ "turnId": turn_id,
+ "roundIndex": round_idx,
+ "timestamp": round_ts_ms,
+ "textItems": text_items,
+ "toolItems": tool_items,
+ "thinkingItems": thinking_items,
+ "status": "completed",
+ }
+ )
+
+ original = self._strip_user_query_wrapper(user_text) if user_text else ""
+ synthesized.append(
+ {
+ "schema_version": 2,
+ "turnId": turn_id,
+ "turnIndex": turn_idx,
+ "sessionId": session_id,
+ "timestamp": user_ts_ms,
+ "kind": "user_dialog",
+ "userMessage": {
+ "id": user_msg.get("id"),
+ "content": user_text,
+ "timestamp": user_ts_ms,
+ "metadata": {"original_text": original},
+ },
+ "modelRounds": model_rounds,
+ "status": "completed",
+ }
+ )
+
+ return synthesized or None
+
+ @staticmethod
+ def _count_rounds(turns: list[dict[str, Any]]) -> int:
+ """Total number of modelRounds across all turns (used to pick richer source)."""
+ return sum(len(t.get("modelRounds") or []) for t in turns)
+
+ def _load_turns_preferring_snapshot(
+ self, session_dir: Path
+ ) -> list[dict[str, Any]]:
+ """Load turns, preferring the snapshot-derived source when it is richer.
+
+ BitFun's ``exec`` mode can leave ``turns/`` with only a synthetic
+ ``-final-round`` placeholder while the full conversation lives in
+ ``snapshots/context-*.json``. When the snapshot has strictly more
+ rounds than the turn files we use the snapshot-derived turns;
+ otherwise we keep the turn-file data (which carries richer metadata
+ like ``durationMs`` and subagent fields).
+ """
+ turns_from_files = self._load_turns(session_dir)
+ synthesized = self._synthesize_turns_from_snapshot(session_dir)
+ if synthesized is None:
+ return turns_from_files
+
+ file_rounds = self._count_rounds(turns_from_files)
+ snap_rounds = self._count_rounds(synthesized)
+ if snap_rounds > file_rounds:
+ self.logger.debug(
+ "Using BitFun snapshot-derived turns (%d rounds) over turn files (%d rounds) in %s",
+ snap_rounds,
+ file_rounds,
+ session_dir,
+ )
+ return synthesized
+ return turns_from_files
+
+ def _round_to_steps(
+ self,
+ rnd: dict[str, Any],
+ turn: dict[str, Any],
+ next_step_id: int,
+ *,
+ default_model_name: str | None,
+ ) -> tuple[list[Step], int]:
+ """Convert one modelRound into ATIF steps (text + thinking + tools)."""
+ items: list[dict[str, Any]] = []
+ for ti in rnd.get("textItems") or []:
+ items.append({"_kind": "text", **ti})
+ for th in rnd.get("thinkingItems") or []:
+ items.append({"_kind": "thinking", **th})
+ for to in rnd.get("toolItems") or []:
+ items.append({"_kind": "tool", **to})
+ items.sort(key=lambda x: (x.get("orderIndex") or 0, x.get("timestamp") or 0))
+
+ new_steps: list[Step] = []
+ model_id = rnd.get("modelId") or default_model_name
+ pending_reasoning: list[str] = []
+
+ def _flush_reasoning() -> str | None:
+ if not pending_reasoning:
+ return None
+ joined = "\n\n".join(part for part in pending_reasoning if part)
+ pending_reasoning.clear()
+ return joined or None
+
+ for item in items:
+ kind = item["_kind"]
+ if kind == "thinking":
+ content = item.get("content") or ""
+ if content:
+ pending_reasoning.append(content)
+ continue
+ if kind == "text":
+ new_steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(
+ item.get("timestamp") or rnd.get("timestamp")
+ ),
+ source="agent",
+ message=item.get("content") or "",
+ model_name=model_id,
+ reasoning_content=_flush_reasoning(),
+ extra={
+ "turn_id": turn.get("turnId"),
+ "round_id": rnd.get("id"),
+ "round_index": rnd.get("roundIndex"),
+ "model_alias": rnd.get("modelAlias"),
+ "provider_id": rnd.get("providerId"),
+ "status": item.get("status"),
+ "round_status": rnd.get("status"),
+ "attempt_count": rnd.get("attemptCount"),
+ "failure_category": rnd.get("failureCategory"),
+ },
+ )
+ )
+ next_step_id += 1
+ continue
+ if kind == "tool":
+ tc_block = item.get("toolCall") or {}
+ tool_call_id = tc_block.get("id") or item.get("id") or ""
+ raw_input = tc_block.get("input")
+ if isinstance(raw_input, dict):
+ arguments = raw_input
+ else:
+ arguments = {"input": raw_input}
+
+ tool_name = item.get("toolName") or ""
+
+ tool_extra = {
+ "tool_item_id": item.get("id"),
+ "queue_wait_ms": item.get("queueWaitMs"),
+ "preflight_ms": item.get("preflightMs"),
+ "confirmation_wait_ms": item.get("confirmationWaitMs"),
+ "execution_ms": item.get("executionMs"),
+ "interruption_reason": item.get("interruptionReason"),
+ }
+ tool_extra = {
+ k: v for k, v in tool_extra.items() if v is not None
+ } or None
+
+ tool_call = ToolCall(
+ tool_call_id=tool_call_id,
+ function_name=tool_name,
+ arguments=arguments,
+ extra=tool_extra,
+ )
+
+ tool_result = item.get("toolResult") or {}
+ rfa = tool_result.get("resultForAssistant")
+ raw_result = tool_result.get("result")
+ if isinstance(rfa, str) and rfa:
+ content: str | None = rfa
+ elif raw_result is not None:
+ try:
+ content = json.dumps(raw_result, ensure_ascii=False)
+ except (TypeError, ValueError):
+ content = str(raw_result)
+ else:
+ content = None
+
+ obs_extra = {
+ "raw_result": raw_result,
+ "success": tool_result.get("success"),
+ "error": tool_result.get("error"),
+ "tool_duration_ms": tool_result.get("durationMs"),
+ }
+ obs_extra = {
+ k: v for k, v in obs_extra.items() if v is not None
+ } or None
+
+ subagent_sid = item.get("subagentSessionId")
+ sub_model_id = item.get("subagentModelId")
+ sub_ref = (
+ [
+ SubagentTrajectoryRef(
+ trajectory_id=subagent_sid,
+ session_id=subagent_sid,
+ extra={
+ "tool_call_id": tool_call_id,
+ "tool_name": tool_name,
+ "subagent_model_id": sub_model_id,
+ },
+ )
+ ]
+ if subagent_sid
+ else None
+ )
+
+ obs_result = ObservationResult(
+ source_call_id=tool_call_id,
+ content=content,
+ subagent_trajectory_ref=sub_ref,
+ extra=obs_extra,
+ )
+
+ new_steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(
+ item.get("startTime")
+ or item.get("timestamp")
+ or rnd.get("timestamp")
+ ),
+ source="agent",
+ message=item.get("aiIntent") or f"Executed {tool_name}",
+ model_name=model_id,
+ reasoning_content=_flush_reasoning(),
+ tool_calls=[tool_call],
+ observation=Observation(results=[obs_result]),
+ extra={
+ "turn_id": turn.get("turnId"),
+ "round_id": rnd.get("id"),
+ "tool_status": item.get("status"),
+ "is_subagent_dispatch": bool(subagent_sid),
+ },
+ )
+ )
+ next_step_id += 1
+ continue
+
+ if not new_steps:
+ new_steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(rnd.get("timestamp")),
+ source="agent",
+ message="",
+ model_name=model_id,
+ extra={
+ "turn_id": turn.get("turnId"),
+ "round_id": rnd.get("id"),
+ "round_index": rnd.get("roundIndex"),
+ "round_status": rnd.get("status"),
+ "attempt_count": rnd.get("attemptCount"),
+ "failure_category": rnd.get("failureCategory"),
+ "duration_ms": rnd.get("durationMs"),
+ "is_placeholder_empty_round": True,
+ },
+ )
+ )
+ next_step_id += 1
+
+ return new_steps, next_step_id
+
+ @staticmethod
+ def _parse_record_ts_ms(record: dict[str, Any]) -> int | None:
+ """Parse a token record's ISO-8601 timestamp into epoch milliseconds."""
+ raw = record.get("timestamp")
+ if not isinstance(raw, str):
+ return None
+ try:
+ dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
+ except ValueError:
+ return None
+ return int(dt.timestamp() * 1000)
+
+ @staticmethod
+ def _parse_llm_latency_ms(record: dict[str, Any]) -> tuple[int | None, str | None]:
+ """Return usable non-negative latency and an unavailable reason, if any."""
+ raw = record.get("llm_latency_ms")
+ if raw is None:
+ return None, "missing_latency"
+ if isinstance(raw, bool):
+ return None, "missing_latency"
+ if isinstance(raw, int):
+ latency = raw
+ elif isinstance(raw, float) and raw.is_integer():
+ latency = int(raw)
+ else:
+ return None, "missing_latency"
+ if latency < 0:
+ return None, "missing_latency"
+ if latency == 0:
+ return 0, "zero_latency"
+ return latency, None
+
+ @staticmethod
+ def _build_tps_extra(
+ *,
+ completion_tokens: int,
+ llm_latency_ms: int | None,
+ unavailable_reason: str | None,
+ model_call_count: int = 1,
+ ) -> dict[str, Any]:
+ """Build step-level TPS fields for one (possibly merged) token record.
+
+ ``completion_tokens_per_second`` is completion tokens over the LLM
+ call's *end-to-end* latency (``llm_latency_ms``, which includes queueing
+ and time-to-first-token), so it reflects effective throughput rather
+ than raw decode speed. Step-level keys (``llm_latency_ms``,
+ ``tps_model_call_count``) are deliberately named differently from the
+ trajectory-summary keys produced by ``_build_final_tps_extra``
+ (``total_llm_latency_ms``, ``model_call_count``).
+ """
+ extra: dict[str, Any] = {}
+ if llm_latency_ms is not None:
+ extra["llm_latency_ms"] = llm_latency_ms
+ if llm_latency_ms and completion_tokens >= 0:
+ extra["completion_tokens_per_second"] = round(
+ completion_tokens * 1000.0 / llm_latency_ms, 2
+ )
+ extra["tps_completion_tokens"] = completion_tokens
+ extra["tps_model_call_count"] = model_call_count
+ extra["tps_latency_coverage"] = "complete"
+ elif unavailable_reason is not None:
+ extra["tps_unavailable_reason"] = unavailable_reason
+ return extra
+
+ @staticmethod
+ def _combine_tps_extras(
+ a_extra: dict[str, Any],
+ b_extra: dict[str, Any],
+ *,
+ total_completion_tokens: int,
+ ) -> dict[str, Any]:
+ covered_completion = int(a_extra.get("tps_completion_tokens") or 0) + int(
+ b_extra.get("tps_completion_tokens") or 0
+ )
+ covered_latency = int(a_extra.get("llm_latency_ms") or 0) + int(
+ b_extra.get("llm_latency_ms") or 0
+ )
+ covered_calls = int(a_extra.get("tps_model_call_count") or 0) + int(
+ b_extra.get("tps_model_call_count") or 0
+ )
+
+ combined: dict[str, Any] = {}
+ if covered_latency > 0:
+ combined["llm_latency_ms"] = covered_latency
+ combined["tps_completion_tokens"] = covered_completion
+ combined["tps_model_call_count"] = covered_calls
+ combined["completion_tokens_per_second"] = round(
+ covered_completion * 1000.0 / covered_latency, 2
+ )
+ combined["tps_latency_coverage"] = (
+ "complete"
+ if covered_completion == total_completion_tokens
+ else "partial"
+ )
+ elif a_extra.get("llm_latency_ms") == 0 or b_extra.get("llm_latency_ms") == 0:
+ combined["llm_latency_ms"] = 0
+ combined["tps_unavailable_reason"] = "zero_latency"
+ else:
+ combined["tps_unavailable_reason"] = "missing_latency"
+ return combined
+
+ def _build_metrics_from_record(self, record: dict[str, Any]) -> Metrics:
+ """Convert one BitFun TokenUsageRecord into an ATIF Metrics object."""
+ in_tok = int(record.get("input_tokens") or 0)
+ out_tok = int(record.get("output_tokens") or 0)
+ cached = int(record.get("cached_tokens") or 0)
+ model_id = record.get("model_id")
+ cost = self._compute_cost_via_litellm(model_id, in_tok, cached, out_tok)
+ llm_latency_ms, tps_unavailable_reason = self._parse_llm_latency_ms(record)
+ extra = {
+ "token_details": record.get("token_details"),
+ "total_tokens": record.get("total_tokens"),
+ "cached_tokens_available": record.get("cached_tokens_available"),
+ "record_timestamp": record.get("timestamp"),
+ "record_model_id": model_id,
+ }
+ extra.update(
+ self._build_tps_extra(
+ completion_tokens=out_tok,
+ llm_latency_ms=llm_latency_ms,
+ unavailable_reason=tps_unavailable_reason,
+ )
+ )
+ extra = {k: v for k, v in extra.items() if v is not None} or None
+ return Metrics(
+ prompt_tokens=in_tok,
+ completion_tokens=out_tok,
+ cached_tokens=cached,
+ cost_usd=cost,
+ extra=extra,
+ )
+
+ def _merge_metrics(self, a: Metrics, b: Metrics) -> Metrics:
+ """Combine two Metrics objects (for multiple token records on one step)."""
+ p = (a.prompt_tokens or 0) + (b.prompt_tokens or 0)
+ c = (a.completion_tokens or 0) + (b.completion_tokens or 0)
+ cache = (a.cached_tokens or 0) + (b.cached_tokens or 0)
+ if a.cost_usd is not None and b.cost_usd is not None:
+ cost = a.cost_usd + b.cost_usd
+ else:
+ cost = None
+ extra = {**(a.extra or {}), **(b.extra or {})}
+ extra.update(
+ self._combine_tps_extras(
+ a.extra or {},
+ b.extra or {},
+ total_completion_tokens=c,
+ )
+ )
+ extra = extra or None
+ return Metrics(
+ prompt_tokens=p,
+ completion_tokens=c,
+ cached_tokens=cache,
+ cost_usd=cost,
+ extra=extra,
+ )
+
+ def _allocate_records_to_steps(
+ self,
+ steps: list[Step],
+ turns: list[dict[str, Any]],
+ records_for_traj: list[dict[str, Any]],
+ ) -> None:
+ """Attach a `Metrics` object to the first assistant-source step of the
+ round whose timestamp is nearest the record timestamp (per design
+ decision Q5). Records that cannot be matched to a round in their turn
+ fall through to the last assistant-source step of the turn.
+ """
+ if not records_for_traj:
+ return
+
+ first_step_by_round: dict[tuple[str, str], Step] = {}
+ last_agent_step_by_turn: dict[str, Step] = {}
+ for step in steps:
+ if step.source != "agent":
+ continue
+ extra = step.extra or {}
+ turn_id = extra.get("turn_id")
+ round_id = extra.get("round_id")
+ if isinstance(turn_id, str):
+ last_agent_step_by_turn[turn_id] = step
+ if (
+ isinstance(turn_id, str)
+ and isinstance(round_id, str)
+ and (turn_id, round_id) not in first_step_by_round
+ ):
+ first_step_by_round[(turn_id, round_id)] = step
+
+ records_by_turn: dict[str, list[dict[str, Any]]] = {}
+ for rec in records_for_traj:
+ tid = rec.get("turn_id")
+ if isinstance(tid, str):
+ records_by_turn.setdefault(tid, []).append(rec)
+
+ for turn in turns:
+ turn_id = turn.get("turnId")
+ if not isinstance(turn_id, str):
+ continue
+ turn_records = records_by_turn.get(turn_id, [])
+ if not turn_records:
+ continue
+ rounds = list(turn.get("modelRounds") or [])
+ if not rounds:
+ target = last_agent_step_by_turn.get(turn_id)
+ if target is None:
+ continue
+ for rec in turn_records:
+ new_m = self._build_metrics_from_record(rec)
+ if target.metrics is None:
+ target.metrics = new_m
+ else:
+ target.metrics = self._merge_metrics(target.metrics, new_m)
+ continue
+
+ round_targets: list[Step | None] = []
+ for rnd in rounds:
+ key = (turn_id, rnd.get("id"))
+ step = first_step_by_round.get(key)
+ if step is not None:
+ round_targets.append(step)
+ else:
+ round_targets.append(last_agent_step_by_turn.get(turn_id))
+
+ round_ts = [rnd.get("timestamp") or 0 for rnd in rounds]
+ for rec in turn_records:
+ rec_ts = self._parse_record_ts_ms(rec) or 0
+ best_idx = min(
+ range(len(round_ts)),
+ key=lambda i: abs(round_ts[i] - rec_ts),
+ )
+ target = round_targets[best_idx] or last_agent_step_by_turn.get(turn_id)
+ if target is None:
+ continue
+ new_m = self._build_metrics_from_record(rec)
+ if target.metrics is None:
+ target.metrics = new_m
+ else:
+ target.metrics = self._merge_metrics(target.metrics, new_m)
+
+ def _build_final_tps_extra(
+ self, records_for_traj: list[dict[str, Any]]
+ ) -> dict[str, Any]:
+ """Aggregate trajectory-level TPS over the records of one trajectory.
+
+ ``records_for_traj`` is already scoped to this trajectory's
+ ``is_subagent`` value by ``_convert_events_to_trajectory``, so this
+ computes the summary over whichever scope (main or subagent) the
+ trajectory represents — keeping the summary consistent with the
+ step-level TPS attached in ``_build_metrics_from_record``.
+ """
+ if not records_for_traj:
+ return {}
+
+ total_completion = 0
+ covered_completion = 0
+ total_latency = 0
+ covered_calls = 0
+ saw_zero_latency = False
+ saw_missing_latency = False
+
+ for record in records_for_traj:
+ completion = int(record.get("output_tokens") or 0)
+ total_completion += completion
+ latency, reason = self._parse_llm_latency_ms(record)
+ if latency and latency > 0:
+ covered_completion += completion
+ total_latency += latency
+ covered_calls += 1
+ elif reason == "zero_latency":
+ saw_zero_latency = True
+ else:
+ saw_missing_latency = True
+
+ extra: dict[str, Any] = {}
+ if total_latency > 0:
+ extra["total_llm_latency_ms"] = total_latency
+ extra["model_call_count"] = covered_calls
+ extra["tps_completion_tokens"] = covered_completion
+ extra["completion_tokens_per_second"] = round(
+ covered_completion * 1000.0 / total_latency, 2
+ )
+ extra["tps_latency_coverage"] = (
+ "complete" if covered_completion == total_completion else "partial"
+ )
+ elif saw_zero_latency:
+ extra["total_llm_latency_ms"] = 0
+ extra["tps_unavailable_reason"] = "zero_latency"
+ elif saw_missing_latency:
+ extra["tps_unavailable_reason"] = "missing_latency"
+ return extra
+
+ def _build_final_metrics(
+ self,
+ steps: list[Step],
+ metadata: dict[str, Any],
+ records_for_traj: list[dict[str, Any]],
+ subagent_trajectories: list[Trajectory],
+ subagent_count: int,
+ turns: list[dict[str, Any]] | None = None,
+ ) -> FinalMetrics:
+ prompt = 0
+ completion = 0
+ cached = 0
+ has_any = False
+ cost_total: float = 0.0
+ every_step_priced = True
+ for step in steps:
+ if step.metrics is None:
+ continue
+ has_any = True
+ prompt += step.metrics.prompt_tokens or 0
+ completion += step.metrics.completion_tokens or 0
+ cached += step.metrics.cached_tokens or 0
+ if step.metrics.cost_usd is None:
+ every_step_priced = False
+ else:
+ cost_total += step.metrics.cost_usd
+
+ # When no token_usage records exist (typical for subagents), fall back
+ # to tokenDetails embedded in each modelRound.
+ has_cached_from_details = False
+ if not has_any and turns:
+ for turn in turns:
+ for rnd in turn.get("modelRounds") or []:
+ td = rnd.get("tokenDetails") or {}
+ cached_count = td.get("cachedContentTokenCount")
+ if isinstance(cached_count, int):
+ cached += cached_count
+ has_cached_from_details = True
+
+ total_cost = cost_total if (has_any and every_step_priced) else None
+
+ duration_ms: int | None = None
+ if isinstance(metadata.get("createdAt"), int) and isinstance(
+ metadata.get("lastActiveAt"), int
+ ):
+ duration_ms = metadata["lastActiveAt"] - metadata["createdAt"]
+
+ models_used = sorted(
+ {
+ rec["model_id"]
+ for rec in records_for_traj
+ if isinstance(rec.get("model_id"), str)
+ }
+ )
+ subagent_total_tokens = 0
+ for subagent in subagent_trajectories:
+ fm = subagent.final_metrics
+ if fm is None:
+ continue
+ subagent_total_tokens += (fm.total_prompt_tokens or 0) + (
+ fm.total_completion_tokens or 0
+ )
+
+ extra_fields: dict[str, Any] = {
+ "main_session_tool_calls": metadata.get("toolCallCount"),
+ "main_session_turn_count": metadata.get("turnCount"),
+ "main_session_duration_ms": duration_ms,
+ "models_used": models_used or None,
+ "subagent_session_count": subagent_count or None,
+ "subagent_total_tokens": subagent_total_tokens or None,
+ }
+ extra_fields.update(self._build_final_tps_extra(records_for_traj))
+ extra: dict[str, Any] | None = {
+ k: v for k, v in extra_fields.items() if v is not None
+ } or None
+
+ return FinalMetrics(
+ total_prompt_tokens=prompt if has_any else None,
+ total_completion_tokens=completion if has_any else None,
+ total_cached_tokens=cached
+ if (has_any or has_cached_from_details)
+ else None,
+ total_cost_usd=total_cost,
+ total_steps=len(steps),
+ extra=extra,
+ )
+
+ def _apply_stdout_token_stats_fallback(
+ self,
+ final_metrics: FinalMetrics,
+ *,
+ is_subagent: bool,
+ steps: list[Step] | None = None,
+ ) -> None:
+ """Fill final metrics from stdout totals when structured records are absent."""
+ if is_subagent or final_metrics.total_prompt_tokens is not None:
+ return
+
+ stats = self._load_stdout_token_stats()
+ if not stats:
+ return
+
+ prompt = int(stats["prompt_tokens"])
+ completion = int(stats["completion_tokens"])
+ cached = stats["cached_tokens"]
+ cost = (
+ self._compute_cost_via_litellm(
+ None,
+ prompt,
+ cached,
+ completion,
+ )
+ if cached is not None and stats["cached_tokens_coverage"] == "true"
+ else None
+ )
+
+ final_metrics.total_prompt_tokens = prompt
+ final_metrics.total_completion_tokens = completion
+ final_metrics.total_cached_tokens = cached
+ final_metrics.total_cost_usd = cost
+
+ extra = dict(final_metrics.extra or {})
+ extra.update(
+ {
+ "token_usage_source": "bitfun_stdout",
+ "stdout_token_stats_count": stats["record_count"],
+ "stdout_total_tokens": stats["total_tokens"],
+ "cached_tokens_available": stats["cached_tokens_available"],
+ "cached_tokens_coverage": stats["cached_tokens_coverage"],
+ }
+ )
+ final_metrics.extra = extra
+
+ if steps:
+ target = next(
+ (step for step in reversed(steps) if step.source == "agent"), None
+ )
+ if target is not None and target.metrics is None:
+ target.metrics = Metrics(
+ prompt_tokens=prompt,
+ completion_tokens=completion,
+ cached_tokens=cached,
+ cost_usd=cost,
+ extra={
+ "token_usage_source": "bitfun_stdout",
+ "allocation": "aggregate_attached_to_last_agent_step",
+ "stdout_token_stats_count": stats["record_count"],
+ "stdout_total_tokens": stats["total_tokens"],
+ "cached_tokens_available": stats["cached_tokens_available"],
+ "cached_tokens_coverage": stats["cached_tokens_coverage"],
+ },
+ )
+
+ @staticmethod
+ def _sum_trajectory_token_counts(
+ trajectory: Trajectory,
+ ) -> tuple[int, int, int | None, float | None]:
+ """Sum main-session metrics plus direct BitFun task subagents."""
+ prompt = 0
+ completion = 0
+ cached = 0
+ has_cached = False
+ cost = 0.0
+ has_cost = False
+ all_metrics_priced = True
+
+ for current in [trajectory, *(trajectory.subagent_trajectories or [])]:
+ fm = current.final_metrics
+ if fm is not None:
+ prompt += fm.total_prompt_tokens or 0
+ completion += fm.total_completion_tokens or 0
+ if fm.total_cached_tokens is not None:
+ has_cached = True
+ cached += fm.total_cached_tokens
+ if fm.total_cost_usd is None:
+ all_metrics_priced = False
+ else:
+ has_cost = True
+ cost += fm.total_cost_usd
+
+ return (
+ prompt,
+ completion,
+ cached if has_cached else None,
+ cost if has_cost and all_metrics_priced else None,
+ )
+
+ def _embed_subagents(
+ self,
+ *,
+ steps: list[Step],
+ session_dir: Path,
+ token_records: list[dict[str, Any]],
+ into: list[Trajectory],
+ missing: set[str],
+ ) -> int:
+ """Walk tool steps, deduplicate by subagent session id, and embed each.
+
+ For every distinct `subagentSessionId` referenced from this trajectory:
+ 1. Locate `//`. If missing, record it in `missing`
+ and strip any tentative `subagent_trajectory_ref` from the parent
+ observation pointing at this sid.
+ 2. Build a direct subagent Trajectory and set `trajectory_id`.
+ Override `agent.name` with the dispatch tool name and
+ `agent.model_name` with `toolItem.subagentModelId` when present.
+ 3. Append to `into`.
+ Returns the number of trajectories embedded.
+ """
+ sessions_root = session_dir.parent
+ self._attach_subagent_refs_from_metadata(steps=steps, session_dir=session_dir)
+
+ refs_by_sid: dict[
+ str,
+ list[tuple[Step, ObservationResult, SubagentTrajectoryRef]],
+ ] = {}
+ for step in steps:
+ if step.observation is None:
+ continue
+ for result in step.observation.results:
+ for ref in result.subagent_trajectory_ref or []:
+ if not ref.trajectory_id:
+ continue
+ refs_by_sid.setdefault(ref.trajectory_id, []).append(
+ (step, result, ref)
+ )
+
+ if not refs_by_sid:
+ return 0
+
+ embedded = 0
+ for sub_sid, refs in refs_by_sid.items():
+ sub_dir = sessions_root / sub_sid
+ if not (sub_dir / "metadata.json").is_file():
+ missing.add(sub_sid)
+ for _step, result, ref in refs:
+ if result.subagent_trajectory_ref:
+ remaining = [
+ r for r in result.subagent_trajectory_ref if r is not ref
+ ]
+ result.subagent_trajectory_ref = remaining or None
+ continue
+
+ try:
+ sub_traj = self._convert_events_to_trajectory(
+ sub_dir, is_subagent=True, token_records=token_records
+ )
+ except Exception:
+ self.logger.exception("Failed to embed BitFun subagent %s", sub_sid)
+ sub_traj = None
+
+ if sub_traj is None:
+ missing.add(sub_sid)
+ for _step, result, ref in refs:
+ if result.subagent_trajectory_ref:
+ remaining = [
+ r for r in result.subagent_trajectory_ref if r is not ref
+ ]
+ result.subagent_trajectory_ref = remaining or None
+ continue
+
+ sub_traj.trajectory_id = sub_sid
+ tool_name = None
+ model_override = None
+ for _step, _result, ref in refs:
+ rex = ref.extra or {}
+ tool_name = tool_name or rex.get("tool_name")
+ model_override = model_override or rex.get("subagent_model_id")
+ if tool_name:
+ sub_traj.agent.name = tool_name
+ if model_override:
+ sub_traj.agent.model_name = model_override
+ agent_extra = dict(sub_traj.agent.extra or {})
+ first_extra = refs[0][2].extra or {}
+ if first_extra.get("tool_call_id"):
+ agent_extra["parent_task_tool_id"] = first_extra["tool_call_id"]
+ sub_traj.agent.extra = agent_extra or None
+
+ into.append(sub_traj)
+ embedded += 1
+
+ return embedded
+
+ @staticmethod
+ def _load_session_metadata(session_dir: Path) -> dict[str, Any] | None:
+ meta_path = session_dir / "metadata.json"
+ if not meta_path.is_file():
+ return None
+ try:
+ metadata = json.loads(meta_path.read_text())
+ except (OSError, json.JSONDecodeError):
+ return None
+ return metadata if isinstance(metadata, dict) else None
+
+ def _attach_subagent_refs_from_metadata(
+ self, *, steps: list[Step], session_dir: Path
+ ) -> int:
+ """Backfill subagent refs from child metadata relationship fields.
+
+ Newer BitFun session exports can store the parent-child link only on the
+ child session's metadata.relationship block instead of duplicating the
+ child id on the parent tool item as subagentSessionId.
+ """
+ parent_metadata = self._load_session_metadata(session_dir) or {}
+ parent_session_id = parent_metadata.get("sessionId") or session_dir.name
+ sessions_root = session_dir.parent
+ if not sessions_root.is_dir():
+ return 0
+
+ targets_by_call_id: dict[str, list[tuple[Step, ObservationResult]]] = {}
+ fallback_steps_by_call_id: dict[str, list[Step]] = {}
+ existing_ref_ids: set[str] = set()
+
+ for step in steps:
+ for tool_call in step.tool_calls or []:
+ fallback_steps_by_call_id.setdefault(tool_call.tool_call_id, []).append(
+ step
+ )
+ if step.observation is None:
+ continue
+ for result in step.observation.results:
+ for ref in result.subagent_trajectory_ref or []:
+ if ref.trajectory_id:
+ existing_ref_ids.add(ref.trajectory_id)
+ if result.source_call_id:
+ targets_by_call_id.setdefault(result.source_call_id, []).append(
+ (step, result)
+ )
+
+ attached = 0
+ for sub_dir in sessions_root.iterdir():
+ if not sub_dir.is_dir() or sub_dir == session_dir:
+ continue
+ metadata = self._load_session_metadata(sub_dir)
+ if not metadata or metadata.get("sessionKind") != "subagent":
+ continue
+
+ relationship = metadata.get("relationship")
+ if not isinstance(relationship, dict):
+ continue
+ if relationship.get("kind") not in (None, "subagent"):
+ continue
+
+ rel_parent_sid = relationship.get("parentSessionId")
+ if rel_parent_sid != parent_session_id:
+ continue
+
+ parent_tool_call_id = relationship.get("parentToolCallId")
+ if not parent_tool_call_id:
+ continue
+
+ sub_sid = metadata.get("sessionId") or sub_dir.name
+ if sub_sid in existing_ref_ids:
+ continue
+
+ targets = list(targets_by_call_id.get(parent_tool_call_id) or [])
+ if not targets:
+ for step in fallback_steps_by_call_id.get(parent_tool_call_id) or []:
+ if step.observation and step.observation.results:
+ targets.append((step, step.observation.results[0]))
+ if not targets:
+ continue
+
+ subagent_type = relationship.get("subagentType") or metadata.get(
+ "agentType"
+ )
+ ref = SubagentTrajectoryRef(
+ trajectory_id=sub_sid,
+ session_id=sub_sid,
+ extra={
+ "tool_call_id": parent_tool_call_id,
+ "tool_name": subagent_type or "Task",
+ "subagent_model_id": metadata.get("modelName"),
+ "relationship_source": "metadata",
+ },
+ )
+
+ for step, result in targets:
+ refs = list(result.subagent_trajectory_ref or [])
+ if not any(r.trajectory_id == sub_sid for r in refs):
+ refs.append(ref)
+ result.subagent_trajectory_ref = refs
+ step_extra = dict(step.extra or {})
+ step_extra["is_subagent_dispatch"] = True
+ step_extra["subagent_relationship_source"] = "metadata"
+ step.extra = step_extra
+ attached += 1
+
+ existing_ref_ids.add(sub_sid)
+
+ return attached
+
+ def _convert_events_to_trajectory(
+ self,
+ session_dir: Path,
+ *,
+ is_subagent: bool = False,
+ token_records: list[dict[str, Any]] | None = None,
+ ) -> Trajectory | None:
+ """Convert one BitFun session into an ATIF Trajectory.
+
+ When `is_subagent=True`, the resulting trajectory is meant to be embedded
+ in a parent's `subagent_trajectories[]`; the caller is responsible for
+ setting `trajectory_id` after this method returns.
+ """
+ meta_path = session_dir / "metadata.json"
+ if not meta_path.is_file():
+ self.logger.debug(f"No metadata.json in {session_dir}")
+ return None
+ try:
+ metadata = json.loads(meta_path.read_text())
+ except (OSError, json.JSONDecodeError) as exc:
+ self.logger.debug(f"Failed to parse {meta_path}: {exc}")
+ return None
+
+ session_id: str = metadata.get("sessionId") or session_dir.name
+ default_model_name = metadata.get("modelName") or self.model_name
+
+ turns = self._load_turns_preferring_snapshot(session_dir)
+
+ steps: list[Step] = []
+ next_step_id = 1
+ for turn in turns:
+ kind = turn.get("kind", "user_dialog")
+ if kind == "local_command":
+ continue
+ if kind == "manual_compaction":
+ steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(turn.get("timestamp")),
+ source="system",
+ message="",
+ is_copied_context=True,
+ extra={
+ "turn_id": turn.get("turnId"),
+ "turn_index": turn.get("turnIndex"),
+ "turn_kind": "manual_compaction",
+ },
+ )
+ )
+ next_step_id += 1
+ continue
+
+ user_msg = turn.get("userMessage") or {}
+ user_text = self._user_text_from_message(user_msg)
+ steps.append(
+ Step(
+ step_id=next_step_id,
+ timestamp=self._ts_iso(
+ user_msg.get("timestamp") or turn.get("timestamp")
+ ),
+ source="user",
+ message=user_text,
+ extra={
+ "turn_id": turn.get("turnId"),
+ "turn_index": turn.get("turnIndex"),
+ "turn_kind": kind,
+ "user_message_id": user_msg.get("id"),
+ },
+ )
+ )
+ next_step_id += 1
+
+ for rnd in turn.get("modelRounds") or []:
+ new_steps, next_step_id = self._round_to_steps(
+ rnd,
+ turn,
+ next_step_id,
+ default_model_name=default_model_name,
+ )
+ steps.extend(new_steps)
+
+ if not steps:
+ self.logger.debug(f"No steps produced from BitFun session {session_id}")
+ return None
+
+ if token_records is None:
+ token_records = self._load_token_records()
+
+ records_for_traj = [
+ rec
+ for rec in token_records
+ if rec.get("session_id") == session_id
+ and bool(rec.get("is_subagent")) == is_subagent
+ ]
+ self._allocate_records_to_steps(steps, turns, records_for_traj)
+
+ subagent_trajectories: list[Trajectory] = []
+ missing_subagents: set[str] = set()
+ if not is_subagent:
+ embed_count = self._embed_subagents(
+ steps=steps,
+ session_dir=session_dir,
+ token_records=token_records,
+ into=subagent_trajectories,
+ missing=missing_subagents,
+ )
+ else:
+ embed_count = 0
+
+ notes: str | None = None
+ if missing_subagents:
+ notes = (
+ "Subagent session(s) referenced but missing from cp-back: "
+ + ", ".join(sorted(missing_subagents))
+ )
+
+ agent_fields: dict[str, Any] = {
+ "agent_type": metadata.get("agentType"),
+ "session_kind": metadata.get("sessionKind"),
+ "workspace_path": metadata.get("workspacePath"),
+ "schema_version": metadata.get("schema_version"),
+ }
+ agent_extra: dict[str, Any] | None = {
+ k: v for k, v in agent_fields.items() if v is not None
+ } or None
+
+ final_metrics = self._build_final_metrics(
+ steps=steps,
+ metadata=metadata,
+ records_for_traj=records_for_traj,
+ subagent_trajectories=subagent_trajectories,
+ subagent_count=embed_count,
+ turns=turns,
+ )
+ self._apply_stdout_token_stats_fallback(
+ final_metrics,
+ is_subagent=is_subagent,
+ steps=steps,
+ )
+
+ trajectory = Trajectory(
+ schema_version=_ATIF_SCHEMA_VERSION,
+ session_id=session_id,
+ agent=Agent(
+ name=AgentName.BITFUN_CLI.value,
+ version=self.version() or "unknown",
+ model_name=default_model_name,
+ extra=agent_extra,
+ ),
+ steps=steps,
+ final_metrics=final_metrics,
+ subagent_trajectories=subagent_trajectories or None,
+ notes=notes,
+ )
+ return trajectory
+
+ def populate_context_post_run(self, context: AgentContext) -> None:
+ session_dir = self._get_session_dir()
+ if not session_dir:
+ self.logger.debug("No BitFun session directory found")
+ return
+ try:
+ trajectory = self._convert_events_to_trajectory(session_dir)
+ except Exception:
+ self.logger.exception("Failed to convert BitFun events to trajectory")
+ return
+ if not trajectory:
+ return
+
+ trajectory_path = self.logs_dir / "trajectory.json"
+ try:
+ trajectory_path.write_text(
+ format_trajectory_json(trajectory.to_json_dict())
+ )
+ self.logger.debug(f"Wrote BitFun trajectory to {trajectory_path}")
+ except OSError as exc:
+ self.logger.debug(
+ f"Failed to write trajectory file {trajectory_path}: {exc}"
+ )
+
+ if trajectory.final_metrics:
+ fm = trajectory.final_metrics
+ prompt, completion, cached, cost = self._sum_trajectory_token_counts(
+ trajectory
+ )
+ context.cost_usd = cost
+ context.n_input_tokens = prompt
+ context.n_cache_tokens = cached
+ context.n_output_tokens = completion
+ bitfun_metadata: dict[str, Any] = {
+ "trajectory_path": "agent/trajectory.json",
+ "session_id": trajectory.session_id,
+ "agent_version": trajectory.agent.version,
+ "model_name": trajectory.agent.model_name,
+ "total_steps": fm.total_steps,
+ }
+ artifact_paths = {
+ "bitfun_data_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR,
+ "agent/bitfun",
+ ),
+ "cli_log_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "cli.log",
+ "agent/bitfun/cli.log",
+ ),
+ "ai_request_audit_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "ai-request-audit.jsonl",
+ "agent/bitfun/ai-request-audit.jsonl",
+ ),
+ "cli_logs_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "cli-logs",
+ "agent/bitfun/cli-logs",
+ ),
+ "request_traces_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "request-traces",
+ "agent/bitfun/request-traces",
+ ),
+ "cp_back_manifest_path": (
+ self.logs_dir / _BITFUN_DATA_SUBDIR / "cp-back-manifest.json",
+ "agent/bitfun/cp-back-manifest.json",
+ ),
+ "final_app_config_path": (
+ self.logs_dir
+ / _BITFUN_DATA_SUBDIR
+ / "config"
+ / "app.redacted.json",
+ _APP_CONFIG_REDACTED_ARTIFACT_PATH,
+ ),
+ }
+ for key, (path, artifact_path) in artifact_paths.items():
+ if path.exists():
+ bitfun_metadata[key] = artifact_path
+ if fm.extra:
+ for key in (
+ "token_usage_source",
+ "stdout_token_stats_count",
+ "stdout_total_tokens",
+ "cached_tokens_available",
+ "cached_tokens_coverage",
+ ):
+ if key in fm.extra:
+ bitfun_metadata[key] = fm.extra[key]
+ metadata = dict(context.metadata or {})
+ metadata["bitfun"] = {
+ k: v for k, v in bitfun_metadata.items() if v is not None
+ }
+ context.metadata = metadata
+
+ async def _exec(
+ self,
+ environment: BaseEnvironment,
+ command: str,
+ user: str | int | None = None,
+ env: dict[str, str] | None = None,
+ cwd: str | None = None,
+ timeout_sec: int | None = None,
+ ) -> Any:
+ merged_env = env
+ if self._extra_env:
+ merged_env = dict(env) if env else {}
+ merged_env.update(self._extra_env)
+
+ self.logger.debug(
+ f"Running command: {command}",
+ extra={"user": str(user), "env": merged_env or {}},
+ )
+
+ exec_command = (
+ command
+ if self._task_os(environment) == TaskOS.WINDOWS
+ else f"set -o pipefail; {command}"
+ )
+
+ result = await environment.exec(
+ command=exec_command,
+ user=user,
+ env=merged_env,
+ cwd=cwd,
+ timeout_sec=timeout_sec,
+ )
+ if result.return_code != 0:
+ try:
+ await environment.prepare_logs_for_host()
+ except Exception as exc:
+ self.logger.warning(
+ f"Failed to prepare BitFun logs before persisting failure output: {exc}"
+ )
+ try:
+ self._persist_failure_output(result.stdout, result.stderr)
+ except OSError as exc:
+ self.logger.warning(f"Failed to persist BitFun failure output: {exc}")
+ self.logger.debug(
+ "Command failed",
+ extra={
+ "return_code": result.return_code,
+ "stdout": self._truncate_output(result.stdout),
+ "stderr": self._truncate_output(result.stderr),
+ },
+ )
+ raise NonZeroAgentExitCodeError(
+ f"Command failed (exit {result.return_code}): {command}\n"
+ f"stdout: {self._truncate_output(result.stdout)}\n"
+ f"stderr: {self._truncate_output(result.stderr)}"
+ )
+
+ self.logger.debug(
+ "Command outputs captured",
+ extra={
+ "stdout": self._truncate_output(result.stdout),
+ "stderr": self._truncate_output(result.stderr),
+ },
+ )
+ return result
+
+ def _build_run_shell(
+ self, instruction: str, environment: BaseEnvironment | None = None
+ ) -> str:
+ _ = instruction
+ if environment is not None and self._task_os(environment) == TaskOS.WINDOWS:
+ task_os = self._task_os(environment)
+ return quote_shell_arg(self._run_script_path(environment), task_os)
+
+ bp = shlex.quote(self._binary_path)
+ msg = shlex.quote(instruction)
+ agent_flag = shlex.quote(self._exec_agent)
+ patch_part = ""
+ patch_setup = ""
+ if self._output_patch_path:
+ patch_q = shlex.quote(self._output_patch_path)
+ patch_part = f" --output-patch {patch_q}"
+ patch_setup = (
+ f"PATCH_PATH={patch_q}\n"
+ 'mkdir -p "$(dirname "$PATCH_PATH")" 2>/dev/null || true\n'
+ )
+ return (
+ "set -o pipefail\n"
+ "mkdir -p /logs/agent\n"
+ "if command -v stdbuf >/dev/null 2>&1; then\n"
+ f" bitfun_tee() {{ stdbuf -oL tee {_AGENT_LOG}; }}\n"
+ "else\n"
+ f" bitfun_tee() {{ tee {_AGENT_LOG}; }}\n"
+ "fi\n"
+ f"{patch_setup}"
+ f"{bp} exec --agent {agent_flag}{patch_part} -- {msg} "
+ "2>&1 | bitfun_tee\n"
+ "rc=${PIPESTATUS[0]}\n"
+ "exit $rc"
+ )
+
+ def _build_register_config_command(
+ self, environment: BaseEnvironment | None = None
+ ) -> str | None:
+ if self._bitfun_config is None:
+ return None
+ if environment is not None and self._task_os(environment) == TaskOS.WINDOWS:
+ return None
+
+ config_json = json.dumps(self._bitfun_config, indent=2)
+ escaped = shlex.quote(config_json)
+ return (
+ _bitfun_config_root_shell()
+ + 'mkdir -p "$BITFUN_CONFIG_ROOT/config"\n'
+ + f"printf '%s\\n' {escaped} > \"$BITFUN_CONFIG_ROOT/config/app.json\""
+ )
+
+ def _build_app_config_probe_command(
+ self, environment: BaseEnvironment | None = None
+ ) -> str:
+ if environment is not None and self._task_os(environment) == TaskOS.WINDOWS:
+ configured_path = (
+ self._windows_user_root_for(environment) + "\\config\\app.json"
+ )
+ return (
+ f"echo source={configured_path}& "
+ f'if exist "{configured_path}" '
+ f'(echo exists=true& for %I in ("{configured_path}") do echo size_bytes=%~zI) '
+ "else (echo exists=false & echo size_bytes=0)"
+ )
+
+ return (
+ _bitfun_config_root_shell()
+ + 'APP_CONFIG_SRC="$BITFUN_CONFIG_ROOT/config/app.json"\n'
+ + 'printf "source=%s\\n" "$APP_CONFIG_SRC"\n'
+ + 'if [ -f "$APP_CONFIG_SRC" ]; then\n'
+ + ' printf "exists=true\\n"\n'
+ + ' printf "size_bytes=%s\\n" "$(wc -c < "$APP_CONFIG_SRC" 2>/dev/null || printf 0)"\n'
+ + "else\n"
+ + ' printf "exists=false\\n"\n'
+ + ' printf "size_bytes=0\\n"\n'
+ + "fi\n"
+ )
+
+ @staticmethod
+ def _is_sensitive_config_key(key: str) -> bool:
+ normalized = key.lower().replace("-", "_").replace(" ", "_")
+ return normalized in _SENSITIVE_CONFIG_KEYS or normalized.endswith(
+ _SENSITIVE_CONFIG_SUFFIXES
+ )
+
+ @classmethod
+ def _redact_config_secrets(cls, value: Any) -> Any:
+ if isinstance(value, dict):
+ return {
+ key: _REDACTED_CONFIG_VALUE
+ if isinstance(key, str) and cls._is_sensitive_config_key(key)
+ else cls._redact_config_secrets(child)
+ for key, child in value.items()
+ }
+ if isinstance(value, list):
+ return [cls._redact_config_secrets(item) for item in value]
+ return value
+
+ @staticmethod
+ def _parse_app_config_probe_output(stdout: str | None) -> dict[str, str]:
+ parsed: dict[str, str] = {}
+ for line in (stdout or "").splitlines():
+ key, sep, value = line.partition("=")
+ if sep:
+ parsed[key.strip()] = value
+ return parsed
+
+ @staticmethod
+ def _probe_size_bytes(probe: dict[str, str]) -> int:
+ try:
+ return int(probe.get("size_bytes") or 0)
+ except ValueError:
+ return 0
+
+ def _new_app_config_capture_temp_path(self, suffix: str) -> Path:
+ self.logs_dir.parent.mkdir(parents=True, exist_ok=True)
+ fd, path = tempfile.mkstemp(
+ prefix=".bitfun-app-config-",
+ suffix=suffix,
+ dir=self.logs_dir.parent,
+ )
+ os.close(fd)
+ return Path(path)
+
+ def _windows_config_path(self, environment: BaseEnvironment) -> str:
+ return self._windows_user_root_for(environment) + "\\config\\app.json"
+
+ async def _upload_windows_prompt(
+ self, instruction: str, environment: BaseEnvironment
+ ) -> None:
+ prompt_path = self._new_app_config_capture_temp_path(".prompt.txt")
+ try:
+ prompt_path.write_text(instruction, encoding="utf-8")
+ await environment.upload_file(prompt_path, self._prompt_path(environment))
+ finally:
+ prompt_path.unlink(missing_ok=True)
+
+ def _windows_run_script(self, environment: BaseEnvironment) -> str:
+ task_os = self._task_os(environment)
+ binary_path = quote_shell_arg(self._binary_path_for(environment), task_os)
+ agent_flag = quote_shell_arg(self._exec_agent, task_os)
+ prompt_path = quote_shell_arg(self._prompt_path(environment), task_os)
+ agent_log_path = quote_shell_arg(self._agent_log_path(environment), task_os)
+ patch_path = self._output_patch_path_for(environment)
+ patch_part = ""
+ if patch_path:
+ patch_part = f" --output-patch {quote_shell_arg(patch_path, task_os)}"
+
+ return (
+ "@echo off\r\n"
+ "setlocal EnableExtensions\r\n"
+ f"echo Harbor BitFun command started> {agent_log_path}\r\n"
+ f"echo BITFUN_USER_ROOT=%BITFUN_USER_ROOT%>> {agent_log_path}\r\n"
+ f"echo BITFUN_HOME=%BITFUN_HOME%>> {agent_log_path}\r\n"
+ f"type {prompt_path} | "
+ f"{binary_path} exec --agent {agent_flag}{patch_part} --no-title "
+ f">> {agent_log_path} 2>&1\r\n"
+ 'set "BITFUN_RC=%ERRORLEVEL%"\r\n'
+ f"echo BITFUN_RC=%BITFUN_RC%>> {agent_log_path}\r\n"
+ "exit /b %BITFUN_RC%\r\n"
+ )
+
+ async def _upload_windows_run_script(self, environment: BaseEnvironment) -> None:
+ script_path = self._new_app_config_capture_temp_path(".bitfun-run.bat")
+ try:
+ script_path.write_text(
+ self._windows_run_script(environment),
+ encoding="utf-8",
+ newline="",
+ )
+ await environment.upload_file(
+ script_path, self._run_script_path(environment)
+ )
+ finally:
+ script_path.unlink(missing_ok=True)
+
+ async def _register_windows_config(self, environment: BaseEnvironment) -> None:
+ if self._bitfun_config is None:
+ return
+
+ config_path = self._new_app_config_capture_temp_path(".app.json")
+ try:
+ config_path.write_text(
+ json.dumps(self._bitfun_config, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ await environment.upload_file(
+ config_path,
+ self._windows_config_path(environment),
+ )
+ finally:
+ config_path.unlink(missing_ok=True)
+
+ async def _upload_app_config_capture_manifest(
+ self,
+ environment: BaseEnvironment,
+ app_config: dict[str, Any],
+ temp_paths: list[Path],
+ ) -> None:
+ current_manifest = self._new_app_config_capture_temp_path(".manifest.json")
+ updated_manifest = self._new_app_config_capture_temp_path(
+ ".manifest.updated.json"
+ )
+ temp_paths.extend([current_manifest, updated_manifest])
+
+ manifest: dict[str, Any] = {}
+ try:
+ await environment.download_file(
+ self._remote_cp_back_manifest_path(environment),
+ current_manifest,
+ )
+ loaded = json.loads(current_manifest.read_text())
+ if isinstance(loaded, dict):
+ manifest = loaded
+ except Exception as exc:
+ self.logger.debug(
+ "BitFun final app config: could not load existing manifest: %s",
+ exc,
+ )
+
+ manifest["app_config"] = app_config
+ updated_manifest.write_text(json.dumps(manifest, indent=2) + "\n")
+ await environment.upload_file(
+ updated_manifest, self._remote_cp_back_manifest_path(environment)
+ )
+
+ async def _capture_final_app_config(self, environment: BaseEnvironment) -> None:
+ app_config: dict[str, Any] = {
+ "source": None,
+ "exists": False,
+ "size_bytes": 0,
+ "target": None,
+ "redacted": False,
+ "raw_saved": False,
+ "capture_error": None,
+ }
+ temp_paths: list[Path] = []
+
+ try:
+ probe_prefix = (
+ ""
+ if self._task_os(environment) == TaskOS.WINDOWS
+ else "set -o pipefail; "
+ )
+ probe_result = await environment.exec(
+ command=f"{probe_prefix}{self._build_app_config_probe_command(environment)}",
+ env=self._env_for_run(environment),
+ )
+ if probe_result.return_code != 0:
+ raise RuntimeError(f"probe failed with exit {probe_result.return_code}")
+
+ probe = self._parse_app_config_probe_output(probe_result.stdout)
+ source = probe.get("source") or None
+ exists = probe.get("exists") == "true"
+ app_config.update(
+ {
+ "source": source,
+ "exists": exists,
+ "size_bytes": self._probe_size_bytes(probe),
+ }
+ )
+
+ if exists:
+ if source is None:
+ raise RuntimeError("app config probe did not return source")
+
+ raw_path = self._new_app_config_capture_temp_path(".raw.json")
+ redacted_path = self._new_app_config_capture_temp_path(".redacted.json")
+ temp_paths.extend([raw_path, redacted_path])
+
+ await environment.download_file(source, raw_path)
+ try:
+ raw_config = json.loads(raw_path.read_text())
+ except json.JSONDecodeError:
+ app_config["capture_error"] = "invalid JSON"
+ else:
+ redacted_config = self._redact_config_secrets(raw_config)
+ redacted_path.write_text(
+ json.dumps(redacted_config, indent=2) + "\n"
+ )
+ remote_config_dir = self._remote_bitfun_config_dir(environment)
+ if self._task_os(environment) == TaskOS.WINDOWS:
+ mkdir_result = await environment.ensure_dirs(
+ [remote_config_dir], chmod=False
+ )
+ if mkdir_result is not None and mkdir_result.return_code != 0:
+ raise RuntimeError(
+ f"mkdir failed with exit {mkdir_result.return_code}"
+ )
+ else:
+ mkdir_result = await environment.exec(
+ command=f"mkdir -p {shlex.quote(remote_config_dir)}",
+ env=self._env_for_run(environment),
+ )
+ if mkdir_result.return_code != 0:
+ raise RuntimeError(
+ f"mkdir failed with exit {mkdir_result.return_code}"
+ )
+ await environment.upload_file(
+ redacted_path,
+ self._remote_app_config_redacted_path(environment),
+ )
+ app_config.update(
+ {
+ "target": _APP_CONFIG_REDACTED_ARTIFACT_PATH,
+ "redacted": True,
+ }
+ )
+ except Exception as exc:
+ if app_config["capture_error"] is None:
+ app_config["capture_error"] = str(exc)
+ self.logger.debug("BitFun final app config capture failed: %s", exc)
+ finally:
+ try:
+ await self._upload_app_config_capture_manifest(
+ environment,
+ app_config,
+ temp_paths,
+ )
+ except Exception as exc:
+ self.logger.debug(
+ "BitFun final app config manifest update failed: %s",
+ exc,
+ )
+ for path in temp_paths:
+ try:
+ path.unlink(missing_ok=True)
+ except OSError as exc:
+ self.logger.debug(
+ "BitFun final app config temp cleanup failed for %s: %s",
+ path,
+ exc,
+ )
+
+ def _persist_failure_output(self, stdout: str | None, stderr: str | None) -> None:
+ parts: list[str] = []
+ if stdout:
+ parts.append(stdout)
+ if stderr:
+ if parts:
+ parts.append("\n--- stderr ---\n")
+ parts.append(stderr)
+ if not parts:
+ return
+ body = _format_failure_log_text("".join(parts))
+ path = self.logs_dir / "bitfun.txt"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(body, errors="replace")
+
+ def _log_cp_back_gaps(self) -> None:
+ cli_log = self.logs_dir / _BITFUN_DATA_SUBDIR / "cli.log"
+ if not cli_log.is_file():
+ self.logger.debug("BitFun cp-back: missing cli.log at %s", cli_log)
+ elif cli_log.stat().st_size == 0:
+ self.logger.debug("BitFun cp-back: empty cli.log at %s", cli_log)
+ audit_log = self.logs_dir / _BITFUN_DATA_SUBDIR / "ai-request-audit.jsonl"
+ if not audit_log.is_file():
+ self.logger.debug(
+ "BitFun cp-back: missing ai-request-audit.jsonl at %s",
+ audit_log,
+ )
+ request_traces_root = self.logs_dir / _BITFUN_DATA_SUBDIR / "request-traces"
+ if not request_traces_root.is_dir():
+ self.logger.debug(
+ "BitFun cp-back: missing request-traces directory at %s",
+ request_traces_root,
+ )
+ sessions_root = self.logs_dir / _BITFUN_DATA_SUBDIR / "sessions"
+ if not sessions_root.is_dir():
+ self.logger.debug(
+ "BitFun cp-back: missing sessions directory at %s",
+ sessions_root,
+ )
+ return
+ session_dirs = [p for p in sessions_root.iterdir() if p.is_dir()]
+ if not session_dirs:
+ self.logger.debug(
+ "BitFun cp-back: no session subdirectories under %s",
+ sessions_root,
+ )
+
+ def _cp_back_command(self, environment: BaseEnvironment | None = None) -> str:
+ if environment is not None and self._task_os(environment) == TaskOS.WINDOWS:
+ task_os = self._task_os(environment)
+ env_paths = self._env_paths(environment)
+ bitfun_dir = quote_shell_arg(str(env_paths.agent_dir / "bitfun"), task_os)
+ bitfun_dir_probe = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/"), task_os
+ )
+ sessions_dir = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/sessions"), task_os
+ )
+ sessions_dir_probe = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/sessions/"), task_os
+ )
+ request_traces_dir = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/request-traces"), task_os
+ )
+ request_traces_dir_probe = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/request-traces/"), task_os
+ )
+ token_usage_dir = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/token_usage"), task_os
+ )
+ cli_logs_dir = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/cli-logs"), task_os
+ )
+ cli_log_path = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/cli.log"), task_os
+ )
+ audit_log_path = quote_shell_arg(
+ str(env_paths.agent_dir / "bitfun/ai-request-audit.jsonl"),
+ task_os,
+ )
+ manifest_path = quote_shell_arg(
+ self._remote_cp_back_manifest_path(environment), task_os
+ )
+ user_root = self._windows_user_root_for(environment)
+ home_root = self._windows_home_for(environment)
+ commands = [
+ f"if not exist {bitfun_dir_probe} mkdir {bitfun_dir}",
+ f"if not exist {sessions_dir_probe} mkdir {sessions_dir}",
+ (
+ f'for /d %P in ("{home_root}\\projects\\*") do '
+ f'if exist "%P\\sessions" xcopy /E /I /Y "%P\\sessions" {sessions_dir} >nul 2>nul'
+ ),
+ (
+ f'if exist "{user_root}\\data\\token_usage" '
+ f'xcopy /E /I /Y "{user_root}\\data\\token_usage" {token_usage_dir} >nul 2>nul'
+ ),
+ (
+ f'if exist "{user_root}\\cli-logs" '
+ f'xcopy /E /I /Y "{user_root}\\cli-logs" {cli_logs_dir} >nul 2>nul'
+ ),
+ (
+ f'if exist "{user_root}\\logs\\bitfun-cli.log" '
+ f'copy /Y "{user_root}\\logs\\bitfun-cli.log" {cli_log_path} >nul 2>nul'
+ ),
+ (
+ f'if exist "{user_root}\\logs\\ai-request-audit.jsonl" '
+ f'copy /Y "{user_root}\\logs\\ai-request-audit.jsonl" {audit_log_path} >nul 2>nul'
+ ),
+ f"if not exist {request_traces_dir_probe} mkdir {request_traces_dir}",
+ (
+ f'for /d %P in ("{home_root}\\projects\\*") do '
+ f'if exist "%P\\request-traces" xcopy /E /I /Y "%P\\request-traces" {request_traces_dir} >nul 2>nul'
+ ),
+ f'echo {{"windows_cp_back":true}} > {manifest_path}',
+ ]
+ patch_path = self._output_patch_path_for(environment)
+ if patch_path:
+ patch_q = quote_shell_arg(patch_path, task_os)
+ meta_q = quote_shell_arg(f"{patch_path}.meta.json", task_os)
+ commands.append(
+ f"if exist {patch_q} "
+ f'(echo {{"present":true,"created_empty_placeholder":false}} > {meta_q}) '
+ f"else (type nul > {patch_q} & "
+ f'echo {{"present":false,"created_empty_placeholder":true}} > {meta_q})'
+ )
+ commands.append("exit /b 0")
+ return " & ".join(commands)
+
+ command = _CP_BACK_COMMAND
+ if self._output_patch_path:
+ patch_path = shlex.quote(self._output_patch_path)
+ meta_path = shlex.quote(f"{self._output_patch_path}.meta.json")
+ command += f"""\
+PATCH_PATH={patch_path}
+PATCH_META_PATH={meta_path}
+mkdir -p "$(dirname "$PATCH_PATH")" 2>/dev/null || true
+if [ -f "$PATCH_PATH" ]; then
+ printf '%s\\n' '{{"present":true,"created_empty_placeholder":false}}' > "$PATCH_META_PATH" 2>/dev/null || true
+else
+ : > "$PATCH_PATH" 2>/dev/null || true
+ printf '%s\\n' '{{"present":false,"created_empty_placeholder":true}}' > "$PATCH_META_PATH" 2>/dev/null || true
+fi
+"""
+ return command + "exit 0\n"
+
+ def _env_for_run(
+ self, environment: BaseEnvironment | None = None
+ ) -> dict[str, str]:
+ env: dict[str, str] = {}
+ for key in _ENV_PASSTHROUGH:
+ val = os.environ.get(key)
+ if val:
+ env[key] = val
+ for key, val in os.environ.items():
+ if key.startswith("BITFUN_") and val:
+ env[key] = val
+ if environment is not None and self._task_os(environment) == TaskOS.WINDOWS:
+ env.setdefault(
+ "BITFUN_USER_ROOT",
+ _WINDOWS_BITFUN_USER_ROOT.replace("/", "\\"),
+ )
+ env.setdefault("BITFUN_HOME", _WINDOWS_BITFUN_HOME.replace("/", "\\"))
+ env.update(self._extra_env)
+ return env
+
+ async def _capture_repo_baseline(self, environment: BaseEnvironment) -> None:
+ if self._task_os(environment) == TaskOS.WINDOWS:
+ return
+ await self.exec_as_root(
+ environment,
+ command=f"mkdir -p {shlex.quote(self._patch_logs_dir_in_env(environment))}",
+ )
+ await self.exec_as_agent(
+ environment,
+ command=build_repo_baseline_capture_script(
+ self._patch_logs_dir_in_env(environment)
+ ),
+ )
+
+ async def _capture_repo_final_state(self, environment: BaseEnvironment) -> None:
+ if self._task_os(environment) == TaskOS.WINDOWS:
+ return
+ await self.exec_as_agent(
+ environment,
+ command=build_repo_final_capture_script(
+ self._patch_logs_dir_in_env(environment)
+ ),
+ )
+
+ @with_prompt_template
+ async def run(
+ self,
+ instruction: str,
+ environment: BaseEnvironment,
+ context: AgentContext,
+ ) -> None:
+ _ = context
+ baseline_captured = False
+ try:
+ task_os = self._task_os(environment)
+ if task_os == TaskOS.WINDOWS:
+ await self._register_windows_config(environment)
+ await self._upload_windows_prompt(instruction, environment)
+ await self._upload_windows_run_script(environment)
+ else:
+ config_command = self._build_register_config_command(environment)
+ if config_command:
+ await self.exec_as_agent(
+ environment,
+ command=config_command,
+ env=self._env_for_run(environment),
+ )
+ if task_os != TaskOS.WINDOWS:
+ await self._capture_repo_baseline(environment)
+ baseline_captured = True
+ await self.exec_as_agent(
+ environment,
+ command=self._build_run_shell(instruction, environment),
+ env=self._env_for_run(environment),
+ )
+ finally:
+ if baseline_captured:
+ try:
+ await self._capture_repo_final_state(environment)
+ except Exception as exc:
+ self.logger.debug(
+ f"Failed to capture BitFun final repo state: {exc}"
+ )
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=self._cp_back_command(environment),
+ env=self._env_for_run(environment),
+ )
+ self._log_cp_back_gaps()
+ except Exception as exc:
+ self.logger.debug(f"BitFun cp-back failed (non-fatal): {exc}")
+ try:
+ await self._capture_final_app_config(environment)
+ except Exception as exc:
+ self.logger.debug(
+ f"BitFun final app config capture failed (non-fatal): {exc}"
+ )
diff --git a/src/harbor/agents/installed/codeagent/__init__.py b/src/harbor/agents/installed/codeagent/__init__.py
new file mode 100644
index 00000000000..8113f3523dc
--- /dev/null
+++ b/src/harbor/agents/installed/codeagent/__init__.py
@@ -0,0 +1,3 @@
+from harbor.agents.installed.codeagent.agent import CodeAgent
+
+__all__ = ["CodeAgent"]
diff --git a/src/harbor/agents/installed/codeagent/agent.py b/src/harbor/agents/installed/codeagent/agent.py
new file mode 100644
index 00000000000..e734f444b75
--- /dev/null
+++ b/src/harbor/agents/installed/codeagent/agent.py
@@ -0,0 +1,1225 @@
+from __future__ import annotations
+
+import json
+import re
+import shlex
+import uuid
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+from harbor.agents.installed.base import (
+ BaseInstalledAgent,
+ CliFlag,
+ with_prompt_template,
+)
+from harbor.environments.base import BaseEnvironment
+from harbor.models.agent.context import AgentContext
+from harbor.models.agent.name import AgentName
+from harbor.models.trajectories import (
+ Agent,
+ FinalMetrics,
+ Metrics,
+ Observation,
+ ObservationResult,
+ Step,
+ ToolCall,
+ Trajectory,
+)
+from harbor.models.trial.paths import EnvironmentPaths
+
+from harbor.agents.installed.codeagent.host import (
+ InstallSpec,
+ PreparedBinary,
+ prepare_binary,
+)
+
+
+DEFAULT_BINARY_NAME = "codeagentcli"
+PATCH_ARTIFACTS_SUBDIR = "patch"
+DEFAULT_INSTRUCTION_REF_PROMPT = "Please read and follow the instructions in this file:"
+
+
+def build_repo_baseline_capture_script(log_dir: str) -> str:
+ return f"""set -eu
+LOG_DIR={shlex.quote(log_dir)}
+mkdir -p "$LOG_DIR"
+if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
+ echo "not-a-git-repository" > "$LOG_DIR/repo-capture.error.txt"
+ exit 0
+fi
+export GIT_AUTHOR_NAME="Harbor CodeAgent"
+export GIT_AUTHOR_EMAIL="codeagent@harbor.invalid"
+export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
+export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
+REPO_ROOT="$(git rev-parse --show-toplevel)"
+cd "$REPO_ROOT"
+echo "$REPO_ROOT" > "$LOG_DIR/repo-root.txt"
+git rev-parse HEAD > "$LOG_DIR/git-head.before.txt" 2>/dev/null || true
+git status --porcelain=v1 > "$LOG_DIR/git-status.before.txt" 2>/dev/null || true
+git log --oneline --decorate -n 20 > "$LOG_DIR/git-log.before.txt" 2>/dev/null || true
+TMP_INDEX="$(mktemp)"
+trap 'rm -f "$TMP_INDEX"' EXIT
+rm -f "$TMP_INDEX"
+GIT_INDEX_FILE="$TMP_INDEX" git read-tree -m HEAD
+GIT_INDEX_FILE="$TMP_INDEX" git add -A
+BASE_TREE="$(GIT_INDEX_FILE="$TMP_INDEX" git write-tree)"
+BASE_COMMIT="$(printf 'harbor-codeagent-baseline\\n' | git commit-tree "$BASE_TREE")"
+echo "$BASE_COMMIT" > "$LOG_DIR/git-baseline-commit.txt"
+"""
+
+
+def build_repo_final_capture_script(log_dir: str) -> str:
+ return f"""set -eu
+LOG_DIR={shlex.quote(log_dir)}
+if [ ! -f "$LOG_DIR/repo-root.txt" ] || [ ! -f "$LOG_DIR/git-baseline-commit.txt" ]; then
+ echo "missing-baseline" > "$LOG_DIR/fix-patch.error.txt"
+ exit 0
+fi
+export GIT_AUTHOR_NAME="Harbor CodeAgent"
+export GIT_AUTHOR_EMAIL="codeagent@harbor.invalid"
+export GIT_COMMITTER_NAME="$GIT_AUTHOR_NAME"
+export GIT_COMMITTER_EMAIL="$GIT_AUTHOR_EMAIL"
+REPO_ROOT="$(cat "$LOG_DIR/repo-root.txt")"
+BASE_COMMIT="$(cat "$LOG_DIR/git-baseline-commit.txt")"
+cd "$REPO_ROOT"
+git rev-parse HEAD > "$LOG_DIR/git-head.after.txt" 2>/dev/null || true
+git status --porcelain=v1 > "$LOG_DIR/git-status.after.txt" 2>/dev/null || true
+git log --oneline --decorate -n 20 > "$LOG_DIR/git-log.after.txt" 2>/dev/null || true
+TMP_INDEX="$(mktemp)"
+trap 'rm -f "$TMP_INDEX"' EXIT
+rm -f "$TMP_INDEX"
+GIT_INDEX_FILE="$TMP_INDEX" git read-tree -m HEAD
+GIT_INDEX_FILE="$TMP_INDEX" git add -A
+FINAL_TREE="$(GIT_INDEX_FILE="$TMP_INDEX" git write-tree)"
+FINAL_COMMIT="$(printf 'harbor-codeagent-final\\n' | git commit-tree "$FINAL_TREE")"
+echo "$FINAL_COMMIT" > "$LOG_DIR/git-final-commit.txt"
+git diff --binary "$BASE_COMMIT" "$FINAL_COMMIT" > "$LOG_DIR/fix.patch" 2>/dev/null || true
+git diff --stat "$BASE_COMMIT" "$FINAL_COMMIT" > "$LOG_DIR/fix.stat.txt" 2>/dev/null || true
+git diff --name-status "$BASE_COMMIT" "$FINAL_COMMIT" > "$LOG_DIR/fix.name-status.txt" 2>/dev/null || true
+find "$LOG_DIR" -maxdepth 4 -type f | sort > "$LOG_DIR/artifacts.index.txt" 2>/dev/null || true
+"""
+
+
+def _stringify(value: Any) -> str:
+ if isinstance(value, str):
+ return value
+ try:
+ return json.dumps(value, ensure_ascii=False)
+ except TypeError:
+ return str(value)
+
+
+def _build_metrics(usage: Any) -> Metrics | None:
+ if not isinstance(usage, dict):
+ return None
+
+ prompt_tokens = usage.get("input_tokens")
+ if prompt_tokens is None:
+ prompt_tokens = usage.get("inputTokens")
+ completion_tokens = usage.get("output_tokens")
+ if completion_tokens is None:
+ completion_tokens = usage.get("outputTokens")
+ cached_tokens = (
+ usage.get("cache_read_input_tokens") or usage.get("cacheReadInputTokens") or 0
+ )
+
+ extra = {
+ key: value
+ for key, value in usage.items()
+ if key
+ not in {
+ "input_tokens",
+ "inputTokens",
+ "output_tokens",
+ "outputTokens",
+ "cache_read_input_tokens",
+ "cacheReadInputTokens",
+ }
+ }
+
+ if (
+ prompt_tokens is None
+ and completion_tokens is None
+ and cached_tokens in (None, 0)
+ and not extra
+ ):
+ return None
+
+ return Metrics(
+ prompt_tokens=prompt_tokens,
+ completion_tokens=completion_tokens,
+ cached_tokens=cached_tokens,
+ cost_usd=None,
+ extra=extra or None,
+ )
+
+
+def _extract_text_reasoning_tool_uses(
+ content: Any,
+) -> tuple[str, str | None, list[dict[str, Any]]]:
+ if isinstance(content, str):
+ return content.strip(), None, []
+
+ text_parts: list[str] = []
+ reasoning_parts: list[str] = []
+ tool_blocks: list[dict[str, Any]] = []
+
+ if isinstance(content, list):
+ for block in content:
+ if not isinstance(block, dict):
+ text_parts.append(_stringify(block))
+ continue
+
+ block_type = block.get("type")
+ if block_type == "tool_use":
+ tool_blocks.append(block)
+ continue
+
+ if block_type in {"thinking", "reasoning", "analysis"}:
+ text_value = block.get("text")
+ if isinstance(text_value, str):
+ reasoning_parts.append(text_value.strip())
+ else:
+ reasoning_parts.append(_stringify(text_value))
+ continue
+
+ if block_type == "text" and isinstance(block.get("text"), str):
+ text_parts.append(block["text"])
+ continue
+
+ text_parts.append(_stringify(block))
+ elif content is not None:
+ text_parts.append(_stringify(content))
+
+ text = "\n\n".join(part.strip() for part in text_parts if part and part.strip())
+ reasoning = "\n\n".join(
+ part.strip() for part in reasoning_parts if part and part.strip()
+ )
+ return text, (reasoning or None), tool_blocks
+
+
+def _format_tool_result(
+ block: dict[str, Any],
+ tool_use_result: Any | None,
+) -> tuple[str | None, dict[str, Any] | None]:
+ parts: list[str] = []
+
+ content = block.get("content")
+ if isinstance(content, str):
+ if content.strip():
+ parts.append(content.strip())
+ elif isinstance(content, list):
+ for item in content:
+ if isinstance(item, dict) and item.get("type") == "text":
+ text_value = item.get("text")
+ if isinstance(text_value, str) and text_value.strip():
+ parts.append(text_value.strip())
+ continue
+ stringified = _stringify(item)
+ if stringified.strip():
+ parts.append(stringified.strip())
+ elif content not in (None, ""):
+ parts.append(_stringify(content))
+
+ metadata: dict[str, Any] | None = None
+ if tool_use_result is not None:
+ metadata = {"tool_use_result": tool_use_result}
+
+ result_text = "\n\n".join(part for part in parts if part).strip()
+ return (result_text or None), metadata
+
+
+def convert_stream_records_to_trajectory(
+ records: list[dict[str, Any]],
+ *,
+ session_id_hint: str | None,
+ agent_name: str,
+ agent_version: str | None,
+ default_model_name: str | None,
+) -> Trajectory | None:
+ if not records:
+ return None
+
+ seen_uuids: set[str] = set()
+ deduped: list[dict[str, Any]] = []
+ for record in records:
+ uuid_value = record.get("uuid")
+ if isinstance(uuid_value, str) and uuid_value:
+ if uuid_value in seen_uuids:
+ continue
+ seen_uuids.add(uuid_value)
+ deduped.append(record)
+ records = deduped
+
+ session_id = session_id_hint
+ for record in records:
+ record_session_id = record.get("session_id")
+ if isinstance(record_session_id, str) and record_session_id:
+ session_id = record_session_id
+ break
+
+ normalized_events: list[dict[str, Any]] = []
+ pending_calls: dict[str, dict[str, Any]] = {}
+ completed_call_ids: set[str] = set()
+ turn_by_message_id: dict[str, dict[str, Any]] = {}
+ final_result: dict[str, Any] | None = None
+
+ for record in records:
+ record_type = record.get("type")
+ if record_type == "result":
+ final_result = record
+ continue
+
+ if record_type == "assistant":
+ message = record.get("message")
+ if not isinstance(message, dict):
+ continue
+
+ text, reasoning, tool_blocks = _extract_text_reasoning_tool_uses(
+ message.get("content")
+ )
+ message_id = message.get("id")
+ model_name = message.get("model") or default_model_name
+ metrics = _build_metrics(message.get("usage"))
+ extra: dict[str, Any] = {}
+ for key in ("stop_reason", "stop_sequence"):
+ if message.get(key) is not None:
+ extra[key] = message.get(key)
+ if record.get("parent_tool_use_id") is not None:
+ extra["parent_tool_use_id"] = record.get("parent_tool_use_id")
+ if record.get("uuid") is not None:
+ extra["uuid"] = record.get("uuid")
+
+ turn = (
+ turn_by_message_id.get(message_id)
+ if isinstance(message_id, str) and message_id
+ else None
+ )
+ if turn is None:
+ turn = {
+ "kind": "agent_step",
+ "timestamp": None,
+ "text": "",
+ "reasoning": None,
+ "metrics": None,
+ "extra": extra or None,
+ "model_name": model_name,
+ "tool_calls": [],
+ }
+ normalized_events.append(turn)
+ if isinstance(message_id, str) and message_id:
+ turn_by_message_id[message_id] = turn
+
+ if text:
+ turn["text"] = (
+ f"{turn['text']}\n\n{text}".strip() if turn["text"] else text
+ )
+ if reasoning:
+ turn["reasoning"] = (
+ f"{turn['reasoning']}\n\n{reasoning}"
+ if turn["reasoning"]
+ else reasoning
+ )
+ if turn["metrics"] is None and metrics is not None:
+ turn["metrics"] = metrics
+
+ tool_specs = turn["tool_calls"]
+ if not isinstance(tool_specs, list):
+ tool_specs = []
+ turn["tool_calls"] = tool_specs
+ for tool_block in tool_blocks:
+ call_id = tool_block.get("id") or tool_block.get("tool_use_id")
+ if (
+ not call_id
+ or call_id in pending_calls
+ or call_id in completed_call_ids
+ ):
+ continue
+
+ raw_arguments = tool_block.get("input")
+ arguments = (
+ raw_arguments
+ if isinstance(raw_arguments, dict)
+ else {"input": raw_arguments}
+ )
+ tool_extra: dict[str, Any] = {}
+ if raw_arguments is not None:
+ tool_extra["raw_arguments"] = raw_arguments
+ if tool_block.get("name") is not None:
+ tool_extra["tool_use_name"] = tool_block.get("name")
+
+ spec = {
+ "call_id": call_id,
+ "tool_name": tool_block.get("name") or "",
+ "arguments": arguments,
+ "extra": tool_extra or None,
+ "output": None,
+ "result_extra": None,
+ }
+ tool_specs.append(spec)
+ pending_calls[call_id] = spec
+ continue
+
+ if record_type == "user":
+ message = record.get("message")
+ if not isinstance(message, dict):
+ continue
+ content = message.get("content")
+ if isinstance(content, str):
+ if content.strip():
+ normalized_events.append(
+ {
+ "kind": "message",
+ "role": "user",
+ "timestamp": None,
+ "text": content,
+ "extra": {"uuid": record.get("uuid")}
+ if record.get("uuid") is not None
+ else None,
+ }
+ )
+ continue
+
+ if isinstance(content, list):
+ text_parts: list[str] = []
+ for block in content:
+ if isinstance(block, dict) and block.get("type") == "tool_result":
+ call_id = block.get("tool_use_id")
+ formatted_output, metadata = _format_tool_result(
+ block, record.get("tool_use_result")
+ )
+ call_info = (
+ pending_calls.pop(call_id, None) if call_id else None
+ )
+ if call_info is not None:
+ result_extra: dict[str, Any] = {}
+ if metadata:
+ result_extra["tool_result_metadata"] = metadata
+ call_info["output"] = formatted_output
+ call_info["result_extra"] = result_extra or None
+ if call_id:
+ completed_call_ids.add(call_id)
+ continue
+
+ if call_id and call_id in completed_call_ids:
+ continue
+ tool_name = block.get("name") or block.get("tool_name") or ""
+ if not tool_name:
+ continue
+ normalized_events.append(
+ {
+ "kind": "tool_call",
+ "timestamp": None,
+ "call_id": call_id or "",
+ "tool_name": tool_name,
+ "arguments": {},
+ "raw_arguments": None,
+ "reasoning": None,
+ "status": None,
+ "message": None,
+ "extra": (
+ {"tool_result_metadata": metadata}
+ if metadata
+ else None
+ ),
+ "metrics": None,
+ "model_name": default_model_name,
+ "output": formatted_output,
+ }
+ )
+ if call_id:
+ completed_call_ids.add(call_id)
+ continue
+
+ if (
+ isinstance(block, dict)
+ and block.get("type") == "text"
+ and isinstance(block.get("text"), str)
+ ):
+ text_parts.append(block["text"])
+ else:
+ text_parts.append(_stringify(block))
+
+ text_message = "\n\n".join(part for part in text_parts if part.strip())
+ if text_message:
+ normalized_events.append(
+ {
+ "kind": "message",
+ "role": "user",
+ "timestamp": None,
+ "text": text_message,
+ }
+ )
+ continue
+
+ if content not in (None, ""):
+ text = _stringify(content)
+ if text.strip():
+ normalized_events.append(
+ {
+ "kind": "message",
+ "role": "user",
+ "timestamp": None,
+ "text": text,
+ }
+ )
+ continue
+
+ if record_type == "system":
+ content = record.get("content")
+ if isinstance(content, str) and content.strip():
+ normalized_events.append(
+ {
+ "kind": "message",
+ "role": "system",
+ "timestamp": None,
+ "text": content,
+ }
+ )
+ continue
+
+ if record_type == "permission_denial":
+ tool_name = record.get("toolName") or "unknown"
+ mode = record.get("mode")
+ message = f"Permission denied for tool {tool_name}"
+ if mode:
+ message += f" (mode={mode})"
+ normalized_events.append(
+ {
+ "kind": "message",
+ "role": "system",
+ "timestamp": None,
+ "text": message,
+ "extra": {"raw": record},
+ }
+ )
+
+ steps: list[Step] = []
+ for event in normalized_events:
+ kind = event.get("kind")
+ if kind == "message":
+ role = event.get("role", "user")
+ source = "agent" if role == "assistant" else role
+ steps.append(
+ Step(
+ step_id=len(steps) + 1,
+ timestamp=event.get("timestamp"),
+ source=source,
+ message=event.get("text") or "",
+ reasoning_content=(
+ event.get("reasoning") if source == "agent" else None
+ ),
+ model_name=(event.get("model_name") if source == "agent" else None),
+ metrics=event.get("metrics") if source == "agent" else None,
+ extra=event.get("extra"),
+ )
+ )
+ continue
+
+ if kind == "agent_step":
+ tool_calls: list[ToolCall] = []
+ results: list[ObservationResult] = []
+ for spec in event.get("tool_calls") or []:
+ call_id = spec.get("call_id")
+ if not call_id:
+ continue
+ tool_calls.append(
+ ToolCall(
+ tool_call_id=call_id,
+ function_name=spec.get("tool_name") or "",
+ arguments=spec.get("arguments") or {},
+ extra=spec.get("extra"),
+ )
+ )
+ if spec.get("output") is not None:
+ results.append(
+ ObservationResult(
+ source_call_id=call_id,
+ content=spec.get("output"),
+ subagent_trajectory_ref=None,
+ extra=spec.get("result_extra"),
+ )
+ )
+ steps.append(
+ Step(
+ step_id=len(steps) + 1,
+ timestamp=event.get("timestamp"),
+ source="agent",
+ message=event.get("text") or "",
+ reasoning_content=event.get("reasoning"),
+ tool_calls=tool_calls or None,
+ observation=Observation(results=results) if results else None,
+ metrics=event.get("metrics"),
+ model_name=event.get("model_name") or default_model_name,
+ extra=event.get("extra"),
+ )
+ )
+ continue
+
+ if kind == "tool_call":
+ call_id = event.get("call_id")
+ tool_name = event.get("tool_name")
+ if not call_id or not tool_name:
+ continue
+ tool_call = ToolCall(
+ tool_call_id=call_id,
+ function_name=tool_name,
+ arguments=event.get("arguments") or {},
+ extra=event.get("extra"),
+ )
+ observation = None
+ if event.get("output") is not None:
+ observation = Observation(
+ results=[
+ ObservationResult(
+ source_call_id=call_id,
+ content=event.get("output"),
+ subagent_trajectory_ref=None,
+ extra=event.get("metadata"),
+ )
+ ]
+ )
+ steps.append(
+ Step(
+ step_id=len(steps) + 1,
+ timestamp=event.get("timestamp"),
+ source="agent",
+ message=event.get("message") or f"Executed {tool_name}",
+ tool_calls=[tool_call],
+ observation=observation,
+ model_name=event.get("model_name") or default_model_name,
+ metrics=event.get("metrics"),
+ extra=event.get("extra"),
+ )
+ )
+
+ if not steps:
+ return None
+
+ final_metrics = None
+ if isinstance(final_result, dict):
+ usage = final_result.get("usage") or {}
+ prompt_tokens = usage.get("input_tokens")
+ if prompt_tokens is None:
+ prompt_tokens = usage.get("inputTokens")
+ completion_tokens = usage.get("output_tokens")
+ if completion_tokens is None:
+ completion_tokens = usage.get("outputTokens")
+ cached_tokens = usage.get("cache_read_input_tokens") or usage.get(
+ "cacheReadInputTokens"
+ )
+ extra = {
+ "num_turns": final_result.get("num_turns"),
+ "permission_denials": final_result.get("permission_denials"),
+ "model_usage": final_result.get("modelUsage"),
+ "subtype": final_result.get("subtype"),
+ "stop_reason": final_result.get("stop_reason"),
+ }
+ final_metrics = FinalMetrics(
+ total_prompt_tokens=prompt_tokens,
+ total_completion_tokens=completion_tokens,
+ total_cached_tokens=cached_tokens,
+ total_cost_usd=final_result.get("total_cost_usd"),
+ total_steps=len(steps),
+ extra={key: value for key, value in extra.items() if value is not None}
+ or None,
+ )
+
+ if final_metrics is None:
+ prompt_values = [
+ step.metrics.prompt_tokens
+ for step in steps
+ if step.metrics and step.metrics.prompt_tokens is not None
+ ]
+ completion_values = [
+ step.metrics.completion_tokens
+ for step in steps
+ if step.metrics and step.metrics.completion_tokens is not None
+ ]
+ cached_values = [
+ step.metrics.cached_tokens
+ for step in steps
+ if step.metrics and step.metrics.cached_tokens is not None
+ ]
+ final_metrics = FinalMetrics(
+ total_prompt_tokens=sum(prompt_values) if prompt_values else None,
+ total_completion_tokens=(
+ sum(completion_values) if completion_values else None
+ ),
+ total_cached_tokens=sum(cached_values) if cached_values else None,
+ total_cost_usd=None,
+ total_steps=len(steps),
+ )
+
+ return Trajectory(
+ schema_version="ATIF-v1.7",
+ session_id=session_id,
+ agent=Agent(
+ name=agent_name,
+ version=agent_version or "unknown",
+ model_name=default_model_name,
+ ),
+ steps=steps,
+ final_metrics=final_metrics,
+ )
+
+
+class CodeAgent(BaseInstalledAgent):
+ """Binary-only Harbor integration for CodeAgentCLI."""
+
+ SUPPORTS_ATIF: bool = True
+
+ CLI_FLAGS = [
+ CliFlag("max_turns", cli="--max-turns", type="int"),
+ CliFlag(
+ "thinking",
+ cli="--thinking",
+ type="enum",
+ choices=["enabled", "adaptive", "disabled"],
+ ),
+ CliFlag(
+ "reasoning_effort",
+ cli="--effort",
+ type="enum",
+ choices=["low", "medium", "high", "xhigh", "max"],
+ ),
+ CliFlag("max_thinking_tokens", cli="--max-thinking-tokens", type="int"),
+ CliFlag("max_budget_usd", cli="--max-budget-usd", type="str"),
+ CliFlag("task_budget", cli="--task-budget", type="int"),
+ CliFlag("append_system_prompt", cli="--append-system-prompt", type="str"),
+ CliFlag("allowed_tools", cli="--allowedTools", type="str"),
+ CliFlag("disallowed_tools", cli="--disallowedTools", type="str"),
+ ]
+
+ _STREAM_FILENAME = "codeagent-stream.jsonl"
+ _STDERR_FILENAME = "codeagent-stderr.txt"
+ _INVOCATION_FILENAME = "codeagent-invocation.json"
+ _BINARY_METADATA_FILENAME = "codeagent-binary-metadata.json"
+ _MCP_CONFIG_FILENAME = "codeagent-mcp-config.json"
+ _RUNTIME_LOG_DIR = EnvironmentPaths.agent_dir
+ _RUNTIME_HOME = _RUNTIME_LOG_DIR
+ # Keep only known heavyweight tool caches outside /logs/agent so trial log
+ # collection still preserves non-cache HOME state.
+ _RUNTIME_CACHE_DIR = PurePosixPath("/tmp/harbor-codeagent-cache")
+ _RUNTIME_GO_CACHE_DIR = _RUNTIME_CACHE_DIR / "go-build"
+ _RUNTIME_YARN_GLOBAL_DIR = _RUNTIME_CACHE_DIR / "yarn"
+ _RUNTIME_YARN_CACHE_DIR = _RUNTIME_YARN_GLOBAL_DIR / "cache"
+ _RUNTIME_CONFIG_DIR = _RUNTIME_LOG_DIR / ".cac"
+ _INPUTS_DIR = _RUNTIME_LOG_DIR / "input"
+ _INSTRUCTION_FILENAME = "instruction.md"
+ _RUNTIME_BINARY_DIR = PurePosixPath("/opt/harbor/codeagent")
+ _REMOTE_BINARY_PATH = _RUNTIME_BINARY_DIR / DEFAULT_BINARY_NAME
+ _SKILLS_TARGET_DIR = _RUNTIME_CONFIG_DIR / "skills"
+ _SESSION_UUID_NAMESPACE = uuid.UUID("0ce34b8b-5476-4b73-bd4a-e0556878928f")
+ _PROXY_ENV_KEYS = (
+ "ALL_PROXY",
+ "HTTP_PROXY",
+ "HTTPS_PROXY",
+ "NO_PROXY",
+ "SSL_CERT_FILE",
+ "NODE_EXTRA_CA_CERTS",
+ "REQUESTS_CA_BUNDLE",
+ )
+ _OPTIONAL_RUNTIME_ENV_KEYS = (
+ "ENTERPRISE_PROTOCOL",
+ "ENTERPRISE_PROVIDER",
+ "ENTERPRISE_SMALL_MODEL",
+ "ENTERPRISE_VL_MODEL",
+ "CODEAGENT3_MAX_OUTPUT_TOKENS",
+ "CODEAGENT3_MAX_CONTEXT_TOKENS",
+ )
+
+ @property
+ def _patch_logs_dir(self) -> Path:
+ return self.logs_dir / PATCH_ARTIFACTS_SUBDIR
+
+ @property
+ def _patch_logs_dir_in_env(self) -> PurePosixPath:
+ return EnvironmentPaths.agent_dir / PATCH_ARTIFACTS_SUBDIR
+
+ def __init__(
+ self,
+ *args,
+ install_mode: str = "binary",
+ binary_path: str | Path | None = None,
+ instruction_mode: str = "inline",
+ instruction_ref_prompt: str = DEFAULT_INSTRUCTION_REF_PROMPT,
+ max_output_tokens: int | None = None,
+ max_tokens: int | None = None,
+ context_window: int | None = None,
+ dynamic_linker_path: str | None = None,
+ library_path: str | list[str] | None = None,
+ **kwargs,
+ ):
+ super().__init__(*args, **kwargs)
+ self._install_mode = install_mode
+ self._binary_path = Path(binary_path).expanduser() if binary_path else None
+ self._instruction_mode = instruction_mode
+ self._instruction_ref_prompt = instruction_ref_prompt
+ self._dynamic_linker_path = dynamic_linker_path
+ self._library_path = self._normalize_library_path(library_path)
+ if (
+ max_output_tokens is not None
+ and max_tokens is not None
+ and int(max_output_tokens) != int(max_tokens)
+ ):
+ raise ValueError(
+ "max_output_tokens and max_tokens were both provided with different values."
+ )
+ self._max_output_tokens = (
+ int(max_output_tokens)
+ if max_output_tokens is not None
+ else (int(max_tokens) if max_tokens is not None else None)
+ )
+ self._context_window = (
+ int(context_window) if context_window is not None else None
+ )
+ self._prepared_binary: PreparedBinary | None = None
+ self._session_id = str(
+ uuid.uuid5(self._SESSION_UUID_NAMESPACE, str(self.logs_dir.resolve()))
+ )
+ self._validate_configuration()
+
+ @staticmethod
+ def _normalize_library_path(library_path: str | list[str] | None) -> str | None:
+ if library_path is None:
+ return None
+ if isinstance(library_path, list):
+ if not library_path or any(not str(path).strip() for path in library_path):
+ raise ValueError("library_path entries must be non-empty.")
+ return ":".join(str(path) for path in library_path)
+ if not str(library_path).strip():
+ raise ValueError("library_path must be non-empty when set.")
+ return str(library_path)
+
+ @staticmethod
+ def name() -> str:
+ return AgentName.CODEAGENT.value
+
+ def version(self) -> str | None:
+ return self._version
+
+ def _validate_configuration(self) -> None:
+ if self._install_mode != "binary":
+ raise ValueError("Only install_mode='binary' is supported.")
+ if self._binary_path is None:
+ raise ValueError("install_mode='binary' requires binary_path to be set.")
+ if self._instruction_mode not in {"inline", "file_ref"}:
+ raise ValueError("instruction_mode must be either 'inline' or 'file_ref'.")
+ if (
+ self._instruction_mode == "file_ref"
+ and not self._instruction_ref_prompt.strip()
+ ):
+ raise ValueError(
+ "instruction_ref_prompt must be non-empty when instruction_mode='file_ref'."
+ )
+ if self._max_output_tokens is not None and self._max_output_tokens <= 0:
+ raise ValueError("max_output_tokens must be a positive integer when set.")
+ if self._context_window is not None and self._context_window <= 0:
+ raise ValueError("context_window must be a positive integer when set.")
+ if self._dynamic_linker_path and not self._library_path:
+ raise ValueError(
+ "library_path must be set when dynamic_linker_path is set."
+ )
+ if self._library_path and not self._dynamic_linker_path:
+ raise ValueError(
+ "dynamic_linker_path must be set when library_path is set."
+ )
+
+ def get_version_command(self) -> str | None:
+ return shlex.join([*self._codeagent_command_prefix(), "--version"])
+
+ def parse_version(self, stdout: str) -> str:
+ match = re.search(r"(\d+(?:\.\d+)+)", stdout.strip())
+ if match:
+ return match.group(1)
+ return stdout.strip()
+
+ def _install_spec(self) -> InstallSpec:
+ if self._binary_path is None:
+ raise RuntimeError("binary_path must be resolved before preparing install.")
+ return InstallSpec(install_mode="binary", binary_path=self._binary_path)
+
+ def _codeagent_command_prefix(self) -> list[str]:
+ binary_path = self._REMOTE_BINARY_PATH.as_posix()
+ if not self._dynamic_linker_path:
+ return [binary_path]
+ if not self._library_path:
+ raise RuntimeError("library_path must be set for dynamic linker execution.")
+ return [
+ self._dynamic_linker_path,
+ "--library-path",
+ self._library_path,
+ binary_path,
+ ]
+
+ async def _prepare_host_binary(self) -> PreparedBinary:
+ prepared = await prepare_binary(self._install_spec())
+ (self.logs_dir / self._BINARY_METADATA_FILENAME).write_text(
+ json.dumps(
+ {
+ "artifact_path": str(prepared.artifact_path),
+ "binary_sha256": prepared.binary_sha256,
+ "binary_size_bytes": prepared.binary_size_bytes,
+ "cache_key": prepared.cache_key,
+ "install_mode": prepared.install_mode,
+ "source_path": str(prepared.source_path),
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
+ return prepared
+
+ async def install(self, environment: BaseEnvironment) -> None:
+ prepared = await self._prepare_host_binary()
+ self._prepared_binary = prepared
+ runtime_dirs = " ".join(
+ shlex.quote(path)
+ for path in dict.fromkeys(
+ path.as_posix()
+ for path in (
+ self._RUNTIME_LOG_DIR,
+ self._RUNTIME_HOME,
+ self._RUNTIME_CACHE_DIR,
+ self._RUNTIME_GO_CACHE_DIR,
+ self._RUNTIME_YARN_GLOBAL_DIR,
+ self._RUNTIME_YARN_CACHE_DIR,
+ self._RUNTIME_CONFIG_DIR,
+ self._INPUTS_DIR,
+ self._SKILLS_TARGET_DIR,
+ )
+ )
+ )
+
+ await self.exec_as_root(
+ environment,
+ command=(
+ "set -euo pipefail; "
+ f"mkdir -p {shlex.quote(self._RUNTIME_BINARY_DIR.as_posix())} && "
+ f"mkdir -p {runtime_dirs} && "
+ f"chmod 0777 {shlex.quote(self._RUNTIME_BINARY_DIR.as_posix())} && "
+ f"chmod -R 0777 {runtime_dirs}"
+ ),
+ )
+ await environment.upload_file(
+ source_path=prepared.artifact_path,
+ target_path=self._REMOTE_BINARY_PATH.as_posix(),
+ )
+ await self.exec_as_root(
+ environment,
+ command=(
+ f"chmod 0755 {shlex.quote(self._RUNTIME_BINARY_DIR.as_posix())} && "
+ f"chmod 0755 {shlex.quote(self._REMOTE_BINARY_PATH.as_posix())}"
+ ),
+ )
+
+ if self.skills_dir:
+ await self.exec_as_root(
+ environment,
+ command=(
+ f"mkdir -p {shlex.quote(self._SKILLS_TARGET_DIR.as_posix())} && "
+ f"cp -r {shlex.quote(self.skills_dir)}/* "
+ f"{shlex.quote(self._SKILLS_TARGET_DIR.as_posix())}/ 2>/dev/null || true"
+ ),
+ )
+
+ def _runtime_env(self) -> dict[str, str]:
+ api_base = self._get_env("ENTERPRISE_API_BASE_URL")
+ api_key = self._get_env("ENTERPRISE_API_KEY")
+ main_model = self.model_name or self._get_env("ENTERPRISE_MAIN_MODEL")
+ missing = [
+ key
+ for key, value in (
+ ("ENTERPRISE_API_BASE_URL", api_base),
+ ("ENTERPRISE_API_KEY", api_key),
+ ("ENTERPRISE_MAIN_MODEL", main_model),
+ )
+ if not value
+ ]
+ if missing:
+ raise ValueError(
+ "CodeAgent requires runtime environment values for: "
+ + ", ".join(missing)
+ )
+
+ env = {
+ "CODEAGENT3_CONFIG_DIR": self._RUNTIME_CONFIG_DIR.as_posix(),
+ "ENTERPRISE_API_BASE_URL": api_base or "",
+ "ENTERPRISE_API_KEY": api_key or "",
+ "ENTERPRISE_MAIN_MODEL": main_model or "",
+ "GOCACHE": self._RUNTIME_GO_CACHE_DIR.as_posix(),
+ "HOME": self._RUNTIME_HOME.as_posix(),
+ "IS_SANDBOX": "1",
+ "YARN_GLOBAL_FOLDER": self._RUNTIME_YARN_GLOBAL_DIR.as_posix(),
+ "YARN_CACHE_FOLDER": self._RUNTIME_YARN_CACHE_DIR.as_posix(),
+ }
+ for key in (*self._OPTIONAL_RUNTIME_ENV_KEYS, *self._PROXY_ENV_KEYS):
+ value = self._get_env(key)
+ if value:
+ env[key] = value
+ if self._max_output_tokens is not None:
+ env["CODEAGENT3_MAX_OUTPUT_TOKENS"] = str(self._max_output_tokens)
+ if self._context_window is not None:
+ env["CODEAGENT3_MAX_CONTEXT_TOKENS"] = str(self._context_window)
+ return env
+
+ def _mcp_config_path(self) -> Path | None:
+ if not self.mcp_servers:
+ return None
+
+ payload: dict[str, dict[str, Any]] = {"mcpServers": {}}
+ for server in self.mcp_servers:
+ if server.transport == "stdio":
+ payload["mcpServers"][server.name] = {
+ "type": "stdio",
+ "command": server.command,
+ "args": server.args,
+ }
+ else:
+ payload["mcpServers"][server.name] = {
+ "type": (
+ "http" if server.transport == "streamable-http" else "sse"
+ ),
+ "url": server.url,
+ }
+
+ path = self.logs_dir / self._MCP_CONFIG_FILENAME
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True))
+ return path
+
+ def _write_invocation_metadata(
+ self,
+ *,
+ runtime_env: dict[str, str],
+ command: list[str],
+ mcp_config_path: Path | None,
+ rendered_instruction_mode: str,
+ instruction_file_path: str | None,
+ ) -> None:
+ prepared = self._prepared_binary
+ payload = {
+ "binary_path_in_environment": self._REMOTE_BINARY_PATH.as_posix(),
+ "command": command,
+ "dynamic_linker_path": self._dynamic_linker_path,
+ "install_mode": self._install_mode,
+ "instruction_mode": rendered_instruction_mode,
+ "instruction_file_path": instruction_file_path,
+ "instruction_ref_prompt": (
+ self._instruction_ref_prompt
+ if rendered_instruction_mode == "file_ref"
+ else None
+ ),
+ "library_path": self._library_path,
+ "mcp_config_path": str(mcp_config_path) if mcp_config_path else None,
+ "model_name": self.model_name,
+ "prepared_binary": (
+ {
+ "artifact_path": str(prepared.artifact_path),
+ "binary_sha256": prepared.binary_sha256,
+ "binary_size_bytes": prepared.binary_size_bytes,
+ "cache_key": prepared.cache_key,
+ "source_path": str(prepared.source_path),
+ }
+ if prepared
+ else None
+ ),
+ "runtime_env_keys": sorted(runtime_env.keys()),
+ "runtime_home": self._RUNTIME_HOME.as_posix(),
+ "session_id": self._session_id,
+ "skills_dir": self.skills_dir,
+ "stderr_path": str(self.logs_dir / self._STDERR_FILENAME),
+ "stream_path": str(self.logs_dir / self._STREAM_FILENAME),
+ "trial_name": self.logs_dir.parent.name,
+ }
+ (self.logs_dir / self._INVOCATION_FILENAME).write_text(
+ json.dumps(payload, indent=2, sort_keys=True)
+ )
+
+ @property
+ def _instruction_host_dir(self) -> Path:
+ return self.logs_dir / "input"
+
+ @property
+ def _instruction_host_path(self) -> Path:
+ return self._instruction_host_dir / self._INSTRUCTION_FILENAME
+
+ @property
+ def _instruction_env_path(self) -> PurePosixPath:
+ return self._INPUTS_DIR / self._INSTRUCTION_FILENAME
+
+ async def _prepare_instruction(
+ self, environment: BaseEnvironment, instruction: str
+ ) -> tuple[str, str | None]:
+ if self._instruction_mode == "inline":
+ return instruction, None
+
+ self._instruction_host_dir.mkdir(parents=True, exist_ok=True)
+ self._instruction_host_path.write_text(instruction)
+ await self.exec_as_root(
+ environment,
+ command=f"mkdir -p {shlex.quote(self._INPUTS_DIR.as_posix())}",
+ )
+ await environment.upload_file(
+ source_path=self._instruction_host_path,
+ target_path=self._instruction_env_path.as_posix(),
+ )
+ prompt = (
+ f"{self._instruction_ref_prompt.rstrip()} "
+ f"{self._instruction_env_path.as_posix()}"
+ )
+ return prompt, self._instruction_env_path.as_posix()
+
+ async def _capture_repo_baseline(self, environment: BaseEnvironment) -> None:
+ await self.exec_as_root(
+ environment,
+ command=f"mkdir -p {shlex.quote(self._patch_logs_dir_in_env.as_posix())}",
+ )
+ await self.exec_as_agent(
+ environment,
+ command=build_repo_baseline_capture_script(
+ self._patch_logs_dir_in_env.as_posix()
+ ),
+ )
+
+ async def _capture_repo_final_state(self, environment: BaseEnvironment) -> None:
+ await self.exec_as_agent(
+ environment,
+ command=build_repo_final_capture_script(
+ self._patch_logs_dir_in_env.as_posix()
+ ),
+ )
+
+ @with_prompt_template
+ async def run(
+ self,
+ instruction: str,
+ environment: BaseEnvironment,
+ context: AgentContext,
+ ) -> None:
+ runtime_env = self._runtime_env()
+ mcp_config_path = self._mcp_config_path()
+ instruction_for_cli, instruction_file_path = await self._prepare_instruction(
+ environment, instruction
+ )
+ args = [
+ *self._codeagent_command_prefix(),
+ "--print",
+ "--output-format",
+ "stream-json",
+ "--verbose",
+ "--permission-mode",
+ "bypassPermissions",
+ "--session-id",
+ self._session_id,
+ "--name",
+ self.logs_dir.parent.name,
+ ]
+ cli_flags = self.build_cli_flags()
+ if cli_flags:
+ args.extend(shlex.split(cli_flags))
+ if self.model_name:
+ args.extend(["--model", self.model_name])
+ if mcp_config_path is not None:
+ args.extend(
+ [
+ "--mcp-config",
+ (EnvironmentPaths.agent_dir / self._MCP_CONFIG_FILENAME).as_posix(),
+ ]
+ )
+ args.append(instruction_for_cli)
+
+ self._write_invocation_metadata(
+ runtime_env=runtime_env,
+ command=args,
+ mcp_config_path=mcp_config_path,
+ rendered_instruction_mode=self._instruction_mode,
+ instruction_file_path=instruction_file_path,
+ )
+
+ await self._capture_repo_baseline(environment)
+
+ stream_path = EnvironmentPaths.agent_dir / self._STREAM_FILENAME
+ stderr_path = EnvironmentPaths.agent_dir / self._STDERR_FILENAME
+ command = (
+ f"{shlex.join(args)} "
+ f"> {shlex.quote(stream_path.as_posix())} "
+ f"2> {shlex.quote(stderr_path.as_posix())}"
+ )
+ try:
+ await self.exec_as_agent(
+ environment,
+ command=command,
+ env=runtime_env,
+ )
+ finally:
+ try:
+ await self._capture_repo_final_state(environment)
+ except Exception as exc: # pragma: no cover - best effort logging
+ self.logger.debug(f"Failed to capture post-run git state: {exc}")
+
+ def _candidate_trajectory_sources(self) -> list[Path]:
+ candidates: list[Path] = []
+ stream_path = self.logs_dir / self._STREAM_FILENAME
+ if stream_path.is_file():
+ candidates.append(stream_path)
+
+ projects_root = self.logs_dir / ".cac" / "projects"
+ if projects_root.is_dir():
+ exact_matches = sorted(projects_root.rglob(f"{self._session_id}.jsonl"))
+ candidates.extend(path for path in exact_matches if path not in candidates)
+ fallback = sorted(projects_root.rglob("*.jsonl"))
+ candidates.extend(path for path in fallback if path not in candidates)
+ return candidates
+
+ def _load_jsonl_records(self, path: Path) -> list[dict[str, Any]]:
+ records: list[dict[str, Any]] = []
+ with path.open("r", encoding="utf-8") as handle:
+ for line in handle:
+ stripped = line.strip()
+ if not stripped or not stripped.startswith("{"):
+ continue
+ try:
+ payload = json.loads(stripped)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(payload, dict):
+ records.append(payload)
+ return records
+
+ def populate_context_post_run(self, context: AgentContext) -> None:
+ trajectory = None
+ trajectory_source = None
+ for candidate in self._candidate_trajectory_sources():
+ records = self._load_jsonl_records(candidate)
+ trajectory = convert_stream_records_to_trajectory(
+ records,
+ session_id_hint=self._session_id,
+ agent_name=self.name(),
+ agent_version=self.version(),
+ default_model_name=self.model_name,
+ )
+ if trajectory is not None:
+ trajectory_source = candidate
+ break
+
+ if trajectory is None:
+ self.logger.debug("No valid CodeAgent trajectory source found")
+ return
+
+ if trajectory_source is not None:
+ (self.logs_dir / "trajectory-source.txt").write_text(
+ str(trajectory_source.resolve())
+ )
+
+ trajectory_path = self.logs_dir / "trajectory.json"
+ trajectory_path.write_text(
+ json.dumps(trajectory.to_json_dict(), indent=2, ensure_ascii=False)
+ )
+
+ metrics = trajectory.final_metrics
+ if metrics is not None:
+ context.cost_usd = metrics.total_cost_usd
+ context.n_input_tokens = metrics.total_prompt_tokens or 0
+ context.n_cache_tokens = metrics.total_cached_tokens or 0
+ context.n_output_tokens = metrics.total_completion_tokens or 0
diff --git a/src/harbor/agents/installed/codeagent/host.py b/src/harbor/agents/installed/codeagent/host.py
new file mode 100644
index 00000000000..dbc2b41a6f0
--- /dev/null
+++ b/src/harbor/agents/installed/codeagent/host.py
@@ -0,0 +1,112 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import shutil
+import tempfile
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Literal
+
+DEFAULT_BINARY_NAME = "codeagentcli"
+InstallMode = Literal["binary"]
+
+_CACHE_ROOT = Path(tempfile.mkdtemp(prefix="harbor-codeagent-"))
+_PREPARE_LOCK = asyncio.Lock()
+_PREPARE_TASKS: dict[str, asyncio.Task["PreparedBinary"]] = {}
+
+
+@dataclass(frozen=True)
+class InstallSpec:
+ install_mode: InstallMode
+ binary_path: Path
+
+
+@dataclass(frozen=True)
+class PreparedBinary:
+ artifact_path: Path
+ install_mode: InstallMode
+ binary_sha256: str
+ binary_size_bytes: int
+ source_path: Path
+ cache_key: str
+
+
+def sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ while chunk := handle.read(1024 * 1024):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def install_spec_cache_key(spec: InstallSpec) -> str:
+ source = spec.binary_path.expanduser().resolve()
+ stat = source.stat()
+ payload = (
+ f"{spec.install_mode}\0{source}\0{stat.st_size}\0{sha256_file(source)}".encode()
+ )
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _write_metadata(path: Path, prepared: PreparedBinary) -> None:
+ import json
+
+ path.write_text(
+ json.dumps(
+ {
+ "artifact_path": str(prepared.artifact_path),
+ "binary_sha256": prepared.binary_sha256,
+ "binary_size_bytes": prepared.binary_size_bytes,
+ "cache_key": prepared.cache_key,
+ "install_mode": prepared.install_mode,
+ "source_path": str(prepared.source_path),
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
+
+
+def _prepare_binary_sync(spec: InstallSpec, cache_key: str) -> PreparedBinary:
+ if spec.install_mode != "binary":
+ raise ValueError("Only install_mode='binary' is supported.")
+
+ source = spec.binary_path.expanduser().resolve()
+ if not source.is_file():
+ raise FileNotFoundError(f"CodeAgent binary not found: {source}")
+
+ cache_dir = _CACHE_ROOT / cache_key
+ cache_dir.mkdir(parents=True, exist_ok=True)
+ artifact = cache_dir / DEFAULT_BINARY_NAME
+ shutil.copy2(source, artifact)
+ artifact.chmod(0o755)
+
+ prepared = PreparedBinary(
+ artifact_path=artifact,
+ install_mode="binary",
+ binary_sha256=sha256_file(artifact),
+ binary_size_bytes=artifact.stat().st_size,
+ source_path=source,
+ cache_key=cache_key,
+ )
+ _write_metadata(cache_dir / "prepared-binary.json", prepared)
+ return prepared
+
+
+async def prepare_binary(spec: InstallSpec) -> PreparedBinary:
+ cache_key = install_spec_cache_key(spec)
+ async with _PREPARE_LOCK:
+ task = _PREPARE_TASKS.get(cache_key)
+ if task is None:
+ task = asyncio.create_task(
+ asyncio.to_thread(_prepare_binary_sync, spec, cache_key)
+ )
+ _PREPARE_TASKS[cache_key] = task
+ try:
+ return await task
+ except Exception:
+ async with _PREPARE_LOCK:
+ if _PREPARE_TASKS.get(cache_key) is task:
+ _PREPARE_TASKS.pop(cache_key, None)
+ raise
diff --git a/src/harbor/analyze/backend.py b/src/harbor/analyze/backend.py
new file mode 100644
index 00000000000..9efbdf182fc
--- /dev/null
+++ b/src/harbor/analyze/backend.py
@@ -0,0 +1,349 @@
+"""Unified backend for LLM analysis commands.
+
+This is the ONLY file in the analyze package that imports claude_agent_sdk.
+It wraps the SDK for use by check.py and analyze.py.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import sys
+import time
+from collections.abc import AsyncIterable, Awaitable, Callable
+from pathlib import Path
+from typing import Any
+
+from claude_agent_sdk import (
+ AssistantMessage,
+ ClaudeAgentOptions,
+ ResultMessage,
+ TextBlock,
+ ThinkingBlock,
+ ToolResultBlock,
+ ToolUseBlock,
+ UserMessage,
+ query,
+)
+
+from harbor.analyze.errors import AggregateTransportError
+
+# Linux passes the full prompt as a single argv element after `--print --`.
+# Per-argument limit is ~128 KiB (MAX_ARG_STRLEN); oversize raises Errno 7 (E2BIG).
+# Leave headroom for CLI flags, model name, and env wrapper overhead.
+_AGGREGATE_ARGV_PROMPT_MAX_BYTES = 120 * 1024
+
+# Claude Agent SDK buffers each stream-json stdout line (default 1 MiB). Stdin user
+# messages and Read tool results embed the full prompt; JSON escaping adds overhead.
+_AGGREGATE_STREAM_BUFFER_MIN_BYTES = 2 * 1024 * 1024
+
+_READ_AGGREGATE_PROMPT_TEMPLATE = (
+ "Read the file at {path} using the Read tool. "
+ "It contains the complete job aggregation prompt (trial summaries and instructions). "
+ "Follow those instructions and produce the job-level summary as plain text."
+)
+
+
+def _prompt_byte_length(prompt: str) -> int:
+ return len(prompt.encode("utf-8"))
+
+
+def _is_argv_transport_error(exc: BaseException) -> bool:
+ if isinstance(exc, OSError) and getattr(exc, "errno", None) == 7:
+ return True
+ msg = str(exc).lower()
+ return "argument list too long" in msg
+
+
+async def _prompt_as_stream(full_prompt: str):
+ yield {
+ "type": "user",
+ "message": {"role": "user", "content": full_prompt},
+ }
+
+
+def _write_aggregate_prompt_file(work_dir: Path, content: str) -> Path:
+ path = work_dir / f".harbor-aggregate-prompt-{int(time.time() * 1000)}.txt"
+ path.write_text(content, encoding="utf-8")
+ return path
+
+
+def _is_empty_text_result(result: str | dict[str, Any]) -> bool:
+ return isinstance(result, str) and not result.strip()
+
+
+def _aggregate_stream_buffer_size(prompt_bytes: int) -> int:
+ return max(
+ _AGGREGATE_STREAM_BUFFER_MIN_BYTES,
+ prompt_bytes * 2 + 512 * 1024,
+ )
+
+
+def normalize_model_name(model: str) -> str:
+ """Normalize model name for Claude Agent SDK.
+
+ Strips the "anthropic/" prefix if present, since the SDK accepts
+ the long model names directly (e.g., "claude-sonnet-4-6").
+
+ Examples:
+ "anthropic/claude-sonnet-4-6" -> "claude-sonnet-4-6"
+ "sonnet" -> "sonnet" (pass-through)
+ """
+ if model.startswith("anthropic/"):
+ return model[len("anthropic/") :]
+ return model
+
+
+def _print_verbose_message(message: AssistantMessage | UserMessage) -> None:
+ """Print verbose debug output to stderr (mirrors quality_checker.py pattern)."""
+ if isinstance(message, AssistantMessage):
+ for block in message.content:
+ if isinstance(block, ThinkingBlock):
+ print(f"\n-- Thinking --\n{block.thinking}", file=sys.stderr)
+ elif isinstance(block, TextBlock):
+ print(f"\n-- Text --\n{block.text}", file=sys.stderr)
+ elif isinstance(block, ToolUseBlock):
+ args = json.dumps(block.input, indent=2)
+ print(f"\n-- Tool: {block.name} --\n{args}", file=sys.stderr)
+ elif isinstance(message, UserMessage):
+ content = message.content
+ if isinstance(content, list):
+ for block in content:
+ if isinstance(block, ToolResultBlock):
+ text: Any = block.content
+ if text is None:
+ text = ""
+ if isinstance(text, list):
+ text = "\n".join(
+ item.get("text", "")
+ for item in text
+ if isinstance(item, dict)
+ )
+ preview = text[:500] + "..." if len(str(text)) > 500 else str(text)
+ print(
+ f"-- Result ({len(str(text))} chars) --\n{preview}",
+ file=sys.stderr,
+ )
+ elif isinstance(content, str) and content:
+ preview = content[:500] + "..." if len(content) > 500 else content
+ print(
+ f"-- Result ({len(content)} chars) --\n{preview}",
+ file=sys.stderr,
+ )
+
+
+async def _run_claude_query(
+ prompt: str | AsyncIterable[dict[str, Any]],
+ *,
+ model: str,
+ cwd: str,
+ tools: list[str] | None = None,
+ add_dirs: list[str] | None = None,
+ output_schema: dict[str, Any] | None = None,
+ verbose: bool = False,
+ sdk_env: dict[str, str] | None = None,
+ max_buffer_size: int | None = None,
+) -> str | dict[str, Any]:
+ inject = dict(sdk_env) if sdk_env else {}
+ effective_key = inject.get("ANTHROPIC_API_KEY")
+ if not effective_key and not os.environ.get("ANTHROPIC_API_KEY"):
+ raise RuntimeError(
+ "ANTHROPIC_API_KEY environment variable is required. "
+ "Set it with: export ANTHROPIC_API_KEY=sk-ant-..."
+ )
+
+ if tools is None:
+ tools = ["Read", "Glob", "Grep"]
+
+ options = ClaudeAgentOptions(
+ permission_mode="bypassPermissions",
+ allowed_tools=tools,
+ cwd=cwd,
+ model=normalize_model_name(model),
+ add_dirs=list(add_dirs) if add_dirs else [],
+ env=inject,
+ max_buffer_size=max_buffer_size,
+ )
+
+ if output_schema is not None:
+ options.max_thinking_tokens = 10000
+ options.output_format = {"type": "json_schema", "schema": output_schema}
+
+ if verbose:
+ if isinstance(prompt, str):
+ print(f"\n── Prompt ──\n{prompt}", file=sys.stderr)
+ else:
+ print("\n── Prompt ──\n(stream prompt)", file=sys.stderr)
+
+ structured_output: dict[str, Any] | None = None
+ text_parts: list[str] = []
+
+ async for message in query(prompt=prompt, options=options):
+ if isinstance(message, AssistantMessage):
+ for block in message.content:
+ if isinstance(block, ToolUseBlock) and block.name == "StructuredOutput":
+ structured_output = block.input
+ if output_schema is None and isinstance(block, TextBlock):
+ text_parts.append(block.text)
+
+ if verbose:
+ if isinstance(message, (AssistantMessage, UserMessage)):
+ _print_verbose_message(message)
+
+ if isinstance(message, ResultMessage):
+ if message.structured_output is not None:
+ structured_output = message.structured_output
+ if verbose:
+ cost = (
+ f"${message.total_cost_usd:.4f}"
+ if message.total_cost_usd is not None
+ else "N/A"
+ )
+ print(
+ f"\n-- Done: {message.num_turns} turns, {cost} --",
+ file=sys.stderr,
+ )
+
+ if output_schema is not None:
+ if structured_output is None:
+ raise ValueError("SDK did not return structured output")
+ return structured_output
+
+ return "\n".join(text_parts)
+
+
+async def query_agent(
+ prompt: str,
+ model: str,
+ cwd: str,
+ tools: list[str] | None = None,
+ add_dirs: list[str] | None = None,
+ output_schema: dict[str, Any] | None = None,
+ verbose: bool = False,
+ sdk_env: dict[str, str] | None = None,
+ max_buffer_size: int | None = None,
+) -> str | dict[str, Any]:
+ """Run a Claude Agent SDK query and return structured or text output.
+
+ Args:
+ prompt: The prompt to send to the agent.
+ model: Model short name (e.g. "sonnet", "opus", "haiku").
+ cwd: Working directory for the agent.
+ tools: List of allowed tool names. Defaults to ["Read", "Glob", "Grep"].
+ add_dirs: Additional directories the agent may access.
+ output_schema: If provided, request structured JSON output matching this schema.
+ verbose: If True, print thinking/tool calls/results to stderr.
+ sdk_env: If set, merged into ``ClaudeAgentOptions.env`` (does not mutate
+ ``os.environ``). When ``ANTHROPIC_API_KEY`` is absent here, the process
+ environment is still used for the key guard below.
+
+ Returns:
+ A dict if output_schema was provided, otherwise a concatenated text string.
+ """
+ return await _run_claude_query(
+ prompt=prompt,
+ model=model,
+ cwd=cwd,
+ tools=tools,
+ add_dirs=add_dirs,
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ max_buffer_size=max_buffer_size,
+ )
+
+
+async def query_llm(
+ prompt: str,
+ model: str,
+ *,
+ work_dir: Path,
+ output_schema: dict[str, Any] | None = None,
+ verbose: bool = False,
+ sdk_env: dict[str, str] | None = None,
+) -> str | dict[str, Any]:
+ """Run a plain LLM call (no tools, no file access).
+
+ Use this for non-agentic tasks like aggregating summaries where
+ all data is already in the prompt. Falls back to stdin and agent Read
+ transport when the prompt exceeds argv limits.
+ """
+ prompt_bytes = _prompt_byte_length(prompt)
+ stream_buffer_size = _aggregate_stream_buffer_size(prompt_bytes)
+ attempts: list[str] = []
+ last_error: str | None = None
+ prompt_file: str | None = None
+
+ async def _argv() -> str | dict[str, Any]:
+ return await _run_claude_query(
+ prompt=prompt,
+ model=model,
+ cwd=".",
+ tools=[],
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ max_buffer_size=stream_buffer_size,
+ )
+
+ async def _stdin() -> str | dict[str, Any]:
+ return await _run_claude_query(
+ prompt=_prompt_as_stream(prompt),
+ model=model,
+ cwd=".",
+ tools=[],
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ max_buffer_size=stream_buffer_size,
+ )
+
+ async def _agent_read() -> str | dict[str, Any]:
+ nonlocal prompt_file
+ path = _write_aggregate_prompt_file(work_dir, prompt)
+ prompt_file = path.name
+ short = _READ_AGGREGATE_PROMPT_TEMPLATE.format(path=path.resolve())
+ return await _run_claude_query(
+ prompt=short,
+ model=model,
+ cwd=str(work_dir),
+ tools=["Read"],
+ add_dirs=[str(work_dir)],
+ output_schema=output_schema,
+ verbose=verbose,
+ sdk_env=sdk_env,
+ max_buffer_size=stream_buffer_size,
+ )
+
+ steps: list[tuple[str, Callable[[], Awaitable[str | dict[str, Any]]]]] = []
+ if prompt_bytes <= _AGGREGATE_ARGV_PROMPT_MAX_BYTES:
+ steps.append(("argv", _argv))
+ steps.append(("stdin", _stdin))
+ steps.append(("agent_read", _agent_read))
+
+ for name, fn in steps:
+ attempts.append(name)
+ try:
+ result = await fn()
+ if _is_empty_text_result(result):
+ last_error = "ValueError: LLM returned empty text"
+ continue
+ if name == "agent_read" and prompt_file:
+ read_path = work_dir / prompt_file
+ if read_path.exists():
+ read_path.unlink(missing_ok=True)
+ prompt_file = None
+ return result
+ except Exception as e:
+ last_error = f"{type(e).__name__}: {e}"
+ if name == "argv" and not _is_argv_transport_error(e):
+ raise
+ continue
+
+ raise AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=prompt_bytes,
+ attempts=attempts,
+ last_error=last_error,
+ prompt_file=prompt_file,
+ )
diff --git a/src/harbor/analyze/errors.py b/src/harbor/analyze/errors.py
new file mode 100644
index 00000000000..6a7e99935f3
--- /dev/null
+++ b/src/harbor/analyze/errors.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass
+class AggregateTransportError(Exception):
+ """All job-aggregate LLM transport attempts failed."""
+
+ reason: str
+ prompt_bytes: int
+ attempts: list[str]
+ last_error: str | None
+ prompt_file: str | None
+
+ def to_dict(self) -> dict[str, object]:
+ return {
+ "reason": self.reason,
+ "prompt_bytes": self.prompt_bytes,
+ "attempts": list(self.attempts),
+ "last_error": self.last_error,
+ "prompt_file": self.prompt_file,
+ }
diff --git a/src/harbor/analyze/profiles.py b/src/harbor/analyze/profiles.py
new file mode 100644
index 00000000000..13df5328fa8
--- /dev/null
+++ b/src/harbor/analyze/profiles.py
@@ -0,0 +1,274 @@
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from urllib.parse import urlparse
+
+import tomllib
+from pydantic import BaseModel, Field
+
+
+class ProfilesConfigurationError(ValueError):
+ pass
+
+
+class AnalyzeModelRow(BaseModel):
+ id: str
+ display_name: str = ""
+ api_model: str
+
+
+class AnalyzeProfileDoc(BaseModel):
+ id: str
+ label: str
+ api_key_env: str = Field(..., min_length=1)
+ base_url_env: str | None = None
+ default_model: str
+ models: list[AnalyzeModelRow]
+
+
+class AnalyzeProfilesDocument(BaseModel):
+ profiles: list[AnalyzeProfileDoc]
+ external_job_report_base_url: str | None = None
+
+ def require_profile(self, profile_id: str) -> AnalyzeProfileDoc:
+ for p in self.profiles:
+ if p.id == profile_id:
+ return p
+ raise KeyError(profile_id)
+
+
+@dataclass(frozen=True)
+class SdkEnvInstructions:
+ api_key_env: str
+ base_url_env: str | None
+ inject: dict[str, str]
+
+
+def built_in_profiles() -> AnalyzeProfilesDocument:
+ anthropic_models = [
+ AnalyzeModelRow(
+ id="haiku",
+ display_name="Haiku (recommended)",
+ api_model="haiku",
+ ),
+ AnalyzeModelRow(id="sonnet", display_name="Sonnet", api_model="sonnet"),
+ AnalyzeModelRow(id="opus", display_name="Opus", api_model="opus"),
+ ]
+ return AnalyzeProfilesDocument(
+ profiles=[
+ AnalyzeProfileDoc(
+ id="anthropic",
+ label="Anthropic (direct)",
+ api_key_env="ANTHROPIC_API_KEY",
+ base_url_env=None,
+ default_model="haiku",
+ models=anthropic_models,
+ )
+ ]
+ )
+
+
+def _require_profile_key(block: dict[str, object], key: str) -> object:
+ if key not in block:
+ raise ProfilesConfigurationError(
+ f"profile missing required key {key!r}",
+ )
+ return block[key]
+
+
+def _external_job_report_base_url(raw: object) -> str | None:
+ if raw is None:
+ return None
+ if not isinstance(raw, str):
+ raise ProfilesConfigurationError(
+ "external_job_report_base_url must be a string"
+ )
+ base_url = raw.rstrip("/")
+ if not base_url:
+ raise ProfilesConfigurationError(
+ "external_job_report_base_url must be a non-empty HTTP or HTTPS URL"
+ )
+ parsed = urlparse(base_url)
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
+ raise ProfilesConfigurationError(
+ "external_job_report_base_url must be a non-empty HTTP or HTTPS URL"
+ )
+ return base_url
+
+
+def load_profiles_from_file(path: Path) -> AnalyzeProfilesDocument:
+ raw = tomllib.loads(path.read_text(encoding="utf-8"))
+ external_job_report_base_url = _external_job_report_base_url(
+ raw.get("external_job_report_base_url")
+ )
+ rows = raw.get("profile") or raw.get("profiles")
+ if rows is None:
+ raise ProfilesConfigurationError("TOML must contain [[profile]] entries")
+ profs: list[AnalyzeProfileDoc] = []
+ seen: set[str] = set()
+ for block in rows:
+ if not isinstance(block, dict):
+ raise ProfilesConfigurationError("Each profile must be a TOML table")
+ models_raw = block.get("model") or []
+ pid = str(_require_profile_key(block, "id"))
+ if pid in seen:
+ raise ProfilesConfigurationError(f"Duplicate profile id: {pid!r}")
+ seen.add(pid)
+ api_key_env = str(_require_profile_key(block, "api_key_env"))
+ default_model = str(_require_profile_key(block, "default_model"))
+ label_raw = block.get("label", pid)
+ label = str(label_raw) if label_raw is not None else pid
+ base_url_raw = block.get("base_url_env")
+ base_url_env = str(base_url_raw) if base_url_raw is not None else None
+ model_rows: list[AnalyzeModelRow] = []
+ for m in models_raw:
+ if not isinstance(m, dict):
+ raise ProfilesConfigurationError("Each profile.model must be a table")
+ mid = str(_require_profile_key(m, "id"))
+ api_model = str(_require_profile_key(m, "api_model"))
+ dn_raw = m.get("display_name", mid)
+ display_name = str(dn_raw) if dn_raw is not None else mid
+ model_rows.append(
+ AnalyzeModelRow(
+ id=mid,
+ display_name=display_name,
+ api_model=api_model,
+ )
+ )
+ profs.append(
+ AnalyzeProfileDoc(
+ id=pid,
+ label=label,
+ api_key_env=api_key_env,
+ base_url_env=base_url_env,
+ default_model=default_model,
+ models=model_rows,
+ )
+ )
+ if not profs[-1].models:
+ raise ProfilesConfigurationError(f"profile {pid!r} has empty models")
+
+ doc = AnalyzeProfilesDocument(
+ profiles=profs,
+ external_job_report_base_url=external_job_report_base_url,
+ )
+
+ dup_model_ids = []
+ for p in doc.profiles:
+ ids = [m.id for m in p.models]
+ if len(ids) != len(set(ids)):
+ dup_model_ids.append(p.id)
+
+ if dup_model_ids:
+ raise ProfilesConfigurationError(
+ f"Duplicate model ids inside profiles: {dup_model_ids!r}"
+ )
+
+ return doc
+
+
+def profiles_for_public_api(doc: AnalyzeProfilesDocument) -> list[dict[str, object]]:
+ out: list[dict[str, object]] = []
+ for p in doc.profiles:
+ item: dict[str, object] = {
+ "id": p.id,
+ "label": p.label,
+ "default_model": p.default_model,
+ "models": [
+ {"id": m.id, "display_name": m.display_name, "api_model": m.api_model}
+ for m in p.models
+ ],
+ "api_key_env": p.api_key_env,
+ }
+ if p.base_url_env:
+ item["base_url_env"] = p.base_url_env
+ out.append(item)
+ return out
+
+
+def profiles_document_for_public_api(
+ doc: AnalyzeProfilesDocument,
+) -> dict[str, object]:
+ out: dict[str, object] = {"profiles": profiles_for_public_api(doc)}
+ if doc.external_job_report_base_url:
+ out["external_job_report"] = {
+ "base_url": doc.external_job_report_base_url,
+ }
+ return out
+
+
+def _resolve_profile_id(profile_id: str | None, doc: AnalyzeProfilesDocument) -> str:
+ if profile_id:
+ return profile_id
+ return doc.profiles[0].id
+
+
+def _missing_env_message(name: str) -> str:
+ return (
+ f"Required environment variable {name!r} is not set or empty "
+ "(load credentials via .env or your process manager)."
+ )
+
+
+def resolve_summarize_invoke(
+ doc: AnalyzeProfilesDocument,
+ *,
+ profile_id: str | None,
+ logical_model_id: str,
+) -> tuple[str, SdkEnvInstructions]:
+ """Returns (api_model, instructions wired to ANT keys).
+
+ FastAPI MUST merge ``TrialSummarizeRequest`` / ``SummarizeRequest`` into a single
+ ``logical_model_id`` **before** calling this (critical because pydantic defaults
+ ``model=\"haiku\"`` even when omitted from JSON):
+
+ ```python
+ payload = req.model_dump(exclude_unset=True)
+ if "model_id" in payload:
+ logical = req.model_id # assumed non-null if key present (validate length)
+ elif "profile_id" in payload:
+ logical = doc.require_profile(req.profile_id).default_model
+ else:
+ logical = req.model
+ ```
+ """
+ pid = _resolve_profile_id(profile_id, doc)
+ profile = doc.require_profile(pid)
+
+ mid = logical_model_id
+ model_row = None
+ for m in profile.models:
+ if m.id == mid:
+ model_row = m
+ break
+ if model_row is None:
+ allowed = ", ".join(sorted(mm.id for mm in profile.models))
+ raise ProfilesConfigurationError(
+ f"Unknown model_id {mid!r} for profile {pid!r}; allowed: {allowed}"
+ )
+
+ api_model = model_row.api_model
+
+ inject: dict[str, str] = {}
+ key_val = os.getenv(profile.api_key_env)
+ if not key_val:
+ raise ProfilesConfigurationError(
+ _missing_env_message(profile.api_key_env),
+ )
+ inject["ANTHROPIC_API_KEY"] = key_val
+
+ base_url_env = profile.base_url_env
+ if base_url_env:
+ bu_val = os.getenv(base_url_env)
+ if not bu_val:
+ raise ProfilesConfigurationError(_missing_env_message(base_url_env))
+ inject["ANTHROPIC_BASE_URL"] = bu_val
+
+ instructions = SdkEnvInstructions(
+ api_key_env=profile.api_key_env,
+ base_url_env=profile.base_url_env,
+ inject=inject,
+ )
+ return api_model, instructions
diff --git a/src/harbor/cli/view.py b/src/harbor/cli/view.py
index cc7b6c519ff..480ef50653b 100644
--- a/src/harbor/cli/view.py
+++ b/src/harbor/cli/view.py
@@ -6,16 +6,25 @@
from pathlib import Path
from typing import Annotated
+from dotenv import load_dotenv
from rich.console import Console
from typer import Argument, Option
console = Console(stderr=True)
+# Repository root, used for local private configuration such as .env.
+REPO_ROOT = Path(__file__).parent.parent.parent.parent
+
# Path to static viewer files (built in CI)
STATIC_DIR = Path(__file__).parent.parent / "viewer" / "static"
# Path to viewer source (for dev mode)
-VIEWER_DIR = Path(__file__).parent.parent.parent.parent / "apps" / "viewer"
+VIEWER_DIR = REPO_ROOT / "apps" / "viewer"
+
+
+def _load_repo_dotenv(repo_root: Path = REPO_ROOT) -> None:
+ """Load repo-local .env without overriding explicit process environment."""
+ load_dotenv(repo_root / ".env", override=False)
def _parse_port_range(port_str: str) -> tuple[int, int]:
@@ -194,6 +203,13 @@ def view_command(
help="Force jobs mode",
),
] = False,
+ analyze_profiles: Annotated[
+ Path | None,
+ Option(
+ "--analyze-profiles",
+ help="TOML file listing analyze profiles (non-secret metadata)",
+ ),
+ ] = None,
) -> None:
"""Start a web server to browse jobs or task definitions.
@@ -207,6 +223,8 @@ def view_command(
harbor view ./jobs --port 9000
harbor view ./jobs --dev
"""
+ _load_repo_dotenv()
+
folder = folder.expanduser().resolve()
if not folder.exists():
console.print(f"[red]Error:[/red] Folder '{folder}' does not exist")
@@ -233,6 +251,11 @@ def view_command(
)
raise SystemExit(1)
+ ap_resolved: Path | None = None
+ if analyze_profiles is not None:
+ ap_resolved = analyze_profiles.expanduser().resolve()
+ os.environ["HARBOR_ANALYZE_PROFILES"] = str(ap_resolved)
+
if dev:
if build:
console.print(
@@ -243,7 +266,13 @@ def view_command(
_run_dev_mode(folder, host, backend_port, mode=mode)
else:
_run_production_mode(
- folder, host, backend_port, mode=mode, no_build=no_build, build=build
+ folder,
+ host,
+ backend_port,
+ mode=mode,
+ no_build=no_build,
+ build=build,
+ analyze_profiles_file=ap_resolved,
)
@@ -255,6 +284,7 @@ def _run_production_mode(
mode: str = "jobs",
no_build: bool = False,
build: bool = False,
+ analyze_profiles_file: Path | None = None,
) -> None:
"""Run in production mode with static files served from the package."""
import uvicorn
@@ -300,15 +330,18 @@ def _run_production_mode(
console.print(" Use --dev flag for development mode with hot reloading.")
console.print()
- app = create_app(folder, mode=mode, static_dir=static_dir)
+ app = create_app(
+ folder,
+ mode=mode,
+ static_dir=static_dir,
+ analyze_profiles_file=analyze_profiles_file,
+ )
folder_label = "Tasks folder" if mode == "tasks" else "Jobs folder"
console.print("[green]Starting Harbor Viewer[/green]")
console.print(f" {folder_label}: {folder}")
console.print(f" Mode: {mode}")
console.print(f" Server: http://{host}:{port}")
- if static_dir is None:
- console.print(f" API docs: http://{host}:{port}/docs")
console.print()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
diff --git a/src/harbor/models/agent/name.py b/src/harbor/models/agent/name.py
index 0644f0c1728..0313f9c91c8 100644
--- a/src/harbor/models/agent/name.py
+++ b/src/harbor/models/agent/name.py
@@ -11,6 +11,7 @@ class AgentName(str, Enum):
TERMINUS_1 = "terminus-1"
TERMINUS_2 = "terminus-2"
AIDER = "aider"
+ BITFUN_CLI = "bitfun-cli"
CODEX = "codex"
CURSOR_CLI = "cursor-cli"
GEMINI_CLI = "gemini-cli"
@@ -36,6 +37,7 @@ class AgentName(str, Enum):
COMPUTER_1 = "computer-1"
EVE = "eve"
DSPY_RLM = "dspy-rlm"
+ CODEAGENT = "codeagent"
@classmethod
def values(cls) -> set[str]:
diff --git a/src/harbor/viewer/__init__.py b/src/harbor/viewer/__init__.py
index 1e9a5c52aa4..d206174e3bc 100644
--- a/src/harbor/viewer/__init__.py
+++ b/src/harbor/viewer/__init__.py
@@ -18,7 +18,9 @@ def create_app_from_env():
if not folder:
raise RuntimeError("HARBOR_VIEWER_FOLDER environment variable not set")
mode = os.environ.get("HARBOR_VIEWER_MODE", "jobs")
- return create_app(Path(folder), mode=mode)
+ ap_path = os.environ.get("HARBOR_ANALYZE_PROFILES")
+ ap_file = Path(ap_path).expanduser() if ap_path else None
+ return create_app(Path(folder), mode=mode, analyze_profiles_file=ap_file)
__all__ = ["create_app", "create_app_from_env"]
diff --git a/src/harbor/viewer/server.py b/src/harbor/viewer/server.py
index f7fb5310761..af78f2d2af7 100644
--- a/src/harbor/viewer/server.py
+++ b/src/harbor/viewer/server.py
@@ -11,6 +11,7 @@
import sys
import tempfile
import textwrap
+from contextlib import asynccontextmanager
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
@@ -40,6 +41,15 @@
from harbor.agents.factory import AgentFactory
from harbor.agents.installed.base import BaseInstalledAgent, CliFlag, EnvVar
+from harbor.analyze.errors import AggregateTransportError
+from harbor.analyze.profiles import (
+ AnalyzeProfilesDocument,
+ ProfilesConfigurationError,
+ built_in_profiles,
+ load_profiles_from_file,
+ profiles_document_for_public_api,
+ resolve_summarize_invoke,
+)
from harbor.db.types import PublicJobVisibility
from harbor.models.agent.name import AgentName
from harbor.models.environment_type import EnvironmentType
@@ -88,6 +98,9 @@ class SummarizeRequest(BaseModel):
environment: str = "docker"
n_concurrent: int = 32
only_failed: bool = False
+ overwrite: bool = False
+ profile_id: str | None = None
+ model_id: str | None = None
class TrialSummarizeRequest(BaseModel):
@@ -96,6 +109,8 @@ class TrialSummarizeRequest(BaseModel):
model: str = "haiku"
agent: str = "claude-code"
environment: str = "docker"
+ profile_id: str | None = None
+ model_id: str | None = None
class UploadJobRequest(BaseModel):
@@ -184,10 +199,46 @@ def _started_at_sort_key(started_at: datetime | None) -> tuple[bool, float]:
RECORDING_MEDIA_TYPE = "video/mp4"
+def _bootstrap_profiles(
+ analyze_profiles_file: Path | None,
+) -> AnalyzeProfilesDocument:
+ if analyze_profiles_file is None:
+ return built_in_profiles()
+ path = analyze_profiles_file.expanduser().resolve()
+ if not path.is_file():
+ raise RuntimeError(
+ f"HARBOR_ANALYZE_PROFILES points to missing file: {path}",
+ )
+ return load_profiles_from_file(path)
+
+
+def trial_summarize_model_resolution(
+ doc: AnalyzeProfilesDocument,
+ request: TrialSummarizeRequest | SummarizeRequest,
+) -> tuple[str | None, str]:
+ """Return (requested_profile_id, logical_model_row_id)."""
+ data = request.model_dump(exclude_unset=True)
+
+ if "model_id" in data:
+ if not request.model_id:
+ raise HTTPException(status_code=422, detail="model_id cannot be empty")
+ return request.profile_id, request.model_id
+
+ if "profile_id" in data:
+ if not request.profile_id:
+ raise HTTPException(status_code=422, detail="profile_id cannot be empty")
+ profile = doc.require_profile(request.profile_id)
+ return request.profile_id, profile.default_model
+
+ return None, request.model
+
+
def create_app(
folder: Path,
mode: str = "jobs",
static_dir: Path | None = None,
+ *,
+ analyze_profiles_file: Path | None = None,
) -> FastAPI:
"""Create the FastAPI application with routes configured for the given directory.
@@ -195,11 +246,22 @@ def create_app(
folder: Directory containing job/trial data or task definitions
mode: "jobs" for job viewer, "tasks" for task definition browser
static_dir: Optional directory containing static viewer files (index.html, assets/)
+ analyze_profiles_file: Optional path to TOML analyze profiles (non-secret metadata).
"""
+ analyze_profiles = _bootstrap_profiles(analyze_profiles_file)
+
+ @asynccontextmanager
+ async def lifespan(app: FastAPI):
+ yield
+
app = FastAPI(
title="Harbor Viewer",
description="API for browsing Harbor jobs and trials",
version="0.1.0",
+ openapi_url=None,
+ docs_url=None,
+ redoc_url=None,
+ lifespan=lifespan,
)
# Allow CORS for local development
@@ -225,6 +287,10 @@ def get_config() -> dict[str, Any]:
"environments": [e.value for e in EnvironmentType],
}
+ @app.get("/api/analyze/profiles")
+ def analyze_profiles_endpoint() -> dict[str, Any]:
+ return profiles_document_for_public_api(analyze_profiles)
+
@app.get("/api/pricing", response_model=ModelPricing)
def get_model_pricing(
model: str = Query(
@@ -270,7 +336,7 @@ def get_model_pricing(
if mode == "tasks":
_register_task_endpoints(app, folder)
else:
- _register_job_endpoints(app, folder)
+ _register_job_endpoints(app, folder, analyze_profiles)
_register_run_endpoints(app, folder)
_register_auth_endpoints(app)
@@ -1240,7 +1306,11 @@ def stop_run(job_name: str) -> dict[str, bool]:
return {"stopped": True}
-def _register_job_endpoints(app: FastAPI, jobs_dir: Path) -> None:
+def _register_job_endpoints(
+ app: FastAPI,
+ jobs_dir: Path,
+ analyze_profiles: AnalyzeProfilesDocument,
+) -> None:
"""Register API endpoints for job browsing."""
scanner = JobScanner(jobs_dir)
@@ -1543,32 +1613,78 @@ def get_job_analysis(job_name: str) -> dict[str, Any]:
return {}
@app.post("/api/jobs/{job_name}/summarize")
- async def summarize_job(job_name: str, request: SummarizeRequest) -> dict[str, int]:
+ async def summarize_job(
+ job_name: str, request: SummarizeRequest
+ ) -> dict[str, str | int | bool | None]:
"""Analyze every trial in a job as a Harbor job (harbor analyze)."""
job_dir = _validate_job_path(job_name)
if not job_dir.exists():
raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")
+ analysis_path = job_dir / "analysis.md"
+ if not request.overwrite and analysis_path.exists():
+ try:
+ return {
+ "summary": analysis_path.read_text(),
+ "n_trials_summarized": 0,
+ "job_summary_created": False,
+ }
+ except Exception:
+ pass
+
from harbor.analyze.analyzer import run_analyze
+ profile_id_hint, logical_model_id = trial_summarize_model_resolution(
+ analyze_profiles, request
+ )
+ try:
+ api_model, instructions = resolve_summarize_invoke(
+ analyze_profiles,
+ profile_id=profile_id_hint,
+ logical_model_id=logical_model_id,
+ )
+ except KeyError:
+ raise HTTPException(
+ status_code=422,
+ detail="Unknown analyze profile",
+ ) from None
+ except ProfilesConfigurationError as e:
+ raise HTTPException(status_code=422, detail=str(e)) from e
+
filter_passing: bool | None = False if request.only_failed else None
try:
report, _ = await run_analyze(
path=job_dir,
agent=request.agent,
- model=request.model,
+ model=api_model,
environment=EnvironmentType(request.environment),
n_concurrent=request.n_concurrent,
filter_passing=filter_passing,
jobs_dir=jobs_dir,
+ agent_env=instructions.inject,
)
+ except AggregateTransportError as e:
+ raise HTTPException(status_code=422, detail=e.to_dict()) from e
except ValueError as e:
if "trial directories found" in str(e):
- return {"n_trials_analyzed": 0}
- raise
+ return {
+ "summary": None,
+ "n_trials_summarized": 0,
+ "job_summary_created": False,
+ }
+ raise HTTPException(status_code=422, detail=str(e)) from e
(job_dir / "analysis.json").write_text(report.model_dump_json(indent=2))
- return {"n_trials_analyzed": sum(1 for r in report.results if not r.error)}
+ n_trials_summarized = sum(1 for r in report.results if not r.error)
+ summaries = [r.summary for r in report.results if r.summary and not r.error]
+ job_summary = "\n\n".join(summaries) if summaries else None
+ if job_summary:
+ analysis_path.write_text(job_summary)
+ return {
+ "summary": job_summary,
+ "n_trials_summarized": n_trials_summarized,
+ "job_summary_created": n_trials_summarized > 0,
+ }
@app.get("/api/jobs/{job_name}/upload")
async def get_upload_status(job_name: str) -> dict[str, Any]:
@@ -2373,13 +2489,36 @@ async def summarize_trial(
from harbor.analyze.analyzer import run_analyze
- report, _ = await run_analyze(
- path=trial_dir,
- agent=request.agent,
- model=request.model,
- environment=EnvironmentType(request.environment),
- jobs_dir=jobs_dir,
+ profile_id_hint, logical_model_id = trial_summarize_model_resolution(
+ analyze_profiles, request
)
+ try:
+ api_model, instructions = resolve_summarize_invoke(
+ analyze_profiles,
+ profile_id=profile_id_hint,
+ logical_model_id=logical_model_id,
+ )
+ except KeyError:
+ raise HTTPException(
+ status_code=422,
+ detail="Unknown analyze profile",
+ ) from None
+ except ProfilesConfigurationError as e:
+ raise HTTPException(status_code=422, detail=str(e)) from e
+
+ try:
+ report, _ = await run_analyze(
+ path=trial_dir,
+ agent=request.agent,
+ model=api_model,
+ environment=EnvironmentType(request.environment),
+ jobs_dir=jobs_dir,
+ agent_env=instructions.inject,
+ )
+ except AggregateTransportError as e:
+ raise HTTPException(status_code=422, detail=e.to_dict()) from e
+ except ValueError as e:
+ raise HTTPException(status_code=422, detail=str(e)) from e
result = report.results[0]
if result.error:
raise HTTPException(status_code=500, detail=result.error)
@@ -2412,6 +2551,83 @@ def get_trajectory(
status_code=500, detail="Failed to parse trajectory.json"
)
+ @app.get("/api/jobs/{job_name}/trajectory-stats")
+ def get_trajectory_stats(job_name: str) -> dict[str, Any]:
+ """Compute aggregate trajectory statistics across all trials in a job."""
+ job_dir = _validate_job_path(job_name)
+ if not job_dir.exists():
+ raise HTTPException(status_code=404, detail=f"Job '{job_name}' not found")
+
+ total_tool_calls = 0
+ total_model_calls = 0
+ total_input_tokens = 0
+ total_cached_tokens = 0
+ has_token_data = False
+ n_trajectories = 0
+
+ for trial_dir in job_dir.iterdir():
+ if not trial_dir.is_dir():
+ continue
+ traj_path = trial_dir / "agent" / "trajectory.json"
+ if not traj_path.is_file():
+ continue
+ try:
+ traj = json.loads(traj_path.read_text())
+ except (OSError, json.JSONDecodeError):
+ continue
+
+ tool_calls = 0
+ model_calls = 0
+ for step in traj.get("steps", []):
+ if step.get("tool_calls"):
+ tool_calls += len(step["tool_calls"])
+ if step.get("source") == "agent":
+ model_calls += 1
+
+ total_tool_calls += tool_calls
+ total_model_calls += model_calls
+
+ # Aggregate token counts from main + subagent final_metrics
+ fm = traj.get("final_metrics") or {}
+ prompt = fm.get("total_prompt_tokens") or 0
+ cached = fm.get("total_cached_tokens") or 0
+ for sub in traj.get("subagent_trajectories") or []:
+ sfm = sub.get("final_metrics") or {}
+ sub_prompt = sfm.get("total_prompt_tokens") or 0
+ sub_cached = sfm.get("total_cached_tokens") or 0
+ # When subagent has cached but no prompt data, treat cached
+ # as a lower-bound estimate for prompt (cached <= prompt).
+ if sub_prompt == 0 and sub_cached > 0:
+ sub_prompt = sub_cached
+ prompt += sub_prompt
+ cached += sub_cached
+ if prompt > 0 or cached > 0:
+ has_token_data = True
+ total_input_tokens += prompt
+ total_cached_tokens += cached
+
+ n_trajectories += 1
+
+ result: dict[str, Any] = {
+ "n_trajectories": n_trajectories,
+ "avg_tool_calls": None,
+ "avg_model_calls": None,
+ "cache_hit_rate": None,
+ }
+
+ if n_trajectories == 0:
+ return result
+
+ result["avg_tool_calls"] = round(total_tool_calls / n_trajectories, 1)
+ result["avg_model_calls"] = round(total_model_calls / n_trajectories, 1)
+
+ if has_token_data and total_input_tokens > 0:
+ result["cache_hit_rate"] = round(
+ total_cached_tokens / total_input_tokens, 4
+ )
+
+ return result
+
@app.get("/api/jobs/{job_name}/trials/{trial_name}/verifier-output")
def get_verifier_output(
job_name: str,
diff --git a/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001-sub/metadata.json b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001-sub/metadata.json
new file mode 100644
index 00000000000..639e2626a34
--- /dev/null
+++ b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001-sub/metadata.json
@@ -0,0 +1 @@
+{"schema_version": 2, "sessionId": "bitfun-golden-001-sub", "sessionName": "test", "agentType": "agentic", "sessionKind": "subagent", "modelName": "openai/gpt-5", "createdAt": 1778000000200, "lastActiveAt": 1778000000280, "turnCount": 1, "messageCount": 2, "toolCallCount": 0, "status": "completed", "tags": [], "workspacePath": "/testbed", "workspaceHostname": "localhost"}
\ No newline at end of file
diff --git a/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001-sub/turns/turn-0000.json b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001-sub/turns/turn-0000.json
new file mode 100644
index 00000000000..c8b163333e2
--- /dev/null
+++ b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001-sub/turns/turn-0000.json
@@ -0,0 +1 @@
+{"schema_version": 2, "turnId": "bitfun-golden-001-sub-turn", "turnIndex": 0, "sessionId": "bitfun-golden-001-sub", "timestamp": 1778000000200, "kind": "user_dialog", "userMessage": {"id": "bitfun-golden-001-sub-turn-user", "content": "\ndo thing\n ", "timestamp": 1778000000200, "metadata": {"original_text": "do thing"}}, "modelRounds": [{"id": "bitfun-golden-001-sub-round", "turnId": "bitfun-golden-001-sub-turn", "roundIndex": 0, "timestamp": 1778000000250, "textItems": [{"id": "bitfun-golden-001-sub-ti", "content": "did it", "isStreaming": false, "timestamp": 1778000000260, "isMarkdown": true, "orderIndex": 0, "status": "completed"}], "toolItems": [], "thinkingItems": [], "startTime": 1778000000250, "endTime": 1778000000260, "durationMs": 10, "providerId": "openai", "modelId": "openai/gpt-5", "modelAlias": null, "attemptCount": 1, "status": "completed"}], "startTime": 1778000000200, "endTime": 1778000000300, "durationMs": 100, "status": "completed"}
\ No newline at end of file
diff --git a/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/metadata.json b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/metadata.json
new file mode 100644
index 00000000000..49e8e1f0375
--- /dev/null
+++ b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/metadata.json
@@ -0,0 +1 @@
+{"schema_version": 2, "sessionId": "bitfun-golden-001", "sessionName": "test", "agentType": "agentic", "sessionKind": "standard", "modelName": "openai/gpt-5", "createdAt": 1778000000000, "lastActiveAt": 1778000000320, "turnCount": 2, "messageCount": 4, "toolCallCount": 1, "status": "completed", "tags": [], "workspacePath": "/testbed", "workspaceHostname": "localhost"}
\ No newline at end of file
diff --git a/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/turns/turn-0000.json b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/turns/turn-0000.json
new file mode 100644
index 00000000000..85057927dd1
--- /dev/null
+++ b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/turns/turn-0000.json
@@ -0,0 +1 @@
+{"schema_version": 2, "turnId": "main-turn-0", "turnIndex": 0, "sessionId": "bitfun-golden-001", "timestamp": 1778000000000, "kind": "user_dialog", "userMessage": {"id": "main-turn-0-user", "content": "\nplease help\n ", "timestamp": 1778000000000, "metadata": {"original_text": "please help"}}, "modelRounds": [{"id": "main-round-0", "turnId": "main-turn-0", "roundIndex": 0, "timestamp": 1778000000050, "textItems": [{"id": "ti-0", "content": "Delegating now.", "isStreaming": false, "timestamp": 1778000000080, "isMarkdown": true, "orderIndex": 1, "status": "completed"}], "toolItems": [{"id": "tc-1", "toolName": "Task", "toolCall": {"id": "tc-1", "input": {"description": "delegate to subagent"}}, "startTime": 1778000000150, "endTime": 1778000000190, "durationMs": 40, "executionMs": 40, "orderIndex": 2, "status": "completed", "toolResult": {"success": true, "result": {"output": "subagent done"}, "resultForAssistant": "subagent done", "durationMs": 40}, "aiIntent": "dispatch subagent to do the thing", "isSubagentItem": true, "subagentSessionId": "bitfun-golden-001-sub", "subagentModelId": "openai/gpt-5"}], "thinkingItems": [{"id": "th-0", "content": "I should delegate.", "isStreaming": false, "isCollapsed": false, "timestamp": 1778000000060, "orderIndex": 0}], "startTime": 1778000000050, "endTime": 1778000000060, "durationMs": 10, "providerId": "openai", "modelId": "openai/gpt-5", "modelAlias": null, "attemptCount": 1, "status": "completed"}], "startTime": 1778000000000, "endTime": 1778000000100, "durationMs": 100, "status": "completed"}
\ No newline at end of file
diff --git a/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/turns/turn-0001.json b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/turns/turn-0001.json
new file mode 100644
index 00000000000..4589b7f0c4f
--- /dev/null
+++ b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/sessions/bitfun-golden-001/turns/turn-0001.json
@@ -0,0 +1 @@
+{"schema_version": 2, "turnId": "main-turn-1", "turnIndex": 1, "sessionId": "bitfun-golden-001", "timestamp": 1778000000300, "kind": "manual_compaction", "userMessage": {"id": "main-turn-1-user", "content": "\n\n ", "timestamp": 1778000000300, "metadata": {}}, "modelRounds": [], "startTime": 1778000000300, "endTime": 1778000000400, "durationMs": 100, "status": "completed"}
\ No newline at end of file
diff --git a/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/token_usage/records/2026-01-01.json b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/token_usage/records/2026-01-01.json
new file mode 100644
index 00000000000..08e6ef0c570
--- /dev/null
+++ b/tests/golden/bitfun_cli/bitfun-golden-001/bitfun/token_usage/records/2026-01-01.json
@@ -0,0 +1 @@
+{"records": [{"model_id": "openai/gpt-5", "session_id": "bitfun-golden-001", "turn_id": "main-turn-0", "timestamp": "2026-05-05T16:53:20.100000Z", "input_tokens": 120, "output_tokens": 80, "cached_tokens": 10, "cached_tokens_available": true, "total_tokens": 200, "is_subagent": false, "token_details": {}}, {"model_id": "openai/gpt-5", "session_id": "bitfun-golden-001-sub", "turn_id": "bitfun-golden-001-sub-turn", "timestamp": "2026-05-05T16:53:20.260000Z", "input_tokens": 40, "output_tokens": 20, "cached_tokens": 0, "cached_tokens_available": false, "total_tokens": 60, "is_subagent": true, "token_details": {}}]}
\ No newline at end of file
diff --git a/tests/golden/bitfun_cli/bitfun-golden-001/expected_trajectory.json b/tests/golden/bitfun_cli/bitfun-golden-001/expected_trajectory.json
new file mode 100644
index 00000000000..695181db172
--- /dev/null
+++ b/tests/golden/bitfun_cli/bitfun-golden-001/expected_trajectory.json
@@ -0,0 +1,225 @@
+{
+ "schema_version": "ATIF-v1.7",
+ "session_id": "bitfun-golden-001",
+ "agent": {
+ "name": "bitfun-cli",
+ "version": "0.0.1",
+ "model_name": "openai/gpt-5",
+ "extra": {
+ "agent_type": "agentic",
+ "session_kind": "standard",
+ "workspace_path": "/testbed",
+ "schema_version": 2
+ }
+ },
+ "steps": [
+ {
+ "step_id": 1,
+ "timestamp": "2026-05-05T16:53:20Z",
+ "source": "user",
+ "message": "please help",
+ "extra": {
+ "turn_id": "main-turn-0",
+ "turn_index": 0,
+ "turn_kind": "user_dialog",
+ "user_message_id": "main-turn-0-user"
+ }
+ },
+ {
+ "step_id": 2,
+ "timestamp": "2026-05-05T16:53:20.080000Z",
+ "source": "agent",
+ "model_name": "openai/gpt-5",
+ "message": "Delegating now.",
+ "reasoning_content": "I should delegate.",
+ "metrics": {
+ "prompt_tokens": 120,
+ "completion_tokens": 80,
+ "cached_tokens": 10,
+ "cost_usd": 0.00093875,
+ "extra": {
+ "token_details": {},
+ "total_tokens": 200,
+ "cached_tokens_available": true,
+ "record_timestamp": "2026-05-05T16:53:20.100000Z",
+ "record_model_id": "openai/gpt-5",
+ "tps_unavailable_reason": "missing_latency"
+ }
+ },
+ "extra": {
+ "turn_id": "main-turn-0",
+ "round_id": "main-round-0",
+ "round_index": 0,
+ "model_alias": null,
+ "provider_id": "openai",
+ "status": "completed",
+ "round_status": "completed",
+ "attempt_count": 1,
+ "failure_category": null
+ }
+ },
+ {
+ "step_id": 3,
+ "timestamp": "2026-05-05T16:53:20.150000Z",
+ "source": "agent",
+ "model_name": "openai/gpt-5",
+ "message": "dispatch subagent to do the thing",
+ "tool_calls": [
+ {
+ "tool_call_id": "tc-1",
+ "function_name": "Task",
+ "arguments": {
+ "description": "delegate to subagent"
+ },
+ "extra": {
+ "tool_item_id": "tc-1",
+ "execution_ms": 40
+ }
+ }
+ ],
+ "observation": {
+ "results": [
+ {
+ "source_call_id": "tc-1",
+ "content": "subagent done",
+ "subagent_trajectory_ref": [
+ {
+ "trajectory_id": "bitfun-golden-001-sub",
+ "session_id": "bitfun-golden-001-sub",
+ "extra": {
+ "tool_call_id": "tc-1",
+ "tool_name": "Task",
+ "subagent_model_id": "openai/gpt-5"
+ }
+ }
+ ],
+ "extra": {
+ "raw_result": {
+ "output": "subagent done"
+ },
+ "success": true,
+ "tool_duration_ms": 40
+ }
+ }
+ ]
+ },
+ "extra": {
+ "turn_id": "main-turn-0",
+ "round_id": "main-round-0",
+ "tool_status": "completed",
+ "is_subagent_dispatch": true
+ }
+ },
+ {
+ "step_id": 4,
+ "timestamp": "2026-05-05T16:53:20.300000Z",
+ "source": "system",
+ "message": "",
+ "is_copied_context": true,
+ "extra": {
+ "turn_id": "main-turn-1",
+ "turn_index": 1,
+ "turn_kind": "manual_compaction"
+ }
+ }
+ ],
+ "final_metrics": {
+ "total_prompt_tokens": 120,
+ "total_completion_tokens": 80,
+ "total_cached_tokens": 10,
+ "total_cost_usd": 0.00093875,
+ "total_steps": 4,
+ "extra": {
+ "main_session_tool_calls": 1,
+ "main_session_turn_count": 2,
+ "main_session_duration_ms": 320,
+ "models_used": [
+ "openai/gpt-5"
+ ],
+ "subagent_session_count": 1,
+ "subagent_total_tokens": 60,
+ "tps_unavailable_reason": "missing_latency"
+ }
+ },
+ "subagent_trajectories": [
+ {
+ "schema_version": "ATIF-v1.7",
+ "session_id": "bitfun-golden-001-sub",
+ "trajectory_id": "bitfun-golden-001-sub",
+ "agent": {
+ "name": "Task",
+ "version": "0.0.1",
+ "model_name": "openai/gpt-5",
+ "extra": {
+ "agent_type": "agentic",
+ "session_kind": "subagent",
+ "workspace_path": "/testbed",
+ "schema_version": 2,
+ "parent_task_tool_id": "tc-1"
+ }
+ },
+ "steps": [
+ {
+ "step_id": 1,
+ "timestamp": "2026-05-05T16:53:20.200000Z",
+ "source": "user",
+ "message": "do thing",
+ "extra": {
+ "turn_id": "bitfun-golden-001-sub-turn",
+ "turn_index": 0,
+ "turn_kind": "user_dialog",
+ "user_message_id": "bitfun-golden-001-sub-turn-user"
+ }
+ },
+ {
+ "step_id": 2,
+ "timestamp": "2026-05-05T16:53:20.260000Z",
+ "source": "agent",
+ "model_name": "openai/gpt-5",
+ "message": "did it",
+ "metrics": {
+ "prompt_tokens": 40,
+ "completion_tokens": 20,
+ "cached_tokens": 0,
+ "cost_usd": 0.00025,
+ "extra": {
+ "token_details": {},
+ "total_tokens": 60,
+ "cached_tokens_available": false,
+ "record_timestamp": "2026-05-05T16:53:20.260000Z",
+ "record_model_id": "openai/gpt-5",
+ "tps_unavailable_reason": "missing_latency"
+ }
+ },
+ "extra": {
+ "turn_id": "bitfun-golden-001-sub-turn",
+ "round_id": "bitfun-golden-001-sub-round",
+ "round_index": 0,
+ "model_alias": null,
+ "provider_id": "openai",
+ "status": "completed",
+ "round_status": "completed",
+ "attempt_count": 1,
+ "failure_category": null
+ }
+ }
+ ],
+ "final_metrics": {
+ "total_prompt_tokens": 40,
+ "total_completion_tokens": 20,
+ "total_cached_tokens": 0,
+ "total_cost_usd": 0.00025,
+ "total_steps": 2,
+ "extra": {
+ "main_session_tool_calls": 0,
+ "main_session_turn_count": 1,
+ "main_session_duration_ms": 80,
+ "models_used": [
+ "openai/gpt-5"
+ ],
+ "tps_unavailable_reason": "missing_latency"
+ }
+ }
+ }
+ ]
+}
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index cba0e1a4009..e68d344c3ad 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -16,7 +16,8 @@ def docker_ready():
On CI runners the Docker service may still be starting when tests begin.
This fixture polls ``docker info`` for up to two minutes and skips the
- requesting test when Docker never becomes available.
+ requesting test when Docker never becomes available or is not running in
+ Windows containers mode.
Tests that need Docker should request this fixture explicitly (or apply
it via ``pytestmark``). It is intentionally **not** ``autouse`` so that
@@ -28,11 +29,14 @@ def docker_ready():
deadline = time.monotonic() + _DOCKER_WAIT_TIMEOUT_SEC
while True:
result = subprocess.run(
- ["docker", "info"],
+ ["docker", "info", "--format", "{{.OSType}}"],
capture_output=True,
+ text=True,
)
- if result.returncode == 0:
+ if result.returncode == 0 and result.stdout.strip() == "windows":
return
+ if result.returncode == 0:
+ pytest.skip("Docker daemon is not running in Windows containers mode")
if time.monotonic() >= deadline:
pytest.skip(f"Docker daemon not ready after {_DOCKER_WAIT_TIMEOUT_SEC}s")
time.sleep(_DOCKER_POLL_INTERVAL_SEC)
diff --git a/tests/integration/test_windows_hello_world.py b/tests/integration/test_windows_hello_world.py
index f0fa1822976..0ee04ae3b12 100644
--- a/tests/integration/test_windows_hello_world.py
+++ b/tests/integration/test_windows_hello_world.py
@@ -42,7 +42,11 @@
],
ids=["bat"],
)
-async def test_windows_hello_world_oracle(task_path: str, tmp_path: Path):
+async def test_windows_hello_world_oracle(
+ task_path: str,
+ tmp_path: Path,
+ docker_ready,
+):
"""Run oracle agent on a Windows hello-world task and verify reward=1.0."""
config = TrialConfig(
task=TaskConfig(path=Path(task_path)),
diff --git a/tests/unit/agents/installed/test_bitfun_cli.py b/tests/unit/agents/installed/test_bitfun_cli.py
new file mode 100644
index 00000000000..3ca393f52d1
--- /dev/null
+++ b/tests/unit/agents/installed/test_bitfun_cli.py
@@ -0,0 +1,3479 @@
+"""Unit tests for BitfunCli."""
+
+import json as _json
+import os
+import shlex
+import shutil
+import subprocess
+from pathlib import Path as _Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+from unittest.mock import patch as _patch
+
+import pytest
+
+from harbor.agents.factory import AgentFactory
+from harbor.agents.installed.base import NonZeroAgentExitCodeError
+from harbor.agents.installed.bitfun_cli import (
+ BitfunCli,
+ build_repo_baseline_capture_script,
+ build_repo_final_capture_script,
+)
+from harbor.models.agent.context import AgentContext
+from harbor.models.agent.name import AgentName
+from harbor.models.task.config import TaskOS
+from harbor.models.trajectories.agent import Agent
+from harbor.models.trajectories.final_metrics import FinalMetrics
+from harbor.models.trajectories.trajectory import Trajectory
+
+_DEFAULT_TS_MS = 1_778_000_000_000 # arbitrary fixed epoch ms
+
+
+def _ts_iso(ms: int) -> str:
+ """Convert BitFun millisecond epoch to an ISO-8601 UTC timestamp string."""
+ from datetime import datetime, timezone
+
+ return (
+ datetime.fromtimestamp(ms / 1000.0, tz=timezone.utc)
+ .isoformat()
+ .replace("+00:00", "Z")
+ )
+
+
+def _make_metadata(
+ session_id: str,
+ *,
+ kind: str = "standard",
+ model: str = "default",
+ workspace: str = "/testbed",
+ turn_count: int = 0,
+ tool_call_count: int = 0,
+ created_at: int = _DEFAULT_TS_MS,
+ last_active_at: int | None = None,
+) -> dict:
+ return {
+ "schema_version": 2,
+ "sessionId": session_id,
+ "sessionName": "test",
+ "agentType": "agentic",
+ "sessionKind": kind,
+ "modelName": model,
+ "createdAt": created_at,
+ "lastActiveAt": last_active_at or (created_at + 1_000),
+ "turnCount": turn_count,
+ "messageCount": turn_count * 2,
+ "toolCallCount": tool_call_count,
+ "status": "completed",
+ "tags": [],
+ "workspacePath": workspace,
+ "workspaceHostname": "localhost",
+ }
+
+
+def _make_text_item(
+ item_id: str,
+ content: str,
+ *,
+ order_index: int = 0,
+ ts: int = _DEFAULT_TS_MS,
+ status: str = "completed",
+) -> dict:
+ return {
+ "id": item_id,
+ "content": content,
+ "isStreaming": False,
+ "timestamp": ts,
+ "isMarkdown": True,
+ "orderIndex": order_index,
+ "status": status,
+ }
+
+
+def _make_thinking_item(
+ item_id: str,
+ content: str,
+ *,
+ order_index: int = 0,
+ ts: int = _DEFAULT_TS_MS,
+) -> dict:
+ return {
+ "id": item_id,
+ "content": content,
+ "isStreaming": False,
+ "isCollapsed": False,
+ "timestamp": ts,
+ "orderIndex": order_index,
+ }
+
+
+def _make_tool_item(
+ item_id: str,
+ tool_name: str,
+ input_args: dict,
+ *,
+ result_text: str | None = None,
+ raw_result: object = None,
+ success: bool = True,
+ error: str | None = None,
+ subagent_sid: str | None = None,
+ subagent_model_id: str | None = None,
+ parent_task_tool_id: str | None = None,
+ order_index: int = 0,
+ ts: int = _DEFAULT_TS_MS,
+ duration_ms: int = 5,
+ ai_intent: str | None = None,
+) -> dict:
+ out: dict = {
+ "id": item_id,
+ "toolName": tool_name,
+ "toolCall": {"id": item_id, "input": input_args},
+ "startTime": ts,
+ "endTime": ts + duration_ms,
+ "durationMs": duration_ms,
+ "executionMs": duration_ms,
+ "orderIndex": order_index,
+ "status": "completed",
+ }
+ if result_text is not None or raw_result is not None:
+ tr: dict = {"success": success}
+ tr["result"] = raw_result if raw_result is not None else {"text": result_text}
+ if result_text is not None:
+ tr["resultForAssistant"] = result_text
+ if error is not None:
+ tr["error"] = error
+ tr["success"] = False
+ tr["durationMs"] = duration_ms
+ out["toolResult"] = tr
+ if ai_intent is not None:
+ out["aiIntent"] = ai_intent
+ if subagent_sid is not None:
+ out["isSubagentItem"] = True
+ out["subagentSessionId"] = subagent_sid
+ if subagent_model_id is not None:
+ out["subagentModelId"] = subagent_model_id
+ if parent_task_tool_id is not None:
+ out["parentTaskToolId"] = parent_task_tool_id
+ return out
+
+
+def _make_round(
+ round_id: str,
+ *,
+ turn_id: str,
+ round_index: int = 0,
+ text_items: list | None = None,
+ tool_items: list | None = None,
+ thinking_items: list | None = None,
+ model_id: str | None = "openai/gpt-5",
+ model_alias: str | None = None,
+ provider_id: str | None = "openai",
+ ts: int = _DEFAULT_TS_MS,
+ duration_ms: int = 10,
+ attempt_count: int = 1,
+ status: str = "completed",
+ failure_category: str | None = None,
+) -> dict:
+ return {
+ "id": round_id,
+ "turnId": turn_id,
+ "roundIndex": round_index,
+ "timestamp": ts,
+ "textItems": text_items or [],
+ "toolItems": tool_items or [],
+ "thinkingItems": thinking_items or [],
+ "startTime": ts,
+ "endTime": ts + duration_ms,
+ "durationMs": duration_ms,
+ "providerId": provider_id,
+ "modelId": model_id,
+ "modelAlias": model_alias,
+ "attemptCount": attempt_count,
+ "status": status,
+ **({"failureCategory": failure_category} if failure_category else {}),
+ }
+
+
+def _make_turn(
+ turn_index: int,
+ turn_id: str,
+ session_id: str,
+ *,
+ kind: str = "user_dialog",
+ user_text: str = "hello",
+ user_content: str | None = None,
+ model_rounds: list | None = None,
+ ts: int = _DEFAULT_TS_MS,
+ duration_ms: int = 100,
+ status: str = "completed",
+) -> dict:
+ return {
+ "schema_version": 2,
+ "turnId": turn_id,
+ "turnIndex": turn_index,
+ "sessionId": session_id,
+ "timestamp": ts,
+ "kind": kind,
+ "userMessage": {
+ "id": f"{turn_id}-user",
+ "content": user_content
+ if user_content is not None
+ else f"\n{user_text}\n ",
+ "timestamp": ts,
+ "metadata": {"original_text": user_text} if user_text else {},
+ },
+ "modelRounds": model_rounds or [],
+ "startTime": ts,
+ "endTime": ts + duration_ms,
+ "durationMs": duration_ms,
+ "status": status,
+ }
+
+
+def _make_token_record(
+ model_id: str,
+ session_id: str,
+ turn_id: str,
+ in_tok: int,
+ out_tok: int,
+ *,
+ cached: int = 0,
+ is_sub: bool = False,
+ ts: int = _DEFAULT_TS_MS,
+ token_details: dict | None = None,
+) -> dict:
+ return {
+ "model_id": model_id,
+ "session_id": session_id,
+ "turn_id": turn_id,
+ "timestamp": _ts_iso(ts),
+ "input_tokens": in_tok,
+ "output_tokens": out_tok,
+ "cached_tokens": cached,
+ "cached_tokens_available": cached > 0,
+ "total_tokens": in_tok + out_tok,
+ "is_subagent": is_sub,
+ "token_details": token_details or {},
+ }
+
+
+def _write_session(
+ logs_dir: _Path,
+ sid: str,
+ *,
+ metadata: dict,
+ turns: list[dict],
+ token_records: list[dict] | None = None,
+ token_records_date: str = "2026-01-01",
+ snapshot_messages: list[dict] | None = None,
+ snapshot_session_id: str | None = None,
+) -> _Path:
+ """Lay out a minimal BitFun cp-back tree under logs_dir/bitfun/."""
+ root = logs_dir / "bitfun" / "sessions" / sid
+ (root / "turns").mkdir(parents=True, exist_ok=True)
+ (root / "metadata.json").write_text(_json.dumps(metadata))
+ for turn in turns:
+ (root / "turns" / f"turn-{turn['turnIndex']:04d}.json").write_text(
+ _json.dumps(turn)
+ )
+ if token_records is not None:
+ records_dir = logs_dir / "bitfun" / "token_usage" / "records"
+ records_dir.mkdir(parents=True, exist_ok=True)
+ (records_dir / f"{token_records_date}.json").write_text(
+ _json.dumps({"records": list(token_records)})
+ )
+ if snapshot_messages is not None:
+ snaps_dir = root / "snapshots"
+ snaps_dir.mkdir(parents=True, exist_ok=True)
+ (snaps_dir / "context-0000.json").write_text(
+ _json.dumps(
+ {
+ "schema_version": 2,
+ "session_id": snapshot_session_id or sid,
+ "turn_index": 0,
+ "messages": list(snapshot_messages),
+ }
+ )
+ )
+ return root
+
+
+class _CaptureEnv:
+ def __init__(
+ self,
+ *,
+ raw_config_text: str | None,
+ probe_stdout: str,
+ existing_manifest: dict | None = None,
+ upload_raises: Exception | None = None,
+ ) -> None:
+ self.raw_config_text = raw_config_text
+ self.probe_stdout = probe_stdout
+ self.existing_manifest = existing_manifest or {}
+ self.upload_raises = upload_raises
+ self.exec_calls: list[dict] = []
+ self.downloads: list[tuple[str, _Path]] = []
+ self.uploads: dict[str, str] = {}
+
+ async def exec(self, **kwargs):
+ self.exec_calls.append(kwargs)
+ command = kwargs["command"]
+ if "APP_CONFIG_SRC" in command:
+ return SimpleNamespace(return_code=0, stdout=self.probe_stdout, stderr="")
+ return SimpleNamespace(return_code=0, stdout="", stderr="")
+
+ async def download_file(self, source_path, target_path):
+ target = _Path(target_path)
+ self.downloads.append((source_path, target))
+ if source_path == "/logs/agent/bitfun/cp-back-manifest.json":
+ target.write_text(_json.dumps(self.existing_manifest))
+ return
+ if self.raw_config_text is None:
+ raise FileNotFoundError(source_path)
+ target.write_text(self.raw_config_text)
+
+ async def upload_file(self, source_path, target_path):
+ if self.upload_raises is not None:
+ raise self.upload_raises
+ self.uploads[target_path] = _Path(source_path).read_text()
+
+
+def _snap_ts(ms: int) -> dict:
+ secs, ms_part = divmod(ms, 1000)
+ return {"secs_since_epoch": secs, "nanos_since_epoch": ms_part * 1_000_000}
+
+
+def _snap_user_msg(turn_id: str, text: str, *, ts: int = _DEFAULT_TS_MS) -> dict:
+ return {
+ "id": f"{turn_id}-user",
+ "role": "User",
+ "content": {"Text": f"\n{text}\n "},
+ "timestamp": _snap_ts(ts),
+ "metadata": {
+ "turn_id": turn_id,
+ "round_id": None,
+ "tokens": None,
+ "semantic_kind": "actual_user_input",
+ },
+ }
+
+
+def _snap_assistant_tool_call_msg(
+ turn_id: str,
+ round_id: str,
+ tool_id: str,
+ tool_name: str,
+ arguments: dict,
+ *,
+ text: str = "",
+ reasoning: str | None = None,
+ ts: int = _DEFAULT_TS_MS,
+) -> dict:
+ return {
+ "id": f"{round_id}-{tool_id}",
+ "role": "Assistant",
+ "content": {
+ "Mixed": {
+ "reasoning_content": reasoning,
+ "text": text,
+ "tool_calls": [
+ {
+ "tool_id": tool_id,
+ "tool_name": tool_name,
+ "arguments": arguments,
+ "is_error": False,
+ }
+ ],
+ }
+ },
+ "timestamp": _snap_ts(ts),
+ "metadata": {"turn_id": turn_id, "round_id": round_id, "tokens": None},
+ }
+
+
+def _snap_tool_result_msg(
+ turn_id: str,
+ round_id: str,
+ tool_id: str,
+ tool_name: str,
+ *,
+ result_for_assistant: str = "ok",
+ raw_result: dict | None = None,
+ is_error: bool = False,
+ ts: int = _DEFAULT_TS_MS,
+) -> dict:
+ return {
+ "id": f"{round_id}-{tool_id}-result",
+ "role": "Tool",
+ "content": {
+ "ToolResult": {
+ "tool_id": tool_id,
+ "tool_name": tool_name,
+ "result": raw_result or {"text": result_for_assistant},
+ "result_for_assistant": result_for_assistant,
+ "is_error": is_error,
+ }
+ },
+ "timestamp": _snap_ts(ts),
+ "metadata": {"turn_id": turn_id, "round_id": round_id, "tokens": None},
+ }
+
+
+def _snap_assistant_text_msg(
+ turn_id: str,
+ round_id: str,
+ text: str,
+ *,
+ ts: int = _DEFAULT_TS_MS,
+) -> dict:
+ return {
+ "id": f"{round_id}-text",
+ "role": "Assistant",
+ "content": {
+ "Mixed": {
+ "reasoning_content": None,
+ "text": text,
+ "tool_calls": [],
+ }
+ },
+ "timestamp": _snap_ts(ts),
+ "metadata": {"turn_id": turn_id, "round_id": round_id, "tokens": None},
+ }
+
+
+def _regenerate_golden_fixture(target_root: _Path) -> None:
+ """One-shot writer used during local fixture authoring.
+
+ Run via:
+ uv run python -c "from pathlib import Path; from tests.unit.agents.installed.test_bitfun_cli import _regenerate_golden_fixture; ..."
+ """
+ target_root.mkdir(parents=True, exist_ok=True)
+ ts = 1_778_000_000_000
+ main_sid = "bitfun-golden-001"
+ sub_sid = "bitfun-golden-001-sub"
+
+ sub_turn = _make_turn(
+ 0,
+ f"{sub_sid}-turn",
+ sub_sid,
+ user_text="do thing",
+ ts=ts + 200,
+ model_rounds=[
+ _make_round(
+ f"{sub_sid}-round",
+ turn_id=f"{sub_sid}-turn",
+ ts=ts + 250,
+ text_items=[
+ _make_text_item(
+ f"{sub_sid}-ti", "did it", order_index=0, ts=ts + 260
+ )
+ ],
+ model_id="openai/gpt-5",
+ )
+ ],
+ )
+ _write_session(
+ target_root,
+ sub_sid,
+ metadata=_make_metadata(
+ sub_sid,
+ kind="subagent",
+ model="openai/gpt-5",
+ workspace="/testbed",
+ created_at=ts + 200,
+ last_active_at=ts + 280,
+ turn_count=1,
+ ),
+ turns=[sub_turn],
+ )
+
+ tool = _make_tool_item(
+ "tc-1",
+ "Task",
+ {"description": "delegate to subagent"},
+ result_text="subagent done",
+ raw_result={"output": "subagent done"},
+ subagent_sid=sub_sid,
+ subagent_model_id="openai/gpt-5",
+ order_index=2,
+ ts=ts + 150,
+ duration_ms=40,
+ ai_intent="dispatch subagent to do the thing",
+ )
+ main_turn = _make_turn(
+ 0,
+ "main-turn-0",
+ main_sid,
+ user_text="please help",
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "main-round-0",
+ turn_id="main-turn-0",
+ round_index=0,
+ ts=ts + 50,
+ thinking_items=[
+ _make_thinking_item(
+ "th-0", "I should delegate.", order_index=0, ts=ts + 60
+ )
+ ],
+ text_items=[
+ _make_text_item(
+ "ti-0", "Delegating now.", order_index=1, ts=ts + 80
+ )
+ ],
+ tool_items=[tool],
+ model_id="openai/gpt-5",
+ )
+ ],
+ )
+ compaction_turn = _make_turn(
+ 1,
+ "main-turn-1",
+ main_sid,
+ user_text="",
+ kind="manual_compaction",
+ ts=ts + 300,
+ )
+ _write_session(
+ target_root,
+ main_sid,
+ metadata=_make_metadata(
+ main_sid,
+ kind="standard",
+ model="openai/gpt-5",
+ workspace="/testbed",
+ created_at=ts,
+ last_active_at=ts + 320,
+ turn_count=2,
+ tool_call_count=1,
+ ),
+ turns=[main_turn, compaction_turn],
+ token_records=[
+ _make_token_record(
+ "openai/gpt-5",
+ main_sid,
+ "main-turn-0",
+ 120,
+ 80,
+ cached=10,
+ ts=ts + 100,
+ ),
+ _make_token_record(
+ "openai/gpt-5",
+ sub_sid,
+ f"{sub_sid}-turn",
+ 40,
+ 20,
+ cached=0,
+ ts=ts + 260,
+ is_sub=True,
+ ),
+ ],
+ token_records_date="2026-01-01",
+ )
+
+
+@pytest.fixture
+def temp_dir(tmp_path):
+ return tmp_path
+
+
+def _exec_commands(mock_env: AsyncMock) -> list[str]:
+ return [call.kwargs["command"] for call in mock_env.exec.call_args_list]
+
+
+def _first_command_containing(commands: list[str], text: str) -> str:
+ return next(command for command in commands if text in command)
+
+
+def _usable_bash_command() -> list[str] | None:
+ candidates = [
+ shutil.which("bash"),
+ r"C:\Program Files\Git\bin\bash.exe",
+ r"C:\Program Files\Git\usr\bin\bash.exe",
+ ]
+ seen: set[str] = set()
+ for candidate in candidates:
+ if not candidate or candidate in seen:
+ continue
+ seen.add(candidate)
+ probe = subprocess.run(
+ [
+ candidate,
+ "-lc",
+ "command -v git >/dev/null && command -v mktemp >/dev/null",
+ ],
+ check=False,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ if probe.returncode == 0:
+ return [candidate, "-lc"]
+ return None
+
+
+def _run_shell(command: str, *, cwd: _Path) -> None:
+ if os.name == "nt":
+ pytest.skip("POSIX repo capture script is exercised on POSIX hosts")
+ bash_command = _usable_bash_command()
+ if bash_command is None:
+ pytest.skip("POSIX bash with git and mktemp is required for repo capture")
+ subprocess.run([*bash_command, command], cwd=cwd, check=True)
+
+
+class TestRepoPatchCapture:
+ def test_final_patch_excludes_preexisting_dirty_state(self, temp_dir):
+ repo = temp_dir / "repo"
+ repo.mkdir()
+ _run_shell("git init", cwd=repo)
+ _run_shell("git config user.email test@example.com", cwd=repo)
+ _run_shell("git config user.name Test", cwd=repo)
+ (repo / "tracked.txt").write_text("base\n")
+ _run_shell("git add tracked.txt && git commit -m base", cwd=repo)
+
+ (repo / "tracked.txt").write_text("base\npreexisting\n")
+ (repo / "preexisting.txt").write_text("from task image\n")
+
+ log_dir = temp_dir / "logs" / "patch"
+ _run_shell(build_repo_baseline_capture_script(log_dir.as_posix()), cwd=repo)
+
+ (repo / "tracked.txt").write_text("base\npreexisting\nagent-change\n")
+ (repo / "agent-new.txt").write_text("new from agent\n")
+
+ _run_shell(build_repo_final_capture_script(log_dir.as_posix()), cwd=repo)
+
+ patch = (log_dir / "fix.patch").read_text()
+ assert "agent-new.txt" in patch
+ assert "agent-change" in patch
+ assert "preexisting.txt" not in patch
+ assert (log_dir / "fix.stat.txt").is_file()
+ assert (log_dir / "fix.name-status.txt").is_file()
+ assert (log_dir / "git-status.before.txt").is_file()
+ assert (log_dir / "git-status.after.txt").is_file()
+ assert (log_dir / "git-baseline-commit.txt").read_text().strip()
+ assert (log_dir / "git-final-commit.txt").read_text().strip()
+
+
+class TestFailureLogFormatting:
+ def test_format_failure_log_returns_full_text_under_limit(self):
+ from harbor.agents.installed.bitfun_cli import _format_failure_log_text
+
+ text = "x" * 1000
+ assert _format_failure_log_text(text) == text
+
+ def test_format_failure_log_head_tail_over_limit(self):
+ from harbor.agents.installed.bitfun_cli import (
+ _FAILURE_LOG_HEAD_BYTES,
+ _FAILURE_LOG_MAX_BYTES,
+ _FAILURE_LOG_TAIL_BYTES,
+ _FAILURE_LOG_TRUNC_MARKER,
+ _format_failure_log_text,
+ )
+
+ text = "a" * (_FAILURE_LOG_MAX_BYTES + 1) + "TAIL_MARKER"
+ out = _format_failure_log_text(text)
+ assert out.startswith("a" * _FAILURE_LOG_HEAD_BYTES)
+ assert _FAILURE_LOG_TRUNC_MARKER in out
+ assert out.endswith("TAIL_MARKER")
+ assert len(out) < len(text)
+ assert len(out) == (
+ _FAILURE_LOG_HEAD_BYTES
+ + len(_FAILURE_LOG_TRUNC_MARKER)
+ + _FAILURE_LOG_TAIL_BYTES
+ )
+
+
+class TestEnvForRun:
+ def test_merges_extra_env(self, temp_dir):
+ agent = BitfunCli(
+ logs_dir=temp_dir,
+ extra_env={"XDG_CONFIG_HOME": "/testbed/.config", "CUSTOM": "1"},
+ )
+ env = agent._env_for_run()
+ assert env["XDG_CONFIG_HOME"] == "/testbed/.config"
+ assert env["CUSTOM"] == "1"
+
+ def test_still_forwards_bitfun_prefixed_host_env(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ with patch.dict(
+ os.environ, {"BITFUN_DEBUG_LOG_PATH": "/tmp/x.log"}, clear=False
+ ):
+ env = agent._env_for_run()
+ assert env["BITFUN_DEBUG_LOG_PATH"] == "/tmp/x.log"
+
+
+class TestExecFailurePersist:
+ @pytest.mark.asyncio
+ async def test_persists_full_stdout_on_nonzero_exit(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(
+ return_code=1,
+ stdout="line\n" * 50 + "FINAL_ERROR_LINE",
+ stderr="",
+ )
+ with pytest.raises(NonZeroAgentExitCodeError) as exc_info:
+ await agent.exec_as_agent(mock_env, command="true")
+ assert "FINAL_ERROR_LINE" in (temp_dir / "bitfun.txt").read_text()
+ assert "exit 1" in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_persists_head_tail_when_output_huge(self, temp_dir):
+ from harbor.agents.installed.bitfun_cli import (
+ _FAILURE_LOG_HEAD_BYTES,
+ _FAILURE_LOG_MAX_BYTES,
+ _FAILURE_LOG_TAIL_BYTES,
+ _FAILURE_LOG_TRUNC_MARKER,
+ )
+
+ agent = BitfunCli(logs_dir=temp_dir)
+ marker = "ENDMARKER"
+ payload = ("a" * (_FAILURE_LOG_MAX_BYTES + 1)) + marker
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=2, stdout=payload, stderr="")
+ with pytest.raises(NonZeroAgentExitCodeError):
+ await agent.exec_as_agent(mock_env, command="true")
+ text = (temp_dir / "bitfun.txt").read_text()
+ assert text.startswith("a" * _FAILURE_LOG_HEAD_BYTES)
+ assert _FAILURE_LOG_TRUNC_MARKER in text
+ assert text.endswith(marker)
+ assert len(text) == (
+ _FAILURE_LOG_HEAD_BYTES
+ + len(_FAILURE_LOG_TRUNC_MARKER)
+ + _FAILURE_LOG_TAIL_BYTES
+ )
+
+ @pytest.mark.asyncio
+ async def test_prepares_logs_before_persisting_failure_output(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(
+ return_code=1,
+ stdout="failure",
+ stderr="",
+ )
+ order = []
+
+ async def prepare_logs_for_host():
+ order.append("prepare")
+
+ def persist_failure_output(stdout, stderr):
+ order.append("persist")
+
+ mock_env.prepare_logs_for_host.side_effect = prepare_logs_for_host
+ with patch.object(
+ agent,
+ "_persist_failure_output",
+ side_effect=persist_failure_output,
+ ):
+ with pytest.raises(NonZeroAgentExitCodeError):
+ await agent.exec_as_agent(mock_env, command="true")
+
+ assert order == ["prepare", "persist"]
+
+ @pytest.mark.asyncio
+ async def test_persist_permission_error_does_not_mask_nonzero_exit(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(
+ return_code=3,
+ stdout="failure",
+ stderr="",
+ )
+ with patch.object(
+ agent,
+ "_persist_failure_output",
+ side_effect=PermissionError("denied"),
+ ):
+ with pytest.raises(NonZeroAgentExitCodeError) as exc_info:
+ await agent.exec_as_agent(mock_env, command="true")
+
+ mock_env.prepare_logs_for_host.assert_awaited_once()
+ assert "exit 3" in str(exc_info.value)
+
+ @pytest.mark.asyncio
+ async def test_success_does_not_write_bitfun_txt(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="ok", stderr="")
+ await agent.exec_as_agent(mock_env, command="true")
+ assert not (temp_dir / "bitfun.txt").exists()
+
+
+class TestBuildRunShell:
+ def test_includes_mkdir_agent_and_pipestatus(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/opt/bitfun-cli")
+ shell = agent._build_run_shell("Fix the bug")
+ assert "mkdir -p /logs/agent" in shell
+ assert "rc=${PIPESTATUS[0]}" in shell
+ assert "exit $rc" in shell
+ assert "/opt/bitfun-cli" in shell
+ assert " exec " in shell
+ assert "tee /logs/agent/bitfun.txt" in shell
+ assert "stdbuf -oL" in shell
+
+ def test_falls_back_to_tee_when_stdbuf_is_unavailable(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ shell = agent._build_run_shell("Fix the bug")
+ assert "command -v stdbuf" in shell
+ assert "bitfun_tee() { stdbuf -oL tee /logs/agent/bitfun.txt; }" in shell
+ assert "bitfun_tee() { tee /logs/agent/bitfun.txt; }" in shell
+ assert "2>&1 | bitfun_tee" in shell
+
+ def test_includes_patch_parent_mkdir_when_patch_enabled(self, temp_dir):
+ agent = BitfunCli(
+ logs_dir=temp_dir, output_patch_path="/logs/agent/bitfun.patch"
+ )
+ shell = agent._build_run_shell("Hi")
+ assert "PATCH_PATH=" in shell
+ assert "/logs/agent/bitfun.patch" in shell
+ assert 'mkdir -p "$(dirname "$PATCH_PATH")"' in shell
+ assert "--output-patch" in shell
+
+ def test_omits_patch_when_disabled(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, output_patch_path=None)
+ shell = agent._build_run_shell("Hi")
+ assert "PATCH_PATH=" not in shell
+ assert "--output-patch" not in shell
+
+ def test_windows_runs_uploaded_bat_script(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ env = SimpleNamespace(os=TaskOS.WINDOWS)
+
+ shell = agent._build_run_shell("Hi", env)
+
+ assert shell == "C:\\logs\\agent\\bitfun-run.bat"
+ assert "set -o pipefail" not in shell
+ assert "tee /logs/agent/bitfun.txt" not in shell
+
+
+class TestRegisterConfigCommand:
+ def _parse_written_config(self, command: str) -> dict:
+ prefix = "printf '%s\\n' "
+ suffix = ' > "$BITFUN_CONFIG_ROOT/config/app.json"'
+ start = command.index(prefix) + len(prefix)
+ end = command.rindex(suffix)
+ quoted_json = command[start:end]
+ return _json.loads(shlex.split(f"cmd {quoted_json}")[1])
+
+ def test_no_bitfun_config_returns_none(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._build_register_config_command() is None
+
+ def test_builds_command_that_overwrites_app_json_with_exact_config(self, temp_dir):
+ bitfun_config = {
+ "app": {"language": "zh-CN"},
+ "ai": {
+ "models": [
+ {
+ "id": "deepseek-v4-pro",
+ "name": "deepseek-v4-pro",
+ "provider": "openai",
+ "model_name": "deepseek-v4-pro",
+ "base_url": "https://api.deepseek.com",
+ "api_key": "${DEEPSEEK_API_KEY}",
+ "enabled": True,
+ "context_window": 1048576,
+ "max_tokens": 65536,
+ "reasoning_mode": "enabled",
+ "reasoning_effort": "max",
+ }
+ ],
+ "default_models": {
+ "primary": "deepseek-v4-pro",
+ "fast": "deepseek-v4-pro",
+ },
+ },
+ "mcp_servers": {"example": {"command": "server --with 'quote'"}},
+ }
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config=bitfun_config)
+
+ command = agent._build_register_config_command()
+
+ assert command is not None
+ assert 'BITFUN_CONFIG_ROOT="${BITFUN_USER_ROOT:-}"' in command
+ assert 'BITFUN_CONFIG_ROOT="${BITFUN_E2E_USER_ROOT:-}"' in command
+ assert 'BITFUN_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"' in command
+ assert 'mkdir -p "$BITFUN_CONFIG_ROOT/config"' in command
+ assert ' > "$BITFUN_CONFIG_ROOT/config/app.json"' in command
+ assert "config.toml" not in command
+ assert self._parse_written_config(command) == bitfun_config
+
+ def test_bitfun_config_must_be_dict(self, temp_dir):
+ kwargs = {"bitfun_config": ["not", "a", "dict"]}
+ with pytest.raises(ValueError, match="bitfun_config must be a dict"):
+ BitfunCli(logs_dir=temp_dir, **kwargs)
+
+
+class TestAppConfigProbeCommand:
+ def test_probe_uses_same_config_root_resolution_as_config_writer(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config={"ai": {"models": []}})
+
+ setup_command = agent._build_register_config_command()
+ probe_command = agent._build_app_config_probe_command()
+
+ assert setup_command is not None
+ for snippet in (
+ 'BITFUN_CONFIG_ROOT="${BITFUN_USER_ROOT:-}"',
+ 'BITFUN_CONFIG_ROOT="${BITFUN_E2E_USER_ROOT:-}"',
+ 'BITFUN_XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
+ 'BITFUN_CONFIG_ROOT="$BITFUN_XDG_CONFIG_HOME/bitfun"',
+ ):
+ assert snippet in setup_command
+ assert snippet in probe_command
+
+ def test_probe_reports_source_exists_and_size_without_json_tooling(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ command = agent._build_app_config_probe_command()
+
+ assert 'APP_CONFIG_SRC="$BITFUN_CONFIG_ROOT/config/app.json"' in command
+ assert "source=%s" in command
+ assert "exists=true" in command
+ assert "exists=false" in command
+ assert "size_bytes=%s" in command
+ assert "jq" not in command
+ assert "python" not in command.lower()
+ assert "node" not in command.lower()
+ assert "/root/.config/bitfun" not in command
+
+
+class TestBitfunConfigRedaction:
+ def test_redacts_sensitive_keys_recursively_outside_ai(self, temp_dir):
+ config = {
+ "ai": {
+ "models": [
+ {
+ "id": "deepseek-v4-pro",
+ "api_key": "sk-secret",
+ "max_tokens": 65536,
+ }
+ ]
+ },
+ "mcp_servers": {
+ "private": {
+ "command": "server",
+ "env": {
+ "ACCESS_TOKEN": "token-secret",
+ "client-secret": "client-secret-value",
+ },
+ }
+ },
+ "auth": {
+ "Authorization": "Bearer secret",
+ "private_key": "-----BEGIN PRIVATE KEY-----",
+ "password": "p@ss",
+ },
+ }
+
+ redacted = BitfunCli._redact_config_secrets(config)
+
+ assert redacted["ai"]["models"][0]["api_key"] == "[REDACTED]"
+ assert redacted["ai"]["models"][0]["max_tokens"] == 65536
+ assert redacted["mcp_servers"]["private"]["env"]["ACCESS_TOKEN"] == "[REDACTED]"
+ assert (
+ redacted["mcp_servers"]["private"]["env"]["client-secret"] == "[REDACTED]"
+ )
+ assert redacted["auth"]["Authorization"] == "[REDACTED]"
+ assert redacted["auth"]["private_key"] == "[REDACTED]"
+ assert redacted["auth"]["password"] == "[REDACTED]"
+ assert config["ai"]["models"][0]["api_key"] == "sk-secret"
+
+ def test_does_not_redact_non_secret_token_or_model_fields(self, temp_dir):
+ config = {
+ "ai": {
+ "models": [
+ {
+ "id": "openai/gpt-5",
+ "model_name": "gpt-5",
+ "context_window": 1048576,
+ "max_tokens": 65536,
+ }
+ ],
+ "token_usage": {"records": 3},
+ }
+ }
+
+ redacted = BitfunCli._redact_config_secrets(config)
+
+ model = redacted["ai"]["models"][0]
+ assert model["id"] == "openai/gpt-5"
+ assert model["model_name"] == "gpt-5"
+ assert model["context_window"] == 1048576
+ assert model["max_tokens"] == 65536
+ assert redacted["ai"]["token_usage"] == {"records": 3}
+
+
+class TestFinalAppConfigCapture:
+ @pytest.mark.asyncio
+ async def test_capture_uploads_only_redacted_config_and_updates_manifest(
+ self, temp_dir
+ ):
+ raw_config = {
+ "ai": {
+ "models": [
+ {
+ "id": "deepseek-v4-pro",
+ "api_key": "sk-secret",
+ "max_tokens": 65536,
+ }
+ ]
+ },
+ "mcp_servers": {"private": {"env": {"ACCESS_TOKEN": "token-secret"}}},
+ }
+ env = _CaptureEnv(
+ raw_config_text=_json.dumps(raw_config),
+ probe_stdout=(
+ "source=/home/agent/.config/bitfun/config/app.json\n"
+ "exists=true\n"
+ "size_bytes=160\n"
+ ),
+ existing_manifest={"cli_log": {"exists": True}},
+ )
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ await agent._capture_final_app_config(env)
+
+ assert "/logs/agent/bitfun/config/app.redacted.json" in env.uploads
+ assert "/logs/agent/bitfun/config/app.json" not in env.uploads
+ redacted = _json.loads(
+ env.uploads["/logs/agent/bitfun/config/app.redacted.json"]
+ )
+ assert redacted["ai"]["models"][0]["api_key"] == "[REDACTED]"
+ assert redacted["ai"]["models"][0]["max_tokens"] == 65536
+ assert redacted["mcp_servers"]["private"]["env"]["ACCESS_TOKEN"] == "[REDACTED]"
+
+ manifest = _json.loads(env.uploads["/logs/agent/bitfun/cp-back-manifest.json"])
+ assert manifest["cli_log"] == {"exists": True}
+ assert manifest["app_config"] == {
+ "source": "/home/agent/.config/bitfun/config/app.json",
+ "exists": True,
+ "size_bytes": 160,
+ "target": "agent/bitfun/config/app.redacted.json",
+ "redacted": True,
+ "raw_saved": False,
+ "capture_error": None,
+ }
+ assert not list(temp_dir.parent.glob(".bitfun-app-config-*.raw.json"))
+ assert not list(temp_dir.parent.glob(".bitfun-app-config-*.redacted.json"))
+
+ @pytest.mark.asyncio
+ async def test_capture_records_absent_source_without_downloading_config(
+ self, temp_dir
+ ):
+ env = _CaptureEnv(
+ raw_config_text=None,
+ probe_stdout=(
+ "source=/home/agent/.config/bitfun/config/app.json\n"
+ "exists=false\n"
+ "size_bytes=0\n"
+ ),
+ )
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ await agent._capture_final_app_config(env)
+
+ assert "/logs/agent/bitfun/config/app.redacted.json" not in env.uploads
+ assert all(
+ source == "/logs/agent/bitfun/cp-back-manifest.json"
+ for source, _target in env.downloads
+ )
+ manifest = _json.loads(env.uploads["/logs/agent/bitfun/cp-back-manifest.json"])
+ assert manifest["app_config"] == {
+ "source": "/home/agent/.config/bitfun/config/app.json",
+ "exists": False,
+ "size_bytes": 0,
+ "target": None,
+ "redacted": False,
+ "raw_saved": False,
+ "capture_error": None,
+ }
+
+ @pytest.mark.asyncio
+ async def test_capture_invalid_json_does_not_upload_raw_or_redacted_config(
+ self, temp_dir
+ ):
+ env = _CaptureEnv(
+ raw_config_text="{not json",
+ probe_stdout=(
+ "source=/home/agent/.config/bitfun/config/app.json\n"
+ "exists=true\n"
+ "size_bytes=9\n"
+ ),
+ )
+ agent = BitfunCli(logs_dir=temp_dir)
+
+ await agent._capture_final_app_config(env)
+
+ assert "/logs/agent/bitfun/config/app.redacted.json" not in env.uploads
+ assert "/logs/agent/bitfun/config/app.json" not in env.uploads
+ manifest = _json.loads(env.uploads["/logs/agent/bitfun/cp-back-manifest.json"])
+ assert manifest["app_config"]["exists"] is True
+ assert manifest["app_config"]["redacted"] is False
+ assert manifest["app_config"]["raw_saved"] is False
+ assert manifest["app_config"]["capture_error"] == "invalid JSON"
+ assert not list(temp_dir.parent.glob(".bitfun-app-config-*.raw.json"))
+
+
+class TestBitfunCliAgent:
+ def test_name(self):
+ assert BitfunCli.name() == AgentName.BITFUN_CLI.value
+
+ def test_registered_in_factory(self):
+ assert AgentName.BITFUN_CLI in AgentFactory._AGENT_MAP
+ assert AgentFactory.get_agent_class(AgentName.BITFUN_CLI) is BitfunCli
+
+ @pytest.mark.asyncio
+ async def test_install_verifies_binary(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/usr/local/bin/bitfun-cli")
+ mock_env = AsyncMock()
+ mock_env.os = TaskOS.LINUX
+ mock_env.exec.return_value = AsyncMock(
+ return_code=0, stdout="bitfun 0.0.1\n", stderr=""
+ )
+ await agent.install(mock_env)
+ assert mock_env.exec.call_count == 1
+ cmd = mock_env.exec.call_args.kwargs["command"]
+ assert "/usr/local/bin/bitfun-cli" in cmd
+ assert "chmod a+x" in cmd
+ assert "--version" in cmd
+
+ @pytest.mark.asyncio
+ async def test_install_verifies_windows_binary(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.os = TaskOS.WINDOWS
+ mock_env.exec.return_value = AsyncMock(
+ return_code=0, stdout="bitfun 0.0.1\n", stderr=""
+ )
+
+ await agent.install(mock_env)
+
+ cmd = mock_env.exec.call_args.kwargs["command"]
+ assert "C:\\bitfun\\bitfun-cli.exe" in cmd
+ assert "--version" in cmd
+ assert "chmod" not in cmd
+ assert "set -euo pipefail" not in cmd
+
+ @pytest.mark.asyncio
+ async def test_run_uses_container_workdir_and_exec(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/opt/bitfun-cli")
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-xx"}, clear=False):
+ await agent.run("Fix the issue", mock_env, AgentContext())
+
+ assert mock_env.exec.call_count == 6
+ commands = _exec_commands(mock_env)
+ assert "mkdir -p /logs/agent/patch" in commands[0]
+ assert "git-baseline-commit.txt" in commands[1]
+ assert "git diff --binary" in commands[3]
+ call_kw = next(
+ call.kwargs
+ for call in mock_env.exec.call_args_list
+ if "/opt/bitfun-cli exec" in call.kwargs["command"]
+ )
+ assert call_kw.get("cwd") is None
+ cmd = call_kw["command"]
+ assert "mkdir -p /logs/agent" in cmd
+ assert "${PIPESTATUS[0]}" in cmd
+ assert "exit $rc" in cmd
+ assert "/opt/bitfun-cli" in cmd
+ assert " exec " in cmd
+ assert "--agent " in cmd
+ assert "agentic" in cmd
+ assert "--output-patch " in cmd
+ assert "/logs/agent/bitfun.patch" in cmd
+ assert "tee /logs/agent/bitfun.txt" in cmd
+ assert call_kw["env"]["OPENAI_API_KEY"] == "sk-xx"
+
+ @pytest.mark.asyncio
+ async def test_run_without_output_patch(self, temp_dir):
+ agent = BitfunCli(
+ logs_dir=temp_dir,
+ binary_path="/bin/bitfun-cli",
+ output_patch_path=None,
+ )
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("Hello", mock_env, AgentContext())
+ cmd = _first_command_containing(
+ _exec_commands(mock_env), "/bin/bitfun-cli exec"
+ )
+ assert "--output-patch" not in cmd
+
+ @pytest.mark.asyncio
+ async def test_run_forwards_bitfun_prefixed_env(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ with patch.dict(
+ os.environ, {"BITFUN_DEBUG_LOG_PATH": "/tmp/x.log"}, clear=False
+ ):
+ await agent.run("Hi", mock_env, AgentContext())
+ main_call = next(
+ call
+ for call in mock_env.exec.call_args_list
+ if "bitfun-cli exec" in call.kwargs["command"]
+ )
+ cp_call = next(
+ call
+ for call in mock_env.exec.call_args_list
+ if "cp-back-manifest.json" in call.kwargs["command"]
+ )
+ assert main_call.kwargs["env"]["BITFUN_DEBUG_LOG_PATH"] == "/tmp/x.log"
+ assert cp_call.kwargs["env"]["BITFUN_DEBUG_LOG_PATH"] == "/tmp/x.log"
+
+ @pytest.mark.asyncio
+ async def test_run_writes_bitfun_config_before_exec(self, temp_dir):
+ bitfun_config = {
+ "app": {"language": "zh-CN"},
+ "ai": {
+ "models": [],
+ "default_models": {
+ "primary": "deepseek-v4-pro",
+ "fast": "deepseek-v4-pro",
+ },
+ },
+ }
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config=bitfun_config)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+
+ await agent.run("Hi", mock_env, AgentContext())
+
+ assert mock_env.exec.call_count == 7
+ setup_cmd = mock_env.exec.call_args_list[0].kwargs["command"]
+ commands = _exec_commands(mock_env)
+ run_cmd = _first_command_containing(commands, "bitfun-cli exec")
+ final_cmd = _first_command_containing(commands, "git diff --binary")
+ cp_cmd = _first_command_containing(commands, "cp-back-manifest.json")
+ probe_cmd = _first_command_containing(commands, "APP_CONFIG_SRC")
+ assert "config/app.json" in setup_cmd
+ assert "deepseek-v4-pro" in setup_cmd
+ assert commands.index(run_cmd) < commands.index(final_cmd)
+ assert commands.index(final_cmd) < commands.index(cp_cmd)
+ assert commands.index(cp_cmd) < commands.index(probe_cmd)
+ assert " exec " in run_cmd
+ assert "config/app.json" not in run_cmd
+ assert "/logs/agent/bitfun" in cp_cmd
+ assert "APP_CONFIG_SRC" in probe_cmd
+
+ @pytest.mark.asyncio
+ async def test_windows_run_uploads_prompt_and_uses_windows_paths(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.os = TaskOS.WINDOWS
+ mock_env.exec.return_value = SimpleNamespace(
+ return_code=0, stdout="", stderr=""
+ )
+ mock_env.upload_file.return_value = None
+
+ await agent.run("Write C:\\app\\greet.bat", mock_env, AgentContext())
+
+ commands = _exec_commands(mock_env)
+ run_cmd = _first_command_containing(commands, "bitfun-run.bat")
+ cp_cmd = _first_command_containing(commands, "windows_cp_back")
+ probe_cmd = _first_command_containing(commands, "source=")
+ assert "set -o pipefail" not in run_cmd
+ assert run_cmd == "C:\\logs\\agent\\bitfun-run.bat"
+ assert "C:\\logs\\agent\\bitfun" in cp_cmd
+ assert "C:\\bitfun-user\\config\\app.json" in probe_cmd
+ assert "%BITFUN_USER_ROOT%" not in probe_cmd
+ uploaded_targets = [
+ call.args[1] for call in mock_env.upload_file.call_args_list if call.args
+ ]
+ assert "C:/logs/agent/bitfun-prompt.txt" in uploaded_targets
+ assert "C:/logs/agent/bitfun-run.bat" in uploaded_targets
+
+ @pytest.mark.asyncio
+ async def test_run_attempts_final_app_config_capture_after_cp_back(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+
+ await agent.run("Hi", mock_env, AgentContext())
+
+ commands = [call.kwargs["command"] for call in mock_env.exec.call_args_list]
+ run_cmd = _first_command_containing(commands, "bitfun-cli exec")
+ final_cmd = _first_command_containing(commands, "git diff --binary")
+ cp_cmd = _first_command_containing(commands, "cp-back-manifest.json")
+ probe_cmd = _first_command_containing(commands, "APP_CONFIG_SRC")
+ assert commands.index(run_cmd) < commands.index(final_cmd)
+ assert commands.index(final_cmd) < commands.index(cp_cmd)
+ assert commands.index(cp_cmd) < commands.index(probe_cmd)
+
+ @pytest.mark.asyncio
+ async def test_run_does_not_exec_main_when_config_write_fails(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, bitfun_config={"ai": {"models": []}})
+ mock_env = AsyncMock()
+ mock_env.exec.side_effect = [
+ AsyncMock(return_code=1, stdout="config failed", stderr=""),
+ AsyncMock(return_code=0, stdout="", stderr=""),
+ AsyncMock(return_code=0, stdout="", stderr=""),
+ ]
+
+ with pytest.raises(NonZeroAgentExitCodeError):
+ await agent.run("Hi", mock_env, AgentContext())
+
+ assert mock_env.exec.call_count == 3
+ setup_cmd = mock_env.exec.call_args_list[0].kwargs["command"]
+ cp_cmd = mock_env.exec.call_args_list[1].kwargs["command"]
+ probe_cmd = mock_env.exec.call_args_list[2].kwargs["command"]
+ assert "config/app.json" in setup_cmd
+ assert " exec " not in cp_cmd
+ assert "/logs/agent/bitfun" in cp_cmd
+ assert "APP_CONFIG_SRC" in probe_cmd
+
+ def test_populate_context_post_run_returns_when_no_session_dir(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ assert ctx.is_empty()
+
+ def test_supports_atif_is_true(self):
+ assert BitfunCli.SUPPORTS_ATIF is True
+
+
+class TestGetSessionDir:
+ def test_picks_unique_standard_session(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ _write_session(
+ temp_dir,
+ "main",
+ metadata=_make_metadata("main", kind="standard"),
+ turns=[],
+ )
+ _write_session(
+ temp_dir,
+ "sub-1",
+ metadata=_make_metadata("sub-1", kind="subagent"),
+ turns=[],
+ )
+ _write_session(
+ temp_dir,
+ "sub-2",
+ metadata=_make_metadata("sub-2", kind="subagent"),
+ turns=[],
+ )
+ result = agent._get_session_dir()
+ assert result is not None
+ assert result.name == "main"
+
+ def test_no_bitfun_dir_returns_none(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._get_session_dir() is None
+
+ def test_falls_back_to_mtime_when_multiple_standards(self, temp_dir):
+ import time
+
+ agent = BitfunCli(logs_dir=temp_dir)
+ a = _write_session(
+ temp_dir,
+ "older",
+ metadata=_make_metadata("older", kind="standard"),
+ turns=[],
+ )
+ time.sleep(0.02)
+ b = _write_session(
+ temp_dir,
+ "newer",
+ metadata=_make_metadata("newer", kind="standard"),
+ turns=[],
+ )
+ now = time.time()
+ os.utime(a, (now - 100, now - 100))
+ os.utime(b, (now, now))
+ result = agent._get_session_dir()
+ assert result is not None
+ assert result.name == "newer"
+
+ def test_skips_dirs_without_metadata(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ (temp_dir / "bitfun" / "sessions" / "junk").mkdir(parents=True)
+ _write_session(
+ temp_dir,
+ "main",
+ metadata=_make_metadata("main", kind="standard"),
+ turns=[],
+ )
+ result = agent._get_session_dir()
+ assert result is not None
+ assert result.name == "main"
+
+
+class TestLoadTokenRecords:
+ def test_returns_empty_when_no_records_dir(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._load_token_records() == []
+
+ def test_loads_records_from_all_date_files(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ records_dir = temp_dir / "bitfun" / "token_usage" / "records"
+ records_dir.mkdir(parents=True)
+ (records_dir / "2026-01-01.json").write_text(
+ _json.dumps(
+ {
+ "records": [
+ _make_token_record("m", "s", "t1", 10, 5),
+ _make_token_record("m", "s", "t2", 20, 10),
+ ]
+ }
+ )
+ )
+ (records_dir / "2026-01-02.json").write_text(
+ _json.dumps({"records": [_make_token_record("m", "s", "t3", 1, 1)]})
+ )
+ records = agent._load_token_records()
+ assert len(records) == 3
+ assert {r["turn_id"] for r in records} == {"t1", "t2", "t3"}
+
+ def test_skips_malformed_record_files(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ records_dir = temp_dir / "bitfun" / "token_usage" / "records"
+ records_dir.mkdir(parents=True)
+ (records_dir / "bad.json").write_text("not json {{{")
+ (records_dir / "good.json").write_text(
+ _json.dumps({"records": [_make_token_record("m", "s", "t", 1, 1)]})
+ )
+ records = agent._load_token_records()
+ assert len(records) == 1
+ assert records[0]["turn_id"] == "t"
+
+
+class TestLoadStdoutTokenStats:
+ def test_returns_none_when_stdout_log_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._load_stdout_token_stats() is None
+
+ def test_sums_turn_token_stats_from_stdout_log(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ (temp_dir / "bitfun.txt").write_text(
+ "\x1b[32mINFO\x1b[0m Dialog turn completed - Token stats: "
+ "turn_id=t1, rounds=2, tools=1, duration=100ms, "
+ "prompt_tokens=10, completion_tokens=5, total_tokens=15\n"
+ "INFO Dialog turn completed - Token stats: "
+ "turn_id=t2, rounds=1, tools=0, duration=50ms, "
+ "prompt_tokens=20, completion_tokens=7, total_tokens=27, "
+ "cached_tokens=3\n"
+ )
+ stats = agent._load_stdout_token_stats()
+ assert stats == {
+ "prompt_tokens": 30,
+ "completion_tokens": 12,
+ "cached_tokens": 3,
+ "total_tokens": 42,
+ "record_count": 2,
+ "cached_tokens_available": False,
+ "cached_tokens_coverage": "partial",
+ }
+
+ def test_parses_partial_cache_coverage_from_stdout_log(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ (temp_dir / "bitfun.txt").write_text(
+ "INFO Dialog turn completed - Token stats: "
+ "turn_id=t1, rounds=5, model_calls=5, tools=4, duration=100ms, "
+ "prompt_tokens=99246, completion_tokens=6225, total_tokens=105471, "
+ "cached_tokens=12345, cached_tokens_available=partial\n"
+ )
+ stats = agent._load_stdout_token_stats()
+ assert stats == {
+ "prompt_tokens": 99246,
+ "completion_tokens": 6225,
+ "cached_tokens": 12345,
+ "total_tokens": 105471,
+ "record_count": 1,
+ "cached_tokens_available": False,
+ "cached_tokens_coverage": "partial",
+ }
+
+ def test_parses_complete_cache_tokens_from_stdout_log(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ (temp_dir / "bitfun.txt").write_text(
+ "INFO Dialog turn completed - Token stats: "
+ "turn_id=t1, rounds=1, tools=0, duration=50ms, "
+ "prompt_tokens=20, completion_tokens=7, total_tokens=27, "
+ "cached_tokens=3, cached_tokens_available=true\n"
+ )
+ stats = agent._load_stdout_token_stats()
+ assert stats == {
+ "prompt_tokens": 20,
+ "completion_tokens": 7,
+ "cached_tokens": 3,
+ "total_tokens": 27,
+ "record_count": 1,
+ "cached_tokens_available": True,
+ "cached_tokens_coverage": "true",
+ }
+
+
+class TestComputeCostViaLitellm:
+ def test_returns_none_when_no_model(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ assert agent._compute_cost_via_litellm(None, 100, 0, 50) is None
+
+ def test_returns_none_when_model_unknown(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ with _patch("litellm.model_cost", {}):
+ assert (
+ agent._compute_cost_via_litellm("totally-fake-model", 100, 0, 50)
+ is None
+ )
+
+ def test_computes_cost_with_cache_rate(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ fake_pricing = {
+ "fake-model": {
+ "input_cost_per_token": 1e-6,
+ "output_cost_per_token": 2e-6,
+ "cache_read_input_token_cost": 1e-7,
+ }
+ }
+ with _patch("litellm.model_cost", fake_pricing):
+ cost = agent._compute_cost_via_litellm("fake-model", 100, 10, 50)
+ assert cost is not None
+ assert abs(cost - (90e-6 + 10e-7 + 100e-6)) < 1e-12
+
+ def test_falls_back_to_input_rate_when_cache_rate_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ fake_pricing = {
+ "m": {"input_cost_per_token": 2e-6, "output_cost_per_token": 4e-6}
+ }
+ with _patch("litellm.model_cost", fake_pricing):
+ cost = agent._compute_cost_via_litellm("m", 100, 30, 50)
+ assert cost is not None
+ assert abs(cost - 4.0e-4) < 1e-12
+
+ def test_strips_provider_prefix(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ fake_pricing = {
+ "gpt-5": {"input_cost_per_token": 1e-6, "output_cost_per_token": 1e-6}
+ }
+ with _patch("litellm.model_cost", fake_pricing):
+ cost = agent._compute_cost_via_litellm("openai/gpt-5", 10, 0, 5)
+ assert cost is not None
+ assert abs(cost - (10e-6 + 5e-6)) < 1e-12
+
+
+class TestBitfunTpsStepMetrics:
+ def test_build_metrics_records_latency_and_tps(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ record = _make_token_record("m", "s", "t", 100, 25)
+ record["llm_latency_ms"] = 5000
+
+ metrics = agent._build_metrics_from_record(record)
+
+ assert metrics.extra is not None
+ assert metrics.extra["llm_latency_ms"] == 5000
+ assert metrics.extra["completion_tokens_per_second"] == 5.0
+ assert metrics.extra["tps_completion_tokens"] == 25
+ assert metrics.extra["tps_model_call_count"] == 1
+ assert metrics.extra["tps_latency_coverage"] == "complete"
+
+ def test_build_metrics_marks_missing_latency(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ record = _make_token_record("m", "s", "t", 100, 25)
+
+ metrics = agent._build_metrics_from_record(record)
+
+ assert metrics.extra is not None
+ assert "llm_latency_ms" not in metrics.extra
+ assert "completion_tokens_per_second" not in metrics.extra
+ assert metrics.extra["tps_unavailable_reason"] == "missing_latency"
+
+ def test_build_metrics_preserves_zero_latency_without_tps(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ record = _make_token_record("m", "s", "t", 100, 25)
+ record["llm_latency_ms"] = 0
+
+ metrics = agent._build_metrics_from_record(record)
+
+ assert metrics.extra is not None
+ assert metrics.extra["llm_latency_ms"] == 0
+ assert "completion_tokens_per_second" not in metrics.extra
+ assert metrics.extra["tps_unavailable_reason"] == "zero_latency"
+
+ def test_merge_metrics_computes_weighted_tps(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ a_record = _make_token_record("m", "s", "t", 100, 20)
+ b_record = _make_token_record("m", "s", "t", 100, 40)
+ a_record["llm_latency_ms"] = 2000
+ b_record["llm_latency_ms"] = 8000
+
+ merged = agent._merge_metrics(
+ agent._build_metrics_from_record(a_record),
+ agent._build_metrics_from_record(b_record),
+ )
+
+ assert merged.extra is not None
+ assert merged.extra["llm_latency_ms"] == 10000
+ assert merged.extra["tps_completion_tokens"] == 60
+ assert merged.extra["tps_model_call_count"] == 2
+ assert merged.extra["tps_latency_coverage"] == "complete"
+ assert merged.extra["completion_tokens_per_second"] == 6.0
+
+ def test_merge_metrics_marks_partial_latency_coverage(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ with_latency = _make_token_record("m", "s", "t", 100, 20)
+ without_latency = _make_token_record("m", "s", "t", 100, 40)
+ with_latency["llm_latency_ms"] = 2000
+
+ merged = agent._merge_metrics(
+ agent._build_metrics_from_record(with_latency),
+ agent._build_metrics_from_record(without_latency),
+ )
+
+ assert merged.extra is not None
+ assert merged.completion_tokens == 60
+ assert merged.extra["llm_latency_ms"] == 2000
+ assert merged.extra["tps_completion_tokens"] == 20
+ assert merged.extra["tps_model_call_count"] == 1
+ assert merged.extra["tps_latency_coverage"] == "partial"
+ assert merged.extra["completion_tokens_per_second"] == 10.0
+
+
+class TestConvertEventsToTrajectoryBasic:
+ def test_basic_user_assistant_pair(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s1"
+ turn = _make_turn(
+ 0,
+ "t1",
+ sid,
+ user_text="hello",
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti1", "hi there", order_index=0)],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid, turn_count=1), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / sid
+ )
+ assert traj is not None
+ assert traj.schema_version == "ATIF-v1.7"
+ assert traj.session_id == sid
+ assert traj.agent.name == "bitfun-cli"
+ assert len(traj.steps) == 2
+ assert traj.steps[0].source == "user"
+ assert traj.steps[0].message == "hello"
+ assert traj.steps[0].step_id == 1
+ assert traj.steps[1].source == "agent"
+ assert traj.steps[1].message == "hi there"
+ assert traj.steps[1].step_id == 2
+ assert traj.steps[1].model_name == "openai/gpt-5"
+
+ def test_stdout_token_stats_fallback_populates_final_metrics(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="fake-model")
+ sid = "stdout-stats"
+ turn = _make_turn(
+ 0,
+ "t1",
+ sid,
+ user_text="hello",
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti1", "hi there", order_index=0)],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid, turn_count=1),
+ turns=[turn],
+ )
+ (temp_dir / "bitfun.txt").write_text(
+ "INFO Dialog turn completed - Token stats: "
+ "turn_id=t1, rounds=1, tools=0, duration=100ms, "
+ "prompt_tokens=100, completion_tokens=20, total_tokens=120\n"
+ )
+ fake_pricing = {
+ "fake-model": {
+ "input_cost_per_token": 1e-6,
+ "output_cost_per_token": 2e-6,
+ }
+ }
+
+ with _patch("litellm.model_cost", fake_pricing):
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / sid
+ )
+
+ assert traj is not None
+ assert traj.final_metrics.total_prompt_tokens == 100
+ assert traj.final_metrics.total_completion_tokens == 20
+ assert traj.final_metrics.total_cached_tokens is None
+ assert traj.final_metrics.total_cost_usd is None
+ assert traj.final_metrics.extra is not None
+ assert traj.final_metrics.extra["token_usage_source"] == "bitfun_stdout"
+ assert traj.final_metrics.extra["cached_tokens_available"] is False
+ assert traj.final_metrics.extra["cached_tokens_coverage"] == "false"
+ assert traj.steps[1].metrics is not None
+ assert traj.steps[1].metrics.prompt_tokens == 100
+ assert traj.steps[1].metrics.completion_tokens == 20
+ assert traj.steps[1].metrics.cached_tokens is None
+ assert traj.steps[1].metrics.cost_usd is None
+ assert traj.steps[1].metrics.extra is not None
+ assert (
+ traj.steps[1].metrics.extra["allocation"]
+ == "aggregate_attached_to_last_agent_step"
+ )
+
+ def test_returns_none_when_metadata_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ bogus = temp_dir / "bitfun" / "sessions" / "x"
+ (bogus / "turns").mkdir(parents=True)
+ assert agent._convert_events_to_trajectory(bogus) is None
+
+ def test_user_query_wrapper_is_stripped_when_metadata_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s2"
+ turn = _make_turn(
+ 0,
+ "t1",
+ sid,
+ user_content="\nplease help\n ",
+ user_text="",
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti1", "ok", order_index=0)],
+ )
+ ],
+ )
+ turn["userMessage"]["metadata"] = {}
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.steps[0].source == "user"
+ assert traj.steps[0].message == "please help"
+
+ def test_step_ids_are_sequential_from_1(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s3"
+ turns = [
+ _make_turn(
+ i,
+ f"t{i}",
+ sid,
+ user_text=f"q{i}",
+ model_rounds=[
+ _make_round(
+ f"r{i}",
+ turn_id=f"t{i}",
+ text_items=[_make_text_item(f"ti{i}", f"a{i}")],
+ )
+ ],
+ )
+ for i in range(3)
+ ]
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid, turn_count=3), turns=turns
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert [s.step_id for s in traj.steps] == list(range(1, len(traj.steps) + 1))
+
+ def test_schema_version_is_atif_v1_7(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s4"
+ turn = _make_turn(
+ 0,
+ "t1",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti", "x")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.schema_version == "ATIF-v1.7"
+
+
+class TestBitfunTpsFinalMetrics:
+ def test_final_metrics_tps_excludes_subagents(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ main_record = _make_token_record("m", "main", "t1", 100, 20)
+ main_record["llm_latency_ms"] = 4000
+ sub_record = _make_token_record("m", "sub", "t2", 100, 100)
+ sub_record["llm_latency_ms"] = 1000
+ sub_record["is_subagent"] = True
+ subagent_trajectory = Trajectory.model_construct(
+ schema_version="ATIF-v1.7",
+ session_id="sub",
+ agent=Agent(name=AgentName.BITFUN_CLI.value, version="test"),
+ steps=[],
+ final_metrics=FinalMetrics(
+ total_prompt_tokens=sub_record["input_tokens"],
+ total_completion_tokens=sub_record["output_tokens"],
+ total_cached_tokens=sub_record["cached_tokens"],
+ total_steps=0,
+ ),
+ )
+
+ final_metrics = agent._build_final_metrics(
+ steps=[],
+ metadata={},
+ records_for_traj=[main_record],
+ subagent_trajectories=[subagent_trajectory],
+ subagent_count=1,
+ )
+
+ assert final_metrics.extra is not None
+ assert final_metrics.extra["total_llm_latency_ms"] == 4000
+ assert final_metrics.extra["model_call_count"] == 1
+ assert final_metrics.extra["tps_completion_tokens"] == 20
+ assert final_metrics.extra["completion_tokens_per_second"] == 5.0
+ assert final_metrics.extra["tps_latency_coverage"] == "complete"
+ assert (
+ final_metrics.extra["subagent_total_tokens"] == sub_record["total_tokens"]
+ )
+
+ def test_final_metrics_marks_missing_latency(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ main_record = _make_token_record("m", "main", "t1", 100, 20)
+
+ final_metrics = agent._build_final_metrics(
+ steps=[],
+ metadata={},
+ records_for_traj=[main_record],
+ subagent_trajectories=[],
+ subagent_count=0,
+ )
+
+ assert final_metrics.extra is not None
+ assert "completion_tokens_per_second" not in final_metrics.extra
+ assert final_metrics.extra["tps_unavailable_reason"] == "missing_latency"
+
+
+class TestThinkingAccumulation:
+ def test_thinking_block_attaches_to_next_text_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ thinking_items=[
+ _make_thinking_item("th1", "thinking A", order_index=0)
+ ],
+ text_items=[_make_text_item("ti1", "answer", order_index=1)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert len(agent_steps) == 1
+ assert agent_steps[0].reasoning_content == "thinking A"
+ assert agent_steps[0].message == "answer"
+
+ def test_multiple_thinking_blocks_joined_with_double_newlines(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ thinking_items=[
+ _make_thinking_item("th1", "first", order_index=0),
+ _make_thinking_item("th2", "second", order_index=1),
+ ],
+ text_items=[_make_text_item("ti1", "answer", order_index=2)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].reasoning_content == "first\n\nsecond"
+
+ def test_thinking_after_text_does_not_attach_backwards(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ text_items=[_make_text_item("ti1", "answer", order_index=0)],
+ thinking_items=[_make_thinking_item("th1", "post", order_index=1)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].reasoning_content is None
+
+
+class TestToolCallMapping:
+ def test_tool_call_uses_result_for_assistant_as_content(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1",
+ "Read",
+ {"file_path": "/x"},
+ result_text="file contents",
+ raw_result={"text": "file contents", "lines": 1},
+ )
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ tool_steps = [s for s in traj.steps if s.tool_calls]
+ assert len(tool_steps) == 1
+ step = tool_steps[0]
+ assert step.tool_calls[0].function_name == "Read"
+ assert step.tool_calls[0].tool_call_id == "tc1"
+ assert step.tool_calls[0].arguments == {"file_path": "/x"}
+ assert step.observation is not None
+ assert step.observation.results[0].source_call_id == "tc1"
+ assert step.observation.results[0].content == "file contents"
+
+ def test_tool_call_falls_back_to_json_dumps_when_result_for_assistant_absent(
+ self, temp_dir
+ ):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1",
+ "Read",
+ {},
+ raw_result={"chunks": [1, 2, 3]},
+ )
+ tool["toolResult"].pop("resultForAssistant", None)
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ content = step.observation.results[0].content
+ assert content is not None
+ assert "chunks" in content
+
+ def test_tool_call_preserves_raw_result_in_observation_extra(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1",
+ "Read",
+ {},
+ result_text="ok",
+ raw_result={"chunks": [1, 2]},
+ )
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ extra = step.observation.results[0].extra or {}
+ assert extra.get("raw_result") == {"chunks": [1, 2]}
+ assert extra.get("success") is True
+
+ def test_tool_error_propagates_to_observation_extra(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1",
+ "Read",
+ {},
+ raw_result={"err": "x"},
+ success=False,
+ error="permission denied",
+ )
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ extra = step.observation.results[0].extra or {}
+ assert extra.get("error") == "permission denied"
+ assert extra.get("success") is False
+
+ def test_tool_call_message_uses_ai_intent_when_present(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1",
+ "Read",
+ {},
+ result_text="ok",
+ ai_intent="read configuration file",
+ )
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ assert step.message == "read configuration file"
+
+ def test_tool_call_arguments_wraps_non_dict_input(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item("tc1", "Echo", "not-a-dict", result_text="ok")
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[_make_round("r", turn_id="t", tool_items=[tool])],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ step = [s for s in traj.steps if s.tool_calls][0]
+ assert step.tool_calls[0].arguments == {"input": "not-a-dict"}
+
+ def test_thinking_attaches_to_tool_call_then_clears(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item(
+ "tc1",
+ "Read",
+ {},
+ result_text="ok",
+ order_index=1,
+ )
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ thinking_items=[
+ _make_thinking_item("th", "plan to read", order_index=0)
+ ],
+ tool_items=[tool],
+ text_items=[_make_text_item("ti", "done", order_index=2)],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ tool_step = [s for s in traj.steps if s.tool_calls][0]
+ text_step = [s for s in traj.steps if s.source == "agent" and not s.tool_calls][
+ 0
+ ]
+ assert tool_step.reasoning_content == "plan to read"
+ assert text_step.reasoning_content is None
+
+
+class TestRoundAndTurnEdgeCases:
+ def test_empty_round_emits_placeholder_agent_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ empty_round = _make_round(
+ "r",
+ turn_id="t",
+ text_items=[],
+ tool_items=[],
+ thinking_items=[],
+ duration_ms=42,
+ attempt_count=3,
+ failure_category="rate_limit",
+ status="failed",
+ )
+ turn = _make_turn(0, "t", sid, model_rounds=[empty_round])
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert len(agent_steps) == 1
+ assert agent_steps[0].message == ""
+ extra = agent_steps[0].extra or {}
+ assert extra.get("round_status") == "failed"
+ assert extra.get("attempt_count") == 3
+ assert extra.get("failure_category") == "rate_limit"
+
+ def test_manual_compaction_turn_emits_system_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ comp_turn = _make_turn(0, "t-comp", sid, kind="manual_compaction")
+ normal_turn = _make_turn(
+ 1,
+ "t-1",
+ sid,
+ user_text="hi",
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t-1",
+ text_items=[_make_text_item("ti", "hello")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid, turn_count=2),
+ turns=[comp_turn, normal_turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ comp_steps = [s for s in traj.steps if s.source == "system"]
+ assert len(comp_steps) == 1
+ assert comp_steps[0].message == ""
+ assert comp_steps[0].is_copied_context is True
+
+ def test_local_command_turn_is_silently_skipped(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ local_turn = _make_turn(0, "t-local", sid, kind="local_command")
+ normal_turn = _make_turn(
+ 1,
+ "t-1",
+ sid,
+ user_text="hi",
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t-1",
+ text_items=[_make_text_item("ti", "hello")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid, turn_count=2),
+ turns=[local_turn, normal_turn],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert all(
+ "t-local" not in (s.extra or {}).get("turn_id", "") for s in traj.steps
+ )
+ assert any(s.source == "user" for s in traj.steps)
+
+ def test_order_index_orders_mixed_items_within_round(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ tool = _make_tool_item("tc", "Read", {}, result_text="ok", order_index=2)
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ thinking_items=[_make_thinking_item("th", "plan", order_index=0)],
+ text_items=[_make_text_item("ti", "preface", order_index=1)],
+ tool_items=[tool],
+ ),
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[turn]
+ )
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert len(agent_steps) == 2
+ assert agent_steps[0].message == "preface"
+ assert agent_steps[0].reasoning_content == "plan"
+ assert agent_steps[1].tool_calls is not None
+ assert agent_steps[1].tool_calls[0].function_name == "Read"
+
+
+class TestTokenAndMetricsAllocation:
+ def test_metrics_assigned_to_first_assistant_step_of_round(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t",
+ ts=ts,
+ text_items=[_make_text_item("ti1", "first", order_index=0)],
+ )
+ ],
+ )
+ records = [
+ _make_token_record("openai/gpt-5", sid, "t", 100, 50, cached=10, ts=ts)
+ ]
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(session_dir, token_records=records)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].metrics is not None
+ m = agent_steps[0].metrics
+ assert m.prompt_tokens == 100
+ assert m.completion_tokens == 50
+ assert m.cached_tokens == 10
+
+ def test_metrics_use_nearest_round_timestamp(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts0 = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts0,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t",
+ round_index=0,
+ ts=ts0 + 100,
+ text_items=[_make_text_item("ti1", "early", order_index=0)],
+ ),
+ _make_round(
+ "r2",
+ turn_id="t",
+ round_index=1,
+ ts=ts0 + 1000,
+ text_items=[_make_text_item("ti2", "late", order_index=0)],
+ ),
+ ],
+ )
+ records = [
+ _make_token_record(
+ "openai/gpt-5",
+ sid,
+ "t",
+ 200,
+ 80,
+ ts=ts0 + 960,
+ )
+ ]
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(session_dir, token_records=records)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].metrics is None
+ assert agent_steps[1].metrics is not None
+ assert agent_steps[1].metrics.prompt_tokens == 200
+
+ def test_step_metrics_absent_when_no_records_match_turn(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t",
+ text_items=[_make_text_item("ti1", "x")],
+ )
+ ],
+ )
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=[],
+ )
+ traj = agent._convert_events_to_trajectory(session_dir, token_records=[])
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert all(s.metrics is None for s in agent_steps)
+
+ def test_subagent_records_excluded_from_main_trajectory_metrics(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "main"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t",
+ ts=ts,
+ text_items=[_make_text_item("ti1", "x")],
+ )
+ ],
+ )
+ records = [
+ _make_token_record("openai/gpt-5", sid, "t", 100, 50, ts=ts),
+ _make_token_record(
+ "openai/gpt-5",
+ sid,
+ "t",
+ 999,
+ 999,
+ ts=ts,
+ is_sub=True,
+ ),
+ ]
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(session_dir, token_records=records)
+ assert traj is not None
+ m = [s for s in traj.steps if s.source == "agent"][0].metrics
+ assert m is not None
+ assert m.prompt_tokens == 100
+ assert m.completion_tokens == 50
+
+ def test_extra_records_attach_to_last_assistant_step_of_turn(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t",
+ ts=ts,
+ text_items=[_make_text_item("ti1", "x", order_index=0)],
+ )
+ ],
+ )
+ records = [
+ _make_token_record("m", sid, "t", 100, 50, ts=ts),
+ _make_token_record("m", sid, "t", 10, 5, ts=ts + 10),
+ ]
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(session_dir, token_records=records)
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].metrics is not None
+ assert agent_steps[0].metrics.prompt_tokens in {100, 110}
+
+
+class TestFinalMetrics:
+ def test_final_metrics_sums_step_metrics(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turns = [
+ _make_turn(
+ 0,
+ "t1",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ ts=ts,
+ text_items=[_make_text_item("ti", "a", order_index=0)],
+ )
+ ],
+ ),
+ _make_turn(
+ 1,
+ "t2",
+ sid,
+ ts=ts + 100,
+ model_rounds=[
+ _make_round(
+ "r2",
+ turn_id="t2",
+ ts=ts + 100,
+ text_items=[_make_text_item("ti", "b", order_index=0)],
+ )
+ ],
+ ),
+ ]
+ records = [
+ _make_token_record("m", sid, "t1", 100, 50, cached=10, ts=ts),
+ _make_token_record("m", sid, "t2", 200, 80, cached=20, ts=ts + 100),
+ ]
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid, turn_count=2),
+ turns=turns,
+ token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(session_dir, token_records=records)
+ assert traj is not None
+ fm = traj.final_metrics
+ assert fm is not None
+ assert fm.total_prompt_tokens == 300
+ assert fm.total_completion_tokens == 130
+ assert fm.total_cached_tokens == 30
+ assert fm.total_steps == len(traj.steps)
+
+ def test_final_metrics_cost_is_none_when_any_step_unpriced(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t",
+ ts=ts,
+ text_items=[_make_text_item("ti", "a")],
+ )
+ ],
+ )
+ records = [_make_token_record("unknown-model", sid, "t", 100, 50, ts=ts)]
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=records,
+ )
+ with _patch("litellm.model_cost", {}):
+ traj = agent._convert_events_to_trajectory(
+ session_dir, token_records=records
+ )
+ assert traj is not None
+ assert traj.final_metrics is not None
+ assert traj.final_metrics.total_cost_usd is None
+
+ def test_final_metrics_extra_includes_session_summary(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t",
+ ts=ts,
+ text_items=[_make_text_item("ti", "a")],
+ )
+ ],
+ )
+ records = [_make_token_record("m", sid, "t", 100, 50, ts=ts)]
+ session_dir = _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(
+ sid,
+ turn_count=1,
+ tool_call_count=0,
+ created_at=ts,
+ last_active_at=ts + 5_000,
+ ),
+ turns=[turn],
+ token_records=records,
+ )
+ traj = agent._convert_events_to_trajectory(session_dir, token_records=records)
+ assert traj is not None
+ extra = (traj.final_metrics.extra or {}) if traj.final_metrics else {}
+ assert extra.get("main_session_turn_count") == 1
+ assert extra.get("main_session_duration_ms") == 5_000
+ assert "m" in (extra.get("models_used") or [])
+
+
+class TestSubagentEmbedding:
+ def _build_sessions_with_subagent(
+ self, temp_dir, *, sub_sid="sub", main_sid="main"
+ ):
+ sub_turn = _make_turn(
+ 0,
+ "st1",
+ sub_sid,
+ user_text="do thing",
+ model_rounds=[
+ _make_round(
+ "sr1",
+ turn_id="st1",
+ text_items=[_make_text_item("sti", "did it")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir,
+ sub_sid,
+ metadata=_make_metadata(sub_sid, kind="subagent", model="openai/gpt-5"),
+ turns=[sub_turn],
+ )
+ tool = _make_tool_item(
+ "tc1",
+ "Task",
+ {"description": "delegate"},
+ result_text="subagent done",
+ subagent_sid=sub_sid,
+ subagent_model_id="openai/gpt-5",
+ )
+ main_turn = _make_turn(
+ 0,
+ "mt1",
+ main_sid,
+ user_text="please",
+ model_rounds=[_make_round("mr1", turn_id="mt1", tool_items=[tool])],
+ )
+ _write_session(
+ temp_dir,
+ main_sid,
+ metadata=_make_metadata(main_sid, kind="standard"),
+ turns=[main_turn],
+ )
+ return temp_dir / "bitfun" / "sessions" / main_sid
+
+ def test_subagent_trajectory_is_embedded(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ session_dir = self._build_sessions_with_subagent(temp_dir)
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.subagent_trajectories is not None
+ assert len(traj.subagent_trajectories) == 1
+ sub = traj.subagent_trajectories[0]
+ assert sub.trajectory_id == "sub"
+ assert sub.agent.name == "Task"
+ assert sub.agent.model_name == "openai/gpt-5"
+
+ def test_populate_context_counts_embedded_subagent_tokens(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ self._build_sessions_with_subagent(temp_dir)
+ records_dir = temp_dir / "bitfun" / "token_usage" / "records"
+ records_dir.mkdir(parents=True, exist_ok=True)
+ (records_dir / "2026-01-01.json").write_text(
+ _json.dumps(
+ {
+ "records": [
+ _make_token_record(
+ "openai/gpt-5",
+ "main",
+ "mt1",
+ 100,
+ 40,
+ cached=5,
+ ),
+ _make_token_record(
+ "openai/gpt-5",
+ "sub",
+ "st1",
+ 30,
+ 5,
+ cached=1,
+ is_sub=True,
+ ),
+ _make_token_record(
+ "openai/gpt-5",
+ "unrelated-sub",
+ "ust1",
+ 900,
+ 90,
+ is_sub=True,
+ ),
+ ]
+ }
+ )
+ )
+
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+
+ assert ctx.n_input_tokens == 130
+ assert ctx.n_output_tokens == 45
+ assert ctx.n_cache_tokens == 6
+
+ payload = _json.loads((temp_dir / "trajectory.json").read_text())
+ assert payload["final_metrics"]["total_prompt_tokens"] == 100
+ assert (
+ payload["subagent_trajectories"][0]["final_metrics"]["total_prompt_tokens"]
+ == 30
+ )
+ assert payload["final_metrics"]["extra"]["subagent_total_tokens"] == 35
+
+ def test_token_count_sum_ignores_nested_subagent_tokens(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ nested = Trajectory.model_construct(
+ schema_version="ATIF-v1.7",
+ session_id="nested",
+ agent=Agent(name="Task", version="test"),
+ steps=[],
+ final_metrics=FinalMetrics(
+ total_prompt_tokens=1000,
+ total_completion_tokens=100,
+ total_cached_tokens=10,
+ total_cost_usd=1.0,
+ total_steps=0,
+ ),
+ )
+ sub = Trajectory.model_construct(
+ schema_version="ATIF-v1.7",
+ session_id="sub",
+ agent=Agent(name="Task", version="test"),
+ steps=[],
+ final_metrics=FinalMetrics(
+ total_prompt_tokens=30,
+ total_completion_tokens=5,
+ total_cached_tokens=1,
+ total_cost_usd=0.1,
+ total_steps=0,
+ ),
+ subagent_trajectories=[nested],
+ )
+ main = Trajectory.model_construct(
+ schema_version="ATIF-v1.7",
+ session_id="main",
+ agent=Agent(name=AgentName.BITFUN_CLI.value, version="test"),
+ steps=[],
+ final_metrics=FinalMetrics(
+ total_prompt_tokens=100,
+ total_completion_tokens=40,
+ total_cached_tokens=5,
+ total_cost_usd=0.2,
+ total_steps=0,
+ ),
+ subagent_trajectories=[sub],
+ )
+
+ prompt, completion, cached, cost = agent._sum_trajectory_token_counts(main)
+ assert (prompt, completion, cached) == (130, 45, 6)
+ assert cost == pytest.approx(0.3)
+
+ def test_parent_observation_references_embedded_subagent(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ session_dir = self._build_sessions_with_subagent(temp_dir)
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ tool_step = next(s for s in traj.steps if s.tool_calls)
+ refs = tool_step.observation.results[0].subagent_trajectory_ref
+ assert refs is not None
+ assert any(ref.trajectory_id == "sub" for ref in refs)
+
+ def test_subagent_relationship_metadata_backfills_parent_ref(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sub_sid, main_sid = "sub", "main"
+ parent_tool_call_id = "tc1"
+ sub_turn = _make_turn(
+ 0,
+ "st1",
+ sub_sid,
+ user_text="do thing",
+ model_rounds=[
+ _make_round(
+ "sr1",
+ turn_id="st1",
+ text_items=[_make_text_item("sti", "did it")],
+ )
+ ],
+ )
+ sub_metadata = _make_metadata(sub_sid, kind="subagent", model="openai/gpt-5")
+ sub_metadata["agentType"] = "Explore"
+ sub_metadata["relationship"] = {
+ "kind": "subagent",
+ "parentSessionId": main_sid,
+ "parentDialogTurnId": "mt1",
+ "parentToolCallId": parent_tool_call_id,
+ "subagentType": "Explore",
+ }
+ _write_session(
+ temp_dir,
+ sub_sid,
+ metadata=sub_metadata,
+ turns=[sub_turn],
+ )
+
+ tool = _make_tool_item(
+ parent_tool_call_id,
+ "Task",
+ {"description": "delegate", "subagent_type": "Explore"},
+ result_text="subagent done",
+ )
+ main_turn = _make_turn(
+ 0,
+ "mt1",
+ main_sid,
+ user_text="please",
+ model_rounds=[_make_round("mr1", turn_id="mt1", tool_items=[tool])],
+ )
+ _write_session(
+ temp_dir,
+ main_sid,
+ metadata=_make_metadata(main_sid, kind="standard"),
+ turns=[main_turn],
+ )
+
+ session_dir = temp_dir / "bitfun" / "sessions" / main_sid
+ traj = agent._convert_events_to_trajectory(session_dir)
+ assert traj is not None
+ assert traj.subagent_trajectories is not None
+ assert len(traj.subagent_trajectories) == 1
+ assert traj.subagent_trajectories[0].trajectory_id == sub_sid
+ assert traj.subagent_trajectories[0].agent.name == "Explore"
+
+ tool_step = next(s for s in traj.steps if s.tool_calls)
+ assert tool_step.extra["is_subagent_dispatch"] is True
+ refs = tool_step.observation.results[0].subagent_trajectory_ref
+ assert refs is not None
+ assert refs[0].trajectory_id == sub_sid
+ assert refs[0].extra["relationship_source"] == "metadata"
+
+ def test_duplicate_subagent_session_id_embedded_only_once(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sub_sid, main_sid = "sub", "main"
+ sub_turn = _make_turn(
+ 0,
+ "st1",
+ sub_sid,
+ model_rounds=[
+ _make_round(
+ "sr1",
+ turn_id="st1",
+ text_items=[_make_text_item("sti", "ok")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir,
+ sub_sid,
+ metadata=_make_metadata(sub_sid, kind="subagent"),
+ turns=[sub_turn],
+ )
+ tool_a = _make_tool_item(
+ "tc1",
+ "Task",
+ {"a": 1},
+ result_text="a-done",
+ subagent_sid=sub_sid,
+ order_index=0,
+ )
+ tool_b = _make_tool_item(
+ "tc2",
+ "Task",
+ {"b": 2},
+ result_text="b-done",
+ subagent_sid=sub_sid,
+ order_index=1,
+ )
+ main_turn = _make_turn(
+ 0,
+ "mt1",
+ main_sid,
+ model_rounds=[
+ _make_round(
+ "mr1",
+ turn_id="mt1",
+ tool_items=[tool_a, tool_b],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir,
+ main_sid,
+ metadata=_make_metadata(main_sid, kind="standard"),
+ turns=[main_turn],
+ )
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / main_sid
+ )
+ assert traj is not None
+ assert traj.subagent_trajectories is not None
+ assert len(traj.subagent_trajectories) == 1
+
+ def test_missing_subagent_dir_omits_embed_but_keeps_step(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ main_sid = "main"
+ tool = _make_tool_item(
+ "tc1",
+ "Task",
+ {"x": 1},
+ result_text="ok",
+ subagent_sid="missing-sub",
+ )
+ main_turn = _make_turn(
+ 0,
+ "mt1",
+ main_sid,
+ model_rounds=[_make_round("mr1", turn_id="mt1", tool_items=[tool])],
+ )
+ _write_session(
+ temp_dir,
+ main_sid,
+ metadata=_make_metadata(main_sid, kind="standard"),
+ turns=[main_turn],
+ )
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / main_sid
+ )
+ assert traj is not None
+ assert not traj.subagent_trajectories
+ tool_step = next(s for s in traj.steps if s.tool_calls)
+ refs = tool_step.observation.results[0].subagent_trajectory_ref
+ assert refs is None or refs == []
+ assert (traj.notes or "").lower().find("missing") >= 0
+
+
+class TestPopulateContextPostRun:
+ def test_writes_trajectory_json_to_logs_dir(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ ts=ts,
+ text_items=[_make_text_item("ti", "hi")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=[_make_token_record("openai/gpt-5", sid, "t", 50, 25, ts=ts)],
+ )
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ out = temp_dir / "trajectory.json"
+ assert out.is_file()
+ payload = _json.loads(out.read_text())
+ assert payload["schema_version"] == "ATIF-v1.7"
+ assert payload["session_id"] == sid
+
+ def test_populates_context_token_counts_from_final_metrics(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ ts = _DEFAULT_TS_MS
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ ts=ts,
+ text_items=[_make_text_item("ti", "hi")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ token_records=[
+ _make_token_record(
+ "openai/gpt-5",
+ sid,
+ "t",
+ 100,
+ 40,
+ cached=5,
+ ts=ts,
+ )
+ ],
+ )
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ assert ctx.n_input_tokens == 100
+ assert ctx.n_output_tokens == 40
+ assert ctx.n_cache_tokens == 5
+ assert ctx.metadata is not None
+ assert ctx.metadata["bitfun"]["trajectory_path"] == "agent/trajectory.json"
+ assert ctx.metadata["bitfun"]["session_id"] == sid
+ assert ctx.metadata["bitfun"]["model_name"] == "default"
+ assert ctx.metadata["bitfun"]["total_steps"] == 2
+
+ def test_populates_context_artifact_paths_when_present(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ turn = _make_turn(
+ 0,
+ "t",
+ sid,
+ model_rounds=[
+ _make_round(
+ "r",
+ turn_id="t",
+ text_items=[_make_text_item("ti", "hi")],
+ )
+ ],
+ )
+ _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ )
+ (temp_dir / "bitfun").mkdir(exist_ok=True)
+ (temp_dir / "bitfun" / "cli.log").write_text("cli log\n")
+ (temp_dir / "bitfun" / "ai-request-audit.jsonl").write_text(
+ '{"thinking":true}\n'
+ )
+ (temp_dir / "bitfun" / "cli-logs" / "20260604T172854").mkdir(parents=True)
+ (temp_dir / "bitfun" / "request-traces" / "trace-0001").mkdir(parents=True)
+ (temp_dir / "bitfun" / "cp-back-manifest.json").write_text("{}\n")
+ (temp_dir / "bitfun" / "config").mkdir(parents=True)
+ (temp_dir / "bitfun" / "config" / "app.redacted.json").write_text("{}")
+
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+
+ assert ctx.metadata is not None
+ assert ctx.metadata["bitfun"]["bitfun_data_path"] == "agent/bitfun"
+ assert ctx.metadata["bitfun"]["cli_log_path"] == "agent/bitfun/cli.log"
+ assert (
+ ctx.metadata["bitfun"]["ai_request_audit_path"]
+ == "agent/bitfun/ai-request-audit.jsonl"
+ )
+ assert ctx.metadata["bitfun"]["cli_logs_path"] == "agent/bitfun/cli-logs"
+ assert (
+ ctx.metadata["bitfun"]["request_traces_path"]
+ == "agent/bitfun/request-traces"
+ )
+ assert (
+ ctx.metadata["bitfun"]["cp_back_manifest_path"]
+ == "agent/bitfun/cp-back-manifest.json"
+ )
+ assert (
+ ctx.metadata["bitfun"]["final_app_config_path"]
+ == "agent/bitfun/config/app.redacted.json"
+ )
+
+ def test_swallows_conversion_errors_and_returns_normally(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "s"
+ session_dir = temp_dir / "bitfun" / "sessions" / sid
+ (session_dir / "turns").mkdir(parents=True)
+ (session_dir / "metadata.json").write_text(_json.dumps(_make_metadata(sid)))
+ (session_dir / "turns" / "turn-0000.json").write_text("{not json")
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+ assert ctx.is_empty()
+ assert not (temp_dir / "trajectory.json").exists()
+
+
+class TestRunCpBackFinally:
+ @pytest.mark.asyncio
+ async def test_run_passes_extra_env_to_main_and_cp_back(self, temp_dir):
+ agent = BitfunCli(
+ logs_dir=temp_dir,
+ extra_env={"XDG_CONFIG_HOME": "/testbed/.config"},
+ )
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ for call in mock_env.exec.call_args_list:
+ assert call.kwargs["env"]["XDG_CONFIG_HOME"] == "/testbed/.config"
+
+ @pytest.mark.asyncio
+ async def test_log_cp_back_gaps_debug_when_artifacts_missing(
+ self, temp_dir, caplog
+ ):
+ import logging
+
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ with caplog.at_level(logging.DEBUG):
+ await agent.run("hi", mock_env, AgentContext())
+ messages = [r.message for r in caplog.records]
+ assert any("missing cli.log" in m for m in messages)
+ assert any(
+ "missing sessions" in m or "no session subdirectories" in m
+ for m in messages
+ )
+ assert any("missing request-traces" in m for m in messages)
+
+ @pytest.mark.asyncio
+ async def test_run_invokes_cp_back_in_finally(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, binary_path="/usr/local/bin/bitfun-cli")
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ assert mock_env.exec.call_count == 6
+ cp_cmd = _first_command_containing(
+ _exec_commands(mock_env), "cp-back-manifest.json"
+ )
+ assert "cp -R" in cp_cmd
+ assert "/logs/agent/bitfun" in cp_cmd
+ assert "PATCH_PATH=/logs/agent/bitfun.patch" in cp_cmd
+ assert "bitfun.patch.meta.json" in cp_cmd
+ assert "created_empty_placeholder" in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_cp_back_command_has_slug_first_then_mtime_fallback(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ cp_cmd = _first_command_containing(
+ _exec_commands(mock_env), "cp-back-manifest.json"
+ )
+ assert "$HOME/.bitfun/projects/testbed" in cp_cmd
+ assert "$HOME/.bitfun/projects/-testbed" in cp_cmd
+ assert '[ -d "$d/sessions" ] && PROJECT_PATH="$d" && break' in cp_cmd
+ assert "LATEST_SESSIONS=$(ls -dt" in cp_cmd
+ assert 'PROJECT_PATH=$(dirname "${LATEST_SESSIONS%/}")' in cp_cmd
+ assert "token_usage" in cp_cmd
+ assert "cli.log" in cp_cmd
+ assert "ai-request-audit.jsonl" in cp_cmd
+ assert "cp-back-manifest.json" in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_cp_back_command_copies_cli_logs_directory(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ cp_cmd = _first_command_containing(
+ _exec_commands(mock_env), "cp-back-manifest.json"
+ )
+ assert "CLI_LOGS_SRC" in cp_cmd
+ assert "$BITFUN_CONFIG_DIR/cli-logs" in cp_cmd
+ assert 'cp -R "$CLI_LOGS_SRC" /logs/agent/bitfun/' in cp_cmd
+ assert '"cli_logs"' in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_cp_back_command_copies_project_request_traces(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+
+ await agent.run("hi", mock_env, AgentContext())
+
+ cp_cmd = _first_command_containing(
+ _exec_commands(mock_env), "cp-back-manifest.json"
+ )
+ assert 'REQUEST_TRACES_SRC="$PROJECT_PATH/request-traces"' in cp_cmd
+ assert 'if [ -d "$REQUEST_TRACES_SRC" ]; then' in cp_cmd
+ assert "mkdir -p /logs/agent/bitfun/request-traces" in cp_cmd
+ assert (
+ 'cp -R "$REQUEST_TRACES_SRC"/. /logs/agent/bitfun/request-traces/' in cp_cmd
+ )
+ assert '"request_traces":{"source":%s,"exists":%s}' in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_log_cp_back_gaps_debug_when_cli_log_empty(self, temp_dir, caplog):
+ import logging
+
+ agent = BitfunCli(logs_dir=temp_dir)
+ (temp_dir / "bitfun" / "sessions").mkdir(parents=True)
+ (temp_dir / "bitfun" / "cli.log").write_text("")
+
+ with caplog.at_level(logging.DEBUG):
+ agent._log_cp_back_gaps()
+
+ messages = [r.message for r in caplog.records]
+ assert any("empty cli.log" in m for m in messages)
+ assert any("missing ai-request-audit.jsonl" in m for m in messages)
+
+ @pytest.mark.asyncio
+ async def test_cp_back_command_skips_patch_placeholder_when_disabled(
+ self, temp_dir
+ ):
+ agent = BitfunCli(logs_dir=temp_dir, output_patch_path=None)
+ mock_env = AsyncMock()
+ mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
+ await agent.run("hi", mock_env, AgentContext())
+ cp_cmd = _first_command_containing(
+ _exec_commands(mock_env), "cp-back-manifest.json"
+ )
+ assert "PATCH_PATH=" not in cp_cmd
+ assert "bitfun.patch.meta.json" not in cp_cmd
+
+ @pytest.mark.asyncio
+ async def test_cp_back_failures_do_not_propagate(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+
+ async def side_effect(*args, **kwargs):
+ if "cp-back-manifest.json" in kwargs["command"]:
+ raise RuntimeError("cp-back boom")
+ return AsyncMock(return_code=0, stdout="", stderr="")
+
+ mock_env.exec.side_effect = side_effect
+ await agent.run("hi", mock_env, AgentContext())
+ commands = _exec_commands(mock_env)
+ assert any("git diff --binary" in command for command in commands)
+ assert any("cp-back-manifest.json" in command for command in commands)
+ assert any("APP_CONFIG_SRC" in command for command in commands)
+
+ @pytest.mark.asyncio
+ async def test_main_exec_failure_still_runs_cp_back(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir)
+ mock_env = AsyncMock()
+
+ async def side_effect(*args, **kwargs):
+ if "bitfun-cli exec" in kwargs["command"]:
+ raise NonZeroAgentExitCodeError("main exec failed")
+ return AsyncMock(return_code=0, stdout="", stderr="")
+
+ mock_env.exec.side_effect = side_effect
+ with pytest.raises(NonZeroAgentExitCodeError):
+ await agent.run("hi", mock_env, AgentContext())
+ commands = _exec_commands(mock_env)
+ run_cmd = _first_command_containing(commands, "bitfun-cli exec")
+ final_cmd = _first_command_containing(commands, "git diff --binary")
+ cp_cmd = _first_command_containing(commands, "cp-back-manifest.json")
+ assert commands.index(run_cmd) < commands.index(final_cmd)
+ assert commands.index(final_cmd) < commands.index(cp_cmd)
+
+
+class TestSnapshotFallback:
+ """Cover BitFun's ``exec``-mode quirk where ``turns/`` is truncated to a
+ final-round placeholder but ``snapshots/context-*.json`` still has the
+ full conversation."""
+
+ def _make_truncated_turn(self, sid: str, turn_id: str, *, ts: int) -> dict:
+ """Mimic the BitFun 0.2.7 ``exec`` artifact: a single ``-final-round``
+ with no tool/thinking items and only the final text."""
+ return _make_turn(
+ 0,
+ turn_id,
+ sid,
+ user_text="fix the bug",
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ f"{turn_id}-final-round",
+ turn_id=turn_id,
+ round_index=0,
+ ts=ts + 100,
+ text_items=[
+ _make_text_item(
+ f"{turn_id}-final-text",
+ "all done",
+ order_index=0,
+ ts=ts + 100,
+ )
+ ],
+ model_id=None,
+ )
+ ],
+ )
+
+ def test_snapshot_used_when_turn_files_only_have_final_round(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="default")
+ sid = "snap-fallback-1"
+ turn_id = "t-1"
+ ts = _DEFAULT_TS_MS
+
+ snapshot_messages = [
+ _snap_user_msg(turn_id, "fix the bug", ts=ts),
+ _snap_assistant_tool_call_msg(
+ turn_id,
+ "r-1",
+ "tool-A",
+ "Grep",
+ {"pattern": "foo"},
+ ts=ts + 10,
+ ),
+ _snap_tool_result_msg(
+ turn_id,
+ "r-1",
+ "tool-A",
+ "Grep",
+ result_for_assistant="3 matches",
+ ts=ts + 20,
+ ),
+ _snap_assistant_tool_call_msg(
+ turn_id,
+ "r-2",
+ "tool-B",
+ "Edit",
+ {"file": "x.py"},
+ reasoning="need to edit",
+ ts=ts + 30,
+ ),
+ _snap_tool_result_msg(
+ turn_id,
+ "r-2",
+ "tool-B",
+ "Edit",
+ result_for_assistant="ok edited",
+ ts=ts + 40,
+ ),
+ _snap_assistant_text_msg(turn_id, "r-3-final", "all done", ts=ts + 50),
+ ]
+
+ _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid, turn_count=1, tool_call_count=0),
+ turns=[self._make_truncated_turn(sid, turn_id, ts=ts)],
+ snapshot_messages=snapshot_messages,
+ snapshot_session_id=sid,
+ )
+
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / sid
+ )
+ assert traj is not None
+ assert traj.session_id == sid
+
+ sources = [s.source for s in traj.steps]
+ assert sources.count("user") == 1
+ assert sources.count("agent") >= 3
+
+ tool_steps = [s for s in traj.steps if s.tool_calls]
+ assert len(tool_steps) == 2
+ names = {s.tool_calls[0].function_name for s in tool_steps}
+ assert names == {"Grep", "Edit"}
+
+ edit_step = next(
+ s for s in tool_steps if s.tool_calls[0].function_name == "Edit"
+ )
+ assert edit_step.reasoning_content == "need to edit"
+ assert edit_step.observation is not None
+ assert edit_step.observation.results[0].content == "ok edited"
+
+ final_text_steps = [
+ s
+ for s in traj.steps
+ if s.source == "agent" and not s.tool_calls and s.message
+ ]
+ assert any(s.message == "all done" for s in final_text_steps)
+
+ def test_turn_files_used_when_snapshot_missing(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "no-snap"
+ turn = _make_turn(
+ 0,
+ "t1",
+ sid,
+ user_text="hi",
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id="t1",
+ text_items=[_make_text_item("ti", "hello")],
+ )
+ ],
+ )
+ _write_session(temp_dir, sid, metadata=_make_metadata(sid), turns=[turn])
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / sid
+ )
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].message == "hello"
+
+ def test_turn_files_used_when_snapshot_has_equal_or_fewer_rounds(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="openai/gpt-5")
+ sid = "rich-turns"
+ turn_id = "t1"
+ ts = _DEFAULT_TS_MS
+
+ turn = _make_turn(
+ 0,
+ turn_id,
+ sid,
+ user_text="hello",
+ ts=ts,
+ model_rounds=[
+ _make_round(
+ "r1",
+ turn_id=turn_id,
+ round_index=0,
+ ts=ts + 100,
+ text_items=[_make_text_item("ti1", "richer answer", order_index=0)],
+ duration_ms=500,
+ ),
+ ],
+ )
+ snapshot_messages = [
+ _snap_user_msg(turn_id, "hello", ts=ts),
+ _snap_assistant_text_msg(turn_id, "r1", "different answer", ts=ts + 100),
+ ]
+ _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid),
+ turns=[turn],
+ snapshot_messages=snapshot_messages,
+ snapshot_session_id=sid,
+ )
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / sid
+ )
+ assert traj is not None
+ agent_steps = [s for s in traj.steps if s.source == "agent"]
+ assert agent_steps[0].message == "richer answer"
+
+ def test_synthesize_returns_none_when_no_snapshot_dir(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="default")
+ sid = "x"
+ session_dir = _write_session(
+ temp_dir, sid, metadata=_make_metadata(sid), turns=[]
+ )
+ assert agent._synthesize_turns_from_snapshot(session_dir) is None
+
+ def test_synthesize_strips_user_query_wrapper(self, temp_dir):
+ agent = BitfunCli(logs_dir=temp_dir, model_name="default")
+ sid = "s"
+ turn_id = "t1"
+ ts = _DEFAULT_TS_MS
+ # Need strictly more rounds than the truncated turn file for snapshot to win.
+ snapshot_messages = [
+ _snap_user_msg(turn_id, "real question", ts=ts),
+ _snap_assistant_tool_call_msg(
+ turn_id, "r1", "tc1", "Read", {"file": "x"}, ts=ts + 10
+ ),
+ _snap_tool_result_msg(turn_id, "r1", "tc1", "Read", ts=ts + 20),
+ _snap_assistant_text_msg(turn_id, "r2", "answer", ts=ts + 30),
+ ]
+ _write_session(
+ temp_dir,
+ sid,
+ metadata=_make_metadata(sid, tool_call_count=0),
+ turns=[self._make_truncated_turn(sid, turn_id, ts=ts)],
+ snapshot_messages=snapshot_messages,
+ snapshot_session_id=sid,
+ )
+ traj = agent._convert_events_to_trajectory(
+ temp_dir / "bitfun" / "sessions" / sid
+ )
+ assert traj is not None
+ user_step = next(s for s in traj.steps if s.source == "user")
+ assert user_step.message == "real question"
+
+
+class TestGoldenIntegration:
+ GOLDEN_ROOT = (
+ _Path(__file__).resolve().parents[3]
+ / "golden"
+ / "bitfun_cli"
+ / "bitfun-golden-001"
+ )
+
+ def test_golden_session_converts_to_expected_trajectory(self, tmp_path):
+ shutil.copytree(
+ self.GOLDEN_ROOT / "bitfun",
+ tmp_path / "bitfun",
+ )
+ agent = BitfunCli(logs_dir=tmp_path, model_name="openai/gpt-5", version="0.0.1")
+ ctx = AgentContext()
+ agent.populate_context_post_run(ctx)
+
+ produced = _json.loads((tmp_path / "trajectory.json").read_text())
+ expected = _json.loads(
+ (self.GOLDEN_ROOT / "expected_trajectory.json").read_text()
+ )
+ assert produced == expected, (
+ "BitFun ATIF output drifted from golden fixture. Either fix the "
+ "conversion or regenerate expected_trajectory.json after a "
+ "review of the diff."
+ )
diff --git a/tests/unit/agents/installed/test_codeagent.py b/tests/unit/agents/installed/test_codeagent.py
new file mode 100644
index 00000000000..a9877247b3d
--- /dev/null
+++ b/tests/unit/agents/installed/test_codeagent.py
@@ -0,0 +1,495 @@
+"""Unit tests for the built-in CodeAgent integration."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from harbor.agents.factory import AgentFactory
+from harbor.agents.installed.codeagent.agent import (
+ CodeAgent,
+ convert_stream_records_to_trajectory,
+)
+from harbor.agents.installed.codeagent.host import (
+ InstallSpec,
+ install_spec_cache_key,
+ prepare_binary,
+)
+from harbor.models.agent.context import AgentContext
+from harbor.models.agent.name import AgentName
+from harbor.models.task.config import MCPServerConfig
+
+
+def _write_binary(path, contents: str = "#!/bin/sh\necho codeagent\n"):
+ path.write_text(contents)
+ path.chmod(0o755)
+ return path
+
+
+def _find_exec_call(mock_environment, needle: str):
+ for call in mock_environment.exec.call_args_list:
+ if needle in call.kwargs["command"]:
+ return call
+ raise AssertionError(f"Expected exec call containing {needle!r}")
+
+
+def _make_stream_records() -> list[dict[str, object]]:
+ return [
+ {
+ "type": "assistant",
+ "uuid": "assistant-1",
+ "session_id": "session-123",
+ "message": {
+ "id": "msg-1",
+ "model": "enterprise/model",
+ "usage": {
+ "input_tokens": 5,
+ "output_tokens": 7,
+ "cache_read_input_tokens": 3,
+ },
+ "content": [
+ {"type": "text", "text": "Inspecting repository"},
+ {
+ "type": "tool_use",
+ "id": "tool-1",
+ "name": "bash",
+ "input": {"command": "pwd"},
+ },
+ ],
+ },
+ },
+ {
+ "type": "user",
+ "uuid": "user-1",
+ "message": {
+ "content": [
+ {
+ "type": "tool_result",
+ "tool_use_id": "tool-1",
+ "content": "ok",
+ }
+ ]
+ },
+ },
+ {
+ "type": "result",
+ "usage": {
+ "input_tokens": 11,
+ "output_tokens": 7,
+ "cache_read_input_tokens": 3,
+ },
+ "total_cost_usd": 0.75,
+ "num_turns": 1,
+ },
+ ]
+
+
+class TestCodeAgentRegistration:
+ def test_name(self):
+ assert CodeAgent.name() == AgentName.CODEAGENT.value
+
+ def test_registered_in_factory(self):
+ assert AgentName.CODEAGENT in AgentFactory._AGENT_MAP
+ assert AgentFactory.get_agent_class(AgentName.CODEAGENT) is CodeAgent
+
+ def test_binary_mode_requires_path(self, temp_dir):
+ with pytest.raises(ValueError, match="binary_path"):
+ CodeAgent(logs_dir=temp_dir, install_mode="binary")
+
+ def test_rejects_non_binary_mode(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ with pytest.raises(ValueError, match="Only install_mode='binary'"):
+ CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="package",
+ binary_path=binary,
+ )
+
+ def test_rejects_conflicting_max_token_aliases(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ with pytest.raises(ValueError, match="max_output_tokens and max_tokens"):
+ CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="binary",
+ binary_path=binary,
+ max_output_tokens=32000,
+ max_tokens=64000,
+ )
+
+ def test_rejects_non_positive_runtime_overrides(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ with pytest.raises(ValueError, match="max_output_tokens"):
+ CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="binary",
+ binary_path=binary,
+ max_output_tokens=0,
+ )
+ with pytest.raises(ValueError, match="context_window"):
+ CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="binary",
+ binary_path=binary,
+ context_window=0,
+ )
+
+ def test_runtime_linker_requires_library_path_pair(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ with pytest.raises(ValueError, match="library_path"):
+ CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="binary",
+ binary_path=binary,
+ dynamic_linker_path="/opt/harbor/codeagent-libs/lib64/ld-linux-x86-64.so.2",
+ )
+ with pytest.raises(ValueError, match="dynamic_linker_path"):
+ CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="binary",
+ binary_path=binary,
+ library_path="/opt/harbor/codeagent-libs/lib/x86_64-linux-gnu",
+ )
+
+ def test_cli_flags_include_new_runtime_controls(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ agent = CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="binary",
+ binary_path=binary,
+ )
+ flag_names = [flag.kwarg for flag in agent.CLI_FLAGS]
+ assert "thinking" in flag_names
+ assert "max_thinking_tokens" in flag_names
+ assert "task_budget" in flag_names
+
+
+class TestCodeAgentHostBinary:
+ def test_install_spec_cache_key_changes_with_binary_contents(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli", "v1\n")
+ key1 = install_spec_cache_key(
+ InstallSpec(install_mode="binary", binary_path=binary)
+ )
+
+ _write_binary(binary, "v2\n")
+ key2 = install_spec_cache_key(
+ InstallSpec(install_mode="binary", binary_path=binary)
+ )
+
+ assert key1 != key2
+
+ @pytest.mark.asyncio
+ async def test_prepare_binary_copies_binary_and_records_metadata(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli", "echo hi\n")
+
+ prepared = await prepare_binary(
+ InstallSpec(install_mode="binary", binary_path=binary)
+ )
+
+ assert prepared.artifact_path.is_file()
+ assert prepared.artifact_path.read_text() == "echo hi\n"
+ assert prepared.source_path == binary.resolve()
+ assert prepared.binary_size_bytes == binary.stat().st_size
+
+
+class TestCodeAgentExecution:
+ @pytest.mark.asyncio
+ async def test_install_uploads_binary_and_records_metadata(
+ self, temp_dir, mock_environment
+ ):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ agent = CodeAgent(logs_dir=temp_dir, binary_path=binary)
+
+ await agent.install(mock_environment)
+
+ upload_kwargs = mock_environment.upload_file.await_args.kwargs
+ assert upload_kwargs["target_path"] == "/opt/harbor/codeagent/codeagentcli"
+ assert upload_kwargs["source_path"].name == "codeagentcli"
+ assert (temp_dir / "codeagent-binary-metadata.json").is_file()
+
+ install_command = _find_exec_call(mock_environment, "chmod -R 0777 /logs/agent")
+ assert "mkdir -p /logs/agent" in install_command.kwargs["command"]
+ assert "mkdir -p /opt/harbor/codeagent" in install_command.kwargs["command"]
+ assert "/logs/agent /logs/agent" not in install_command.kwargs["command"]
+ chmod_command = _find_exec_call(
+ mock_environment, "chmod 0755 /opt/harbor/codeagent/codeagentcli"
+ )
+ assert chmod_command.kwargs["user"] == "root"
+
+ def test_runtime_env_requires_enterprise_values(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ agent = CodeAgent(logs_dir=temp_dir, binary_path=binary)
+
+ with pytest.raises(
+ ValueError,
+ match="ENTERPRISE_API_BASE_URL, ENTERPRISE_API_KEY, ENTERPRISE_MAIN_MODEL",
+ ):
+ agent._runtime_env()
+
+ @pytest.mark.asyncio
+ async def test_run_inline_mode_executes_binary_and_writes_invocation(
+ self, temp_dir, mock_environment
+ ):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ agent = CodeAgent(
+ logs_dir=temp_dir,
+ binary_path=binary,
+ model_name="enterprise/model",
+ extra_env={
+ "ENTERPRISE_API_BASE_URL": "https://api.example.com/v1",
+ "ENTERPRISE_API_KEY": "secret",
+ "HTTPS_PROXY": "https://proxy.example.com:443",
+ },
+ )
+
+ await agent.install(mock_environment)
+ mock_environment.exec.reset_mock()
+ mock_environment.upload_file.reset_mock()
+
+ await agent.run("Fix the bug", mock_environment, AgentContext())
+
+ assert mock_environment.upload_file.await_count == 0
+
+ run_call = _find_exec_call(
+ mock_environment, "> /logs/agent/codeagent-stream.jsonl"
+ )
+ command = run_call.kwargs["command"]
+ runtime_env = run_call.kwargs["env"]
+
+ assert "/opt/harbor/codeagent/codeagentcli --print" in command
+ assert "--permission-mode bypassPermissions" in command
+ assert "--output-format stream-json" in command
+ assert "--model enterprise/model" in command
+ assert "Fix the bug" in command
+ assert runtime_env["ENTERPRISE_MAIN_MODEL"] == "enterprise/model"
+ assert runtime_env["CODEAGENT3_CONFIG_DIR"] == "/logs/agent/.cac"
+ assert runtime_env["HOME"] == "/logs/agent"
+ assert runtime_env["GOCACHE"] == "/tmp/harbor-codeagent-cache/go-build"
+ assert runtime_env["YARN_GLOBAL_FOLDER"] == "/tmp/harbor-codeagent-cache/yarn"
+ assert (
+ runtime_env["YARN_CACHE_FOLDER"] == "/tmp/harbor-codeagent-cache/yarn/cache"
+ )
+ assert "XDG_CACHE_HOME" not in runtime_env
+ for key in ("GOCACHE", "YARN_GLOBAL_FOLDER", "YARN_CACHE_FOLDER"):
+ assert not runtime_env[key].startswith("/logs/agent")
+ assert runtime_env["HTTPS_PROXY"] == "https://proxy.example.com:443"
+
+ invocation = json.loads((temp_dir / "codeagent-invocation.json").read_text())
+ assert (
+ invocation["binary_path_in_environment"]
+ == "/opt/harbor/codeagent/codeagentcli"
+ )
+ assert invocation["instruction_mode"] == "inline"
+ assert invocation["instruction_file_path"] is None
+ assert invocation["model_name"] == "enterprise/model"
+ assert "CODEAGENT3_CONFIG_DIR" in invocation["runtime_env_keys"]
+ assert "ENTERPRISE_API_KEY" in invocation["runtime_env_keys"]
+
+ @pytest.mark.asyncio
+ async def test_run_wraps_binary_with_private_dynamic_linker(
+ self, temp_dir, mock_environment
+ ):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ agent = CodeAgent(
+ logs_dir=temp_dir,
+ binary_path=binary,
+ model_name="enterprise/model",
+ dynamic_linker_path="/opt/harbor/codeagent-libs/lib64/ld-linux-x86-64.so.2",
+ library_path=[
+ "/opt/harbor/codeagent-libs/lib/x86_64-linux-gnu",
+ "/opt/harbor/codeagent-libs/lib64",
+ ],
+ extra_env={
+ "ENTERPRISE_API_BASE_URL": "https://api.example.com/v1",
+ "ENTERPRISE_API_KEY": "secret",
+ },
+ )
+
+ await agent.install(mock_environment)
+ mock_environment.exec.reset_mock()
+
+ await agent.run("Fix the bug", mock_environment, AgentContext())
+
+ run_call = _find_exec_call(
+ mock_environment, "> /logs/agent/codeagent-stream.jsonl"
+ )
+ command = run_call.kwargs["command"]
+ runtime_env = run_call.kwargs["env"]
+
+ assert command.startswith(
+ "set -o pipefail; "
+ "/opt/harbor/codeagent-libs/lib64/ld-linux-x86-64.so.2 "
+ "--library-path "
+ "/opt/harbor/codeagent-libs/lib/x86_64-linux-gnu:"
+ "/opt/harbor/codeagent-libs/lib64 "
+ "/opt/harbor/codeagent/codeagentcli --print"
+ )
+ assert "LD_LIBRARY_PATH" not in runtime_env
+
+ invocation = json.loads((temp_dir / "codeagent-invocation.json").read_text())
+ assert (
+ invocation["dynamic_linker_path"]
+ == "/opt/harbor/codeagent-libs/lib64/ld-linux-x86-64.so.2"
+ )
+ assert invocation["library_path"] == (
+ "/opt/harbor/codeagent-libs/lib/x86_64-linux-gnu:"
+ "/opt/harbor/codeagent-libs/lib64"
+ )
+
+ @pytest.mark.asyncio
+ async def test_run_file_ref_uploads_instruction_and_writes_mcp_config(
+ self, temp_dir, mock_environment
+ ):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ agent = CodeAgent(
+ logs_dir=temp_dir,
+ binary_path=binary,
+ instruction_mode="file_ref",
+ instruction_ref_prompt="/goal Read:",
+ extra_env={
+ "ENTERPRISE_API_BASE_URL": "https://api.example.com/v1",
+ "ENTERPRISE_API_KEY": "secret",
+ "ENTERPRISE_MAIN_MODEL": "enterprise/model",
+ },
+ mcp_servers=[
+ MCPServerConfig(
+ name="stdio-tool",
+ transport="stdio",
+ command="python",
+ args=["-m", "tool.server"],
+ ),
+ MCPServerConfig(
+ name="remote-tool",
+ transport="streamable-http",
+ url="https://mcp.example.com",
+ ),
+ ],
+ )
+
+ await agent.install(mock_environment)
+ mock_environment.exec.reset_mock()
+ mock_environment.upload_file.reset_mock()
+
+ await agent.run(
+ "Follow the file instructions", mock_environment, AgentContext()
+ )
+
+ upload_kwargs = mock_environment.upload_file.await_args.kwargs
+ assert upload_kwargs["source_path"] == temp_dir / "input" / "instruction.md"
+ assert upload_kwargs["target_path"] == "/logs/agent/input/instruction.md"
+ assert (temp_dir / "input" / "instruction.md").read_text() == (
+ "Follow the file instructions"
+ )
+
+ run_call = _find_exec_call(
+ mock_environment, "> /logs/agent/codeagent-stream.jsonl"
+ )
+ command = run_call.kwargs["command"]
+ assert "--mcp-config /logs/agent/codeagent-mcp-config.json" in command
+ assert "/goal Read: /logs/agent/input/instruction.md" in command
+
+ invocation = json.loads((temp_dir / "codeagent-invocation.json").read_text())
+ assert invocation["instruction_mode"] == "file_ref"
+ assert invocation["instruction_file_path"] == "/logs/agent/input/instruction.md"
+ assert invocation["instruction_ref_prompt"] == "/goal Read:"
+ assert invocation["mcp_config_path"] == str(
+ temp_dir / "codeagent-mcp-config.json"
+ )
+
+ mcp_payload = json.loads((temp_dir / "codeagent-mcp-config.json").read_text())
+ assert mcp_payload == {
+ "mcpServers": {
+ "remote-tool": {
+ "type": "http",
+ "url": "https://mcp.example.com",
+ },
+ "stdio-tool": {
+ "args": ["-m", "tool.server"],
+ "command": "python",
+ "type": "stdio",
+ },
+ }
+ }
+
+ @pytest.mark.asyncio
+ async def test_run_sets_runtime_token_overrides(self, temp_dir, mock_environment):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ agent = CodeAgent(
+ logs_dir=temp_dir,
+ install_mode="binary",
+ binary_path=binary,
+ model_name="enterprise/model",
+ max_output_tokens=32000,
+ context_window=200000,
+ extra_env={
+ "ENTERPRISE_API_BASE_URL": "https://api.example.com/v1",
+ "ENTERPRISE_API_KEY": "secret",
+ "ENTERPRISE_MAIN_MODEL": "enterprise/model",
+ },
+ )
+
+ await agent.install(mock_environment)
+ mock_environment.exec.reset_mock()
+
+ await agent.run("Fix the bug", mock_environment, AgentContext())
+
+ run_call = _find_exec_call(
+ mock_environment, "> /logs/agent/codeagent-stream.jsonl"
+ )
+ runtime_env = run_call.kwargs["env"]
+ assert runtime_env["CODEAGENT3_MAX_OUTPUT_TOKENS"] == "32000"
+ assert runtime_env["CODEAGENT3_MAX_CONTEXT_TOKENS"] == "200000"
+
+
+class TestCodeAgentTrajectory:
+ def test_convert_stream_records_to_trajectory_preserves_tool_results(self):
+ trajectory = convert_stream_records_to_trajectory(
+ _make_stream_records(),
+ session_id_hint="fallback-session",
+ agent_name=CodeAgent.name(),
+ agent_version="1.2.3",
+ default_model_name="enterprise/model",
+ )
+
+ assert trajectory is not None
+ assert trajectory.session_id == "session-123"
+ assert trajectory.agent.name == AgentName.CODEAGENT.value
+ assert trajectory.final_metrics.total_cost_usd == 0.75
+ assert len(trajectory.steps) == 1
+
+ step = trajectory.steps[0]
+ assert step.message == "Inspecting repository"
+ assert step.tool_calls is not None
+ assert step.tool_calls[0].function_name == "bash"
+ assert step.observation is not None
+ assert step.observation.results[0].source_call_id == "tool-1"
+ assert step.observation.results[0].content == "ok"
+
+ def test_populate_context_post_run_writes_trajectory_and_context(self, temp_dir):
+ binary = _write_binary(temp_dir / "codeagentcli")
+ stream_path = temp_dir / "codeagent-stream.jsonl"
+ stream_path.write_text(
+ "\n".join(json.dumps(record) for record in _make_stream_records())
+ )
+ agent = CodeAgent(
+ logs_dir=temp_dir,
+ binary_path=binary,
+ model_name="enterprise/model",
+ version="9.9.9",
+ )
+ context = AgentContext()
+
+ agent.populate_context_post_run(context)
+
+ trajectory_payload = json.loads((temp_dir / "trajectory.json").read_text())
+ assert trajectory_payload["agent"]["name"] == AgentName.CODEAGENT.value
+ assert trajectory_payload["agent"]["version"] == "9.9.9"
+ assert trajectory_payload["final_metrics"]["total_cost_usd"] == 0.75
+ assert (temp_dir / "trajectory-source.txt").read_text() == str(
+ stream_path.resolve()
+ )
+
+ assert context.cost_usd == 0.75
+ assert context.n_input_tokens == 11
+ assert context.n_cache_tokens == 3
+ assert context.n_output_tokens == 7
diff --git a/tests/unit/agents/installed/test_codex_mcp.py b/tests/unit/agents/installed/test_codex_mcp.py
index 021a4475af1..532ef97e330 100644
--- a/tests/unit/agents/installed/test_codex_mcp.py
+++ b/tests/unit/agents/installed/test_codex_mcp.py
@@ -80,6 +80,7 @@ class TestCreateRunAgentCommandsMCP:
@pytest.mark.asyncio
async def test_no_mcp_servers_no_config_toml(self, temp_dir, monkeypatch):
monkeypatch.setenv("CODEX_FORCE_API_KEY", "1")
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
agent = Codex(logs_dir=temp_dir, model_name="openai/o3")
mock_env = AsyncMock()
mock_env.exec.return_value = AsyncMock(return_code=0, stdout="", stderr="")
diff --git a/tests/unit/agents/installed/test_simple_agents.py b/tests/unit/agents/installed/test_simple_agents.py
index 824fe271535..e7b157c8c61 100644
--- a/tests/unit/agents/installed/test_simple_agents.py
+++ b/tests/unit/agents/installed/test_simple_agents.py
@@ -6,6 +6,7 @@
import pytest
from harbor.agents.installed.aider import Aider
+from harbor.agents.installed.bitfun_cli import BitfunCli
from harbor.agents.installed.claude_code import ClaudeCode
from harbor.agents.installed.codex import Codex
from harbor.agents.installed.cursor_cli import CursorCli
@@ -29,6 +30,7 @@ class TestSimpleAgentInstall:
"agent_class",
[
Aider,
+ BitfunCli,
ClaudeCode,
Codex,
CursorCli,
@@ -56,6 +58,7 @@ def test_agent_has_install_method(self, agent_class, temp_dir):
"agent_class",
[
Aider,
+ BitfunCli,
ClaudeCode,
Codex,
CursorCli,
diff --git a/tests/unit/analyze/test_aggregate_transport_error.py b/tests/unit/analyze/test_aggregate_transport_error.py
new file mode 100644
index 00000000000..d8877ddca63
--- /dev/null
+++ b/tests/unit/analyze/test_aggregate_transport_error.py
@@ -0,0 +1,34 @@
+import pytest
+
+from harbor.analyze.errors import AggregateTransportError
+
+
+@pytest.mark.unit
+def test_to_dict_includes_required_fields():
+ err = AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=530_432,
+ attempts=["stdin", "agent_read"],
+ last_error="ProcessError: CLI exited",
+ prompt_file=".harbor-aggregate-prompt-1716123456789.txt",
+ )
+ d = err.to_dict()
+ assert d == {
+ "reason": "job_aggregate_failed",
+ "prompt_bytes": 530_432,
+ "attempts": ["stdin", "agent_read"],
+ "last_error": "ProcessError: CLI exited",
+ "prompt_file": ".harbor-aggregate-prompt-1716123456789.txt",
+ }
+
+
+@pytest.mark.unit
+def test_to_dict_omits_none_prompt_file():
+ err = AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=100,
+ attempts=["argv"],
+ last_error="OSError: [Errno 7]",
+ prompt_file=None,
+ )
+ assert err.to_dict()["prompt_file"] is None
diff --git a/tests/unit/analyze/test_analyze_backend_env.py b/tests/unit/analyze/test_analyze_backend_env.py
new file mode 100644
index 00000000000..e3e9244c895
--- /dev/null
+++ b/tests/unit/analyze/test_analyze_backend_env.py
@@ -0,0 +1,45 @@
+import os
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_query_agent_sets_claude_agent_options_env(monkeypatch):
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
+ monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
+
+ captured: dict[str, dict] = {}
+
+ class FakeOpts:
+ def __init__(self, **kw):
+ self.kw = kw
+
+ async def fake_query_agent_import(prompt, options):
+ captured["kw"] = options.kw
+ if False:
+ yield # pragma: no cover
+
+ import harbor.analyze.backend as backend
+
+ with (
+ patch.object(backend, "ClaudeAgentOptions", FakeOpts),
+ patch.object(backend, "query", fake_query_agent_import),
+ ):
+ overlay = {
+ "ANTHROPIC_API_KEY": "sk-test",
+ "ANTHROPIC_BASE_URL": "https://example.invalid",
+ }
+ await backend.query_agent(
+ prompt="hello",
+ model="haiku",
+ cwd="/tmp",
+ sdk_env=overlay,
+ tools=[],
+ output_schema=None,
+ )
+
+ opts_env = captured["kw"]["env"]
+ assert opts_env["ANTHROPIC_API_KEY"] == "sk-test"
+ assert opts_env["ANTHROPIC_BASE_URL"] == "https://example.invalid"
+ assert os.environ.get("ANTHROPIC_API_KEY") is None
diff --git a/tests/unit/analyze/test_analyze_profiles.py b/tests/unit/analyze/test_analyze_profiles.py
new file mode 100644
index 00000000000..b94336a0fbd
--- /dev/null
+++ b/tests/unit/analyze/test_analyze_profiles.py
@@ -0,0 +1,127 @@
+import textwrap
+
+import pytest
+
+from harbor.analyze.profiles import (
+ ProfilesConfigurationError,
+ built_in_profiles,
+ load_profiles_from_file,
+)
+
+
+def test_built_in_has_three_models():
+ doc = built_in_profiles()
+ p = doc.require_profile("anthropic")
+ assert [m.id for m in p.models] == ["haiku", "sonnet", "opus"]
+
+
+def test_load_duplicate_profile_ids_raises(tmp_path):
+ cfg = tmp_path / "dup.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ """
+ [[profile]]
+ id = "a"
+ api_key_env = "KEY_A"
+ default_model = "one"
+
+ [[profile.model]]
+ id = "one"
+ display_name = "One"
+ api_model = "m1"
+
+ [[profile]]
+ id = "a"
+ api_key_env = "KEY_B"
+ default_model = "two"
+
+ [[profile.model]]
+ id = "two"
+ display_name = "Two"
+ api_model = "m2"
+ """
+ ).strip(),
+ encoding="utf-8",
+ )
+ with pytest.raises(ProfilesConfigurationError):
+ load_profiles_from_file(cfg)
+
+
+def test_load_external_job_report_base_url(tmp_path):
+ cfg = tmp_path / "profiles.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ """
+ external_job_report_base_url = "http://reports.example.test:9000/"
+
+ [[profile]]
+ id = "a"
+ api_key_env = "KEY_A"
+ default_model = "one"
+
+ [[profile.model]]
+ id = "one"
+ display_name = "One"
+ api_model = "m1"
+ """
+ ).strip(),
+ encoding="utf-8",
+ )
+
+ doc = load_profiles_from_file(cfg)
+
+ assert doc.external_job_report_base_url == "http://reports.example.test:9000"
+
+
+@pytest.mark.parametrize(
+ "value",
+ [
+ '""',
+ '"ftp://reports.example.test"',
+ '"reports.example.test"',
+ ],
+)
+def test_load_external_job_report_base_url_rejects_invalid_values(tmp_path, value):
+ cfg = tmp_path / "profiles.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ f"""
+ external_job_report_base_url = {value}
+
+ [[profile]]
+ id = "a"
+ api_key_env = "KEY_A"
+ default_model = "one"
+
+ [[profile.model]]
+ id = "one"
+ display_name = "One"
+ api_model = "m1"
+ """
+ ).strip(),
+ encoding="utf-8",
+ )
+
+ with pytest.raises(
+ ProfilesConfigurationError, match="external_job_report_base_url"
+ ):
+ load_profiles_from_file(cfg)
+
+
+def test_resolve_logical_model_maps_to_builtin() -> None:
+ """Resolver receives the already-merged logical model row id."""
+ import os
+
+ from harbor.analyze.profiles import resolve_summarize_invoke
+
+ os.environ.setdefault("ANTHROPIC_API_KEY", "dummy-for-test")
+ os.environ.setdefault("ANTHROPIC_BASE_URL", "https://api.anthropic.com")
+
+ doc = built_in_profiles()
+ api_model, sdk_env_instructions = resolve_summarize_invoke(
+ doc,
+ profile_id=None,
+ logical_model_id="sonnet",
+ )
+ assert api_model == "sonnet"
+ assert sdk_env_instructions.api_key_env == "ANTHROPIC_API_KEY"
diff --git a/tests/unit/analyze/test_query_llm_fallback.py b/tests/unit/analyze/test_query_llm_fallback.py
new file mode 100644
index 00000000000..a06a3dc5a65
--- /dev/null
+++ b/tests/unit/analyze/test_query_llm_fallback.py
@@ -0,0 +1,175 @@
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from harbor.analyze.backend import (
+ _AGGREGATE_ARGV_PROMPT_MAX_BYTES,
+ _is_argv_transport_error,
+ _prompt_as_stream,
+ _prompt_byte_length,
+ _run_claude_query,
+ query_llm,
+)
+from harbor.analyze.errors import AggregateTransportError
+
+
+@pytest.mark.unit
+def test_prompt_byte_length_utf8():
+ assert _prompt_byte_length("café") == 5
+
+
+@pytest.mark.unit
+def test_is_argv_transport_error_errno_7():
+ assert _is_argv_transport_error(OSError(7, "Argument list too long"))
+
+
+@pytest.mark.unit
+def test_is_argv_transport_error_message():
+ assert _is_argv_transport_error(RuntimeError("Argument list too long"))
+
+
+@pytest.mark.unit
+def test_is_argv_transport_error_other():
+ assert not _is_argv_transport_error(RuntimeError("connection reset"))
+
+
+@pytest.mark.unit
+def test_threshold_is_120_kib():
+ assert _AGGREGATE_ARGV_PROMPT_MAX_BYTES == 120 * 1024
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_run_claude_query_accepts_async_iterable_prompt(monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ captured: dict[str, object] = {}
+
+ async def fake_query(*, prompt, options):
+ captured["prompt_is_str"] = isinstance(prompt, str)
+ captured["prompt_type"] = type(prompt).__name__
+ if False:
+ yield # pragma: no cover
+
+ with patch("harbor.analyze.backend.query", side_effect=fake_query):
+ await _run_claude_query(
+ prompt=_prompt_as_stream("x" * 200_000),
+ model="haiku",
+ cwd="/tmp",
+ tools=[],
+ output_schema=None,
+ )
+
+ assert captured["prompt_is_str"] is False
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_small_prompt_uses_argv_only(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ prompt = "small"
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ new_callable=AsyncMock,
+ return_value="summary",
+ ) as mock_run:
+ result = await query_llm(
+ prompt=prompt,
+ model="haiku",
+ work_dir=work_dir,
+ )
+
+ assert result == "summary"
+ mock_run.assert_awaited_once()
+ assert mock_run.await_args.kwargs["prompt"] == prompt
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_large_prompt_skips_argv(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ prompt = "x" * (_AGGREGATE_ARGV_PROMPT_MAX_BYTES + 1)
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ new_callable=AsyncMock,
+ return_value="summary",
+ ) as mock_run:
+ await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ mock_run.assert_awaited_once()
+ sent = mock_run.await_args.kwargs["prompt"]
+ assert not isinstance(sent, str)
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_argv_failure_retries_stdin(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+ prompt = "small"
+
+ async def side_effect(*, prompt, **kwargs):
+ if isinstance(prompt, str):
+ raise OSError(7, "Argument list too long")
+ return "ok"
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ side_effect=side_effect,
+ ):
+ result = await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ assert result == "ok"
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_all_fail_raises_aggregate_error(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+ prompt = "x" * (_AGGREGATE_ARGV_PROMPT_MAX_BYTES + 1)
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ new_callable=AsyncMock,
+ side_effect=RuntimeError("transport failed"),
+ ):
+ with pytest.raises(AggregateTransportError) as exc_info:
+ await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ err = exc_info.value
+ assert err.reason == "job_aggregate_failed"
+ assert err.prompt_bytes == len(prompt.encode("utf-8"))
+ assert err.attempts == ["stdin", "agent_read"]
+ assert err.prompt_file is not None
+ assert (work_dir / err.prompt_file).exists()
+
+
+@pytest.mark.unit
+@pytest.mark.asyncio
+async def test_query_llm_read_success_deletes_temp_file(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
+ work_dir = tmp_path / "job"
+ work_dir.mkdir()
+ prompt = "x" * (_AGGREGATE_ARGV_PROMPT_MAX_BYTES + 1)
+
+ async def run_side_effect(*, prompt, **kwargs):
+ if isinstance(prompt, str):
+ return "job summary"
+ raise RuntimeError("stdin failed")
+
+ with patch(
+ "harbor.analyze.backend._run_claude_query",
+ side_effect=run_side_effect,
+ ):
+ result = await query_llm(prompt=prompt, model="haiku", work_dir=work_dir)
+
+ assert result == "job summary"
+ assert list(work_dir.glob(".harbor-aggregate-prompt-*.txt")) == []
diff --git a/tests/unit/cli/test_view.py b/tests/unit/cli/test_view.py
index 96b77a99173..d25832bf028 100644
--- a/tests/unit/cli/test_view.py
+++ b/tests/unit/cli/test_view.py
@@ -1,3 +1,4 @@
+import os
import sys
from types import SimpleNamespace
from pathlib import Path
@@ -9,6 +10,29 @@
class TestRunProductionMode:
+ def test_loads_repo_env_without_overriding_existing_values(
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+ ):
+ env_file = tmp_path / ".env"
+ env_file.write_text(
+ "MY_ANTHROPIC_BASE_URL=https://api.openbitfun.com\n"
+ "MY_ANTHROPIC_KEY=from-env-file\n"
+ "ANTHROPIC_API_KEY=from-env-file\n"
+ "ANTHROPIC_BASE_URL=https://api.openbitfun.com\n",
+ encoding="utf-8",
+ )
+ monkeypatch.delenv("MY_ANTHROPIC_BASE_URL", raising=False)
+ monkeypatch.delenv("MY_ANTHROPIC_KEY", raising=False)
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "already-set")
+ monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False)
+
+ view._load_repo_dotenv(tmp_path)
+
+ assert os.environ["MY_ANTHROPIC_BASE_URL"] == "https://api.openbitfun.com"
+ assert os.environ["MY_ANTHROPIC_KEY"] == "from-env-file"
+ assert os.environ["ANTHROPIC_API_KEY"] == "already-set"
+ assert os.environ["ANTHROPIC_BASE_URL"] == "https://api.openbitfun.com"
+
def test_starts_server(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
static_dir = tmp_path / "static"
static_dir.mkdir()
@@ -34,7 +58,12 @@ def __init__(self, app: object, host: str, port: int, log_level: str):
view._run_production_mode(tmp_path, "0.0.0.0", 8080)
- create_app.assert_called_once_with(tmp_path, mode="jobs", static_dir=static_dir)
+ create_app.assert_called_once_with(
+ tmp_path,
+ mode="jobs",
+ static_dir=static_dir,
+ analyze_profiles_file=None,
+ )
fake_server.run.assert_called_once()
def test_falls_back_to_api_only_when_static_files_are_missing(
@@ -56,7 +85,12 @@ def test_falls_back_to_api_only_when_static_files_are_missing(
view._run_production_mode(tmp_path, "127.0.0.1", 8080, no_build=True)
- create_app.assert_called_once_with(tmp_path, mode="jobs", static_dir=None)
+ create_app.assert_called_once_with(
+ tmp_path,
+ mode="jobs",
+ static_dir=None,
+ analyze_profiles_file=None,
+ )
fake_server.run.assert_called_once()
diff --git a/tests/unit/test_agent_os_compat.py b/tests/unit/test_agent_os_compat.py
index 034aa93bf85..100ace69429 100644
--- a/tests/unit/test_agent_os_compat.py
+++ b/tests/unit/test_agent_os_compat.py
@@ -37,8 +37,8 @@ def installed_agents(self):
return agents
def test_installed_agents_default_linux_only(self, installed_agents):
- # These are the only agents that should support Windows.
- windows_agents = {"oracle", "nop"}
+ # These are the agents that should support Windows.
+ windows_agents = {"oracle", "nop", "bitfun-cli"}
for name, cls in installed_agents.items():
if name.value in windows_agents:
assert cls.SUPPORTS_WINDOWS is True, (
diff --git a/tests/unit/viewer/test_analyze_profiles_route.py b/tests/unit/viewer/test_analyze_profiles_route.py
new file mode 100644
index 00000000000..f316c1a313e
--- /dev/null
+++ b/tests/unit/viewer/test_analyze_profiles_route.py
@@ -0,0 +1,49 @@
+import textwrap
+from pathlib import Path
+
+from fastapi.testclient import TestClient
+
+from harbor.viewer.server import create_app
+
+
+def test_analyze_profiles_endpoint_builtin(tmp_path: Path) -> None:
+ app = create_app(tmp_path, mode="tasks", analyze_profiles_file=None)
+ resp = TestClient(app).get("/api/analyze/profiles")
+ assert resp.status_code == 200
+ body = resp.json()
+ ids = [p["id"] for p in body["profiles"]]
+ assert "anthropic" in ids
+ assert "external_job_report" not in body
+
+
+def test_analyze_profiles_endpoint_includes_external_job_report(
+ tmp_path: Path,
+) -> None:
+ cfg = tmp_path / "profiles.toml"
+ cfg.write_text(
+ textwrap.dedent(
+ """
+ external_job_report_base_url = "https://reports.example.test/base/"
+
+ [[profile]]
+ id = "corp"
+ label = "Corp"
+ api_key_env = "CORP_KEY"
+ default_model = "sonnet"
+
+ [[profile.model]]
+ id = "sonnet"
+ display_name = "Sonnet"
+ api_model = "anthropic/sonnet"
+ """
+ ).strip(),
+ encoding="utf-8",
+ )
+ app = create_app(tmp_path, mode="tasks", analyze_profiles_file=cfg)
+
+ resp = TestClient(app).get("/api/analyze/profiles")
+
+ assert resp.status_code == 200
+ assert resp.json()["external_job_report"] == {
+ "base_url": "https://reports.example.test/base"
+ }
diff --git a/tests/unit/viewer/test_job_status.py b/tests/unit/viewer/test_job_status.py
index fe3c9707a24..ed582d67719 100644
--- a/tests/unit/viewer/test_job_status.py
+++ b/tests/unit/viewer/test_job_status.py
@@ -40,6 +40,15 @@ def _write_job(
return job_dir
+@pytest.mark.unit
+def test_viewer_does_not_expose_openapi_schema_or_docs(tmp_path: Path) -> None:
+ client = TestClient(create_app(tmp_path))
+
+ assert client.get("/openapi.json").status_code == 404
+ assert client.get("/docs").status_code == 404
+ assert client.get("/redoc").status_code == 404
+
+
@pytest.mark.unit
def test_job_endpoint_exposes_progress_stats(tmp_path: Path) -> None:
_write_job(tmp_path)
diff --git a/tests/unit/viewer/test_summarize_job_aggregate_error.py b/tests/unit/viewer/test_summarize_job_aggregate_error.py
new file mode 100644
index 00000000000..c9b2cbd1c9d
--- /dev/null
+++ b/tests/unit/viewer/test_summarize_job_aggregate_error.py
@@ -0,0 +1,96 @@
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from harbor.analyze.errors import AggregateTransportError
+from harbor.viewer.server import create_app
+
+
+@pytest.mark.unit
+def test_summarize_job_aggregate_transport_error_returns_422(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "dummy-for-test")
+
+ jobs_root = tmp_path
+ job_dir = jobs_root / "my-job"
+ job_dir.mkdir()
+ (job_dir / "trial__a__0").mkdir()
+
+ app = create_app(jobs_root, mode="jobs", analyze_profiles_file=None)
+ client = TestClient(app)
+
+ err = AggregateTransportError(
+ reason="job_aggregate_failed",
+ prompt_bytes=500_000,
+ attempts=["stdin", "agent_read"],
+ last_error="RuntimeError: fail",
+ prompt_file=".harbor-aggregate-prompt-1.txt",
+ )
+
+ with patch(
+ "harbor.analyze.analyzer.run_analyze",
+ new_callable=AsyncMock,
+ side_effect=err,
+ ):
+ resp = client.post(
+ "/api/jobs/my-job/summarize",
+ json={"model": "haiku", "overwrite": True},
+ )
+
+ assert resp.status_code == 422
+ detail = resp.json()["detail"]
+ assert detail["reason"] == "job_aggregate_failed"
+ assert detail["prompt_bytes"] == 500_000
+ assert detail["attempts"] == ["stdin", "agent_read"]
+
+
+@pytest.mark.unit
+def test_summarize_job_analysis_error_returns_422(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "dummy-for-test")
+
+ jobs_root = tmp_path
+ job_dir = jobs_root / "my-job"
+ job_dir.mkdir()
+
+ app = create_app(jobs_root, mode="jobs", analyze_profiles_file=None)
+ client = TestClient(app)
+
+ with patch(
+ "harbor.analyze.analyzer.run_analyze",
+ new_callable=AsyncMock,
+ side_effect=ValueError("All trial analyses failed: rate limited"),
+ ):
+ resp = client.post(
+ "/api/jobs/my-job/summarize",
+ json={"model": "haiku", "overwrite": True},
+ )
+
+ assert resp.status_code == 422
+ assert resp.json()["detail"] == "All trial analyses failed: rate limited"
+
+
+@pytest.mark.unit
+def test_summarize_trial_analysis_error_returns_422(tmp_path, monkeypatch):
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "dummy-for-test")
+
+ jobs_root = tmp_path
+ trial_dir = jobs_root / "my-job" / "trial-a"
+ trial_dir.mkdir(parents=True)
+ (trial_dir / "trial.log").write_text("")
+ (trial_dir / "result.json").write_text("{}")
+
+ app = create_app(jobs_root, mode="jobs", analyze_profiles_file=None)
+ client = TestClient(app)
+
+ with patch(
+ "harbor.analyze.analyzer.run_analyze",
+ new_callable=AsyncMock,
+ side_effect=ValueError("Agent returned invalid structured output"),
+ ):
+ resp = client.post(
+ "/api/jobs/my-job/trials/trial-a/summarize",
+ json={"model": "haiku"},
+ )
+
+ assert resp.status_code == 422
+ assert resp.json()["detail"] == "Agent returned invalid structured output"
diff --git a/tests/unit/viewer/test_summarize_trial.py b/tests/unit/viewer/test_summarize_trial.py
index ebcf5a766c8..040a57fb7d3 100644
--- a/tests/unit/viewer/test_summarize_trial.py
+++ b/tests/unit/viewer/test_summarize_trial.py
@@ -8,6 +8,11 @@
from harbor.viewer.server import create_app
+@pytest.fixture(autouse=True)
+def _anthropic_api_key_for_summarize(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "dummy-for-test")
+
+
def _make_trial(tmp_path: Path, job: str = "job", trial: str = "trial__abc") -> Path:
trial_dir = tmp_path / job / trial
trial_dir.mkdir(parents=True)
@@ -21,8 +26,9 @@ def test_summarize_trial_runs_analyze_and_forwards_environment(tmp_path: Path) -
_make_trial(tmp_path)
captured = {}
- async def fake_run_analyze(path, agent, model, environment, jobs_dir):
+ async def fake_run_analyze(path, agent, model, environment, jobs_dir, **kwargs):
captured["environment"] = environment
+ captured["agent_env"] = kwargs.get("agent_env")
# run_analyze writes analysis.json into the trial dir; the viewer renders it.
result = AnalyzeReportResult(
trial_name=Path(path).name, summary="Generated analysis."
@@ -45,7 +51,7 @@ async def fake_run_analyze(path, agent, model, environment, jobs_dir):
def test_summarize_trial_surfaces_error(tmp_path: Path) -> None:
_make_trial(tmp_path)
- async def fake_run_analyze(path, agent, model, environment, jobs_dir):
+ async def fake_run_analyze(path, agent, model, environment, jobs_dir, **kwargs):
report = AnalyzeReport(
results=[AnalyzeReportResult(trial_name="trial__abc", error="boom")]
)
@@ -69,11 +75,19 @@ def test_summarize_job_runs_analyze_and_persists_report(tmp_path: Path) -> None:
captured = {}
async def fake_run_analyze(
- path, agent, model, environment, n_concurrent, filter_passing, jobs_dir
+ path,
+ agent,
+ model,
+ environment,
+ n_concurrent,
+ filter_passing,
+ jobs_dir,
+ **kwargs,
):
captured["agent"] = agent
captured["environment"] = environment
captured["filter_passing"] = filter_passing
+ captured["agent_env"] = kwargs.get("agent_env")
report = AnalyzeReport(
results=[AnalyzeReportResult(trial_name="trial__abc", summary="ok")]
)
@@ -92,7 +106,11 @@ async def fake_run_analyze(
)
assert response.status_code == 200
- assert response.json() == {"n_trials_analyzed": 1}
+ assert response.json() == {
+ "summary": "ok",
+ "n_trials_summarized": 1,
+ "job_summary_created": True,
+ }
assert captured["agent"] == "codex"
assert captured["environment"].value == "modal"
assert captured["filter_passing"] is False