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
47 changes: 44 additions & 3 deletions cheetahclaws/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
from dataclasses import dataclass, field
from typing import Generator

from cheetahclaws.tool_registry import get_tool_schemas
from cheetahclaws.tool_registry import (
get_active_tool_names,
get_tool_schemas,
normalize_tool_profile,
)
from cheetahclaws.tools import execute_tool
from cheetahclaws import tools as _tools_init # ensure built-in tools are registered on import
from cheetahclaws.providers import stream, AssistantTurn, TextChunk, ThinkingChunk, detect_provider, nim_next_model
Expand Down Expand Up @@ -161,6 +165,30 @@ def run(
session_id=session_id,
removed=_before_len - len(state.messages))

# Derive the model-visible and executable surface from the same source
# for this turn. This prevents a provider from seeing a schema that
# dispatch would reject (or vice versa), and avoids sending optional
# integration schemas on every coding request.
try:
active_profile = normalize_tool_profile(config.get("tool_profile"))
except ValueError as profile_error:
active_profile = "standard"
_log.warn("invalid_tool_profile",
session_id=session_id,
requested=config.get("tool_profile"),
fallback=active_profile,
error=str(profile_error))
disabled_tools = config.get("disabled_tools") or ()
if not isinstance(disabled_tools, (list, tuple, set, frozenset)):
disabled_tools = ()
active_tool_schemas = get_tool_schemas(active_profile, disabled_tools)
active_tool_names = get_active_tool_names(active_profile, disabled_tools)
config = {
**config,
"tool_profile": active_profile,
"_active_tool_names": active_tool_names,
}

# ── Quota check — before spending tokens ──────────────────────────
# Project this request's INPUT so a single large (tool-heavy) call can't
# blow past the cap, then clamp the OUTPUT cap to the remaining headroom
Expand Down Expand Up @@ -224,7 +252,7 @@ def run(
model=config["model"],
system=system_prompt,
messages=state.messages,
tool_schemas=get_tool_schemas(),
tool_schemas=active_tool_schemas,
config=_call_config,
):
if isinstance(event, (TextChunk, ThinkingChunk)):
Expand Down Expand Up @@ -353,7 +381,7 @@ def run(
# Auto-nudge: text-only reply when the user clearly wanted
# investigation (their message contained an absolute path).
# One shot only — see `_nudges_remaining` init above.
if _nudges_remaining > 0 and get_tool_schemas():
if _nudges_remaining > 0 and active_tool_schemas:
_nudges_remaining -= 1
_nudge_msg = (
"[system reminder] You replied with text and no tool "
Expand Down Expand Up @@ -448,6 +476,12 @@ def run(
# Check permissions first (must be sequential — may prompt user)
permissions: dict[str, bool] = {}
for tc in tool_calls:
if tc["name"] not in active_tool_names:
# Treat a stale/malicious call as an execution error, not a
# permission question. The model never received this schema
# on this turn, so prompting a user for it would be misleading.
permissions[tc["id"]] = True
continue
permitted = _check_permission(tc, config)
if not permitted:
if config.get("permission_mode") == "plan":
Expand Down Expand Up @@ -477,6 +511,13 @@ def run(
def _exec_one(tc):
"""Execute a single tool call, return (tc, result, permitted)."""
tid = tc["id"]
if tc["name"] not in active_tool_names:
return (
tc,
f"Error: tool '{tc['name']}' is not enabled by the "
f"{active_profile!r} tool profile for this turn.",
True,
)
# Read-only dedup short-circuit: skip the actual execute_tool
# call, return the synthetic reminder as the tool result. Marked
# `permitted=True` so downstream loop-error counters don't treat
Expand Down
7 changes: 7 additions & 0 deletions cheetahclaws/commands/config_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,13 @@ def cmd_config(args: str, _state, config) -> bool:
val = json.loads(val)
except json.JSONDecodeError:
pass # leave as string
if key == "tool_profile":
try:
from cheetahclaws.tool_registry import normalize_tool_profile
val = normalize_tool_profile(val)
except ValueError as exc:
err(str(exc))
return False
config[key] = val
save_config(config)
ok(f"Set {key} = {val!r}")
Expand Down
5 changes: 4 additions & 1 deletion cheetahclaws/commands/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ def _est(text: str) -> int:
tool_tokens = 0
try:
from cheetahclaws.tool_registry import get_tool_schemas
tool_tokens = _est(json.dumps(get_tool_schemas()))
tool_tokens = _est(json.dumps(get_tool_schemas(
config.get("tool_profile", "standard"),
config.get("disabled_tools") or (),
)))
except Exception:
tool_tokens = 0

Expand Down
31 changes: 30 additions & 1 deletion cheetahclaws/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@
"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
# 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,
"tool_read_max_output_chars": 50_000,
"web_fetch_max_bytes": 512 * 1024,
"web_search_max_bytes": 512 * 1024,
"web_fetch_max_seconds": 30,
"web_search_max_seconds": 30,
"pdf_extract_max_chars": 50_000,
"pdf_extract_max_pages": 50,
"pdf_extract_max_file_bytes": 32 * 1024 * 1024,
"summarize_max_input_bytes": 16 * 1024 * 1024,
"summarize_chunk_max_output_chars": 8_000,
"summarize_reduce_max_input_chars": 200_000,
# Read-only cache values are post-truncation and capped independently so a
# single large fetch cannot consume unbounded resident memory.
"max_tool_cache_output": 12_000,
"max_agent_depth": 3,
"max_concurrent_agents": 3,
"session_daily_limit": 10000, # max sessions kept per day in daily/
Expand Down Expand Up @@ -155,11 +176,19 @@ def load_config() -> dict:
CONFIG_DIR.mkdir(exist_ok=True)
SESSIONS_DIR.mkdir(exist_ok=True)
cfg = dict(DEFAULTS)
saved_config: dict = {}
if CONFIG_FILE.exists():
try:
cfg.update(json.loads(CONFIG_FILE.read_text()))
saved_config = json.loads(CONFIG_FILE.read_text())
if isinstance(saved_config, dict):
cfg.update(saved_config)
else:
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``.
# 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
72 changes: 69 additions & 3 deletions cheetahclaws/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,12 +215,73 @@ def _render_plan_fragment(config: dict) -> str:
return template.format(plan_file=plan_file)


def _render_active_tool_surface(config: dict) -> str:
"""Describe exactly the profile-filtered tools executable this turn."""
from cheetahclaws.tool_registry import (
get_profile_tool_names,
normalize_tool_profile,
)

try:
profile = normalize_tool_profile(config.get("tool_profile"))
except ValueError:
profile = "standard"
disabled = config.get("disabled_tools") or ()
if not isinstance(disabled, (list, tuple, set, frozenset)):
disabled = ()
names = config.get("_active_tool_names")
if names is None:
names = get_profile_tool_names(profile, disabled)
visible = ", ".join(f"`{name}`" for name in sorted(names)) or "(none)"
planning_hint = ""
if {"EnterPlanMode", "ExitPlanMode"} <= set(names):
# Keep the planning cue with its optional tools rather than paying for
# it on every standard coding turn. This also makes the prompt
# deterministic: it must not depend on slash-command imports.
planning_hint = (
"- For complex or multi-file work, use `EnterPlanMode` before "
"making changes, then finish with `ExitPlanMode`.\n"
)
return (
"# Active Tool Surface\n"
f"- Profile: `{profile}`\n"
f"- Enabled tools: {visible}\n"
"- Call only the enabled tools above; a tool mentioned elsewhere is not "
"available unless it appears in this list.\n"
f"{planning_hint}"
)


def _tmux_available() -> bool:
try:
from cheetahclaws.tmux_tools import tmux_available
return tmux_available()
except ImportError:
except Exception:
# Optional integrations must not prevent prompt construction when an
# older supported Python cannot import their modern type annotations.
return False


def _tmux_fragment_enabled(config: dict) -> bool:
"""Show tmux instructions only when its executable tool is active."""
from cheetahclaws.tool_registry import (
get_active_tool_names,
normalize_tool_profile,
)

try:
profile = normalize_tool_profile(config.get("tool_profile"))
except ValueError:
profile = "standard"
if profile != "full" or not _tmux_available():
return False
disabled = config.get("disabled_tools") or ()
if not isinstance(disabled, (list, tuple, set, frozenset)):
disabled = ()
names = config.get("_active_tool_names")
if names is None:
names = get_active_tool_names(profile, disabled)
return "TmuxNewSession" in names and "TmuxNewSession" not in disabled


def _render_commands_block() -> str:
Expand Down Expand Up @@ -283,8 +344,13 @@ def build_system_prompt(config: dict | None = None) -> str:
# picked them up later, which is wrong for non-Claude families.
provider = detect_provider(model_id) if model_id else ""

# Optional integration instructions must agree with the exact active tool
# set. A detected binary alone does not make a tool callable.
tmux_fragment = load_fragment("tmux") if _tmux_fragment_enabled(cfg) else ""

parts: list[str] = [
pick_base_prompt(provider, model_id),
_render_active_tool_surface(cfg),
_render_env_block(cfg),
]

Expand All @@ -296,8 +362,8 @@ def build_system_prompt(config: dict | None = None) -> str:
if memory_ctx:
parts.append(f"# Memory\nYour persistent memories:\n{memory_ctx}")

if _tmux_available():
parts.append(load_fragment("tmux"))
if tmux_fragment:
parts.append(tmux_fragment)

if cfg.get("permission_mode") == "plan":
parts.append(_render_plan_fragment(cfg))
Expand Down
67 changes: 11 additions & 56 deletions cheetahclaws/prompts/base/default.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,10 @@ You are a highly capable autonomous agent. Do not act submissive or artificially
If the user asks you to monitor a process, run a background loop, or execute long-running tasks, DO NOT refuse by claiming you are "just a chat interface" or "require a prompt to take action."
Instead, you must proactively write the necessary background scripts (Python, Bash, etc.) using the Write tool, and execute them in the background using the Bash tool (e.g. `python script.py &` or `nohup ...`). You have full system access to solve any automation request.

# Available Tools

## File & Shell
- **Read**: Read file contents with line numbers
- **Write**: Create or overwrite files
- **Edit**: Replace text in a file (exact string replacement)
- **Bash**: Execute shell commands. Default timeout is 30s. For slow commands (npm install, npx, pip install, builds), set timeout to 120-300.
- **Glob**: Find files by pattern (e.g. **/*.py)
- **Grep**: Search file contents with regex
- **WebFetch**: Fetch and extract content from a URL
- **WebSearch**: Search the web via DuckDuckGo

## Multi-Agent
- **Agent**: Spawn a sub-agent. Params: `subagent_type` (coder / reviewer / researcher / tester / general-purpose), `isolation="worktree"` for parallel coding, `name` for addressing, `wait=false` for background.
- **SendMessage** / **CheckAgentResult** / **ListAgentTasks** / **ListAgentTypes**: sub-agent lifecycle.

## Memory
- **MemorySave** / **MemoryDelete** / **MemorySearch** / **MemoryList**: persistent memory (user + project scopes).

## Skills
- **Skill** / **SkillList**: invoke or list reusable prompt templates.

## MCP (Model Context Protocol)
External tools registered as `mcp__<server_name>__<tool_name>`. Use `/mcp` to list servers.

## Task Management & Background Jobs
- **SleepTimer**: Put yourself to sleep for `seconds`. Use whenever the user asks for a timer/reminder.
- **TaskCreate** / **TaskUpdate** / **TaskGet** / **TaskList**: structured task list with `blocks` / `blocked_by` edges.

**Workflow:** break multi-step plans into tasks at the start → mark in_progress when starting each → mark completed when done → use TaskList to review.

## Planning
- **EnterPlanMode** / **ExitPlanMode**: read-only analysis phase that writes only to the plan file.
Use plan mode for multi-file tasks, architectural decisions, or unclear requirements — NOT for single-file fixes.

## Interaction
- **AskUserQuestion**: Pause and ask the user a clarifying question mid-task, with optional numbered choices.

## Plugins
Plugins extend cheetahclaws with additional tools, skills, and MCP servers. Use `/plugin` to list, install, enable/disable, update, and get recommendations.
# Tool Availability
Only call tools listed in the **Active Tool Surface** section. The schemas sent
with this request are authoritative; do not assume optional web, document,
multi-agent, plugin, or task-management tools are enabled.

# Working Style
- **Lead with the answer.** Put evidence and `file:line` references after, not before.
Expand All @@ -61,32 +25,28 @@ Plugins extend cheetahclaws with additional tools, skills, and MCP servers. Use
You are an agent in a CLI, not a chat assistant. Default to **action over conversation**.

When the user gives you a path, a filename, a directory, or asks you to "look at / analyze / check / fix / explain" something:
1. **Explore first.** Use Bash `ls`, Glob `**/*`, Grep, or Read to discover what's there. A directory is not "missing information" — it's an invitation to enumerate. A vague request like "fix the bug" is not "unclear" until you have read the relevant code and confirmed there are multiple plausible interpretations.
1. **Explore first.** Use the available inspection tools to discover what's there. A directory is not "missing information" — it's an invitation to enumerate. A vague request like "fix the bug" is not "unclear" until you have inspected the relevant code and confirmed there are multiple plausible interpretations.
2. **Verify, then act.** Read the files you'll touch before Editing. Cite `file:line` for every claim.
3. **Only then, if a real ambiguity remains** (e.g. you found two unrelated bugs and don't know which one the user meant), use AskUserQuestion — and frame the question with what you already discovered, not as a generic "please tell me more".
3. **Only then, if a real ambiguity remains** (e.g. you found two unrelated bugs and don't know which one the user meant), ask a focused question grounded in what you discovered, not a generic "please tell me more".

Asking the user for information you could have found yourself in one tool call is the single most common failure mode. Avoid it.

# Tool Use Principles
- **Maximize parallel tool calls.** When multiple independent pieces of information are needed, batch them in the same turn — running five reads in parallel costs the same latency as running one. Only call tools sequentially when a later call depends on an earlier result.
- **Glob vs Grep vs Read**: Glob finds paths by name, Grep finds content by pattern, Read fetches full file contents. Do not run a Read when a Grep answer is enough.
- **Read before Edit.** Always Read (or Grep) the target string first to confirm it byte-for-byte. Never guess file contents.
- **Choose the narrowest available inspection tool.** Prefer locating paths or matching text over loading a whole file when that answers the question.
- **Inspect before modifying.** Confirm the target text byte-for-byte before a change. Never guess file contents.
- **Tool outputs may be truncated at 32000 characters.** If a result looks empty, short, or ambiguous, inspect it for an error prefix (e.g. `Error:`, `[exit=1]`) before retrying — a blank response usually indicates a failed command, not a silent success.
- **Trust your internal reasoning.** Do not narrate intermediate deliberation in visible output ("Let me first think about…", "I need to figure out…"). The user sees only your answers and tool calls.

# Stop Conditions
Return control to the user when:
- The user's stated goal is fully satisfied **and verified** (tests pass, file exists, command succeeds, build compiles).
- You have attempted three different approaches to the same sub-problem and all failed — summarize what you tried and ask the user how to proceed instead of a fourth blind attempt.
- Required information is **genuinely** unrecoverable from the workspace (e.g. an external API key, a stakeholder decision, intent that no amount of exploration could disambiguate). Use AskUserQuestion only after you have first searched for the answer with tool calls — never as a substitute for `ls`, Glob, or Read.
- Required information is **genuinely** unrecoverable from the workspace (e.g. an external API key, a stakeholder decision, intent that no amount of exploration could disambiguate). Ask only after you have first searched for the answer with available tools.

# Safe vs Unsafe Actions
- Under `auto` permission mode (the default), the harness **auto-runs without asking**:
- Read / Grep / Glob / WebFetch / WebSearch (read-only)
- Bash commands on the allow-list (`git status`, `ls`, `python -c`, etc.)
- Under `auto`, the harness **prompts the user before running**: `Write` / `Edit` (even though the
checkpoint system makes them reversible) and any `Bash` command not on the allow-list. Don't
assume an edit will go through silently — it is confirmed first.
- Under `auto` permission mode (the default), read-only and allow-listed actions can run without asking.
- Under `auto`, edits and other state-changing actions may require confirmation. Don't assume an edit will go through silently.
- Other modes: `accept-edits` is like `auto` but also auto-runs `Write`/`Edit` (other Bash still
prompts); `accept-all` runs everything without prompting; `manual` prompts for every call
including reads; `plan` is read-only (edits/writes are refused except to the plan file).
Expand All @@ -96,8 +56,3 @@ Return control to the user when:
mode: `git push --force`, `git reset --hard origin/main`, `git clean -fd`, credential-bearing
`curl`, writes to production endpoints, any action on files outside `allowed_root`.
- When in doubt about reversibility, ask.

# Multi-Agent Guidelines
- Use Agent with `subagent_type` to leverage specialized agents for focused tasks (reviewer / researcher / tester).
- Use `isolation="worktree"` when parallel agents need to modify files without conflicts.
- Use `wait=false` + `name=...` to run multiple agents in parallel, then collect results.
Loading
Loading