- Increased UI update interval from 350ms to 500ms
- Reduced dot animation frequency from 60ms to 250ms
- Reduced splash animation frequency from 50ms to 100ms
- Added content hash cache for rendered agent messages to avoid
re-parsing markdown and re-running Pygments on every tick
- Added guard to prevent redundant scroll_end callbacks from queuing
during rapid updates
Closes#581
* feat: add bedrock + vertex optional extras with install docs and import hints (#574)
Declare [project.optional-dependencies] with vertex (google-auth) and
bedrock (boto3) extras so "strix-agent[vertex]" / "strix-agent[bedrock]"
install the provider SDKs. Add an Installation section to the Bedrock docs
mirroring Vertex, and a _provider_import_hint helper in warm_up_llm that
surfaces a pip-install hint when a provider dependency is missing.
Fixes#574, #573
* fix(providers): use pipx in install hint to match docs
A pipx-installed strix can't add an extra with 'pip install' (wrong env);
mirror the documented 'pipx install "strix-agent[...]"' command. Addresses
Greptile review.
* fix: resolve pre-commit check failures
- Change RuntimeError to TypeError for type validation in report/writer.py
- Update pyupgrade to v3.21.2 for Python 3.14 compatibility
* chore: add pytest test infrastructure
Mirror the layout introduced on feature/438-token_budget: pytest +
pytest-asyncio dev deps, asyncio_mode auto, a tests.* mypy override, and
pytest in the mypy pre-commit hook deps so the tests/ package type-checks.
* feat: add --mount and large-target pre-flight for local repos (#492)
Large local targets were copied into the sandbox file-by-file via the SDK
LocalDir entry, which stalls on big repos and could leave /workspace empty.
- --mount <path> bind-mounts a host directory read-only at /workspace/<subdir>
instead of copying it, bypassing the per-file stream.
- A size pre-flight (STRIX_MAX_LOCAL_COPY_MB, default 1024) fails fast with a
clear message suggesting --mount when a non-mounted local target is too big.
* fix: reject empty --mount paths
An empty or whitespace-only --mount value resolves to the current working
directory and would silently bind-mount it into the sandbox. Reject it.
* fix: dedupe local targets so a dir is never both copied and mounted
If the same directory is passed via --target and --mount (or as duplicate
values), it previously produced two targets — copied AND bind-mounted, and
the copied one could trip the size pre-flight. Dedupe by resolved path,
preferring the bind mount.
* fix: treat non-positive STRIX_MAX_LOCAL_COPY_MB as disabled
Previously a value of 0 (or negative) made every local target count as
oversized, aborting all local scans. Now <= 0 disables the pre-flight.
* fix: log unreadable subtrees during size pre-flight
os.walk silently swallowed directory-listing errors, so a permission-denied
subtree could make a large repo under-count and slip past the pre-flight.
Surface such omissions via an onerror warning.
* docs: document --mount and STRIX_MAX_LOCAL_COPY_MB
Add CLI reference + example for --mount, document the size pre-flight env var,
note the read-only-is-not-a-hard-boundary caveat and that remote repos are not
size-checked, and clarify the backends docstring on when bind mounts apply.
* Update strix/interface/main.py
* Update strix/runtime/docker_client.py
---------
* fix: resolve pre-commit check failures
- Change RuntimeError to TypeError for type validation in report/writer.py
- Update pyupgrade to v3.21.2 for Python 3.14 compatibility
* feat(cli): add --max-budget-usd flag
Raises BudgetExceededError in ReportUsageHooks after each LLM call when
accumulated cost reaches the limit, with clean "stopped" status and
child-agent cancellation in non-interactive mode.
* test: add budget enforcement unit tests
7 tests covering no-budget, under-budget, at-limit, over-limit, error
message content, None report state, and exception hierarchy.
Also adds pytest/pytest-asyncio to dev deps and a mypy override for tests.
* fix(budget): validate positive budget and check the live cost ledger
Two hardening fixes for --max-budget-usd enforcement:
- Reject non-positive budgets. ReportUsageHooks now raises ValueError for
max_budget_usd <= 0, and the CLI validates the flag via a custom argparse
type so '--max-budget-usd 0' fails fast with a friendly message instead of
silently killing the scan on the first model response.
- Read the live cost. The budget check now reads ReportState.get_total_llm_cost()
(the live ledger) instead of the persisted run-record snapshot, so it stays
accurate even when a usage save fails after a model call.
* fix(budget): stop the entire scan deterministically when the limit is hit
Previously a BudgetExceededError was handled per-agent: it was swallowed in
interactive mode (the loop kept waiting), a child's error escaped its detached
task as an unretrieved-exception warning, the parent was never released from
wait_for_message, and the stop was logged at ERROR with a traceback as if the
agent had failed.
Replace that with a single scan-wide signal on the coordinator:
- AgentCoordinator.trigger_budget_stop() sets a flag and wakes every parked
agent; wait_for_message returns as soon as the flag is set.
- The run loops check coordinator.budget_stopped and raise to exit cleanly,
marking themselves 'stopped'. The root's exception reaches run_strix_scan's
handler, which cancels descendants and tears the scan down once; child
exceptions are swallowed in their detached task.
- The budget stop is logged at INFO, not as a failure.
This is deterministic regardless of tree depth or which agent first sees the
limit, fixing the interactive/TUI hang where a deep agent's stop never reached
a parked root. Also re-raises BudgetExceededError explicitly in the stream
handler so it can't be mistaken for the LiteLLM 'after shutdown' race.
* fix(budget): treat a budget stop as a clean stop in the TUI
Add an explicit BudgetExceededError handler in the TUI scan thread so that, if
the error ever reaches it, the budget stop is logged as a graceful stop rather
than surfaced as a red scan error by the broad 'except Exception'. The runner
normally absorbs the error and returns cleanly, so this is defensive depth for
a money-spending feature.
* docs(cli): document --max-budget-usd behavior and limitations
Clarify that the budget is cumulative across all agents, checked after each
model response, that the scan stops cleanly (not as a failure), that the value
must be > 0, and that spend can slightly overshoot due to in-flight calls and
best-effort cost estimation.
* Apply suggestions from code review
---------
Warn on bare unknown model names before warm-up. is_known_openai_bare_model
consults litellm.model_cost and matches only entries whose
litellm_provider == "openai". When the configured STRIX_LLM has no
provider prefix, isn't a known OpenAI model, and no LLM_API_BASE is
set, show a clear panel pointing the user at the <provider>/<model>
form and exit before issuing the doomed request — no more chasing an
"Incorrect API key" 401 from OpenAI when the user actually meant
deepseek/, anthropic/, etc. Custom-base configs are still allowed
through unconfirmed.
Disable LiteLLM's message-logging and streaming-logging knobs to cut
noise and skip one of the two end-of-stream submit paths. The other
path at streaming_handler.py:2206 schedules work on a global
ThreadPoolExecutor that loses to atexit shutdown when the interpreter
is winding down; the SDK's stream consumer surfaces that as a fatal
"cannot schedule new futures after shutdown" RuntimeError even though
the actual stream content was already delivered. Catch and swallow
that specific RuntimeError in _run_cycle so the scan isn't killed by
an upstream end-of-stream logging race.
Drop every hand-rolled provider table and per-model gating that had
accumulated in the model-handling layer:
* normalize_model_name no longer auto-prefixes bare claude-* / gemini-*
names. Users supply the full <provider>/<model> form. The function
became literally model_name.strip(), so callers now inline that and
the function is removed.
* tool_choice="required" is gone everywhere. Thinking-mode endpoints
(Anthropic, DeepSeek /beta) reject it; modern reasoning models don't
need it; non-interactive runs already have
_append_noninteractive_tool_required_message as the convergence
backstop. model_supports_reasoning, model_known_to_registry, and
_model_cost_entry were only used to gate this and follow it out.
* Reasoning(effort=...) is now attached whenever
STRIX_REASONING_EFFORT is non-none. litellm.drop_params=True absorbs
it for non-reasoning models.
* Warm-up's bare-name OpenAI 401 hint is removed (false-positive prone,
relied on substring matching).
* reset_tool_choice on SandboxAgent is no-op now (no tool_choice gets
set) and is removed.
* report/dedupe.py was still routing through stock MultiProvider, so
non-OpenAI configs failed the dedupe LLM pass; switch it to
StrixProvider.
Verified end-to-end against modern provider strings (openai/gpt-5.4,
anthropic/claude-opus-4-7, deepseek/deepseek-reasoner,
gemini/gemini-2.5-pro, groq/, xai/, mistral/, together_ai/, perplexity/,
openrouter/, litellm/ legacy form, and whitespace-padded input): 18/18
cases route correctly, env vars mirror via litellm.validate_environment,
and ModelSettings carries no tool_choice. mypy strict passes.
Users had to type STRIX_LLM=litellm/deepseek/deepseek-chat — the
litellm/ wrapper was Strix-internal plumbing surfacing in user config.
Add StrixProvider, a MultiProvider subclass that routes any non-OpenAI
prefix (deepseek/, anthropic/, groq/, xai/, mistral/, openrouter/, …)
through LitellmProvider with the prefix preserved. normalize_model_name
no longer adds litellm/ to anything; bare claude-* / gemini-* shorthands
expand to anthropic/<model> / gemini/<model> instead of the wrapped form.
Wire StrixProvider into warm_up_llm and RunConfig.model_provider.
litellm/<provider>/<model> and any-llm/<provider>/<model> still resolve
unchanged for users on older config.
Refresh stale model names in the env-validation messages and the
warm-up hint (gpt-5.4, claude-opus-4-7, deepseek-reasoner).
Verified 24-case end-to-end matrix: OpenAI direct vs. LitellmProvider
routing, env-var mirroring via validate_environment, supports_reasoning
detection, and tool_choice gating all behave correctly across modern
providers including the user's unknown DeepSeek SKU.
A bare model name without a provider prefix routes through the SDK's
default OpenAI provider, so configuring STRIX_LLM=deepseek-v4-pro with
LLM_API_KEY=<deepseek key> sends that key to api.openai.com and
surfaces a confusing "Incorrect API key" error pointing at the OpenAI
dashboard.
When warm-up fails with an OpenAI-shaped error AND the configured
model is still unprefixed after normalize_model_name, append a hint
that points the user at the '<provider>/<model>' form with concrete
examples.
Five rounds of sweep across the tree. Net ~544 lines removed.
Removed:
- Section-divider banners and one-line section labels (# Display
utilities, # ----- list_requests -----, # CVSS breakdown, etc.).
- Module-level prose docstrings on internal modules. Kept one-line
summaries; trimmed multi-paragraph narration about SDK/Strix
responsibility splits, cache strategies, three-source precedence.
- Internal-helper docstrings that just restate the function name —
caido_api helpers (caido_url, get_client, view_request, etc.),
settings-class one-liners (LLMSettings, RuntimeSettings, ...),
UI helper docstrings.
- Args/Returns blocks on non-LLM-facing internal helpers
(build_strix_agent, render_system_prompt, create_or_reuse,
bootstrap_caido) — kept only the genuinely non-obvious params.
- Internal-history phrasing — "Mirrors main-branch shape",
"pre-SDK harness", "previous lookup matched no attribute".
- Narrative comments inside function bodies that explained what the
next line does, design rationale obvious from the surrounding code,
or "we used to..." asides.
- Trailing periods on every error-string literal across the tool tree.
- Duplicated roundtripTime quirk comment (kept the LLM-facing copy in
tools/proxy/tools.py).
Kept (every one names an upstream bug, vendored-code provenance, or
non-obvious data quirk):
- core/runner.py: SDK replay-with-empty-initial-input + on_agent_end
lifecycle gap.
- runtime/docker_client.py: VERBATIM COPY block of the upstream
_create_container body, pinned to SDK v0.14.6.
- runtime/session_manager.py: NO_PROXY for agent-browser CDP loopback.
- tools/proxy/caido_api.py: generated-pydantic Request.raw quirk,
replay double-history pitfall.
- tools/proxy/tools.py: Caido roundtripTime=0 quirk for proxy
captures.
Four passes of audit-and-patch on the tool surface, condensed.
Tool API shape:
- Todo tools collapse to a single list-based form (one arg per tool,
always a list, no dual-mode validator). Result-field names line up
across the family — created_count / updated_count / marked_count /
deleted_count, and _mark returns a single "marked" key plus the new
status instead of marked_done / marked_pending.
- list_notes splits the overloaded total_count into filtered_count
(matches) and total_count (grand total), matching list_todos. All
three notes mutations now echo total_count and note_id.
- finish_scan drops the machine-code error strings; a single human
"error" key carries the reason on every failure path.
- scope_rules delete echoes a message so the renderer's success
branch has something to surface.
Failure-key unification: every tool now uses {"success": False,
"error": "..."} on failure paths. Touched thinking, web_search,
reporting, and finish. Trailing periods on error strings swept clean
across the whole tool tree.
Tool prompts (docstring re-imports vs main):
- create_vulnerability_report re-imports the CWE reference catalog,
multi-part fix rules, fix_before/fix_after PR-suggestion mechanics,
the COMMON MISTAKES list, the informational-vs-actionable
distinction, and file-path examples.
- web_search re-imports concrete example queries.
- list_sitemap docstring fixed hasDescendants -> has_descendants
(the camelCase reference never matched our snake_case schema).
- create_agent.skills description "Comma-separated" -> "List of".
- factory.py module docstring no longer claims there's no runtime
skill-loading tool. agents_graph module docstring lists stop_agent.
- system_prompt nudges loading the matching skill before guessing
payloads or syntax from memory.
TUI:
- proxy_renderer was reading stale field names from the pre-SDK
schema (requests / total_count / statusCode / matches /
showing_lines); now reads entries / page_info / status_code / hits
/ page+total_lines. Three proxy operations were rendering empty
before this.
- Idle-pane placeholder text trimmed to "Loading...".
exec_command, write_stdin, apply_patch, view_image, load_skill,
list_sitemap, and view_sitemap_entry were falling through to the
generic dict-dumper. They now render in the same visual language as
the rest of the toolset: the terminal pair uses the >_ icon with
pygments bash highlighting; apply_patch and view_image use the file-
edit diamond with colored +/- diff lines and per-language syntax
highlighting; sitemap and load_skill mirror the proxy and skill
patterns already established.
Both backends share session/version/first-run helpers in
strix/telemetry/_common.py and fire from the same four call sites in
strix/interface/main.py and strix/report/state.py. STRIX_TELEMETRY is
the single toggle for both.
- view_agent_graph status summary now derives buckets from the canonical
Status literal via get_args, so adding a new status in core.agents
auto-flows into the summary. The previous hardcoded five-bucket list
silently omitted "failed" — buckets stopped summing to total whenever
an agent failed.
- stop_agent rejects targets that are already in a terminal status
(completed / stopped / crashed / failed) with a model-readable error
pointing at view_agent_graph and send_message_to_agent. request_stop
unconditionally overwrites status, so without this guard calling
stop_agent on a completed agent erased the "completed" history.
- StopAgentRenderer added — was falling back to the generic key/value
renderer; the rest of the agents_graph tools have purpose-built ones.
- agent_finish root-rejection payload trimmed from
{success, agent_completed, error, parent_notified} to {success, error}.
The lifecycle gate only reads success+agent_completed and they were
always False/False on this branch, so the extra fields were dead weight.
- wait_for_message renames its top-level outcome field from "status" to
"wait_outcome" — "status" overloaded with the coordinator's agent
status literal (which also has "stopped" as a value, different
meaning). Redundant "agent_waiting" boolean dropped (true iff
wait_outcome == "waiting"). Consumer at factory._wait_tool_parked
updated to match.
- send_message_to_agent now refuses self-send with a pointer at think /
agent_finish / finish_scan instead of looping a message into your
own session.
- SendMessageToAgentRenderer read args.get("agent_id") but the tool's
param is target_agent_id, so the TUI silently never showed the target.
Fixed.
- Restored skill validation lost during the SDK migration: skills
module re-exports get_all_skill_names and validate_requested_skills
(excluding internal scan_modes/coordination categories from the
user-selectable set). create_agent now validates skills before
spawning instead of silently accepting unknown names.
Re-add list_sitemap and view_sitemap_entry from main, ported to the
new caido-sdk-client layout via raw GraphQL queries (the typed SDK
doesn't expose sitemap operations, but the Caido server still
supports sitemapRootEntries / sitemapDescendantEntries / sitemapEntry).
Wired through caido_api (sandbox-importable helpers), the host-side
@function_tool wrappers, factory _BASE_TOOLS, the system prompt, the
python skill doc, and the public proxy docs.
While threading these through, lock down the output contract across
every proxy tool so the model sees one consistent shape:
- All tools wrap success/failure in {"success": bool, "error"?: str}
- Canonical field names: status_code, length, roundtrip_ms (omitted
when 0), is_tls, has_descendants. snake_case everywhere on output;
camelCase stays only on the input side where it's the GraphQL
schema.
- repeat_request now returns a structured response that matches
list_requests' response_summary shape (parse_raw_response parses
the raw bytes into status_code / length / headers / body), with
body capped at 8KB and a body_truncated flag so the model knows
when to fetch the full body via view_request.
- RepeatRequestRenderer was reading non-existent top-level keys
(status_code, response_time_ms, body) and silently displaying
nothing useful — now reads the structured response shape.
send_request was a thin wrapper over the Caido Replay API that the model
could replicate with a one-liner `curl` via exec_command. The sandbox's
HTTP_PROXY env captures all such traffic for free, so the tool was
adding bugs (duplicate dispatch, dropped responses) without adding
capability. Removed across factory, tools module, sandbox-importable
caido_api helper, TUI renderer, prompt template, skill doc, and public
docs. repeat_request stays — it operates on captured request IDs with
structured modifications, which curl can't replicate cleanly.
Three caido-sdk-client workarounds that were hitting us through both
send_request and repeat_request:
- replay_send_raw used to pass CreateReplaySessionFromRaw to
sessions.create(), which seeds a stored entry server-side, then
called send() — producing two history rows per call. Empty-create +
send produces one dispatched request.
- The same helper read result.entry.response_raw, an attribute that
doesn't exist on ReplayEntry, so response bytes were silently
dropped. Fixed to walk result.entry.response.raw with proper None
guards.
- get_request_with_client passed include_request_raw / include_response_raw
based on the requested part, but the SDK's generated pydantic models
declare raw as required even though the GraphQL fragment makes it
conditional via @include. Passing False crashed view_request with a
pydantic validation error. Always request both raw bodies; the caller
picks which to surface.
Also wrapped replay.send() in asyncio.wait_for(30s) so a stalled Caido
dispatch (notably loopback targets that don't route cleanly through the
sandbox proxy) fails fast with a model-readable error instead of
hanging the agent until the function_tool 120s budget expires.
Finally, list_requests now omits the roundtrip_ms field when Caido
reports 0 — proxy-captured unscoped traffic consistently reports 0
while scoped/replay traffic carries real measurements, so the absence
of the field is now informative ("Caido didn't measure this") rather
than misleading ("this request took 0ms").
Two fixes that surfaced from a single broken run.
(1) Source mounting was double-broken:
- ``session_manager.create_or_reuse`` mounted the *parent* of the first
local source under a hardcoded ``"sources"`` key, so the host's
unrelated content leaked in at ``/workspace/sources/...`` while the
agent's task prompt advertised ``/workspace/<workspace_subdir>``
(from ``_build_root_task``). Result: the agent looked at
``/workspace/empty/`` (per the prompt), found nothing, and bailed.
- ``backends._docker_backend`` never called ``await session.start()``
after ``client.create()`` — the SDK's manifest application
(``LocalDir`` materialization, mount setup) only runs inside
``start()`` (or ``async with session:``). So even with the right
``entries`` the workspace would have been empty anyway.
Fix: thread ``args.local_sources`` (already populated by
``collect_local_sources``) all the way through to the session manager,
build ``Manifest.entries`` keyed by each source's ``workspace_subdir``,
and call ``session.start()`` in the docker backend so the SDK actually
materializes the entries. Drop the now-unused ``_resolve_sources_path``
helpers from ``cli.py`` and ``tui.py``.
(2) Scan-failure visibility was nonexistent in TUI mode:
- The SDK's ``on_agent_end`` hook only fires after the agent reaches its
first turn. A failure earlier (model routing, sandbox bring-up, …)
left the root agent stuck at ``status=running`` in the bus and
tracer, so the TUI animated "Initializing" forever.
- ``scan_target`` in ``tui.py`` caught the exception and called
``logging.exception`` but never propagated it. ``run_tui`` returned
cleanly when the user finally ctrl-q'd, so ``main.py`` happily
printed the success-completion banner over a dead scan.
Fix: in ``run_strix_scan``'s ``except BaseException`` block, finalize
the root agent as ``"failed"`` in both the bus and the tracer (with the
error message attached). Capture the exception on
``StrixTUIApp._scan_error`` from the scan thread; ``run_tui`` re-raises
it after ``app.run_async()`` returns so ``main.py``'s existing handler
prints the traceback. Add a ``"failed"`` branch to
``_get_status_display_content`` that shows the error message in red,
mirroring the existing ``llm_failed`` branch.
Three critical correctness fixes + six TUI/audit/UX fixes from the
parallel-agent audit. All changes verified by an end-to-end smoke
that builds, persists, and re-hydrates state across two simulated
process boundaries.
Critical (resume integrity):
1. ``bus.cancel_descendants_graceful`` now calls ``_maybe_snapshot``
after mutating the ``stopping`` set. Previously, a process crash
between user-initiated graceful-stop and the next finalize lost
the stop signal — respawned agents would run forever instead of
exiting. ``_respawn_subagents`` also gains a guard that skips
agents in ``stopping`` so a previously-cancelled agent is not
resurrected on resume.
2. ``Tracer.hydrate_from_run_dir`` now **raises** on corrupt
``vulnerabilities.json`` instead of swallowing the exception. The
prior behaviour silently reset ``vulnerability_reports`` to empty,
so the next ``add_vulnerability_report`` would allocate ``vuln-0001``
and overwrite the prior MD on disk — silent data loss.
3. ``--instruction`` passed on resume now reaches the model. The CLI
captures whether the user explicitly passed an instruction
(``args.user_explicit_instruction``) before ``_load_resume_state``
loads the persisted one. ``run_strix_scan`` reads
``scan_config["resume_instruction"]`` and, on resume, sends the
new instruction to root's bus inbox before calling
``run_with_continuation`` (which uses ``initial_input=[]`` for SDK
replay). The inject filter surfaces it on the next turn.
4. ``--resume X`` errors loudly when ``scan_state.json`` exists but
``bus.json`` doesn't. Previously this silently fresh-started in
the same dir, confusing the user who explicitly asked to resume.
TUI / audit / UX:
5. ``Tracer.hydrate_from_run_dir`` now reads ``bus.json`` too and
pre-populates ``tracer.agents`` from the snapshot's ``statuses`` /
``names`` / ``parent_of``. Before this, the TUI tree on resume
showed only currently-running agents; completed/crashed children
from the prior run were invisible.
6. ``Tracer.hydrate_from_run_dir`` also seeds ``self._llm_stats`` from
``bus.stats_live + bus.stats_completed`` so the resume's footer
shows cumulative tokens / requests across the prior run plus the
resume segment, instead of resetting to zero.
7. ``Tracer.save_run_data`` now also writes ``run_metadata.json``
(start_time, run_id, run_name, targets, status), and
``hydrate_from_run_dir`` restores ``start_time`` from it. Prior
behaviour reset start_time to ``now()`` on every Tracer init,
breaking the final report's duration calc on resumed scans.
8. Per-agent todos persist to ``{run_dir}/todos.json`` (atomic write
on every CRUD). ``hydrate_todos_from_disk`` (called from
``run_strix_scan``) reloads them so respawned subagents find
their lists intact. Previously, the module-level
``_todos_storage`` was lost on every process restart.
9. ``_load_resume_state`` validates each ``cloned_repo_path`` from
the persisted ``scan_state.json`` still exists on disk. Previously
a deleted clone dir would let the resume proceed with an empty
source tree, with agents silently scanning nothing.
Bonus: ``bus.finalize`` no longer pops ``parent_of`` and ``names``
for finalized agents. Routing protection (don't accept ``send`` to
finalized agents) comes from the ``statuses[id]`` terminal-state
check in ``send`` itself, so dropping those keys was overzealous and
made completed children invisible in ``view_agent_graph`` and the
TUI tree.
Adds an explicit ``--resume RUN_NAME`` flag that loads the prior
run's persisted scan state from ``strix_runs/<run_name>/scan_state.json``
and replays it (targets, scan_mode, instruction, local_sources,
diff_scope, scope_mode, diff_base) so the user never has to retype
their original args.
The exit panel now suggests ``strix --resume <run_name>`` instead of
``--run-name``. Same single-line, same dim-label / coloured-value
styling as ``Target`` / ``Output`` rows, gated on
``not scan_completed``.
CLI contract:
* ``--resume X`` cannot be combined with ``--target`` (parser error).
* ``--resume X`` errors with a clear message if
``strix_runs/X/scan_state.json`` is missing.
* Fresh runs persist scan_state.json once at the end of setup —
after target normalization, repo cloning, local-source
collection, diff-scope resolution, and final instruction
composition. So whatever the agent saw on first run is exactly
what the resumed run sees.
Internally the resume path stays implicit (presence of bus.json
triggers it inside ``run_strix_scan``); ``--resume`` is a UX layer
that:
1. Sets ``args.run_name = args.resume``.
2. Pre-populates ``args.targets_info`` and friends from disk.
3. Skips the fresh-only steps (target re-parse, repo clone,
diff-scope re-resolution) — the persisted values were already
finalized on the first run.
HARNESS_WIKI.md: drop the "delete the run dir to force fresh"
instruction.
When a scan ends without calling ``finish_scan`` (Ctrl+C, TUI quit,
crash), ``display_completion_message`` now appends one extra line
inside the existing completion panel:
Resume strix --run-name <run_name>
Same ``dim``-label / coloured-value styling as the panel's ``Target``
and ``Output`` rows. Only rendered when ``scan_completed`` is False —
a finished scan doesn't need a resume nudge.
Triggers ``orchestration/scan.py``'s implicit-resume path on the next
invocation (presence of ``{run_dir}/bus.json`` is the trigger), so
the user gets back exactly where they left off — root + every
non-terminal subagent's full LLM history, bus topology, prior
findings.
Covers both ``run_cli`` and ``run_tui`` paths since
``display_completion_message`` is called from ``main()`` regardless
of which front-end ran.
When the user shuts down a run (Ctrl+C in CLI, Ctrl+Q / quit dialog
in TUI, or an uncaught exception during the scan), print a Rich
panel telling them the exact command to pick up where they left off:
strix --run-name <run_name>
The panel only appears when ``strix_runs/<run_name>/bus.json``
exists — i.e. the scan registered at least the root agent and has
snapshot state worth resuming from. Suppressed when:
* No run-name was assigned (Ctrl+C before sandbox bring-up).
* The run dir doesn't exist or has no bus.json yet.
Implementation:
* ``strix/interface/utils.py`` gains ``format_resume_hint(run_name)
-> Panel | None``.
* ``cli.py`` calls it in the SIGINT/SIGTERM/SIGHUP handler before
``sys.exit(1)``, and in the ``except Exception`` arm before the
re-raise.
* ``tui.py:run_tui`` calls it in a ``finally`` after
``app.run_async()`` so the hint lands on the real terminal once
Textual has restored it (whether the user pressed Ctrl+Q,
confirmed the quit dialog, or the run completed naturally).
Five gaps from the post-implementation audit, closed:
1. **SDK logger captured.** The openai-agents SDK uses
``logging.getLogger("openai.agents")`` for its own lifecycle events
(Runner.run starts, tool dispatch, model retries, exceptions).
Previous setup only attached handlers to the ``strix`` root, so
SDK-internal events were dropped. Tracked-roots tuple now covers
both, with the same FileHandler/StreamHandler/Filter chain.
2. **Proxy tool exception tracebacks.** Every ``@function_tool`` in
``strix/tools/proxy/tools.py`` returns a JSON error to the LLM via
the ``_err(name, exc)`` helper. The tracebacks were silently
formatted away — the LLM saw the message, the human reading the
log saw nothing. ``_err`` now emits ``logger.exception(...)``
covering all five tools at once.
3. **CLI bootstrap.** ``strix/interface/main.py`` had its module
``logger`` removed by the previous commit and was emitting nothing.
Restored, plus log lines for env validation, docker check, LLM
warm-up, and image pull (debug for already-present, info for
pull, exception for failures).
4. **Docker client.** ``strix/runtime/docker_client.py`` had no
logger. Container creation now logs caps + exposed ports at DEBUG
and the resulting container id at INFO.
5. **PostHog telemetry.** ``strix/telemetry/posthog.py`` had no
logger. Now logs send success/failure at DEBUG, version-detection
failures at DEBUG, and disabled-skip at DEBUG (so the log shows
when telemetry is off, instead of being silent about it).
Every scan now writes a complete log file at ``{run_dir}/strix.log``
captured from the moment ``run_dir`` is resolved through teardown.
Stdlib ``logging`` only — no parallel framework.
New ``strix/telemetry/logging.py``:
* ``setup_scan_logging(run_dir, debug=)`` attaches a ``FileHandler``
(DEBUG, all ``strix.*``) plus a ``StreamHandler`` (ERROR by
default; DEBUG via ``STRIX_DEBUG=1``).
* ``ContextVar``-backed ``scan_id`` and ``agent_id`` injected by a
``Filter`` so every line is auto-tagged across asyncio tasks
without callers passing them explicitly.
* Third-party noise (``httpx``, ``litellm``, ``openai``,
``anthropic``, ``urllib3``, ``httpcore``) capped at WARNING.
* Returns a teardown handle for ``finally`` cleanup.
Wiring:
* ``orchestration/scan.py`` calls ``setup_scan_logging`` once per
scan after ``run_dir`` resolves; sets scan_id; tears down in
``finally``. Adds INFO logs for sandbox bring-up + scan
start/end.
* ``orchestration/hooks.py`` sets/clears ``agent_id`` ContextVar in
``on_agent_start`` / ``on_agent_end`` and emits INFO for agent
lifecycle, DEBUG for every tool start/end and LLM call.
* ``interface/main.py`` drops the ``setLevel(ERROR)`` silencer.
Coverage expanded across ~20 files (orchestration, agents, runtime,
llm, tools, interface, config, skills) with INFO for lifecycle and
DEBUG for verbose detail. Per the system instructions in
``logger.warning(f"…{e}")`` were converted to module logger calls.
Three top-level files that didn't earn their place:
- ``strix/io/scan_artifacts.py`` had a single consumer (the Tracer);
collapsing it into ``strix/telemetry/`` puts it next to that consumer.
``strix/io/`` is gone.
- ``strix/run_config_factory.py`` held two helpers that didn't earn the
factoring. ``make_agent_context`` was a 17-line dict-spelling function
whose argument names were identical to its dict keys — replaced with
inline dict literals at the two call sites. ``make_run_config`` had
enough RunConfig assembly logic to justify a helper, but with only
two callers (root scan + ``create_agent``) inlining is cleaner than
keeping a top-level file. ``DEFAULT_RETRY`` moves to
``strix/llm/retry.py`` next to its other LLM-policy peers; the dead
``STRIX_DEFAULT_MAX_TURNS`` constant is dropped.
- ``strix/entry.py`` is a misnomer — it isn't *the* entry point (that's
``strix/interface/main.py`` for the CLI), it's the per-scan bring-up
driver: build the bus, bring up the sandbox, build the root agent +
child factory, format the scope-context block, register root in bus,
open SQLiteSession, hand off to ``run_with_continuation``. That all
lives next to its peers in ``strix/orchestration/`` now, renamed to
``scan.py`` so the role is obvious.
No behavior change. Net -125 LoC.
Final pass after re-audit. Three sub-specs landed:
**XML simplification** — the legacy XML envelopes were prompt-engineering
ceremony, not parser primitives (the SDK uses native tool-calling). Drop
the verbose wrappers in favor of one-liner labeled headers. Side benefit:
fixes the unescaped-content XML-injection bug the audit caught (peer
content containing ``</content>`` no longer breaks the wrapper).
- ``_format_inter_agent_message``: ``<inter_agent_message><sender>...
<content>...`` 9-line XML → ``[Message from {name} ({id}) | type=... |
priority=...]\n{content}``.
- ``_render_completion_report``: ``<agent_completion_report><agent_info>
...<results>...`` XML → human-readable structured text with section
headers and bulleted lists.
- ``inherited_context``: ``<inherited_context_from_parent>...`` →
``== Inherited context from parent (background only) ==``.
**MG1: TUI stop-agent uses graceful cancel.** ``tui.py`` was calling
``bus.cancel_descendants`` (hard, ``task.cancel()`` mid-stream) for the
stop-agent button. Switched to ``bus.cancel_descendants_graceful``, which
uses ``RunResultStreaming.cancel(mode="after_turn")`` to let each agent
finish its current turn (and save to session) before honoring the cancel.
The hard path remains in ``entry.py`` for KeyboardInterrupt where
graceful isn't possible.
**MG2: Document hook lock-free stats mutation.** Added a comment in
``hooks.on_llm_start`` explaining why ``warned_85`` / ``warned_final``
are mutated lock-free: SDK serializes ``on_llm_start`` per agent, so this
hook is the sole writer to those keys; ``record_usage`` only writes
disjoint keys (in/out/cached/calls).
**AG3: Auto-load ``coordination/root_agent`` skill for the root.**
Legacy auto-loaded the orchestration-guidance skill for root agents
only. Threaded ``is_root`` through ``render_system_prompt`` →
``_resolve_skills``; root agents now get the skill, children don't.
Skipped (per user direction): whitebox-wiki integration (CG2-4) — the
auto-injection / auto-update of the shared repo wiki was a pre-migration
feature; user opted not to restore it.
Audit found 8 behavioral gaps between post-migration and the legacy
``BaseAgent.agent_loop``. All 8 are now closed using SDK-native
primitives — no custom workarounds, no shadow state machines.
What was broken / different:
- G1: ``inherit_context`` was dead code; children always started fresh.
- G2: TUI user message couldn't interrupt an in-flight LLM/tool turn.
- G3: ``llm_failed`` state never set; hard failures propagated as crashes.
- G4: No graceful ``stop_agent`` tool.
- G5: Parked subagents waited forever (no auto-resume timeout).
- G6: Inter-agent messages used a plain header instead of legacy XML.
- G7: Completion reports used JSON instead of legacy XML.
- G11/G12: Turn counter reset per cycle; budget warnings could re-fire.
What we did:
Bus extensions (``orchestration/bus.py``):
- ``streams`` registry + ``attach_stream`` ctx manager + ``request_interrupt``
for SDK-native ``RunResultStreaming.cancel(mode="after_turn")``.
- ``mark_llm_failed`` + ``wait_for_user_message`` (filtered: only ``from="user"``
satisfies; peer messages don't unstick a stuck model).
- ``stopping: set[str]`` for graceful programmatic exit.
- ``cancel_descendants_graceful`` — leaves-first via ``request_interrupt``.
- ``record_usage`` increments ``calls`` unconditionally so it doubles as the
per-agent-lifetime turn counter (legacy ``state.iteration`` parity).
- ``warned_85`` / ``warned_final`` flags on ``stats_live`` for once-fire
budget warnings.
Run loop rewrite (``orchestration/run_loop.py``):
- ``Runner.run`` → ``Runner.run_streamed`` with ``bus.attach_stream`` so
cancel has a target. Catch ``(AgentsException, APIError)`` after retries
exhaust; in interactive mode call ``mark_llm_failed`` + wait for user.
- ``UserError`` / ``MaxTurnsExceeded`` / ``CancelledError`` propagate.
- Outer loop: ``asyncio.wait_for(bus.wait_for_message, timeout=300)`` for
interactive subagents (root waits forever). ``TimeoutError`` injects
``"Waiting timeout reached. Resuming execution."``.
- Honors ``bus.stopping`` at top of each iteration.
Hooks (``orchestration/hooks.py``):
- Counter source moved from per-cycle ``ctx["turn_count"]`` to
per-lifetime ``bus.stats_live[agent_id]["calls"]``.
- Warnings guarded by once-flags — exactly-once across all cycles.
Filter (``orchestration/filter.py``):
- Restored legacy ``<inter_agent_message>`` XML envelope with the
``<delivery_notice>DO NOT echo back</delivery_notice>`` instruction.
Agents-graph (``tools/agents_graph/tools.py``):
- G1: ``create_agent`` reads ``ctx.turn_input`` (SDK populates it before
tool execution at ``run_internal/turn_resolution.py:806``). Wraps as
one ``<inherited_context_from_parent>`` block.
- G7: ``agent_finish`` emits the legacy ``<agent_completion_report>``
XML. ``child_ctx["task"] = task`` threaded so the report echoes the
original task.
- G4: New ``stop_agent`` tool — refuses self-stop, refuses already-
finalized targets, ``cascade=True`` uses ``cancel_descendants_graceful``.
TUI (``interface/tui.py``):
- ``_send_user_message`` schedules ``bus.send`` AND
``bus.request_interrupt(target, mode="after_turn")`` — SDK finishes
current turn cleanly, next cycle picks up the user's message.
Factory (``agents/factory.py``):
- Registered ``stop_agent`` in ``_BASE_TOOLS``.
Out of scope:
- G8 (``[ABORTED BY USER]`` marker) is auto-resolved by G2 — the SDK
saves the full assistant message before honoring
``cancel(mode="after_turn")``, so partial content is preserved in the
session.
Verified all bus behaviors with a smoke test. Lint at baseline.
Replaces 200+ lines of bespoke env-loader / persist / change-detection
machinery with ``pydantic_settings.BaseSettings`` (already a transitive
of ``openai-agents → mcp``, no new direct dep).
What was wrong with ``Config``:
- 14 knobs flat in one namespace, weak grouping by comment-block.
- ``Config._applied_from_default`` and ``Config._config_file_override``
were externally mutated from ``interface/main.py:532-534``. Private
members were part of the public contract.
- Stringly-typed values: every caller had to coerce
(``int(Config.get("llm_timeout") or "300")``,
``... not in {"0", "false", "no", "off"}``).
- Dead knob: ``strix_llm_max_retries`` declared, persisted, listed in
``_LLM_CANONICAL_NAMES`` — zero readers (``DEFAULT_RETRY``
hardcodes ``max_retries=5``). Dropped.
- ``_LLM_CANONICAL_NAMES`` tuple maintained alongside class vars —
duplicate source of truth.
- ``_tracked_names()`` introspected ``vars(cls).items()`` filtered on
``(v is None or isinstance(v, str))`` — fragile.
- Awkward path: ``strix/config/config.py`` inside ``strix/config/``
with ``__init__.py`` just re-exporting.
- Dual access for the same fact: ``web_search`` read
``os.getenv("PERPLEXITY_API_KEY")`` while ``main.py`` read
``Config.get("perplexity_api_key")``.
New shape:
- ``strix/config/settings.py`` — typed dataclass tree:
``Settings.{llm,runtime,telemetry,integrations}``. Each sub-model is
its own ``BaseSettings`` so it reads env independently. Field-level
``alias=`` and ``validation_alias=AliasChoices(...)`` mirror the
existing flat env-var names — user-facing env contract is unchanged.
Bool fields auto-parse ``"0"``/``"false"``/``"no"``/``"off"``;
int fields auto-coerce.
- ``strix/config/loader.py`` — thin ``load_settings()``,
``apply_config_override(path)``, ``persist_current()`` with module
cache. JSON file reader walks aliases to populate sub-models, dropping
entries already covered by env (so env still wins).
- 13 callsites migrated from ``Config.get("...")`` to
``load_settings().<group>.<field>``.
- ``posthog._is_enabled()`` collapses to one line.
- ``--config <path>`` flow simplified: one
``apply_config_override(...)`` call replaces three lines of
class-private mutation.
Drive-by — drop ``is_whitebox`` from ``scan_config`` dict:
- It was being derived as ``bool(args.local_sources)`` in three places
(``cli.py``, ``tui.py``, ``main.py``) and stuffed into the dict for
``entry.py`` to read back. The fact is fully derivable from
``scan_config["targets"]`` — any target with ``type == "local_code"``.
- New helper ``is_whitebox_scan(targets)`` in ``interface/utils.py``
alongside the other target-classification utilities.
- ``entry.py`` computes once; ``main.py``'s posthog start uses the same
helper. Triplicate derivation gone.
Verified: ruff at baseline (3), mypy at baseline (69). Six smoke tests
pass — defaults / JSON-only / env-wins-over-JSON / alias-chain
fallback / bool parsing / ``is_whitebox_scan``.
The split between ``strix/sandbox/`` and ``strix/runtime/`` was
artificial — both were managing the same backend. ``strix/sandbox/``
also collided uncomfortably with the SDK's ``agents.sandbox.*``
namespace. ``runtime/`` (which matches ``STRIX_RUNTIME_BACKEND``) is
the canonical home for everything Docker / Daytona / K8s lifecycle.
While merging, also rip out two pieces of Docker-specific coupling:
- ``caido_bootstrap`` was POSTing ``loginAsGuest`` from the host via
``aiohttp`` to ``http://127.0.0.1:{forwarded_port}``. That assumed
Docker port forwarding; Daytona / K8s expose ports differently.
Now we ``session.exec`` curl from *inside* the container — the
SDK's runtime-agnostic exec primitive — so any backend works as
long as it implements ``exec``. The host-side Caido ``Client``
still uses the runtime's exposed-port URL for post-bootstrap calls,
but that goes through the SDK's own ``resolve_exposed_port``
abstraction (also runtime-agnostic).
- The bootstrap retry loop now doubles as the readiness probe, so
``healthcheck.wait_for_tcp_ready`` (and the entire
``healthcheck.py`` module) goes away.
Drive-by simplification: drop ``caido_host_port`` plumbing entirely.
It was only piped through ``make_agent_context`` → child contexts
without ever being read; only ``caido_client`` is consumed.
Drops ``aiohttp`` runtime dep (it stays only as a transitive of the
Caido SDK).