Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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` |
Expand Down
4 changes: 3 additions & 1 deletion cheetahclaws/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
17 changes: 10 additions & 7 deletions cheetahclaws/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down
4 changes: 2 additions & 2 deletions cheetahclaws/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ()
Expand Down Expand Up @@ -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 ()
Expand Down
14 changes: 7 additions & 7 deletions cheetahclaws/tool_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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}.")
Expand Down
81 changes: 66 additions & 15 deletions cheetahclaws/tools/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion cheetahclaws/tools/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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. ``<div class>``) 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"]):
Expand Down
2 changes: 1 addition & 1 deletion cheetahclaws/web/static/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
46 changes: 46 additions & 0 deletions docs/guides/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,3 +355,49 @@ cheetahclaws --model custom/deepseek-ai/deepseek-v4-pro
- **Other:** `owl`

</details>

---

## 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.
2 changes: 1 addition & 1 deletion docs/guides/web-ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading