* feat(llm): log the upstream provider behind OpenRouter
LiteLLM drops OpenRouter's top-level `provider` from stream chunks (including error chunks) and the `metadata.provider_name` of non-2xx replies, so the request log could not say which provider served or failed an attempt. Record both, plus OpenRouter's `metadata.error_type`, per attempt and emit them as `upstream=` / `upstream_error=` on the llm_request line.
* feat(llm): retry 4xx model errors
* test: let the blocked-provider test run out of 4xx retries
* refactor: retry any 4xx or 5xx status
* fix: don't retry 401, 402 or 403
* fix: don't retry 404
LiteLLM drops OpenRouter's top-level `provider` from stream chunks (including error chunks) and the `metadata.provider_name` of non-2xx replies, so the request log could not say which provider served or failed an attempt. Record both, plus OpenRouter's `metadata.error_type`, per attempt and emit them as `upstream=` / `upstream_error=` on the llm_request line.
* fix(execution): only strip images on image-rejection errors
The image-strip recovery fired on any 400/404/422, wiping a session's screenshots and replaying the turn on unrelated errors (e.g. an upstream incomplete-tool-call 400, a context overflow). Require an image-related message too.
* fix(execution): match only replies that mean the model takes no images
* fix(execution): match DeepSeek's image rejection
* fix(execution): guess at Vercel AI Gateway's image rejection
* feat(llm): scrub images from every request to a text-only model
Replace images at the MCP call before any result_transform, give load_skill the
same agent_browser screenshot swap as the system prompt, and add a
call_model_input_filter that turns any image left in the input into a tool error
while chaining any filter already set.
* chore: fix the ruff and bandit failures on main
* feat(llm): keep images away from text-only models
Leave view_image out of the tools, replace MCP image blocks with a text note,
and swap agent_browser's screenshot section for text-only guidance when the
run's model does not accept images.
* refactor(llm): disable view_image via is_enabled and gate skill image guidance by modality
* refactor(skills): render opt-in Jinja skills instead of comment markers
* Tweaked skill
* feat(llm): detect whether a model accepts image input
Add model_supports_images(): OpenRouter models are checked against architecture.input_modalities from OpenRouter's public model list (fetched once per process), others against LiteLLM's supports_vision. Unknown models and lookup failures count as image-capable. Not wired in yet.
* refactor(llm): read image support from litellm's catalog, assuming it for unknown models
* style: drop a trailing space in the image-support docstring
A `repository` target is cloned into the sandbox workspace and collected into
`local_sources` exactly like a `local_code` target, but the two white-box checks
only looked at `type == "local_code"`. Scanning a GitHub repo, alone or beside
a domain, therefore ran with is_whitebox=False: the source-aware skills were
not preloaded for the root agent (coordination/source_aware_whitebox is not
loadable on demand) and telemetry recorded the scan as black-box. The docs
describe this invocation as white-box testing. Regressed in 1e641e5, which
replaced `bool(args.local_sources)` with the `local_code` check.
Add one `is_whitebox_scan` in a dependency-free `strix.core.targets` (the
interface layer imports it before the agents SDK may be imported, so it cannot
live in core.inputs) and use it from both the runner and scan setup. A
repository that was not cloned still does not count, as it has no source.
Tests: unit cases for each target mix, and a runner-level case asserting the
root agent is built with is_whitebox=True for a cloned repository (it fails on
main for the repository cases and passes for the local_code and web controls).
Fixes#884. Supersedes #885, which no longer applies to main.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TavwVMH7PvrD68JTDfd6Hb
A stop that lands between the silent-wait check and mark_running was
overwritten with running. resume_silent_user_wait does the check and the
state change under the coordinator lock and leaves anything else alone.
Plain text is now the only channel to the user. wait_for_user takes no
arguments and only parks the agent, so a reply can no longer be written
twice (once as text, once as a tool argument). The driver bounces a
wait_for_user call that follows no assistant text back into recovery.
TUI and viewer render wait_for_user as the waiting marker alone and keep
rendering recorded respond_to_user events with their message.
docker.from_env() ignores the current docker context, so Docker Desktop on
macOS (socket under ~/.docker/run unless the default-socket option is on),
OrbStack and Colima reported DOCKER NOT AVAILABLE while `docker ps` worked.
Every failure also printed the same "ensure Docker Desktop is running" text
followed by a RuntimeError traceback.
- resolve the endpoint as the CLI does: DOCKER_HOST, then DOCKER_CONTEXT or
the current context, then the default socket; the sandbox backend uses the
same resolution so startup and scan talk to the same daemon
- classify the SDK error (socket missing, permission denied, connection
refused, Windows named pipe) and print the fix for the current platform,
the endpoint that was tried and the underlying error
- exit 1 cleanly instead of raising after the panel
- telemetry reports docker_unavailable_<reason>
parse_arguments() rejected --fail-on without -n before main() could switch
to headless, so a CI job running `strix -t x --fail-on high` without -n
stopped at an argument error instead of scanning. The terminal check now
lives in cli_args as terminal_attached(); the parser only enforces the
headless-only flag when a terminal is attached and the TUI would open.
Without a tty on stdin and stdout (CI, nohup, pipes, cron, TERM=dumb) the Go
TUI handshakes fine and then exits 1 the moment Bubble Tea tries to take over
the screen. Because the backend was already activated, that exit escaped the
pre-activation mapping and surfaced as a raw traceback:
RuntimeError: Bubble Tea TUI exited with status 1.
Now main() checks for a terminal right after argument parsing. With a target
it switches to the headless path (same as -n) and prints one dim notice.
Without a target the start screen is the only way to enter one, so it prints
a panel telling the user to pass -t <target> -n and exits 1. A bare --resume
is left to the picker, which already lists runs when there is no terminal.
If the sidecar still dies after startup, check_return_code raises
TuiProcessExitedError, run_tui maps it to InteractiveInterfaceExitedError,
and main() prints an INTERACTIVE INTERFACE STOPPED panel with the -n hint
under its own telemetry name instead of re-raising.
preflight_request takes api_base_setting so a dedupe model that timed out
points the user at DEDUPE_LLM_API_BASE, not LLM_API_BASE. warm_up_llm is
now exercised with a dedicated dedupe model: own headers, same preflight
timeout, resolved through resolve_dedupe_model.
The warm-up request used settings.llm.timeout (LLM_TIMEOUT, 300s) both as
the request timeout and the wait_for bound, so a wrong LLM_API_BASE or a
dead proxy hung five minutes and then printed an empty Error line.
Add LlmSettings.preflight_timeout (LLM_PREFLIGHT_TIMEOUT, default 30) and
a shared preflight_request() used for the main and dedupe models. When it
expires the panel names the model, the limit and the setting.
Add uv lock --check, a wheel install smoke test, a PyInstaller dry run,
actionlint and zizmor on the workflows, CodeQL, and Dependabot for action
pins, uv, Go and npm dependencies. Release caches are disabled so zizmor's
cache-poisoning audit passes.