diff --git a/README.md b/README.md
index f083429..cb1d45f 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ Other install methods: [one-line install script](#alternative-one-line-install-s
## π₯π₯π₯ News (Pacific Time)
+- July 20, 2026: **Bounded-I/O fixes and a configurable tool surface.** `tool_profile` selects how many tool schemas are sent each turn β `full` (default, nothing hidden) / `standard` (compact coding) / `research` / `orchestration` β to cut prompt tokens on small-context models, switchable with `/config tool_profile=standard`. Also fixes two bounded-I/O regressions: `SummarizeLargeFile` no longer "summarizes" its own chunk-failure markers (clean `Error` when map/reduce fails), and the DuckDuckGo parser no longer crashes on a valueless `class` attribute. [Details](docs/news.md)
- July 11, 2026: **Terminal tab title tracks the live task, plus a cross-turn fix for the Anthropic prompt cache.** [Details](docs/news.md)
- July 10, 2026 (**v3.5.85**): **REPL quality-of-life.** Live typing-time completion now works on *every* install β `prompt_toolkit` is a **core dependency** (no `[autosuggest]` extra needed, so `pip install` / `uv tool install` both get it out of the box); **`/model` gained a Tab-completion picker** (provider/model + a two-level LiteLLM tree, PR #166); and sessions now **autosave every turn** (atomic write + `fsync`) so a crash or power-loss mid-conversation stays recoverable via `/resume` β the loud daily/history save still happens once on exit. [Details](docs/news.md)
- July 9, 2026: **Official Docker image + one-command publish.** Pre-built image on Docker Hub (`docker pull chauncygu/cheetahclaws`) so you can run the Web UI without cloning; fixes a first-run `PermissionError` by pre-creating the `.cheetahclaws`/`workspace` dirs owned by the non-root user, makes the compose `image` overridable via `CHEETAH_IMAGE`, and adds `scripts/docker-publish.sh` (auto-reads the version, multi/single-arch). New docs sections: **Pull from Docker Hub** and **Interactive setup / CLI mode**. [Details](docs/news.md)
@@ -166,6 +167,7 @@ Claude Code is a powerful, production-grade AI coding assistant β but its sour
| Multi-provider | Anthropic Β· OpenAI Β· Gemini Β· Kimi Β· Qwen Β· Zhipu Β· DeepSeek Β· MiniMax Β· Ollama Β· LM Studio Β· Custom endpoint |
| Agent loop | Streaming API + automatic tool-use loop; the whole loop is in `agent.py` |
| 28 built-in tools | Read Β· Write Β· Edit Β· Bash Β· Glob Β· Grep Β· WebFetch Β· WebSearch Β· NotebookEdit Β· GetDiagnostics Β· Memory* Β· Agent/SendMessage Β· Skill Β· AskUserQuestion Β· Task* Β· SleepTimer Β· EnterPlanMode/ExitPlanMode Β· *(MCP + plugin tools auto-added)* |
+| Tool profiles | `tool_profile` trims the tool surface sent each turn to save prompt tokens: `full` (default, everything) Β· `standard` (compact coding) Β· `research` (web + documents) Β· `orchestration` (agents + tasks). Set with `/config tool_profile=standard`. [Guide](docs/guides/usage.md#tool-profiles-tool_profile) |
| MCP integration | Connect any MCP server (stdio/SSE/HTTP); tools auto-registered β see [extensions guide](docs/guides/extensions.md) |
| Plugin system | Install/enable/update plugins from git URLs or local paths; multi-scope; recommendation engine |
| Task management | `TaskCreate/Update/Get/List`, sequential IDs, dependency edges, persisted to `.cheetahclaws/tasks.json` |
diff --git a/cheetahclaws/agent.py b/cheetahclaws/agent.py
index d13a2fd..1c569bd 100644
--- a/cheetahclaws/agent.py
+++ b/cheetahclaws/agent.py
@@ -172,7 +172,9 @@ def run(
try:
active_profile = normalize_tool_profile(config.get("tool_profile"))
except ValueError as profile_error:
- active_profile = "standard"
+ # Fall back to the full surface rather than silently hiding tools
+ # because of a typo'd profile name.
+ active_profile = "full"
_log.warn("invalid_tool_profile",
session_id=session_id,
requested=config.get("tool_profile"),
diff --git a/cheetahclaws/config.py b/cheetahclaws/config.py
index 3d8f9ae..9ba9a16 100644
--- a/cheetahclaws/config.py
+++ b/cheetahclaws/config.py
@@ -36,10 +36,12 @@
"thinking_budget": 10000,
"custom_base_url": "", # for "custom" provider
"max_tool_output": 32000,
- # Tool schemas are part of every provider request. Keep the normal coding
- # loop small; opt into research/orchestration, or use ``full`` for every
- # legacy/plugin/MCP tool.
- "tool_profile": "standard", # standard | research | orchestration | full
+ # Tool schemas are part of every provider request. Default to the full
+ # surface so no capability (web, sub-agents, MCP, plugins) silently
+ # disappears for users who rely on it; opt into ``standard`` (compact
+ # coding-only), ``research``, or ``orchestration`` to shrink the surface
+ # and save prompt tokens when a session doesn't need everything.
+ "tool_profile": "full", # full | standard | research | orchestration
# Bound input work before a tool result reaches the generic output cap.
"tool_read_max_bytes": 256 * 1024,
"tool_read_scan_max_bytes": 2 * 1024 * 1024,
@@ -186,9 +188,10 @@ def load_config() -> dict:
saved_config = {}
except Exception:
pass
- # A missing profile consistently receives the compact default, including
- # old config files. Users who need every optional integration can opt in
- # explicitly with ``tool_profile=full``.
+ # A missing profile (including old config files) inherits the ``full``
+ # DEFAULTS value above, so upgrading never removes a capability a user
+ # already relied on. Shrinking the surface is an explicit opt-in via
+ # ``tool_profile=standard`` (or research/orchestration).
# Backward-compat: legacy single api_key β anthropic_api_key
if cfg.get("api_key") and not cfg.get("anthropic_api_key"):
cfg["anthropic_api_key"] = cfg.pop("api_key")
diff --git a/cheetahclaws/context.py b/cheetahclaws/context.py
index b63fa8c..5e5eb74 100644
--- a/cheetahclaws/context.py
+++ b/cheetahclaws/context.py
@@ -225,7 +225,7 @@ def _render_active_tool_surface(config: dict) -> str:
try:
profile = normalize_tool_profile(config.get("tool_profile"))
except ValueError:
- profile = "standard"
+ profile = "full"
disabled = config.get("disabled_tools") or ()
if not isinstance(disabled, (list, tuple, set, frozenset)):
disabled = ()
@@ -272,7 +272,7 @@ def _tmux_fragment_enabled(config: dict) -> bool:
try:
profile = normalize_tool_profile(config.get("tool_profile"))
except ValueError:
- profile = "standard"
+ profile = "full"
if profile != "full" or not _tmux_available():
return False
disabled = config.get("disabled_tools") or ()
diff --git a/cheetahclaws/tool_registry.py b/cheetahclaws/tool_registry.py
index 9176b54..b4ee617 100644
--- a/cheetahclaws/tool_registry.py
+++ b/cheetahclaws/tool_registry.py
@@ -49,8 +49,8 @@ class ToolDef:
"MemorySave", "MemoryDelete", "MemorySearch", "MemoryList", "MemoryVerify",
})
_RESEARCH_TOOLS = frozenset({
- "WebFetch", "WebSearch", "Research", "ReadPDF", "ReadImage",
- "ReadSpreadsheet", "SummarizeLargeFile",
+ "WebFetch", "WebSearch", "WebBrowse", "Research", "ReadPDF", "ReadImage",
+ "ReadSpreadsheet", "ReadEmail", "SummarizeLargeFile",
})
_ORCHESTRATION_TOOLS = frozenset({
"Agent", "SendMessage", "CheckAgentResult", "ListAgentTasks",
@@ -73,15 +73,15 @@ def _default_profiles(name: str) -> FrozenSet[str]:
def normalize_tool_profile(profile: str | None) -> str:
"""Return a validated profile name.
- Missing values intentionally select ``standard``: this is the safe and
- token-efficient default. A caller needing every legacy integration can
- set ``tool_profile=full`` explicitly.
+ Missing/empty values select ``full``: upgrading must never silently drop a
+ capability a user relied on. Callers that want a smaller, token-cheaper
+ surface opt in explicitly with ``standard``/``research``/``orchestration``.
"""
if profile is None:
- return "standard"
+ return "full"
if not isinstance(profile, str):
raise ValueError("Tool profile must be a string.")
- normalized = (profile or "standard").strip().lower()
+ normalized = (profile or "full").strip().lower()
if normalized not in _PROFILE_NAMES:
choices = ", ".join(sorted(_PROFILE_NAMES))
raise ValueError(f"Unknown tool profile '{profile}'. Choose one of: {choices}.")
diff --git a/cheetahclaws/tools/files.py b/cheetahclaws/tools/files.py
index 0642480..f89757a 100644
--- a/cheetahclaws/tools/files.py
+++ b/cheetahclaws/tools/files.py
@@ -752,6 +752,20 @@ def _summarize_chunk_via_llm(text: str, focus: str, config: dict,
return "".join(out).strip() or "[chunk-summarize: empty response]"
+def _is_failed_chunk(summary_text: str | None) -> bool:
+ """Whether a map/reduce result is a failure marker rather than real output.
+
+ ``_summarize_chunk_via_llm`` records failures as marker strings (never
+ ``None``): the error path returns ``"[chunk-summarize error: ...]"`` and an
+ empty model response returns ``"[chunk-summarize: empty response]"`` β both
+ begin with ``"[chunk-summarize"``. Detecting them keeps failure text out of
+ the reduce prompt (otherwise the reduce model 'summarizes' the errors) and
+ lets the caller report degraded coverage instead of a confident-looking
+ summary of nothing.
+ """
+ return summary_text is None or summary_text.startswith("[chunk-summarize")
+
+
def _plan_chunks(content: str, model_ctx: int) -> list[str]:
"""Split `content` into N chunks each fitting within
`(model_ctx - reserved) / chars_per_token` chars, with a small overlap
@@ -822,6 +836,11 @@ def _summarize_large_file(params: dict, config: dict) -> str:
if n_chunks == 1:
summary = _summarize_chunk_via_llm(chunks[0], focus, config, mode="single")
+ if _is_failed_chunk(summary):
+ return (
+ f"Error: summarizing `{p.name}` failed β {summary} "
+ "(model/provider issue). Retry, or summarize a narrower range."
+ )
return (
f"Summary of `{p.name}` (single-shot, ~{n_tokens_est:,} tokens "
f"in {n_chars:,} chars; model context {model_ctx:,}):\n\n"
@@ -856,11 +875,15 @@ def _do_chunk(i_text):
merged_parts: list[str] = []
merged_chars = 0
included_chunks = 0
+ failed_chunks = 0
last_chunk_clipped = False
for i, summary_text in enumerate(chunk_summaries):
# Skip a failed chunk (do not abort the whole merge on the first one),
- # and stop only once the reduce-input budget is exhausted.
- if summary_text is None:
+ # and stop only once the reduce-input budget is exhausted. A map
+ # failure is an error-marker string, not None, so test the marker β
+ # otherwise the reduce model would 'summarize' the error text.
+ if _is_failed_chunk(summary_text):
+ failed_chunks += 1
continue
if merged_chars >= reduce_cap:
break
@@ -876,25 +899,53 @@ def _do_chunk(i_text):
# miss it).
last_chunk_clipped = True
break
+
+ # Every chunk failed β nothing real to reduce. Reducing an all-error (or
+ # empty) aggregate would return a confident summary of failures, so surface
+ # a clean error instead.
+ if included_chunks == 0:
+ return (
+ f"Error: summarizing `{p.name}` failed β all {n_chunks} chunk "
+ "summaries errored (model/provider issue). Retry, or summarize a "
+ "narrower range."
+ )
+
merged_input = "".join(merged_parts)
final = _summarize_chunk_via_llm(merged_input, focus, config, mode="reduce")
+ if _is_failed_chunk(final):
+ return (
+ f"Error: summarizing `{p.name}` failed at the reduce stage β "
+ f"{final}. Summarize a narrower range, or raise "
+ "summarize_reduce_max_input_chars."
+ )
# Report the chunks actually merged rather than the total, and warn when
- # the reduce budget dropped later chunk summaries β or clipped the last
- # included one β so the caller is not told the whole file was covered when
- # it was not.
+ # coverage is incomplete β because some chunk summaries failed, because the
+ # reduce budget dropped later ones, or because the last included one was
+ # clipped β so the caller is never told the whole file was covered when it
+ # was not. Chunks neither merged nor failed were dropped at the reduce cap.
+ cap_dropped = n_chunks - included_chunks - failed_chunks
if included_chunks < n_chunks or last_chunk_clipped:
- if included_chunks < n_chunks:
- coverage = f"{included_chunks}/{n_chunks} chunks merged"
- detail = f"merged only {included_chunks} of {n_chunks} chunk summaries"
- else:
- coverage = f"{n_chunks} chunks, last clipped"
- detail = f"clipped the last of {n_chunks} chunk summaries"
+ reasons = []
+ if failed_chunks:
+ reasons.append(f"{failed_chunks} failed to summarize")
+ if cap_dropped:
+ reasons.append(
+ f"{cap_dropped} dropped at the {reduce_cap:,}-char reduce cap"
+ )
+ if last_chunk_clipped:
+ reasons.append("the last included summary was clipped")
+ coverage = f"{included_chunks}/{n_chunks} chunks merged"
+ detail = "; ".join(reasons) if reasons else "coverage incomplete"
+ hint = (
+ "raise summarize_reduce_max_input_chars or summarize a narrower "
+ "range for full coverage"
+ if (cap_dropped or last_chunk_clipped)
+ else "retry, or summarize a narrower range"
+ )
coverage_notice = (
- f"\n\n[... reduce stage {detail} at its {reduce_cap:,}-char input "
- "cap; this summary may omit the file's later sections β raise "
- "summarize_reduce_max_input_chars or summarize a narrower range "
- "for full coverage ...]"
+ f"\n\n[... incomplete coverage: {detail}; this summary may omit "
+ f"parts of the file β {hint} ...]"
)
else:
coverage = f"{n_chunks} chunks"
diff --git a/cheetahclaws/tools/web.py b/cheetahclaws/tools/web.py
index 310de9f..f64d906 100644
--- a/cheetahclaws/tools/web.py
+++ b/cheetahclaws/tools/web.py
@@ -73,7 +73,10 @@ def __init__(self, result_cap: int = 8, field_cap: int = 4_000):
@staticmethod
def _classes(attrs) -> set[str]:
- return set(dict(attrs).get("class", "").split())
+ # A valueless attribute (e.g. ``
``) yields ('class', None),
+ # so ``.get("class", "")`` returns None, not the default β guard with
+ # ``or ""`` so a single bare attribute can't crash the whole parse.
+ return set((dict(attrs).get("class") or "").split())
def _finish_current(self) -> None:
if self._current and (self._current["title"] or self._current["link"]):
diff --git a/cheetahclaws/web/static/js/settings.js b/cheetahclaws/web/static/js/settings.js
index 5585f0f..08ef08a 100644
--- a/cheetahclaws/web/static/js/settings.js
+++ b/cheetahclaws/web/static/js/settings.js
@@ -100,7 +100,7 @@ Object.assign(ChatApp.prototype, {
_renderConfig(cfg) {
document.getElementById('sp-current-model').textContent = cfg.model || '(not set)';
document.getElementById('sp-permission').value = cfg.permission_mode || 'auto';
- document.getElementById('sp-tool-profile').value = cfg.tool_profile || 'standard';
+ document.getElementById('sp-tool-profile').value = cfg.tool_profile || 'full';
document.getElementById('sp-thinking').className =
'sp-toggle' + (cfg.thinking ? ' on' : '');
document.getElementById('sp-verbose').className =
diff --git a/docs/guides/usage.md b/docs/guides/usage.md
index 5bdf4be..6bec7fa 100644
--- a/docs/guides/usage.md
+++ b/docs/guides/usage.md
@@ -355,3 +355,49 @@ cheetahclaws --model custom/deepseek-ai/deepseek-v4-pro
- **Other:** `owl`
+
+---
+
+## Tool Profiles (`tool_profile`)
+
+Every model request carries the JSON schemas of the tools the agent may call.
+The **tool profile** selects how much of that surface is advertised on each
+turn β a smaller surface means fewer prompt tokens and less for the model to
+choose between, which helps on small-context or weaker local models.
+
+The default is **`full`**, so out of the box **nothing is hidden** β web,
+sub-agents, MCP, plugins, and every built-in tool are available. Shrinking the
+surface is always an explicit opt-in.
+
+| Profile | Tools advertised | Use when |
+|---------|------------------|----------|
+| `full` *(default)* | Everything registered β coding, web/documents, multi-agent + tasks, plan mode, email, MCP, and plugins | You want the complete surface (default behavior). |
+| `standard` | Compact coding set only: `Read`, `Write`, `Edit`, `Bash`, `Glob`, `Grep`, `GetDiagnostics`, `NotebookEdit`, `AskUserQuestion`, and the `Memory*` tools | Plain coding sessions; smallest prompt / best for small-context models. |
+| `research` | `standard` **+** `WebFetch`, `WebSearch`, `WebBrowse`, `Research`, `ReadPDF`, `ReadImage`, `ReadSpreadsheet`, `ReadEmail`, `SummarizeLargeFile` | Web + document research without multi-agent overhead. |
+| `orchestration` | `standard` **+** `Agent`, `SendMessage`, `CheckAgentResult`, `ListAgentTasks`, `ListAgentTypes`, `Skill`, `SkillList`, `TaskCreate`/`TaskUpdate`/`TaskGet`/`TaskList`, `EnterPlanMode`, `ExitPlanMode`, `SleepTimer` | Multi-agent workflows, task lists, and plan mode. |
+
+Every non-`full` profile still includes the `standard` coding tools, so you
+never lose Read/Write/Edit/Bash by narrowing the surface.
+
+**Set it:**
+
+```bash
+# In a CLI session (persists to ~/.cheetahclaws/config.json):
+/config tool_profile=standard
+
+# Or edit ~/.cheetahclaws/config.json directly:
+# "tool_profile": "research"
+```
+
+In the **Web UI**, use the *Tool Surface* dropdown in Settings, or
+`PATCH /api/config` with `{"config": {"tool_profile": "research"}}`. An
+unknown value is rejected (`400` on the API, an error on the CLI).
+
+> **Notes**
+> - A config that predates this setting (or omits it) inherits `full`, so
+> upgrading never silently removes a capability you relied on.
+> - Sub-agents inherit the parent session's `tool_profile`. If you rely on
+> `researcher` sub-agents reaching the web, keep the parent on `full` (the
+> default) or `research`.
+> - The profile only changes what is **advertised** per turn; it does not
+> uninstall anything. Switch back to `full` at any time.
diff --git a/docs/guides/web-ui.md b/docs/guides/web-ui.md
index 109f95c..416ba8f 100644
--- a/docs/guides/web-ui.md
+++ b/docs/guides/web-ui.md
@@ -286,7 +286,7 @@ All `/api/*` routes other than `/api/auth/*` and the ops endpoints require a val
| Route | Method | Purpose |
|-------|--------|---------|
| `/api/config?sid=...` | GET | Read safe config keys for a session |
-| `/api/config` | PATCH | `{session_id, config: {key:value, ...}}` β writable keys: `model`, `permission_mode`, `verbose`, `thinking`, `thinking_budget`, `max_tokens`, plus per-provider API keys (session-only, not persisted) |
+| `/api/config` | PATCH | `{session_id, config: {key:value, ...}}` β writable keys: `model`, `permission_mode`, `verbose`, `thinking`, `thinking_budget`, `max_tokens`, `tool_profile` (`full`/`standard`/`research`/`orchestration`; an invalid value returns `400`), plus per-provider API keys (session-only, not persisted) |
| `/api/models` | GET | `{providers: [{provider, models, context_limit, needs_api_key, has_api_key}, ...]}` |
### Ops
diff --git a/docs/news.md b/docs/news.md
index 6d058a5..01c2fde 100644
--- a/docs/news.md
+++ b/docs/news.md
@@ -3,6 +3,7 @@
## π₯π₯π₯ News (Pacific Time)
+- July 20, 2026: **Bounded-I/O fixes and a configurable tool surface.** Two things. **(1) `tool_profile` config.** Every model request ships the JSON schemas of the tools the agent may call; the new `tool_profile` selects how much of that surface is advertised each turn β a smaller surface means fewer prompt tokens and less for a weak or small-context model to choose between. Four values: **`full`** (default β everything registered: coding, web/documents, multi-agent + tasks, plan mode, email, MCP, and plugins), **`standard`** (compact coding set only β `Read`/`Write`/`Edit`/`Bash`/`Glob`/`Grep`/`GetDiagnostics`/`NotebookEdit`/`AskUserQuestion` and the `Memory*` tools), **`research`** (`standard` **+** `WebFetch`/`WebSearch`/`WebBrowse`/`Research`/`ReadPDF`/`ReadImage`/`ReadSpreadsheet`/`ReadEmail`/`SummarizeLargeFile`), and **`orchestration`** (`standard` **+** `Agent`/`SendMessage`/`CheckAgentResult`/`ListAgentTasks`/`ListAgentTypes`/`Skill`/`SkillList`/`Task*`/`EnterPlanMode`/`ExitPlanMode`/`SleepTimer`). Every non-`full` profile still includes the `standard` coding tools, so narrowing the surface never costs you Read/Write/Edit/Bash. Switch with `/config tool_profile=standard`, the Web UI **Tool Surface** dropdown, or `PATCH /api/config` (an unknown value is rejected β `400` on the API, an error on the CLI). The default is `full` and a config that omits the key **inherits `full`**, so upgrading never silently drops a capability a user relied on; sub-agents inherit the parent session's profile. The selected profile is applied consistently across the provider tool schemas, execution dispatch, the system prompt's *Active Tool Surface* block, `/config` validation, the Web API, and the read-only tool-result cache key. **(2) Two bounded-I/O regression fixes.** `SummarizeLargeFile` recorded a failed map chunk as an error-marker *string* (`[chunk-summarize error: β¦]`), not `None`, so the reduce stage neither skipped it nor warned β a file whose chunks all failed came back as a confident "summary" of the error text. It now detects those markers, keeps them out of the reduce prompt, warns on incomplete coverage (distinguishing failed chunks from reduce-cap drops), and returns a clean `Error` when every chunk fails, when the reduce call fails, or when a single-shot summary fails. Separately, the DuckDuckGo result parser called `dict(attrs).get("class", "").split()`, which returns `None` (crashing the *entire* search) on a valueless `class` attribute such as `
`; it is now guarded with `or ""`. Adds regression tests to `tests/test_summarize_large_file.py` and `tests/test_bounded_tool_io.py` and regenerates the golden prompt fixture (made order-independent under the `full` default); full suite green (**2570 passed, 5 skipped**). New docs: a **Tool Profiles** section in [docs/guides/usage.md](guides/usage.md#tool-profiles-tool_profile), and the `/api/config` writable-keys list in [docs/guides/web-ui.md](guides/web-ui.md) now includes `tool_profile`. **Not a breaking change** β the default tool surface is unchanged (`full`), and the summarize/parser fixes only affect failure paths.
- July 11, 2026: **Claude-Code-style terminal tab title, and a cross-turn fix for the Anthropic prompt cache.** Two changes. **(1) Animated terminal tab title.** The terminal window/tab title now tracks the live task instead of showing the shell default: a pulsing glyph + the user's current prompt while the model works (`βΆ β³ β» CheetahClaws β
`), and a static badge when idle (`β CheetahClaws β `). It is emitted as an **OSC 0** escape sequence in lock-step with the existing spinner thread (no extra thread), de-duped so the tab is only rewritten when the glyph or task actually changes, and **auto-disabled on non-TTYs / pipes / CI / `TERM=dumb`** so escape bytes never leak into redirected output. Toggle with `/config terminal_title=false`. It works out of the box in **iTerm2 / Terminal.app / most terminals**, which show OSC titles by default. **VS Code / Cursor / Windsurf hide program-set titles by default** β the tab renders `${process}` and the program title lands in the ignored `${sequence}` variable β so on **first launch inside one of those editors** CheetahClaws configures `terminal.integrated.tabs.title` for the user, exactly once (a `~/.cheetahclaws/vscode_terminal_title.done` marker prevents re-nagging). That edit is deliberately conservative: it **backs up settings.json**, inserts the key **textually so JSONC comments and formatting survive**, then **re-parses the result and aborts if any key would be dropped or the file would not parse**, and it **never overwrites a value the user already set**. The new **`/terminal-setup`** command re-runs it on demand and reports "nothing to do" in terminals that already show titles natively. Implemented in `ui/render.py` (OSC-0 title module + a hook in the spinner loop) and `ui/vscode_setup.py` (the safe settings editor), wired at REPL start and per-turn in `cli.py`, with the `terminal_title` config default (on) and the `/terminal-setup` command. **(2) Prompt-cache prefix fix.** The Anthropic `cache_control` breakpoint was placed at the end of the *whole* system string. Because CheetahClaws rebuilds the system prompt each turn and its `# Environment` block embeds a live `git status`, editing files between turns changed that block and invalidated the entire cached system prefix β dragging the large, static base prompt down with it. (Within-turn caching, the tool loop's 5β50 back-to-back calls, was unaffected: the system prompt is frozen for the turn.) The breakpoint now sits on the **stable span *before* the environment block**; the two system text blocks concatenate byte-for-byte, so the model sees identical content β purely a caching-boundary change β and the volatile tail rides along uncached. Adds 2 tests to `tests/test_prompt_cache.py`. Full suite green (**2508 passed, 8 skipped**; the 2 pre-existing macOS-only failures are unchanged). **Not a breaking change** β the terminal title is additive and disable-able, and the cache change is transparent to output. See [docs/guides/reference.md](guides/reference.md#terminal-tab-title) Β· [docs/guides/features.md](guides/features.md).
- July 10, 2026 (**v3.5.85**): **REPL quality-of-life β completion works on every install, `/model` gets a Tab picker, and sessions autosave every turn.** Three related changes. **(1) `prompt_toolkit` is now a core dependency.** The typing-time completion menu (slash commands, subcommands, the new `/model` picker) is driven by `prompt_toolkit`, but it was an *optional* extra (`[autosuggest]`), so only environments that happened to already have it β e.g. a fat Anaconda base β got the rich experience; a clean `pip install cheetahclaws` or an isolated `uv tool install cheetahclaws` fell back to bare readline (Tab-only, no live dropdown). Since the interactive REPL *is* the product, `prompt_toolkit>=3.0.43` moved from `[project.optional-dependencies].autosuggest` into `[project].dependencies` (and into the core block of `requirements.txt`), so **every** install method now gets live completion out of the box. The readline fallback path in `ui/input.py` is untouched β it still covers any environment where `prompt_toolkit` genuinely can't be installed. The `[autosuggest]` extra is kept as a harmless no-op alias for backward compatibility. **(2) `/model` dynamic completion (PR #166).** Typing `/model ` and pressing Tab now offers a `provider/model` picker β one default model per configured provider plus a two-level `litellm//` tree you can drill into β instead of forcing you to remember and hand-type long model strings. Completions are context-aware (`/model openai/g` narrows to OpenAI models; `litellm/openrouter/` expands that backend). Wired into both the `prompt_toolkit` and readline completers via a new dynamic-completions registry; the `litellm` provider also gained a small curated starting model list to seed the picker (any valid LiteLLM string still works regardless of the list). **(3) Per-turn crash-safe session autosave.** Previously the live transcript was written to `session_latest.json` only on a clean exit / `Ctrl+C` / budget-pause, so a power-loss or hard kill mid-conversation lost everything since the session started (file edits and explicit `/remember` writes were already immediate, so only the transcript was at risk). A new `autosave_session()` (in `commands/session.py`) is now called at the **end of every turn** in `run_query`: it rewrites *only* `session_latest.json` via a temp file + `flush()` + `os.fsync()` + atomic `os.replace()` (durable against a power cut, and a crash can never leave a half-written file), stays silent (no console spam), reuses one stable `session_id` so each turn overwrites the same file, and is best-effort (never raises into the REPL). It deliberately does **not** write a `daily/` copy, append to `history.json`, or touch SQLite β those remain exit-time finalization steps in `save_latest()`, which still prints the loud `Session saved β β¦` paths on quit. Net effect: `/resume` now recovers a conversation after a crash, not just after a clean exit. See [docs/PR/resume_Feature.md](PR/resume_Feature.md) Β· [docs/guides/reference.md](guides/reference.md) (`/model`, `/resume`) Β· [docs/guides/features.md](guides/features.md) (Session persistence). Version bumped `3.5.84` β **`3.5.85`** in `pyproject.toml`. **Not a breaking change** β no runtime behavior changes for existing installs beyond the always-on completion and autosave; publishing the new release (git tag + PyPI) is what lets users pull the `prompt_toolkit` core-dependency change via `pip install -U` / `uv tool upgrade`.
- July 9, 2026: **Official Docker image on Docker Hub + a one-command publish script, plus a first-run permission fix.** You can now run CheetahClaws without cloning the source: `docker pull chauncygu/cheetahclaws` and `docker run --rm -p 8080:8080 chauncygu/cheetahclaws` brings up the Web UI. Three parts shipped. **(1) First-run `PermissionError` fixed.** The image runs as a non-root `cheetah` user (uid 1000), but the `Dockerfile` declared `VOLUME ["/home/cheetah/.cheetahclaws"]` and set `WORKDIR /workspace` *without* pre-creating those directories, so Docker created them root-owned β and the very first launch died with `PermissionError: [Errno 13] ... '/home/cheetah/.cheetahclaws/sessions'` when `config.load_config` tried to `mkdir` the sessions dir. The `Dockerfile` now `mkdir -p`s both `.cheetahclaws` and `/workspace` and `chown`s them to `cheetah` **before** `USER cheetah`, so the anonymous volume and workspace inherit correct ownership and startup succeeds with no host mount required. **(2) Compose image is overridable.** `docker-compose.yml` still builds and tags `cheetahclaws:latest` locally by default, but the `image:` key is now `${CHEETAH_IMAGE:-cheetahclaws:latest}` β set `CHEETAH_IMAGE=chauncygu/cheetahclaws:latest docker compose up -d` to run the published image and skip the build. **(3) `scripts/docker-publish.sh`** reads the version from `pyproject.toml`, tags both `:` and `:latest`, and pushes; multi-arch (`linux/amd64,linux/arm64`) via buildx by default, or `SINGLE_ARCH=1` for a host-arch-only `docker build`+`push`, with `DRY_RUN=1` to preview and `PUSH_LATEST=0` to skip the floating tag. **Docs:** [docs/guides/docker.md](guides/docker.md) gains a **Pull from Docker Hub** section (pull/run, compose override, maintainer publish) and an **Interactive setup / CLI mode** section explaining that the default `--web` image configures the model in the Web UI Settings panel, and how to get the `pip`-style first-run wizard instead (`docker run -it β¦ --setup`, with the required `-v β¦:/home/cheetah/.cheetahclaws` config-persistence mount). Current published tags: `chauncygu/cheetahclaws:latest` and `:3.5.84` (amd64). **Not a breaking change** β no source behavior changes; native `pip install cheetahclaws` is unaffected.
diff --git a/tests/e2e_prompt_regression.py b/tests/e2e_prompt_regression.py
index 3f192aa..1686209 100644
--- a/tests/e2e_prompt_regression.py
+++ b/tests/e2e_prompt_regression.py
@@ -44,6 +44,10 @@ def _mask(prompt: str) -> str:
def _generate_masked_prompt(tmp_path, monkeypatch) -> str:
"""Build a prompt with all optional blocks forced off, then mask dynamics."""
+ # The default profile is ``full``, whose surface enumerates the registry.
+ # Import the built-in tools so that enumeration is the complete, stable set
+ # regardless of what else a suite happened to import first.
+ import cheetahclaws.tools # noqa: F401
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(_context, "get_memory_context", lambda: "")
monkeypatch.setattr(_context, "get_git_info", lambda: "")
@@ -83,6 +87,8 @@ def _regenerate() -> None:
import tempfile
from unittest import mock
+ import cheetahclaws.tools # noqa: F401 β populate the full-profile registry
+
# Manually replicate the monkeypatches the pytest fixture applies.
with tempfile.TemporaryDirectory() as tmp:
with mock.patch.object(_context, "get_memory_context", return_value=""), \
diff --git a/tests/fixtures/golden_default_prompt.txt b/tests/fixtures/golden_default_prompt.txt
index 921e5c5..2175898 100644
--- a/tests/fixtures/golden_default_prompt.txt
+++ b/tests/fixtures/golden_default_prompt.txt
@@ -58,9 +58,10 @@ Return control to the user when:
- When in doubt about reversibility, ask.
# Active Tool Surface
-- Profile: `standard`
-- Enabled tools: `AskUserQuestion`, `Bash`, `Edit`, `GetDiagnostics`, `Glob`, `Grep`, `MemoryDelete`, `MemoryList`, `MemorySave`, `MemorySearch`, `MemoryVerify`, `NotebookEdit`, `Read`, `Write`
+- Profile: `full`
+- Enabled tools: `Agent`, `AskUserQuestion`, `Bash`, `CheckAgentResult`, `Edit`, `EnterPlanMode`, `ExitPlanMode`, `GetDiagnostics`, `Glob`, `Grep`, `ListAgentTasks`, `ListAgentTypes`, `MemoryDelete`, `MemoryList`, `MemorySave`, `MemorySearch`, `MemoryVerify`, `NotebookEdit`, `Read`, `ReadEmail`, `ReadImage`, `ReadPDF`, `ReadSpreadsheet`, `Research`, `SendEmail`, `SendMessage`, `Skill`, `SkillList`, `SleepTimer`, `SummarizeLargeFile`, `TaskCreate`, `TaskGet`, `TaskList`, `TaskUpdate`, `WebBrowse`, `WebFetch`, `WebSearch`, `Write`
- Call only the enabled tools above; a tool mentioned elsewhere is not available unless it appears in this list.
+- For complex or multi-file work, use `EnterPlanMode` before making changes, then finish with `ExitPlanMode`.
# Environment
- Current date:
@@ -123,10 +124,8 @@ These commands the **user** can invoke at the REPL prompt β they are NOT tools
- `/thinking` β Toggle extended thinking
- `/trade` β Alias for /trading
- `/trading` `[analyze | review | verify | price | indicators | discover | rank | factors | anomaly | monitor | paper | calibration | watch | scan | manage | optimize | ml | backtest | walkforward | status | history | memory]` β AI trading agent β analyze, discover (auto-find candidates from insider clusters / earnings beats / sector rotation / factors), rank, anomaly detection, market monitor with bridge alerts, paper-trade tracker, calibration, position review, managed portfolios ($Xβ1-week PnL), MV optimization, ML stacker, alt-data (insider / sentiment / trends), walk-forward backtest
-- `/tts` `[status]` β AI voice generator: text β any style β audio file
- `/verbose` β Toggle verbose output
- `/video` `[status | niches]` β AI video factory: storyβvoiceβimagesβmp4
-- `/voice` `[lang | status | device]` β Voice input (record β STT)
- `/web` `[status | --no-auth | --host]` β Start the web terminal / chat UI in background
- `/wechat` `[stop | status]` β WeChat bridge (iLink Bot API)
- `/worker` β Auto-implement pending tasks
diff --git a/tests/test_agent_tool_profiles.py b/tests/test_agent_tool_profiles.py
index 92c8898..0857056 100644
--- a/tests/test_agent_tool_profiles.py
+++ b/tests/test_agent_tool_profiles.py
@@ -35,7 +35,7 @@ def fake_stream(**kwargs):
monkeypatch.setattr(agent, "stream", fake_stream)
- list(run("hello", AgentState(), _config(), "system"))
+ list(run("hello", AgentState(), _config(tool_profile="standard"), "system"))
names = {schema["name"] for schema in seen_schemas[0]}
assert "Read" in names
@@ -75,7 +75,7 @@ def fake_stream(**_kwargs):
monkeypatch.setattr(agent, "stream", fake_stream)
state = AgentState()
- list(run("hello", state, _config(), "system"))
+ list(run("hello", state, _config(tool_profile="standard"), "system"))
tool_results = [m for m in state.messages if m.get("role") == "tool"]
assert len(tool_results) == 1
diff --git a/tests/test_bounded_tool_io.py b/tests/test_bounded_tool_io.py
index bcdf12a..b31cd1a 100644
--- a/tests/test_bounded_tool_io.py
+++ b/tests/test_bounded_tool_io.py
@@ -193,6 +193,22 @@ def iter_bytes(self, **_kwargs):
assert "WebSearch stopped after 128 response bytes" in result
+def test_ddg_parser_survives_valueless_class_attribute():
+ """A bare ``class`` attribute (value None) must not crash the parser and
+ kill the whole search β regression for `dict(attrs).get('class','').split()`
+ returning None on a valueless attribute."""
+ from cheetahclaws.tools.web import _DuckDuckGoResultParser
+
+ parser = _DuckDuckGoResultParser()
+ parser.feed(
+ ''
+ )
+ assert parser.results, "valueless class attribute killed result parsing"
+ assert parser.results[0]["link"] == "https://ok.test"
+
+
def test_webfetch_follows_redirects_with_one_shared_elapsed_budget(monkeypatch):
calls = []
diff --git a/tests/test_summarize_large_file.py b/tests/test_summarize_large_file.py
index d506153..ff184e7 100644
--- a/tests/test_summarize_large_file.py
+++ b/tests/test_summarize_large_file.py
@@ -235,6 +235,101 @@ def fake_summarize_chunk(text, focus, config, mode="single", **kw):
assert f"{len(map_calls)} chunks" in out
+def test_summarize_all_chunks_failing_returns_clean_error(tmp_path, monkeypatch):
+ """Every map chunk failing must yield an Error, not a confident summary of
+ the failure markers. Failures are error-marker strings, not None."""
+ import cheetahclaws.tools.files as _f
+ monkeypatch.setattr(
+ "cheetahclaws.compaction.get_context_limit", lambda m: 32768,
+ )
+ p = tmp_path / "huge.txt"
+ p.write_text("X" * (200 * 1024), encoding="utf-8")
+
+ reduce_calls = []
+
+ def failing_chunk(text, focus, config, mode="single", **kw):
+ if mode == "reduce":
+ reduce_calls.append(text)
+ return "reduced!"
+ # Simulate the real failure marker emitted by _summarize_chunk_via_llm.
+ return "[chunk-summarize error: RuntimeError: provider down]"
+
+ monkeypatch.setattr(_f, "_summarize_chunk_via_llm", failing_chunk)
+ out = _summarize_large_file({"file_path": str(p)}, {"model": "test-32k-model"})
+
+ assert out.startswith("Error")
+ assert "all" in out and "errored" in out
+ # The reduce stage must never run on all-error input.
+ assert reduce_calls == []
+
+
+def test_summarize_partial_chunk_failures_warn_and_skip_markers(tmp_path, monkeypatch):
+ """A minority of failing chunks: their error text is kept out of the reduce
+ input, and the summary carries an incomplete-coverage warning."""
+ import cheetahclaws.tools.files as _f
+ monkeypatch.setattr(
+ "cheetahclaws.compaction.get_context_limit", lambda m: 32768,
+ )
+ p = tmp_path / "huge.txt"
+ p.write_text("X" * (200 * 1024), encoding="utf-8")
+
+ reduce_input = {}
+
+ def mixed_chunk(text, focus, config, mode="single", **kw):
+ if mode == "reduce":
+ reduce_input["text"] = text
+ return "merged summary"
+ # First chunk fails; the rest succeed.
+ if kw.get("chunk_idx") == 1:
+ return "[chunk-summarize: empty response]"
+ return f"chunk-{kw.get('chunk_idx')}-ok"
+
+ monkeypatch.setattr(_f, "_summarize_chunk_via_llm", mixed_chunk)
+ out = _summarize_large_file({"file_path": str(p)}, {"model": "test-32k-model"})
+
+ assert not out.startswith("Error")
+ assert "chunk-summarize" not in reduce_input["text"] # marker never merged
+ assert "incomplete coverage" in out
+ assert "failed to summarize" in out
+
+
+def test_summarize_reduce_stage_failure_returns_clean_error(tmp_path, monkeypatch):
+ """If the reduce call itself fails, surface an Error instead of returning a
+ 'summary' that is just the reduce error marker."""
+ import cheetahclaws.tools.files as _f
+ monkeypatch.setattr(
+ "cheetahclaws.compaction.get_context_limit", lambda m: 32768,
+ )
+ p = tmp_path / "huge.txt"
+ p.write_text("X" * (200 * 1024), encoding="utf-8")
+
+ def reduce_fails(text, focus, config, mode="single", **kw):
+ if mode == "reduce":
+ return "[chunk-summarize error: TimeoutError: reduce timed out]"
+ return f"chunk-{kw.get('chunk_idx')}-ok"
+
+ monkeypatch.setattr(_f, "_summarize_chunk_via_llm", reduce_fails)
+ out = _summarize_large_file({"file_path": str(p)}, {"model": "test-32k-model"})
+
+ assert out.startswith("Error")
+ assert "reduce stage" in out
+
+
+def test_summarize_single_shot_failure_returns_clean_error(tmp_path, monkeypatch):
+ """A single-shot failure marker must be reported as an Error, not a summary."""
+ import cheetahclaws.tools.files as _f
+
+ def fail_single(text, focus, config, mode="single", **kw):
+ return "[chunk-summarize error: ValueError: boom]"
+
+ monkeypatch.setattr(_f, "_summarize_chunk_via_llm", fail_single)
+ p = tmp_path / "small.txt"
+ p.write_text("tiny content", encoding="utf-8")
+ out = _summarize_large_file({"file_path": str(p)}, {"model": "claude-opus-4-7"})
+
+ assert out.startswith("Error")
+
+
def test_summarize_missing_file_error(monkeypatch):
"""Missing file β Error: ... returned, NOT a crash."""
out = _summarize_large_file(
diff --git a/tests/test_tool_profile_config.py b/tests/test_tool_profile_config.py
index 04042e5..be89517 100644
--- a/tests/test_tool_profile_config.py
+++ b/tests/test_tool_profile_config.py
@@ -11,14 +11,16 @@
from cheetahclaws.tool_registry import normalize_tool_profile
-def test_legacy_saved_config_uses_compact_default_tool_surface(monkeypatch, tmp_path):
+def test_legacy_saved_config_keeps_the_full_tool_surface(monkeypatch, tmp_path):
+ # A config predating tool profiles must keep every capability it had before
+ # (web, sub-agents, MCP, ...): upgrading never silently removes tools.
config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"model": "test"}), encoding="utf-8")
monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path)
monkeypatch.setattr(config_module, "CONFIG_FILE", config_file)
monkeypatch.setattr(config_module, "SESSIONS_DIR", tmp_path / "sessions")
- assert config_module.load_config()["tool_profile"] == "standard"
+ assert config_module.load_config()["tool_profile"] == "full"
def test_saved_full_profile_remains_an_explicit_opt_in(monkeypatch, tmp_path):
@@ -31,11 +33,22 @@ def test_saved_full_profile_remains_an_explicit_opt_in(monkeypatch, tmp_path):
assert config_module.load_config()["tool_profile"] == "full"
-def test_fresh_config_uses_compact_standard_profile(monkeypatch, tmp_path):
+def test_fresh_config_uses_full_profile_by_default(monkeypatch, tmp_path):
monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path)
monkeypatch.setattr(config_module, "CONFIG_FILE", tmp_path / "missing.json")
monkeypatch.setattr(config_module, "SESSIONS_DIR", tmp_path / "sessions")
+ assert config_module.load_config()["tool_profile"] == "full"
+
+
+def test_standard_profile_is_an_explicit_opt_in(monkeypatch, tmp_path):
+ # Shrinking the surface to the compact coding set is opt-in, never implicit.
+ config_file = tmp_path / "config.json"
+ config_file.write_text('{"tool_profile": "standard"}', encoding="utf-8")
+ monkeypatch.setattr(config_module, "CONFIG_DIR", tmp_path)
+ monkeypatch.setattr(config_module, "CONFIG_FILE", config_file)
+ monkeypatch.setattr(config_module, "SESSIONS_DIR", tmp_path / "sessions")
+
assert config_module.load_config()["tool_profile"] == "standard"
@@ -78,7 +91,7 @@ def test_web_settings_expose_and_render_the_tool_profile_selector():
assert 'id="sp-tool-profile"' in markup
assert "updateConfig('tool_profile', this.value)" in markup
- assert "sp-tool-profile').value = cfg.tool_profile || 'standard'" in script
+ assert "sp-tool-profile').value = cfg.tool_profile || 'full'" in script
def test_terminal_config_rejects_invalid_tool_profile(monkeypatch):
diff --git a/tests/test_web_api.py b/tests/test_web_api.py
index d8536bc..fc87d9a 100644
--- a/tests/test_web_api.py
+++ b/tests/test_web_api.py
@@ -238,7 +238,7 @@ def test_web_config_gets_and_updates_tool_profile(server_url):
before = c.get(f"/api/config?sid={sid}")
assert before.status_code == 200
- assert before.json()["tool_profile"] == "standard"
+ assert before.json()["tool_profile"] == "full"
updated = c.patch(
"/api/config", json={"session_id": sid, "config": {"tool_profile": "research"}},