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( + '

' + 'Title styled

' + '
Snippet
' + ) + 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"}},