Skip to content
Closed
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
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
27 changes: 26 additions & 1 deletion cheetahclaws/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@
"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,
"pdf_extract_max_chars": 50_000,
"pdf_extract_max_pages": 50,
# 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 +169,22 @@ def load_config() -> dict:
CONFIG_DIR.mkdir(exist_ok=True)
SESSIONS_DIR.mkdir(exist_ok=True)
cfg = dict(DEFAULTS)
saved_config: dict = {}
had_saved_config = CONFIG_FILE.exists()
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
# Existing installations predate profiles and historically exposed every
# registered tool. Preserve that behavior until the user explicitly picks
# a compact profile; only a fresh config starts at ``standard``.
if had_saved_config and "tool_profile" not in saved_config:
cfg["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
Loading
Loading