diff --git a/cheetahclaws/agent.py b/cheetahclaws/agent.py index e12b6483..d13a2fd3 100644 --- a/cheetahclaws/agent.py +++ b/cheetahclaws/agent.py @@ -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 @@ -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 @@ -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)): @@ -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 " @@ -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": @@ -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 diff --git a/cheetahclaws/commands/config_cmd.py b/cheetahclaws/commands/config_cmd.py index c2742878..3c3b7882 100644 --- a/cheetahclaws/commands/config_cmd.py +++ b/cheetahclaws/commands/config_cmd.py @@ -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}") diff --git a/cheetahclaws/commands/core.py b/cheetahclaws/commands/core.py index fd8caf62..23e6991d 100644 --- a/cheetahclaws/commands/core.py +++ b/cheetahclaws/commands/core.py @@ -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 diff --git a/cheetahclaws/config.py b/cheetahclaws/config.py index 6b19a3ca..3d8f9ae8 100644 --- a/cheetahclaws/config.py +++ b/cheetahclaws/config.py @@ -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/ @@ -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") diff --git a/cheetahclaws/context.py b/cheetahclaws/context.py index 051f0409..b63fa8c0 100644 --- a/cheetahclaws/context.py +++ b/cheetahclaws/context.py @@ -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: @@ -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), ] @@ -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)) diff --git a/cheetahclaws/prompts/base/default.md b/cheetahclaws/prompts/base/default.md index d5e35e43..c7e3dfe9 100644 --- a/cheetahclaws/prompts/base/default.md +++ b/cheetahclaws/prompts/base/default.md @@ -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____`. 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. @@ -61,16 +25,16 @@ 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. @@ -78,15 +42,11 @@ Asking the user for information you could have found yourself in one tool call i 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). @@ -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. diff --git a/cheetahclaws/prompts/fragments/plan.md b/cheetahclaws/prompts/fragments/plan.md index 5779156c..7d09d1d3 100644 --- a/cheetahclaws/prompts/fragments/plan.md +++ b/cheetahclaws/prompts/fragments/plan.md @@ -1,8 +1,7 @@ # Plan Mode (ACTIVE) You are in PLAN MODE. Important rules: -- You may ONLY read/analyze code using Read, Glob, Grep, WebFetch, WebSearch +- You may ONLY use enabled read/analysis tools. - You may ONLY write to the plan file: {plan_file} -- Do NOT attempt to Write/Edit any other files — those operations will be blocked -- Use TaskCreate to break down your plan into trackable steps if appropriate +- Do NOT attempt to modify any other files — those operations will be blocked. - Write a detailed, actionable implementation plan to the plan file - When the plan is ready, tell the user to run /plan done to begin implementation diff --git a/cheetahclaws/tool_registry.py b/cheetahclaws/tool_registry.py index a0d3c197..9176b54e 100644 --- a/cheetahclaws/tool_registry.py +++ b/cheetahclaws/tool_registry.py @@ -7,8 +7,9 @@ import hashlib import json +import threading from dataclasses import dataclass -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, FrozenSet, Iterable, List, Optional @dataclass @@ -21,33 +22,127 @@ class ToolDef: func: callable(params: dict, config: dict) -> str read_only: True if the tool never mutates state concurrent_safe: True if safe to run in parallel with other tools + profiles: optional tool-surface profiles this tool belongs to. ``None`` + applies the built-in classification for known tools; third-party + tools default to ``full`` so they are never exposed accidentally. """ name: str schema: Dict[str, Any] func: Callable[[Dict[str, Any], Dict[str, Any]], str] read_only: bool = False concurrent_safe: bool = False + profiles: Optional[FrozenSet[str]] = None # --------------- internal state --------------- _registry: Dict[str, ToolDef] = {} +# ``standard`` deliberately contains the small, high-frequency coding surface. +# Other profiles extend it rather than making the model choose among every +# optional integration on every turn. Unknown/plugin tools remain opt-in via +# ``full`` unless their author explicitly sets ``ToolDef.profiles``. +_PROFILE_NAMES = frozenset({"standard", "research", "orchestration", "full"}) +_STANDARD_TOOLS = frozenset({ + "Read", "Write", "Edit", "Bash", "Glob", "Grep", "GetDiagnostics", + "AskUserQuestion", "NotebookEdit", + "MemorySave", "MemoryDelete", "MemorySearch", "MemoryList", "MemoryVerify", +}) +_RESEARCH_TOOLS = frozenset({ + "WebFetch", "WebSearch", "Research", "ReadPDF", "ReadImage", + "ReadSpreadsheet", "SummarizeLargeFile", +}) +_ORCHESTRATION_TOOLS = frozenset({ + "Agent", "SendMessage", "CheckAgentResult", "ListAgentTasks", + "ListAgentTypes", "Skill", "SkillList", "TaskCreate", "TaskUpdate", + "TaskGet", "TaskList", "EnterPlanMode", "ExitPlanMode", "SleepTimer", +}) + + +def _default_profiles(name: str) -> FrozenSet[str]: + """Classify first-party tools without forcing every registration to change.""" + if name in _STANDARD_TOOLS: + return frozenset({"standard"}) + if name in _RESEARCH_TOOLS: + return frozenset({"research"}) + if name in _ORCHESTRATION_TOOLS: + return frozenset({"orchestration"}) + return frozenset({"full"}) + + +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. + """ + if profile is None: + return "standard" + if not isinstance(profile, str): + raise ValueError("Tool profile must be a string.") + normalized = (profile or "standard").strip().lower() + if normalized not in _PROFILE_NAMES: + choices = ", ".join(sorted(_PROFILE_NAMES)) + raise ValueError(f"Unknown tool profile '{profile}'. Choose one of: {choices}.") + return normalized + + +def _profile_allows(tool: ToolDef, profile: str) -> bool: + if profile == "full": + return True + labels = tool.profiles or _default_profiles(tool.name) + if "standard" in labels: + return True + return profile in labels + # --------------- result cache (read-only tools only) --------------- _CACHE_MAX = 64 # max cached entries _cache: Dict[str, str] = {} # hash → result _cache_order: list[str] = [] # LRU eviction order - - -def _cache_key(name: str, params: Dict[str, Any], session_id: str = "") -> str: - """Create a stable hash from tool name + params + session. +_cache_lock = threading.RLock() +_cache_generation = 0 +_DEFAULT_CACHE_VALUE_MAX = 12_000 +_CACHE_CONFIG_KEYS = ( + # Path authorization is checked inside the tool function. Include it in + # the cache key so a result authorized under one root cannot bypass a + # stricter root later in the same session. + "allowed_root", "_worktree_cwd", + # These settings change source work or visible content for read-only tools. + "tool_read_max_bytes", "tool_read_scan_max_bytes", "tool_read_max_output_chars", + "web_fetch_max_bytes", "web_search_max_bytes", "web_fetch_max_seconds", "web_search_max_seconds", + "pdf_extract_max_chars", "pdf_extract_max_pages", + "pdf_extract_max_file_bytes", "summarize_max_input_bytes", + "summarize_chunk_max_output_chars", "summarize_reduce_max_input_chars", + "max_tool_cache_output", "model", "tool_profile", "disabled_tools", + "_active_tool_names", +) + + +def _cache_key( + name: str, + params: Dict[str, Any], + session_id: str = "", + config: Dict[str, Any] | None = None, +) -> str: + """Create a stable hash from tool name + params + session + output policy. Including the session_id keeps cached results scoped to the originator — in a shared daemon, A's Read of ~/.env never gets handed to B's session. """ + cache_config = {} + for key in _CACHE_CONFIG_KEYS: + if key not in (config or {}): + continue + value = (config or {}).get(key) + # Active/disabled tool sets alter profile-aware redirects and hints. + # Serialize them deterministically rather than relying on set repr. + if isinstance(value, (set, frozenset)): + value = sorted(map(str, value)) + cache_config[key] = value raw = json.dumps( - {"n": name, "p": params, "s": session_id}, + {"n": name, "p": params, "s": session_id, "c": cache_config}, sort_keys=True, default=str, ) return hashlib.sha256(raw.encode()).hexdigest()[:16] @@ -55,14 +150,19 @@ def _cache_key(name: str, params: Dict[str, Any], session_id: str = "") -> str: def clear_tool_cache() -> None: """Clear the tool result cache. Called on file writes to invalidate.""" - _cache.clear() - _cache_order.clear() + global _cache_generation + with _cache_lock: + _cache.clear() + _cache_order.clear() + _cache_generation += 1 # --------------- public API --------------- def register_tool(tool_def: ToolDef) -> None: """Register a tool, overwriting any existing tool with the same name.""" + if tool_def.profiles is None: + tool_def.profiles = _default_profiles(tool_def.name) _registry[tool_def.name] = tool_def @@ -76,9 +176,128 @@ def get_all_tools() -> List[ToolDef]: return list(_registry.values()) -def get_tool_schemas() -> List[Dict[str, Any]]: - """Return the schemas of all registered tools (for API tool parameter).""" - return [t.schema for t in _registry.values()] +def get_tool_schemas( + profile: str | None = "full", + disabled_tools: Iterable[str] | None = None, +) -> List[Dict[str, Any]]: + """Return schemas visible to the model for one tool-surface profile.""" + active_profile = normalize_tool_profile(profile) + disabled = set(disabled_tools or ()) + return [ + tool.schema for tool in _registry.values() + if tool.name not in disabled and _profile_allows(tool, active_profile) + ] + + +def get_active_tool_names( + profile: str | None = "full", + disabled_tools: Iterable[str] | None = None, +) -> FrozenSet[str]: + """Return the executable counterpart to :func:`get_tool_schemas`.""" + return frozenset( + schema["name"] for schema in get_tool_schemas(profile, disabled_tools) + ) + + +def get_profile_tool_names( + profile: str | None = "full", + disabled_tools: Iterable[str] | None = None, +) -> FrozenSet[str]: + """Return the built-in profile surface without importing tool modules. + + Prompt construction can run before the agent imports its tool package. + This lightweight view keeps that prompt profile-aware without triggering + plugin registration or other unrelated import side effects. During an + agent turn, ``_active_tool_names`` remains the authoritative exact set. + """ + active_profile = normalize_tool_profile(profile) + names = set(_STANDARD_TOOLS) + if active_profile in {"research", "full"}: + names.update(_RESEARCH_TOOLS) + if active_profile in {"orchestration", "full"}: + names.update(_ORCHESTRATION_TOOLS) + if active_profile == "full": + names.update(_registry) + names.difference_update(disabled_tools or ()) + return frozenset(names) + + +def _effective_output_cap(config: Dict[str, Any], max_output: int) -> int: + """Keep an individual tool result below model-context safety limits.""" + try: + from cheetahclaws.compaction import get_context_limit + model = config.get("model", "") if config else "" + declared_ctx = get_context_limit(model) or 32768 + # Reserve 16K for system prompt + tool schemas + framing + headroom. + # 0.5× for CJK-safety (1 char ≈ 1 token worst case). + safe_ctx = min(declared_ctx, 30000) + effective_max = max(2000, int((safe_ctx - 16000) * 0.5)) + return min(max_output, effective_max) + except Exception: + # Compaction module unavailable in some test contexts — retain the + # static cap rather than failing dispatch. + return max_output + + +def _truncate_result( + result: str, + params: Dict[str, Any], + max_output: int, + config: Dict[str, Any] | None = None, +) -> str: + """Trim a result while retaining a useful beginning and ending.""" + output_limit = max(1, int(max_output)) + if len(result) <= output_limit: + return result + truncated = len(result) + file_hint = "" + fpath = (params or {}).get("file_path") if isinstance(params, dict) else None + if fpath and isinstance(fpath, str): + active_names = (config or {}).get("_active_tool_names") + profile = (config or {}).get("tool_profile") + summary_available = ( + "SummarizeLargeFile" in active_names + if active_names is not None + else profile in {"research", "full"} + ) + if summary_available: + short_path = fpath[:160] + ("…" if len(fpath) > 160 else "") + file_hint = ( + f" Tip: this came from `{short_path}` — call " + f"`SummarizeLargeFile(file_path='{short_path}')` to get a " + f"complete chunked + map-reduce summary that fits." + ) + else: + short_path = fpath[:160] + ("…" if len(fpath) > 160 else "") + file_hint = ( + f" Tip: this came from `{short_path}` — use Read again with " + "a narrower offset and limit." + ) + marker = ( + f"\n[... {truncated:,} chars truncated to keep total tool " + f"output ≤ {output_limit:,} chars (model context safety).{file_hint}]\n" + ) + if len(marker) >= output_limit: + return marker[:output_limit] + visible_budget = output_limit - len(marker) + first = (visible_budget * 2) // 3 + last = visible_budget - first + return result[:first] + marker + (result[-last:] if last else "") + + +def _cache_put(key: str, value: str, generation: int) -> None: + """Insert a bounded value unless a write invalidated it mid-flight.""" + with _cache_lock: + if generation != _cache_generation: + return + if key in _cache: + if key in _cache_order: + _cache_order.remove(key) + _cache[key] = value + _cache_order.append(key) + while len(_cache_order) > _CACHE_MAX: + old = _cache_order.pop(0) + _cache.pop(old, None) def execute_tool( @@ -102,74 +321,63 @@ def execute_tool( if tool is None: return f"Error: tool '{name}' not found." + active_names = (config or {}).get("_active_tool_names") + if active_names is not None and name not in active_names: + profile = (config or {}).get("tool_profile", "standard") + return ( + f"Error: tool '{name}' is not enabled by the {profile!r} tool " + "profile for this turn. Select a profile that includes it and retry." + ) + + output_cap = _effective_output_cap(config or {}, max_output) + # Cache hit for read-only tools (same name + same params + same session). use_cache = tool.read_only + mutates_files = name in ("Write", "Edit", "Bash", "NotebookEdit") if use_cache: sid = (config or {}).get("_session_id", "") or "" - key = _cache_key(name, params, sid) - if key in _cache: - return _cache[key] + key = _cache_key(name, params, sid, config) + with _cache_lock: + cached = _cache.get(key) + if cached is not None: + if key in _cache_order: + _cache_order.remove(key) + _cache_order.append(key) + generation = _cache_generation + if cached is not None: + # Cache values are already bounded, but cap again because a later + # call can have a smaller context window than the original one. + return _truncate_result(cached, params, output_cap, config) else: # Write tools invalidate cache (file content may have changed) - if name in ("Write", "Edit", "Bash", "NotebookEdit"): + if mutates_files: clear_tool_cache() try: result = tool.func(params, config) except Exception as e: return f"Error executing {name}: {e}" + finally: + # A Read can start after the pre-mutation invalidation but before a + # slow Write/Bash actually changes a file. Clear again so it cannot + # retain that old snapshot once the mutation finishes (or fails). + if mutates_files: + clear_tool_cache() - # Store in cache for read-only tools - if use_cache: - _cache[key] = result - _cache_order.append(key) - # Evict oldest if over limit - while len(_cache_order) > _CACHE_MAX: - old = _cache_order.pop(0) - _cache.pop(old, None) + result = _truncate_result(result, params, output_cap, config) - # Model-aware truncation: the static 32K-char cap is fine for English - # but blows up CJK content (1 token per char). Cap effective max by the - # model's actual context window so a Bash / Read / WebFetch result - # can never single-handedly overflow the next API call. ~30K-token - # conservative ceiling (handles 32K-context models like qwen2.5-72b - # behind a `custom/` provider that lies about context_limit). - try: - from cheetahclaws.compaction import get_context_limit - model = config.get("model", "") if config else "" - declared_ctx = get_context_limit(model) or 32768 - # Reserve 16K for system prompt + tool schemas + framing + headroom. - # 0.5× for CJK-safety (1 char ≈ 1 token worst case). - safe_ctx = min(declared_ctx, 30000) - effective_max = max(2000, int((safe_ctx - 16000) * 0.5)) - if effective_max < max_output: - max_output = effective_max - except Exception: - # Compaction module unavailable in some test contexts — fall back - # to the static 32K cap rather than crashing. - pass - - if len(result) > max_output: - first_half = max_output // 2 - last_quarter = max_output // 4 - truncated = len(result) - first_half - last_quarter - # Surface a SummarizeLargeFile pointer when the truncated tool - # call had a `file_path` arg — gives the model a path forward - # instead of just losing 50%+ of the content. - file_hint = "" - fpath = (params or {}).get("file_path") if isinstance(params, dict) else None - if fpath and isinstance(fpath, str): - file_hint = ( - f" Tip: this came from `{fpath}` — call " - f"`SummarizeLargeFile(file_path='{fpath}')` to get a " - f"complete chunked + map-reduce summary that fits." - ) - result = ( - result[:first_half] - + f"\n[... {truncated} chars truncated to keep total tool " - f"output ≤ {max_output:,} chars (model context safety).\n" - f"{file_hint}]\n" - + result[-last_quarter:] + # Cache only a bounded post-truncation result. This prevents a single + # pathological read-only response from occupying unbounded process RAM. + if use_cache: + try: + cache_cap = int((config or {}).get( + "max_tool_cache_output", _DEFAULT_CACHE_VALUE_MAX + )) + except (TypeError, ValueError): + cache_cap = _DEFAULT_CACHE_VALUE_MAX + cache_cap = max(1_000, min(output_cap, cache_cap)) + _cache_put( + key, _truncate_result(result, params, cache_cap, config), generation, ) return result diff --git a/cheetahclaws/tools/__init__.py b/cheetahclaws/tools/__init__.py index 39e461d6..ee1fdd2f 100644 --- a/cheetahclaws/tools/__init__.py +++ b/cheetahclaws/tools/__init__.py @@ -448,6 +448,16 @@ def execute_tool( """Dispatch tool execution; ask permission for write/destructive ops.""" cfg = config or {} + # This check must run *before* the registry's read-only cache lookup. A + # cache key can cover normal config values, but it cannot safely encode + # ambient authorization such as the filesystem sandbox environment or the + # credential-path denylist. Keep the wrapper check too as defense in depth + # for callers that invoke the registry directly. + if name == "Read" and inputs.get("file_path"): + denied = _check_path_allowed(inputs["file_path"], cfg) + if denied: + return denied + def _check(desc: str) -> bool: if permission_mode == "accept-all": return True @@ -496,7 +506,12 @@ def _read_with_overflow_check(p: dict, c: dict) -> str: denied = _check_path_allowed(p["file_path"], c) if denied: return denied - result = _read(**p) + result = _read( + **p, + max_bytes=c.get("tool_read_max_bytes", 256 * 1024), + scan_max_bytes=c.get("tool_read_scan_max_bytes", 2 * 1024 * 1024), + max_output_chars=c.get("tool_read_max_output_chars", 50_000), + ) # Skip redirect for already-small results (errors, empty, etc.) if not result or len(result) < 8000: return result @@ -578,7 +593,11 @@ def _read_with_overflow_check(p: dict, c: dict) -> str: name="WebFetch", schema=_schemas["WebFetch"], func=lambda p, c: ( - _webfetch(p["url"], p.get("prompt")) + _webfetch( + p["url"], p.get("prompt"), + max_bytes=c.get("web_fetch_max_bytes", 512 * 1024), + max_seconds=c.get("web_fetch_max_seconds", 30), + ) if isinstance(p.get("url"), str) and p["url"].strip() else "Error: WebFetch requires a non-empty 'url' " "argument (the URL to fetch)." @@ -589,7 +608,11 @@ def _read_with_overflow_check(p: dict, c: dict) -> str: name="WebSearch", schema=_schemas["WebSearch"], func=lambda p, c: ( - _websearch(p["query"]) + _websearch( + p["query"], + max_bytes=c.get("web_search_max_bytes", 512 * 1024), + max_seconds=c.get("web_search_max_seconds", 30), + ) if isinstance(p.get("query"), str) and p["query"].strip() else "Error: WebSearch requires a non-empty 'query' " "argument (the search string). Pass it like " diff --git a/cheetahclaws/tools/files.py b/cheetahclaws/tools/files.py index a77bb57a..bdbfb049 100644 --- a/cheetahclaws/tools/files.py +++ b/cheetahclaws/tools/files.py @@ -14,6 +14,63 @@ from cheetahclaws.tool_registry import ToolDef, register_tool +_DEFAULT_PDF_MAX_FILE_BYTES = 32 * 1024 * 1024 +_DEFAULT_SUMMARIZE_MAX_INPUT_BYTES = 16 * 1024 * 1024 +_DEFAULT_SUMMARIZE_CHUNK_OUTPUT_CHARS = 8_000 +_DEFAULT_SUMMARIZE_REDUCE_INPUT_CHARS = 200_000 + + +def _extract_page_prefix(page, fitz_module, char_cap: int) -> tuple[str, bool]: + """Extract a page in clipped bands instead of materializing all page text. + + PyMuPDF's plain ``page.get_text()`` builds the complete page string first. + Reading shallow horizontal bands (and narrower columns for very wide + pages) keeps peak extraction work bounded even for a pathological one-page + PDF. Pages without geometry are rejected safely: the compatibility fallback + of calling ``get_text()`` would materialize the entire page. + """ + rect = getattr(page, "rect", None) + if rect is None or not getattr(rect, "height", 0): + return "", True + + chunks: list[str] = [] + captured = 0 + band_height = min(144.0, max(36.0, float(rect.height) / 16.0)) + max_bands = 256 + page_width = float(getattr(rect, "width", float(rect.x1) - float(rect.x0))) + tile_width = min(612.0, max(72.0, page_width)) + columns = max(1, min(8, int((page_width + tile_width - 1) // tile_width))) + max_tiles = 512 + y = float(rect.y0) + bands_read = 0 + tiles_read = 0 + tile_limit_hit = False + while ( + y < float(rect.y1) + and captured < char_cap + and bands_read < max_bands + and tiles_read < max_tiles + ): + for column in range(columns): + if captured >= char_cap or tiles_read >= max_tiles: + tile_limit_hit = tiles_read >= max_tiles + break + x0 = float(rect.x0) + column * tile_width + x1 = min(x0 + tile_width, float(rect.x1)) + clip = fitz_module.Rect(x0, y, x1, min(y + band_height, rect.y1)) + text = page.get_text("text", clip=clip) + remaining = char_cap - captured + if len(text) > remaining: + chunks.append(text[:remaining]) + return "".join(chunks), True + chunks.append(text) + captured += len(text) + tiles_read += 1 + y += band_height + bands_read += 1 + return "".join(chunks), y < float(rect.y1) or tile_limit_hit + + def _read_pdf(params: dict, config: dict) -> str: """Read text content from a PDF file.""" try: @@ -35,27 +92,81 @@ def _read_pdf(params: dict, config: dict) -> str: return f"Error: not a PDF file: {file_path}" try: - doc = fitz.open(str(p)) - total = len(doc) - - # Parse page range - if pages: - page_list = _parse_page_range(pages, total) - else: - page_list = list(range(min(total, 50))) # default: first 50 pages + try: + char_cap = int(config.get("pdf_extract_max_chars", 50_000)) + except (TypeError, ValueError): + char_cap = 50_000 + try: + page_cap = int(config.get("pdf_extract_max_pages", 50)) + except (TypeError, ValueError): + page_cap = 50 + try: + file_byte_cap = int(config.get( + "pdf_extract_max_file_bytes", _DEFAULT_PDF_MAX_FILE_BYTES, + )) + except (TypeError, ValueError): + file_byte_cap = _DEFAULT_PDF_MAX_FILE_BYTES + char_cap = max(1_000, char_cap) + page_cap = max(1, page_cap) + file_byte_cap = max(1_024, file_byte_cap) + if p.stat().st_size > file_byte_cap: + return ( + f"Error: PDF is larger than the {file_byte_cap:,}-byte extraction " + "limit; use a narrower source file or raise pdf_extract_max_file_bytes." + ) - text_parts = [] - for i in page_list: - if 0 <= i < total: + doc = fitz.open(str(p)) + try: + total = len(doc) + + # Parse page range. Explicit page lists are capped too: otherwise + # a request such as ``1-999999`` allocates and extracts far more + # than one agent turn can safely use. + if pages: + page_list, page_range_truncated = _parse_page_range_capped( + pages, total, max_pages=page_cap, + ) + else: + page_list = list(range(min(total, page_cap))) + page_range_truncated = total > page_cap + + text_parts = [] + extracted_chars = 0 + source_truncated = page_range_truncated + for i in page_list: + if extracted_chars >= char_cap: + source_truncated = True + break + if not (0 <= i < total): + continue page = doc[i] - text = page.get_text() - if text.strip(): - text_parts.append(f"--- Page {i+1} ---\n{text.strip()}") - - doc.close() + text, page_truncated = _extract_page_prefix( + page, fitz, char_cap - extracted_chars, + ) + source_truncated = source_truncated or page_truncated + clean_text = text.strip() + if not clean_text: + continue + remaining = char_cap - extracted_chars + if len(clean_text) > remaining: + clean_text = clean_text[:remaining] + source_truncated = True + text_parts.append(f"--- Page {i+1} ---\n{clean_text}") + extracted_chars += len(clean_text) + if extracted_chars >= char_cap: + source_truncated = True + break + finally: + doc.close() if not text_parts: - return f"PDF has {total} pages but no extractable text (may be scanned/image-only)." + message = f"PDF has {total} pages but no extractable text (may be scanned/image-only)." + if source_truncated: + message += ( + f" Extraction also stopped at {char_cap:,} characters or " + f"{page_cap} pages; use a narrower `pages` range." + ) + return message header = f"PDF: {p.name} ({total} pages, showing {len(text_parts)})\n\n" content = "\n\n".join(text_parts) @@ -67,12 +178,18 @@ def _read_pdf(params: dict, config: dict) -> str: # ReadPDF response itself routes the model to the right tool # before the raw 70KB+ of PDF text overflows the next API call. # See _maybe_redirect_to_summarize for the threshold logic. - redirect = _maybe_redirect_to_summarize(full_text, str(p), config) + redirect = None if config.get("_skip_summary_redirect") else _maybe_redirect_to_summarize( + full_text, str(p), config, + ) if redirect: return redirect - if len(content) > 50000: - content = content[:50000] + f"\n\n[... truncated, {len(content)-50000} chars remaining ...]" + if source_truncated: + content += ( + f"\n\n[... ReadPDF stopped at {char_cap:,} extracted characters " + f"or {page_cap} pages; use a narrower `pages` range or " + "SummarizeLargeFile for complete coverage ...]" + ) return header + content @@ -250,19 +367,46 @@ def _format_table(rows: list[list], title: str, total_hint: str = "") -> str: return "\n".join(lines) -def _parse_page_range(spec: str, total: int) -> list[int]: - """Parse page range like '1-5', '3', '1,3,5-8'.""" +def _parse_page_range_capped( + spec: str, + total: int, + max_pages: int | None = None, +) -> tuple[list[int], bool]: + """Parse a page range and state whether a requested page was omitted.""" pages = [] + seen = set() + + def _add(page: int) -> bool: + # Ignore out-of-range singleton values just as ranges are clamped. + # They must not consume the page budget ahead of valid requests. + if not 0 <= page < total: + return False + if page in seen: + return False + if max_pages is not None and len(pages) >= max_pages: + return True + seen.add(page) + pages.append(page) + return False + for part in spec.split(","): part = part.strip() if "-" in part: a, b = part.split("-", 1) start = max(int(a) - 1, 0) end = min(int(b), total) - pages.extend(range(start, end)) + for page in range(start, end): + if _add(page): + return sorted(pages), True elif part.isdigit(): - pages.append(int(part) - 1) - return sorted(set(pages)) + if _add(int(part) - 1): + return sorted(pages), True + return sorted(pages), False + + +def _parse_page_range(spec: str, total: int, max_pages: int | None = None) -> list[int]: + """Parse page range like '1-5', '3', '1,3,5-8'.""" + return _parse_page_range_capped(spec, total, max_pages)[0] # ── Register ───────────────────────────────────────────────────────────── @@ -370,7 +514,7 @@ def _parse_page_range(spec: str, total: int) -> list[int]: def _estimate_text_tokens(text: str) -> int: """Rough conservative token estimator for plain text. Matches the chars/2.8 ratio compaction.estimate_tokens uses.""" - return int(len(text) * _TOKENS_PER_CHAR) + return len(text) if _is_cjk_heavy(text) else int(len(text) * _TOKENS_PER_CHAR) def _is_cjk_heavy(text: str, sample_chars: int = 2000) -> bool: @@ -437,10 +581,31 @@ def _maybe_redirect_to_summarize(text: str, file_path: str, if estimated_tokens <= safe_tool_result_tokens: return None + active_names = config.get("_active_tool_names") + if active_names is not None: + summary_enabled = "SummarizeLargeFile" in active_names + else: + profile = config.get("tool_profile") + # Preserve direct-call compatibility while respecting callers that + # explicitly request the compact standard profile. + summary_enabled = profile is None or profile in {"research", "full"} + # Generate a redirect with a small preview so the model has *some* # context to decide on a focus. preview_chars = min(1500, len(text) // 8) preview = text[:preview_chars].rstrip() + if not summary_enabled: + return ( + f"[ReadTooLarge: file `{file_path}` is too large to return " + f"directly — estimated {estimated_tokens:,} tokens vs model " + f"context {declared_ctx:,} (safe tool-result ceiling " + f"{safe_tool_result_tokens:,}).\n\n" + "USE INSTEAD: Call `Read` again with a narrower `offset` and " + "`limit`. A document-summary tool is not enabled for this tool " + "profile.\n\n" + f"PREVIEW (first {preview_chars} chars, for context only):\n\n" + f"```\n{preview}\n```]" + ) return ( f"[ReadTooLarge: file `{file_path}` is too large to return " f"directly — estimated {estimated_tokens:,} tokens vs model " @@ -467,11 +632,34 @@ def _read_file_for_summary(file_path: str, config: dict) -> str: return f"Error: {file_path} is a directory, not a file" suffix = p.suffix.lower() if suffix == ".pdf": - # Reuse the existing PDF reader; "all pages" by default - return _read_pdf({"file_path": str(p)}, config) + # Reuse the bounded PDF extractor but bypass its user-facing redirect: + # otherwise this summarizer would summarize its own "call + # SummarizeLargeFile" instruction rather than the PDF text. + internal = {**config, "_skip_summary_redirect": True} + content = _read_pdf({"file_path": str(p)}, internal) + if "[... ReadPDF stopped" in content: + return ( + "Error: PDF exceeds the configured extraction cap for summarization; " + "use a narrower `pages` range or raise the PDF extraction limits." + ) + return content # Plain text / code / markdown / etc. try: - return p.read_text("utf-8", errors="replace") + try: + byte_cap = int(config.get( + "summarize_max_input_bytes", _DEFAULT_SUMMARIZE_MAX_INPUT_BYTES, + )) + except (TypeError, ValueError): + byte_cap = _DEFAULT_SUMMARIZE_MAX_INPUT_BYTES + byte_cap = max(1_024, byte_cap) + if p.stat().st_size > byte_cap: + return ( + f"Error: file exceeds the {byte_cap:,}-byte summary input limit; " + "use a smaller file or raise summarize_max_input_bytes." + ) + with p.open("rb") as handle: + raw = handle.read(byte_cap) + return raw.decode("utf-8", errors="replace") except Exception as e: return f"Error reading {file_path}: {type(e).__name__}: {e}" @@ -533,13 +721,27 @@ def _summarize_chunk_via_llm(text: str, focus: str, config: dict, ) out: list[str] = [] + try: + output_cap = int(config.get( + "summarize_chunk_max_output_chars", _DEFAULT_SUMMARIZE_CHUNK_OUTPUT_CHARS, + )) + except (TypeError, ValueError): + output_cap = _DEFAULT_SUMMARIZE_CHUNK_OUTPUT_CHARS + output_cap = max(500, output_cap) + output_chars = 0 internal = {**config, "no_tools": True} try: for ev in stream(config["model"], sys_msg, [{"role": "user", "content": user_msg}], [], internal): if isinstance(ev, TextChunk): - out.append(ev.text) + remaining = output_cap - output_chars + if remaining <= 0: + break + out.append(ev.text[:remaining]) + output_chars += min(len(ev.text), remaining) + if len(ev.text) > remaining: + break except Exception as e: return f"[chunk-summarize error: {type(e).__name__}: {str(e)[:200]}]" return "".join(out).strip() or "[chunk-summarize: empty response]" @@ -554,13 +756,15 @@ def _plan_chunks(content: str, model_ctx: int) -> list[str]: ~10 chunk-budgets → 10 chunks etc. (no hard cap — grows with file) """ + cjk_heavy = _is_cjk_heavy(content) n_tokens = _estimate_text_tokens(content) chunk_token_budget = max(_SUMMARIZE_MIN_CHUNK_TOKENS, model_ctx - _SUMMARIZE_RESERVED_TOKENS) if n_tokens <= chunk_token_budget: return [content] # Compute target char-size per chunk so all chunks roughly equal. - chunk_char_budget = int(chunk_token_budget / _TOKENS_PER_CHAR) + chars_per_token = 1.0 if cjk_heavy else _TOKENS_PER_CHAR + chunk_char_budget = int(chunk_token_budget / chars_per_token) n_chunks = (n_tokens // chunk_token_budget) + 1 overlap_chars = 200 base_size = (len(content) + (n_chunks - 1) * overlap_chars) // n_chunks @@ -634,11 +838,26 @@ def _do_chunk(i_text): for i, summary_text in ex.map(_do_chunk, enumerate(chunks)): chunk_summaries[i] = summary_text - # Reduce - merged_input = "\n\n".join( - f"=== Chunk {i + 1}/{n_chunks} ===\n{s}" - for i, s in enumerate(chunk_summaries) if s is not None - ) + # Reduce only a bounded aggregate of map outputs. The source and every map + # response are capped independently, so a pathological document cannot + # build an unbounded in-memory reduce prompt. + try: + reduce_cap = int(config.get( + "summarize_reduce_max_input_chars", _DEFAULT_SUMMARIZE_REDUCE_INPUT_CHARS, + )) + except (TypeError, ValueError): + reduce_cap = _DEFAULT_SUMMARIZE_REDUCE_INPUT_CHARS + reduce_cap = max(2_000, reduce_cap) + merged_parts: list[str] = [] + merged_chars = 0 + for i, summary_text in enumerate(chunk_summaries): + if summary_text is None or merged_chars >= reduce_cap: + break + part = f"=== Chunk {i + 1}/{n_chunks} ===\n{summary_text}\n\n" + remaining = reduce_cap - merged_chars + merged_parts.append(part[:remaining]) + merged_chars += min(len(part), remaining) + merged_input = "".join(merged_parts) final = _summarize_chunk_via_llm(merged_input, focus, config, mode="reduce") return ( @@ -656,7 +875,7 @@ def _do_chunk(i_text): "Summarize a file that may be too large to fit in your context " "window. Reads the file (PDF / txt / md / code), splits it " "into N chunks adaptive to file size (1 chunk if it fits, " - "more for larger files — no hard cap), summarizes each chunk " + "more for larger files within the configured input cap), summarizes each chunk " "in parallel via sub-LLM calls (up to 8 workers), then merges " "into one unified summary. Use this for papers, books, long " "logs, large code files, or any document where Read would " diff --git a/cheetahclaws/tools/fs.py b/cheetahclaws/tools/fs.py index fa553e09..05429f52 100644 --- a/cheetahclaws/tools/fs.py +++ b/cheetahclaws/tools/fs.py @@ -5,6 +5,14 @@ from pathlib import Path +# A Read call should never materialize an arbitrarily large file (or even an +# arbitrarily long single line) before the agent can apply its output cap. +_LINE_SCAN_BYTES = 8 * 1024 +_DEFAULT_READ_MAX_BYTES = 256 * 1024 +_DEFAULT_READ_SCAN_MAX_BYTES = 2 * 1024 * 1024 +_DEFAULT_READ_MAX_OUTPUT_CHARS = 50_000 + + def _read_preserving_newlines(p: Path) -> str: """Read a text file without newline translation. @@ -41,19 +49,221 @@ def maybe_truncate_diff(diff_text: str, max_lines: int = 80) -> str: # ── Read ───────────────────────────────────────────────────────────────── -def _read(file_path: str, limit: int = None, offset: int = None) -> str: +def _read_logical_line(handle, capture_bytes: int | None, scan_remaining: int): + """Read one newline-preserving binary line with strict byte budgets. + + Reading bytes rather than ``TextIOWrapper.readline(size)`` prevents a + UTF-8-heavy line from turning a character limit into a several-times-larger + byte read. Any bytes after a line ending are rewound, so a CRLF split at a + chunk boundary is never exposed as a spurious blank line. + """ + chunks: list[bytes] = [] + captured = 0 + scanned = 0 + + while True: + remaining = scan_remaining - scanned + if remaining <= 0: + return b"".join(chunks), False, False, scanned, False, True + + request_bytes = min(_LINE_SCAN_BYTES, remaining) + if capture_bytes is not None: + capture_remaining = capture_bytes - captured + if capture_remaining <= 0: + return b"".join(chunks), False, False, scanned, True, False + request_bytes = min(request_bytes, capture_remaining) + piece = handle.read(request_bytes) + if not piece: + return b"".join(chunks), bool(chunks), True, scanned, False, False + + cr_index = piece.find(b"\r") + lf_index = piece.find(b"\n") + newline_index = min( + (idx for idx in (cr_index, lf_index) if idx >= 0), + default=-1, + ) + take = len(piece) if newline_index < 0 else newline_index + 1 + is_cr = newline_index >= 0 and piece[newline_index:newline_index + 1] == b"\r" + if is_cr and take < len(piece) and piece[take:take + 1] == b"\n": + take += 1 + + tail = piece[take:] + if tail: + # ``handle`` is a regular binary file, so this only moves its + # cursor back over bytes already read in the small bounded chunk. + handle.seek(-len(tail), 1) + selected = piece[:take] + if capture_bytes is not None: + chunks.append(selected) + captured += len(selected) + scanned += len(selected) + + if newline_index >= 0: + if is_cr and take == len(piece): + # A CR at the end of a chunk may start CRLF. Probe only when + # the caller still has budget to retain that final LF. + can_probe = ( + scanned < scan_remaining + and (capture_bytes is None or captured < capture_bytes) + ) + if can_probe: + next_byte = handle.read(1) + if next_byte == b"\n": + if capture_bytes is not None: + chunks.append(next_byte) + scanned += 1 + captured += 1 + elif next_byte: + handle.seek(-1, 1) + else: + # The caller stopped exactly at CR. Treat this as an + # incomplete line rather than later splitting CRLF into + # a blank logical line. + return ( + b"".join(chunks), False, False, scanned, + capture_bytes is not None and captured >= capture_bytes, + scanned >= scan_remaining, + ) + return b"".join(chunks), True, False, scanned, False, False + + if capture_bytes is not None and captured >= capture_bytes: + return b"".join(chunks), False, False, scanned, True, False + if scanned >= scan_remaining: + return b"".join(chunks), False, False, scanned, False, True + + +def _with_stop_marker(rendered: list[str], marker: str, output_limit: int) -> str: + """Add a stop marker without letting it exceed the configured output cap.""" + marker = marker[:output_limit] + if not rendered: + return marker + visible = "".join(rendered) + return visible[:max(0, output_limit - len(marker))] + marker + + +def _read( + file_path: str, + limit: int = None, + offset: int = None, + max_bytes: int = _DEFAULT_READ_MAX_BYTES, + scan_max_bytes: int = _DEFAULT_READ_SCAN_MAX_BYTES, + max_output_chars: int = _DEFAULT_READ_MAX_OUTPUT_CHARS, +) -> str: + """Stream a numbered text slice with bounded I/O and rendered output.""" p = Path(file_path) if not p.exists(): return f"Error: file not found: {file_path}" if p.is_dir(): return f"Error: {file_path} is a directory" try: - lines = _read_preserving_newlines(p).splitlines(keepends=True) - start = offset or 0 - chunk = lines[start:start + limit] if limit else lines[start:] - if not chunk: + start = max(0, int(offset or 0)) + line_limit = int(limit) if limit else None + byte_limit = max(1, int(max_bytes or _DEFAULT_READ_MAX_BYTES)) + scan_limit = max(1, int(scan_max_bytes or _DEFAULT_READ_SCAN_MAX_BYTES)) + output_limit = max(1, int(max_output_chars or _DEFAULT_READ_MAX_OUTPUT_CHARS)) + rendered: list[str] = [] + rendered_lines = 0 + source_bytes = 0 + scanned_bytes = 0 + rendered_chars = 0 + line_no = 0 + source_budget_hit = False + scan_budget_hit = False + output_budget_hit = False + + file_size = p.stat().st_size + with p.open("rb") as handle: + while True: + if line_limit is not None and rendered_lines >= line_limit: + break + if source_bytes >= byte_limit: + # A size check avoids an extra unbounded text-buffer read + # solely to distinguish exact EOF from a longer file. + source_budget_hit = handle.tell() < file_size + break + if scanned_bytes >= scan_limit: + scan_budget_hit = True + break + + if line_no < start: + _, ended, eof, consumed, _, scan_hit = _read_logical_line( + handle, None, scan_limit - scanned_bytes, + ) + scanned_bytes += consumed + if not ended: + if scan_hit: + scan_budget_hit = True + if eof or scan_budget_hit: + break + else: + line_no += 1 + if eof: + break + continue + + prefix = f"{line_no + 1:6}\t" + output_room = output_limit - rendered_chars + if output_room <= len(prefix): + output_budget_hit = True + break + source_remaining = byte_limit - source_bytes + output_content_room = output_room - len(prefix) + capture_bytes = min(source_remaining, output_content_room) + source_constrained = source_remaining <= output_content_room + raw, ended, eof, consumed, capture_hit, scan_hit = _read_logical_line( + handle, max(1, capture_bytes), scan_limit - scanned_bytes, + ) + scanned_bytes += consumed + if not raw and eof: + break + # A cap can split a multi-byte codepoint. Dropping only the + # incomplete tail preserves valid UTF-8 and the byte ceiling. + text = raw.decode( + "utf-8", errors="ignore" if capture_hit or scan_hit else "replace", + ) + line_no += 1 + source_bytes += len(raw) + formatted = prefix + text + rendered.append(formatted) + rendered_chars += len(formatted) + rendered_lines += 1 + if capture_hit: + if source_constrained: + source_budget_hit = handle.tell() < file_size + else: + output_budget_hit = True + break + if scan_hit: + scan_budget_hit = True + break + if not ended: + # EOF after a final line with no terminator is still a + # valid logical line; any other incomplete line hit a cap. + if not eof: + scan_budget_hit = True + break + + if scan_budget_hit: + marker = ( + f"[... Read stopped after scanning {scan_limit:,} bytes; " + "use a smaller offset or a narrower file ...]\n" + ) + return _with_stop_marker(rendered, "\n" + marker if rendered else marker, output_limit) + elif source_budget_hit: + marker = ( + f"[... Read stopped after {byte_limit:,} source bytes; use " + "offset and limit to request another line range ...]\n" + ) + return _with_stop_marker(rendered, "\n" + marker if rendered else marker, output_limit) + elif output_budget_hit: + marker = ( + f"[... Read output capped at {output_limit:,} characters to " + "keep memory and model context bounded ...]\n" + ) + return _with_stop_marker(rendered, "\n" + marker if rendered else marker, output_limit) + if not rendered: return "(empty file)" - return "".join(f"{start + i + 1:6}\t{l}" for i, l in enumerate(chunk)) + return "".join(rendered) except Exception as e: return f"Error: {e}" diff --git a/cheetahclaws/tools/web.py b/cheetahclaws/tools/web.py index d6fa8732..1e2e6bc8 100644 --- a/cheetahclaws/tools/web.py +++ b/cheetahclaws/tools/web.py @@ -1,52 +1,420 @@ """tools_web.py — Web tool implementations: WebFetch, WebSearch.""" from __future__ import annotations -import re +import asyncio +import time +from html.parser import HTMLParser +from urllib.parse import urljoin -def _webfetch(url: str, prompt: str = None) -> str: +_DEFAULT_WEB_FETCH_MAX_BYTES = 512 * 1024 +_DEFAULT_WEB_MAX_SECONDS = 30 + + +def _coerce_max_seconds(value: int | float) -> float: + try: + return float(max(1, float(value))) + except (TypeError, ValueError): + return float(_DEFAULT_WEB_MAX_SECONDS) + + +class _HTMLTextExtractor(HTMLParser): + """Extract visible HTML text without regex backtracking hazards.""" + + def __init__(self, char_cap: int): + super().__init__(convert_charrefs=True) + self._char_cap = char_cap + self._parts: list[str] = [] + self._chars = 0 + self._ignored_depth = 0 + + def handle_starttag(self, tag, attrs): + if tag.lower() in {"script", "style", "noscript", "template"}: + self._ignored_depth += 1 + + def handle_endtag(self, tag): + if tag.lower() in {"script", "style", "noscript", "template"}: + self._ignored_depth = max(0, self._ignored_depth - 1) + + def handle_data(self, data): + if self._ignored_depth or not data or self._chars >= self._char_cap: + return + text = " ".join(data.split()) + if not text: + return + remaining = self._char_cap - self._chars + self._parts.append(text[:remaining]) + self._chars += min(len(text), remaining) + + def get_text(self) -> str: + return " ".join(self._parts).strip() + + +class _DuckDuckGoResultParser(HTMLParser): + """Small, bounded parser for DuckDuckGo's HTML-only result cards.""" + + def __init__(self, result_cap: int = 8, field_cap: int = 4_000): + super().__init__(convert_charrefs=True) + self._result_cap = result_cap + self._field_cap = field_cap + self.results: list[dict[str, str]] = [] + self._current: dict[str, str] | None = None + self._result_div_depth = 0 + self._title_depth = 0 + self._snippet_depth = 0 + + @staticmethod + def _classes(attrs) -> set[str]: + return set(dict(attrs).get("class", "").split()) + + def _finish_current(self) -> None: + if self._current and (self._current["title"] or self._current["link"]): + self.results.append(self._current) + self._current = None + self._result_div_depth = 0 + self._title_depth = 0 + self._snippet_depth = 0 + + def handle_starttag(self, tag, attrs): + classes = self._classes(attrs) + if tag.lower() == "div" and "result" in classes: + self._finish_current() + if len(self.results) < self._result_cap: + self._current = {"title": "", "link": "", "snippet": ""} + self._result_div_depth = 1 + if self._current is None: + return + if tag.lower() == "div" and not ("result" in classes and self._result_div_depth == 1): + self._result_div_depth += 1 + if "result__title" in classes: + self._title_depth += 1 + elif self._title_depth: + self._title_depth += 1 + if "result__snippet" in classes: + self._snippet_depth += 1 + elif self._snippet_depth: + self._snippet_depth += 1 + if tag.lower() == "a" and self._title_depth: + href = dict(attrs).get("href") + if href: + self._current["link"] = href[:self._field_cap] + + def handle_endtag(self, tag): + if tag.lower() == "div" and self._current is not None: + self._result_div_depth -= 1 + if self._result_div_depth <= 0: + self._finish_current() + return + if self._title_depth: + self._title_depth -= 1 + if self._snippet_depth: + self._snippet_depth -= 1 + + def handle_data(self, data): + if self._current is None or not data: + return + field = "title" if self._title_depth else "snippet" if self._snippet_depth else None + if not field: + return + text = " ".join(data.split()) + if not text: + return + existing = self._current[field] + if len(existing) < self._field_cap: + separator = " " if existing else "" + self._current[field] = (existing + separator + text)[:self._field_cap] + + def close(self): + super().close() + self._finish_current() + + +def _read_response_bytes( + response, + max_bytes: int, + max_seconds: int | float = _DEFAULT_WEB_MAX_SECONDS, + deadline: float | None = None, +) -> tuple[bytes, bool]: + """Consume at most ``max_bytes`` from a streamed HTTP response.""" + data = bytearray() + truncated = False + deadline = deadline if deadline is not None else time.monotonic() + _coerce_max_seconds(max_seconds) + try: + content_length = int(response.headers.get("content-length", "")) + except (TypeError, ValueError): + content_length = None + + if time.monotonic() >= deadline: + return b"", True + # With identity encoding, raw chunks avoid HTTPX's decoded-byte chunker + # buffering a slow drip until it reaches 64 KiB before we can check time. + iterator = response.iter_raw() if hasattr(response, "iter_raw") else response.iter_bytes(chunk_size=64 * 1024) + for chunk in iterator: + if time.monotonic() >= deadline: + truncated = True + break + remaining = max_bytes - len(data) + if remaining <= 0: + truncated = True + break + if len(chunk) > remaining: + data.extend(chunk[:remaining]) + truncated = True + break + data.extend(chunk) + if len(data) == max_bytes: + # Stop immediately: an unknown length is conservatively marked as + # truncated rather than waiting for another possibly slow chunk. + truncated = content_length is None or content_length > len(data) + break + + if content_length is not None: + truncated = truncated or content_length > len(data) + return bytes(data), truncated + + +def _stream_identity_bytes(httpx, url: str, *, params: dict | None, + headers: dict[str, str], max_bytes: int, + deadline: float) -> tuple[bytes | None, bool, dict, str, str | None]: + """Follow a small redirect chain without resetting the total deadline.""" + current_url = url + current_params = params + for redirect_count in range(6): + remaining = deadline - time.monotonic() + if remaining <= 0: + return None, True, {}, "utf-8", "Error: web request exceeded its elapsed-time budget." + with httpx.stream( + "GET", current_url, params=current_params, headers=headers, + timeout=remaining, follow_redirects=False, + ) as response: + response_headers = dict(response.headers) + status = getattr(response, "status_code", 0) + location = response_headers.get("location") + if status in {301, 302, 303, 307, 308} and location: + if redirect_count == 5: + return None, True, {}, "utf-8", "Error: web request exceeded 5 redirects." + current_url = urljoin(str(getattr(response, "url", current_url)), location) + current_params = None + continue + response.raise_for_status() + content_encoding = response_headers.get("content-encoding", "identity").lower() + if content_encoding not in {"", "identity"}: + return ( + None, True, {}, "utf-8", + "Error: compressed HTTP responses are not accepted; retry with identity encoding.", + ) + raw, truncated = _read_response_bytes( + response, max_bytes, deadline=deadline, + ) + return raw, truncated, response_headers, response.encoding or "utf-8", None + return None, True, {}, "utf-8", "Error: web request could not resolve redirects." + + +async def _read_response_bytes_async( + response, + max_bytes: int, + deadline: float, +) -> tuple[bytes, bool]: + """Async counterpart that permits cancellation while a body is stalled.""" + data = bytearray() + truncated = False + try: + content_length = int(response.headers.get("content-length", "")) + except (TypeError, ValueError): + content_length = None + + async for chunk in response.aiter_raw(): + if time.monotonic() >= deadline: + truncated = True + break + remaining = max_bytes - len(data) + if remaining <= 0: + truncated = True + break + if len(chunk) > remaining: + data.extend(chunk[:remaining]) + truncated = True + break + data.extend(chunk) + if len(data) == max_bytes: + truncated = content_length is None or content_length > len(data) + break + + if content_length is not None: + truncated = truncated or content_length > len(data) + return bytes(data), truncated + + +async def _stream_identity_bytes_async_impl( + httpx, + url: str, + *, + params: dict | None, + headers: dict[str, str], + max_bytes: int, + deadline: float, +) -> tuple[bytes | None, bool, dict, str, str | None]: + """Async transport path; outer ``wait_for`` owns the global deadline.""" + remaining = deadline - time.monotonic() + if remaining <= 0: + return None, True, {}, "utf-8", "Error: web request exceeded its elapsed-time budget." + + async with httpx.AsyncClient( + headers=headers, + timeout=remaining, + follow_redirects=False, + ) as client: + current_url = url + current_params = params + for redirect_count in range(6): + request = client.build_request( + "GET", current_url, params=current_params, + ) + response = await client.send(request, stream=True) + try: + response_headers = dict(response.headers) + status = getattr(response, "status_code", 0) + location = response_headers.get("location") + if status in {301, 302, 303, 307, 308} and location: + if redirect_count == 5: + return None, True, {}, "utf-8", "Error: web request exceeded 5 redirects." + current_url = urljoin(str(getattr(response, "url", current_url)), location) + current_params = None + continue + response.raise_for_status() + content_encoding = response_headers.get("content-encoding", "identity").lower() + if content_encoding not in {"", "identity"}: + return ( + None, True, {}, "utf-8", + "Error: compressed HTTP responses are not accepted; retry with identity encoding.", + ) + raw, truncated = await _read_response_bytes_async( + response, max_bytes, deadline, + ) + return raw, truncated, response_headers, response.encoding or "utf-8", None + finally: + await response.aclose() + return None, True, {}, "utf-8", "Error: web request could not resolve redirects." + + +async def _stream_identity_bytes_async( + httpx, + url: str, + *, + params: dict | None, + headers: dict[str, str], + max_bytes: int, + deadline: float, +) -> tuple[bytes | None, bool, dict, str, str | None]: + """Enforce one cancellable elapsed-time budget for the entire request.""" + remaining = deadline - time.monotonic() + if remaining <= 0: + return None, True, {}, "utf-8", "Error: web request exceeded its elapsed-time budget." + try: + return await asyncio.wait_for( + _stream_identity_bytes_async_impl( + httpx, url, params=params, headers=headers, + max_bytes=max_bytes, deadline=deadline, + ), + timeout=remaining, + ) + except asyncio.TimeoutError: + return None, True, {}, "utf-8", "Error: web request exceeded its elapsed-time budget." + + +def _stream_bounded_identity_bytes(httpx, url: str, *, params: dict | None, + headers: dict[str, str], max_bytes: int, + deadline: float) -> tuple[bytes | None, bool, dict, str, str | None]: + """Use cancellable async I/O in production, retaining a minimal test fallback.""" + if not hasattr(httpx, "AsyncClient"): + # Lightweight fake HTTP clients used by unit tests only implement the + # synchronous ``stream`` API. Real httpx always exposes AsyncClient. + return _stream_identity_bytes( + httpx, url, params=params, headers=headers, + max_bytes=max_bytes, deadline=deadline, + ) + return asyncio.run(_stream_identity_bytes_async( + httpx, url, params=params, headers=headers, + max_bytes=max_bytes, deadline=deadline, + )) + + +def _webfetch( + url: str, + prompt: str = None, + max_bytes: int = _DEFAULT_WEB_FETCH_MAX_BYTES, + max_seconds: int | float = _DEFAULT_WEB_MAX_SECONDS, +) -> str: try: import httpx - r = httpx.get(url, headers={"User-Agent": "NanoClaude/1.0"}, - timeout=30, follow_redirects=True) - r.raise_for_status() - ct = r.headers.get("content-type", "") - if "html" in ct: - text = re.sub(r"]*>.*?", "", r.text, - flags=re.DOTALL | re.IGNORECASE) - text = re.sub(r"]*>.*?", "", text, - flags=re.DOTALL | re.IGNORECASE) - text = re.sub(r"<[^>]+>", " ", text) - text = re.sub(r"\s+", " ", text).strip() - else: - text = r.text - return text[:25000] + byte_limit = max(1, int(max_bytes or _DEFAULT_WEB_FETCH_MAX_BYTES)) + seconds = _coerce_max_seconds(max_seconds) + deadline = time.monotonic() + seconds + raw, source_truncated, response_headers, encoding, error = _stream_bounded_identity_bytes( + httpx, url, params=None, + headers={"User-Agent": "NanoClaude/1.0", "Accept-Encoding": "identity"}, + max_bytes=byte_limit, deadline=deadline, + ) + if error: + return error + assert raw is not None + content_type = response_headers.get("content-type", "") + + text = raw.decode(encoding, errors="replace") + if "html" in content_type.lower(): + extractor = _HTMLTextExtractor(char_cap=25_000) + extractor.feed(text) + extractor.close() + text = extractor.get_text() + + output = text[:25000] + if source_truncated: + output += ( + f"\n\n[... WebFetch stopped after {len(raw):,} response bytes " + "at its configured byte bound or elapsed collection budget ...]" + ) + return output except ImportError: return "Error: httpx not installed — run: pip install httpx" except Exception as e: return f"Error: {e}" -def _websearch(query: str) -> str: +def _websearch( + query: str, + max_bytes: int = _DEFAULT_WEB_FETCH_MAX_BYTES, + max_seconds: int | float = _DEFAULT_WEB_MAX_SECONDS, +) -> str: try: import httpx + byte_limit = max(1, int(max_bytes or _DEFAULT_WEB_FETCH_MAX_BYTES)) + seconds = _coerce_max_seconds(max_seconds) + deadline = time.monotonic() + seconds url = "https://html.duckduckgo.com/html/" - r = httpx.get(url, params={"q": query}, - headers={"User-Agent": "Mozilla/5.0 (compatible)"}, - timeout=30, follow_redirects=True) - titles = re.findall( - r'class="result__title"[^>]*>.*?]*href="([^"]+)"[^>]*>(.*?)', - r.text, re.DOTALL, - ) - snippets = re.findall( - r'class="result__snippet"[^>]*>(.*?)', r.text, re.DOTALL, + raw, source_truncated, _headers, encoding, error = _stream_bounded_identity_bytes( + httpx, url, params={"q": query}, + headers={ + "User-Agent": "Mozilla/5.0 (compatible)", + "Accept-Encoding": "identity", + }, + max_bytes=byte_limit, deadline=deadline, ) - results = [] - for i, (link, title) in enumerate(titles[:8]): - t = re.sub(r"<[^>]+>", "", title).strip() - s = re.sub(r"<[^>]+>", "", snippets[i]).strip() if i < len(snippets) else "" - results.append(f"**{t}**\n{link}\n{s}") - return "\n\n".join(results) if results else "No results found" + if error: + return error + assert raw is not None + parser = _DuckDuckGoResultParser() + parser.feed(raw.decode(encoding, errors="replace")) + parser.close() + output = "\n\n".join( + f"**{result['title']}**\n{result['link']}\n{result['snippet']}" + for result in parser.results + ) or "No results found" + if source_truncated: + output += ( + f"\n\n[... WebSearch stopped after {len(raw):,} response bytes " + "at its configured byte bound or elapsed collection budget ...]" + ) + return output except ImportError: return "Error: httpx not installed — run: pip install httpx" except Exception as e: diff --git a/cheetahclaws/web/api.py b/cheetahclaws/web/api.py index 1b935d70..4f73bcd3 100644 --- a/cheetahclaws/web/api.py +++ b/cheetahclaws/web/api.py @@ -151,12 +151,12 @@ def _web_handle_slash(line: str, state, config): _SAFE_CONFIG_KEYS = frozenset({ "model", "permission_mode", "max_tokens", "verbose", "thinking", "thinking_budget", "max_tool_output", "max_agent_depth", - "shell_policy", "log_level", + "shell_policy", "log_level", "tool_profile", }) _WRITABLE_CONFIG_KEYS = frozenset({ "model", "permission_mode", "verbose", "thinking", - "thinking_budget", "max_tokens", + "thinking_budget", "max_tokens", "tool_profile", # API keys — written to session config only, not persisted to disk "anthropic_api_key", "openai_api_key", "gemini_api_key", "kimi_api_key", "qwen_api_key", "zhipu_api_key", @@ -863,8 +863,16 @@ def get_safe_config(self) -> dict: return result def update_config(self, updates: dict) -> dict: + if not isinstance(updates, dict): + raise ValueError("Config update must be an object.") + normalized_profile = None + if "tool_profile" in updates: + from cheetahclaws.tool_registry import normalize_tool_profile + normalized_profile = normalize_tool_profile(updates["tool_profile"]) for k, v in updates.items(): if k in _WRITABLE_CONFIG_KEYS: + if k == "tool_profile": + v = normalized_profile self.config[k] = v # Persist non-secret config keys to DB (secrets stay session-only) try: diff --git a/cheetahclaws/web/chat.html b/cheetahclaws/web/chat.html index 3c6c161e..9c4df65f 100644 --- a/cheetahclaws/web/chat.html +++ b/cheetahclaws/web/chat.html @@ -662,6 +662,15 @@

Behavior

+
+ + +
diff --git a/cheetahclaws/web/server.py b/cheetahclaws/web/server.py index 89208819..4751ba58 100644 --- a/cheetahclaws/web/server.py +++ b/cheetahclaws/web/server.py @@ -1869,8 +1869,16 @@ def _sse_callback(evt_dict): _send_json(sock, chat_sess.get_safe_config(), request_origin=origin) elif method == "PATCH" and chat_sess: - updated = chat_sess.update_config(body_json.get("config", {})) - _send_json(sock, updated, request_origin=origin) + try: + updated = chat_sess.update_config(body_json.get("config", {})) + except ValueError as exc: + _send_http( + sock, "400 Bad Request", "application/json", + json.dumps({"error": str(exc)}).encode(), + request_origin=origin, + ) + else: + _send_json(sock, updated, request_origin=origin) else: _send_http(sock, "404 Not Found", "text/plain", b"session not found", request_origin=origin) diff --git a/cheetahclaws/web/static/js/settings.js b/cheetahclaws/web/static/js/settings.js index f9808c6b..5585f0f6 100644 --- a/cheetahclaws/web/static/js/settings.js +++ b/cheetahclaws/web/static/js/settings.js @@ -100,6 +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-thinking').className = 'sp-toggle' + (cfg.thinking ? ' on' : ''); document.getElementById('sp-verbose').className = diff --git a/tests/e2e_plan_tools.py b/tests/e2e_plan_tools.py index f8ff7aba..7282e8d5 100644 --- a/tests/e2e_plan_tools.py +++ b/tests/e2e_plan_tools.py @@ -153,6 +153,9 @@ def _run(tmpdir): print(SEP) from cheetahclaws.context import build_system_prompt config["permission_mode"] = "auto" + # Plan tools live on the explicit orchestration surface; the compact + # default coding surface deliberately does not advertise them. + config["tool_profile"] = "orchestration" prompt = build_system_prompt(config) assert "EnterPlanMode" in prompt assert "ExitPlanMode" in prompt diff --git a/tests/e2e_prompt_regression.py b/tests/e2e_prompt_regression.py index a13f93f1..3f192aae 100644 --- a/tests/e2e_prompt_regression.py +++ b/tests/e2e_prompt_regression.py @@ -30,6 +30,9 @@ (re.compile(r"^- Current date: .+$", re.M), "- Current date: "), (re.compile(r"^- Working directory: .+$", re.M), "- Working directory: "), (re.compile(r"^- Platform: .+$", re.M), "- Platform: "), + # Voice commands are optional and cannot import on Python versions below + # the package's supported baseline, so they are not prompt-shape signals. + (re.compile(r"^- `/(?:tts|voice)`.*\n", re.M), ""), ] @@ -65,7 +68,7 @@ def test_default_prompt_matches_golden(tmp_path, monkeypatch): f"Regenerate with: python {__file__} --regenerate" ) actual = _generate_masked_prompt(tmp_path, monkeypatch) - expected = _FIXTURE.read_text(encoding="utf-8") + expected = _mask(_FIXTURE.read_text(encoding="utf-8")) assert actual == expected, ( "Default prompt drifted from golden fixture.\n" f"If this change is intentional, regenerate the fixture:\n" diff --git a/tests/fixtures/golden_default_prompt.txt b/tests/fixtures/golden_default_prompt.txt index 07207b8c..921e5c56 100644 --- a/tests/fixtures/golden_default_prompt.txt +++ b/tests/fixtures/golden_default_prompt.txt @@ -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____`. 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. @@ -61,16 +25,16 @@ 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. @@ -78,15 +42,11 @@ Asking the user for information you could have found yourself in one tool call i 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). @@ -97,10 +57,10 @@ Return control to the user when: `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. +# Active Tool Surface +- Profile: `standard` +- Enabled tools: `AskUserQuestion`, `Bash`, `Edit`, `GetDiagnostics`, `Glob`, `Grep`, `MemoryDelete`, `MemoryList`, `MemorySave`, `MemorySearch`, `MemoryVerify`, `NotebookEdit`, `Read`, `Write` +- Call only the enabled tools above; a tool mentioned elsewhere is not available unless it appears in this list. # Environment - Current date: diff --git a/tests/test_agent_tool_profiles.py b/tests/test_agent_tool_profiles.py new file mode 100644 index 00000000..92c8898e --- /dev/null +++ b/tests/test_agent_tool_profiles.py @@ -0,0 +1,83 @@ +"""Agent-level contract: the advertised and executable tool sets match.""" +from __future__ import annotations + +from cheetahclaws import agent +from cheetahclaws.agent import AgentState, run +from cheetahclaws.providers import AssistantTurn + + +def _turn(text="", tool_calls=None): + value = AssistantTurn.__new__(AssistantTurn) + value.text = text + value.tool_calls = tool_calls or [] + value.in_tokens = 1 + value.out_tokens = 1 + value.cache_read_tokens = 0 + value.cache_write_tokens = 0 + return value + + +def _config(**extra): + return { + "model": "test", + "permission_mode": "accept-all", + "_session_id": "tool-profile-test", + **extra, + } + + +def test_standard_profile_sends_only_the_compact_surface(monkeypatch): + seen_schemas = [] + + def fake_stream(**kwargs): + seen_schemas.append(kwargs["tool_schemas"]) + yield _turn("done") + + monkeypatch.setattr(agent, "stream", fake_stream) + + list(run("hello", AgentState(), _config(), "system")) + + names = {schema["name"] for schema in seen_schemas[0]} + assert "Read" in names + assert "MemorySearch" in names + assert "WebFetch" not in names + assert "Agent" not in names + assert "ReadPDF" not in names + + +def test_research_profile_exposes_web_and_document_tools(monkeypatch): + seen_schemas = [] + + def fake_stream(**kwargs): + seen_schemas.append(kwargs["tool_schemas"]) + yield _turn("done") + + monkeypatch.setattr(agent, "stream", fake_stream) + + list(run("hello", AgentState(), _config(tool_profile="research"), "system")) + + names = {schema["name"] for schema in seen_schemas[0]} + assert {"Read", "WebFetch", "WebSearch", "ReadPDF"} <= names + assert "Agent" not in names + + +def test_tool_outside_profile_is_rejected_without_permission_prompt(monkeypatch): + replies = iter([ + _turn(tool_calls=[{ + "id": "stale-web", "name": "WebFetch", + "input": {"url": "https://example.test"}, + }]), + _turn("done"), + ]) + + def fake_stream(**_kwargs): + yield next(replies) + + monkeypatch.setattr(agent, "stream", fake_stream) + state = AgentState() + list(run("hello", state, _config(), "system")) + + tool_results = [m for m in state.messages if m.get("role") == "tool"] + assert len(tool_results) == 1 + assert "not enabled" in tool_results[0]["content"] + assert "standard" in tool_results[0]["content"] diff --git a/tests/test_bounded_tool_io.py b/tests/test_bounded_tool_io.py new file mode 100644 index 00000000..bcdf12ad --- /dev/null +++ b/tests/test_bounded_tool_io.py @@ -0,0 +1,447 @@ +"""Regression tests for bounded input work in high-volume tools.""" +from __future__ import annotations + +import sys +import threading +import time +import types +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from cheetahclaws.tools.files import ( + _extract_page_prefix, + _parse_page_range, + _parse_page_range_capped, + _read_pdf, +) +from cheetahclaws.tools.fs import _read +from cheetahclaws.tools.web import _webfetch, _websearch + + +def test_read_streams_requested_line_range(tmp_path): + path = tmp_path / "lines.txt" + path.write_text("zero\none\ntwo\nthree\n", encoding="utf-8") + + result = _read(str(path), offset=1, limit=2, max_bytes=1_000) + + assert " 2\tone" in result + assert " 3\ttwo" in result + assert "zero" not in result + assert "three" not in result + + +def test_read_caps_a_single_huge_line_without_loading_it(tmp_path): + path = tmp_path / "minified.js" + path.write_bytes(b"x" * 200_000 + b"\nnext\n") + + result = _read(str(path), max_bytes=128) + + assert "Read stopped after 128 source bytes" in result + assert len(result) < 1_000 + + +def test_read_never_returns_more_than_the_source_byte_cap_for_utf8(tmp_path): + path = tmp_path / "emoji.txt" + path.write_text("😀" * 10 + "\n", encoding="utf-8") + + result = _read(str(path), max_bytes=5, scan_max_bytes=100) + visible = result.split("\n[...", 1)[0].split("\t", 1)[1] + + assert len(visible.encode("utf-8")) <= 5 + assert "stopped after 5 source bytes" in result + + +def test_read_does_not_claim_truncation_at_exact_source_byte_eof(tmp_path): + path = tmp_path / "exact.txt" + path.write_bytes(b"exact") + + result = _read(str(path), max_bytes=5, scan_max_bytes=100) + + assert "exact" in result + assert "stopped after" not in result + + +def test_read_keeps_crlf_as_one_line_when_cr_hits_chunk_boundary(tmp_path): + path = tmp_path / "crlf-boundary.txt" + path.write_bytes(b"x" * 65_535 + b"\r\nsecond\r\n") + + result = _read(str(path), offset=1, limit=1, max_bytes=100_000) + + assert " 2\tsecond\r\n" in result + assert " 2\t\n" not in result + + +def test_webfetch_streams_and_caps_response(monkeypatch): + class FakeResponse: + headers = {"content-type": "text/html", "content-length": "1000"} + encoding = "utf-8" + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def raise_for_status(self): + return None + + def iter_bytes(self, **_kwargs): + yield b"

" + b"a" * 100 + yield b"b" * 900 + b"

" + + fake_httpx = types.SimpleNamespace(stream=lambda *_args, **_kwargs: FakeResponse()) + monkeypatch.setitem(sys.modules, "httpx", fake_httpx) + + result = _webfetch("https://example.test", max_bytes=128) + + assert "WebFetch stopped after 128 response bytes" in result + assert len(result) < 400 + + +def test_webfetch_stops_at_exact_cap_without_waiting_for_another_chunk(monkeypatch): + seen = [] + + class FakeResponse: + headers = {"content-type": "text/plain"} + encoding = "utf-8" + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def raise_for_status(self): + return None + + def iter_bytes(self, **_kwargs): + seen.append("first") + yield b"a" * 128 + seen.append("second") + yield b"never-read" + + fake_httpx = types.SimpleNamespace(stream=lambda *_args, **_kwargs: FakeResponse()) + monkeypatch.setitem(sys.modules, "httpx", fake_httpx) + + result = _webfetch("https://example.test", max_bytes=128) + + assert result.startswith("a" * 128) + assert seen == ["first"] + + +def test_webfetch_rejects_compressed_responses_before_decoding(monkeypatch): + seen = {} + + class FakeResponse: + headers = {"content-type": "text/plain", "content-encoding": "gzip"} + encoding = "utf-8" + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def raise_for_status(self): + return None + + def iter_bytes(self, **_kwargs): + pytest.fail("compressed body must not be decoded") + + def stream(*_args, **kwargs): + seen.update(kwargs) + return FakeResponse() + + monkeypatch.setitem(sys.modules, "httpx", types.SimpleNamespace(stream=stream)) + result = _webfetch("https://example.test") + + assert "compressed HTTP responses" in result + assert seen["headers"]["Accept-Encoding"] == "identity" + assert seen["follow_redirects"] is False + assert 0 < seen["timeout"] <= 30 + + +def test_websearch_streams_and_parses_bounded_html(monkeypatch): + class FakeResponse: + headers = {"content-length": "1000"} + encoding = "utf-8" + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def raise_for_status(self): + return None + + def iter_bytes(self, **_kwargs): + yield ( + b'

' + b'Bounded result

' + b'
A safe snippet
' + ) + + fake_httpx = types.SimpleNamespace(stream=lambda *_args, **_kwargs: FakeResponse()) + monkeypatch.setitem(sys.modules, "httpx", fake_httpx) + + result = _websearch("bounded", max_bytes=128) + + assert "Bounded result" in result + assert "https://example.test" in result + assert "WebSearch stopped after 128 response bytes" in result + + +def test_webfetch_follows_redirects_with_one_shared_elapsed_budget(monkeypatch): + calls = [] + + class RedirectResponse: + status_code = 302 + headers = {"location": "/next"} + encoding = "utf-8" + url = "https://example.test/start" + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def raise_for_status(self): + return None + + class FinalResponse: + status_code = 200 + headers = {"content-type": "text/plain"} + encoding = "utf-8" + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def raise_for_status(self): + return None + + def iter_raw(self): + yield b"done" + + responses = iter([RedirectResponse(), FinalResponse()]) + + def stream(*args, **kwargs): + calls.append((args, kwargs)) + return next(responses) + + monkeypatch.setitem(sys.modules, "httpx", types.SimpleNamespace(stream=stream)) + result = _webfetch("https://example.test/start", max_seconds=3) + + assert result == "done" + assert len(calls) == 2 + assert calls[0][1]["params"] is None + assert calls[1][0][1] == "https://example.test/next" + assert calls[1][1]["params"] is None + assert calls[0][1]["timeout"] <= 3 + assert calls[1][1]["timeout"] <= calls[0][1]["timeout"] + + +def test_webfetch_cancels_a_slow_drip_at_the_global_deadline(): + """A raw byte just before the deadline must not start a new full wait. + + This exercises the real async httpx path. The server sends one byte, + then keeps the connection open for longer than the configured budget. + ``asyncio.wait_for`` must cancel the pending body read near the deadline. + """ + pytest.importorskip("httpx") + + class SlowDripHandler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - required by BaseHTTPRequestHandler + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", "2") + self.end_headers() + self.wfile.write(b"a") + self.wfile.flush() + time.sleep(3) + try: + self.wfile.write(b"b") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + pass + + def log_message(self, *_args): + return None + + server = ThreadingHTTPServer(("127.0.0.1", 0), SlowDripHandler) + server.daemon_threads = True + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + started = time.monotonic() + result = _webfetch( + f"http://127.0.0.1:{server.server_port}/slow-drip", + max_seconds=1, + ) + elapsed = time.monotonic() - started + finally: + server.shutdown() + server.server_close() + + assert "exceeded its elapsed-time budget" in result + # A synchronous per-read timeout would wait for the server's 3-second + # sleep. Allow modest scheduler variance around the 1-second budget. + assert elapsed < 2 + + +def test_pdf_extract_stops_after_character_cap(monkeypatch, tmp_path): + class Rect: + def __init__(self, x0, y0, x1, y1): + self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1 + self.height = y1 - y0 + + class FakePage: + rect = Rect(0, 0, 100, 100) + + def __init__(self, text): + self._text = text + + def get_text(self, _mode, *, clip): + return self._text + + class FakeDoc: + def __init__(self): + self.pages = [FakePage("a" * 900), FakePage("b" * 900)] + self.closed = False + + def __len__(self): + return len(self.pages) + + def __getitem__(self, index): + return self.pages[index] + + def close(self): + self.closed = True + + doc = FakeDoc() + monkeypatch.setitem( + sys.modules, "fitz", types.SimpleNamespace(open=lambda _p: doc, Rect=Rect), + ) + path = tmp_path / "sample.pdf" + path.write_bytes(b"%PDF-fake") + + result = _read_pdf( + {"file_path": str(path)}, + {"pdf_extract_max_chars": 1_000, "pdf_extract_max_pages": 50}, + ) + + assert doc.closed is True + assert "ReadPDF stopped at 1,000 extracted characters" in result + assert len(result) < 1_500 + + +def test_pdf_page_range_is_bounded_before_building_a_large_list(): + assert _parse_page_range("1-1000000", 1_000_000, max_pages=3) == [0, 1, 2] + + +def test_pdf_page_range_reports_when_explicit_request_is_capped(): + pages, truncated = _parse_page_range_capped("1-3", 3, max_pages=2) + assert pages == [0, 1] + assert truncated is True + + +def test_pdf_page_range_ignores_out_of_range_singletons_before_capping(): + pages, truncated = _parse_page_range_capped("999,1,2", 3, max_pages=2) + assert pages == [0, 1] + assert truncated is False + + +def test_pdf_extract_uses_bounded_clipped_bands(): + class Rect: + def __init__(self, x0, y0, x1, y1): + self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1 + self.height = y1 - y0 + + class Page: + rect = Rect(0, 0, 100, 1_000_000_000) + + def __init__(self): + self.calls = [] + + def get_text(self, mode, *, clip): + self.calls.append((mode, clip)) + return "" + + page = Page() + fake_fitz = types.SimpleNamespace(Rect=Rect) + text, truncated = _extract_page_prefix(page, fake_fitz, 1_000) + + assert text == "" + assert truncated is True + assert len(page.calls) == 256 + + +def test_read_stops_seeking_when_offset_exceeds_scan_budget(tmp_path): + path = tmp_path / "many-lines.txt" + path.write_text("row\n" * 100, encoding="utf-8") + + result = _read( + str(path), offset=50, limit=1, + max_bytes=1_000, scan_max_bytes=16, + ) + + assert "stopped after scanning 16 bytes" in result + + +def test_read_keeps_legacy_cr_newline_offsets(tmp_path): + path = tmp_path / "classic-mac.txt" + path.write_bytes(b"first\rsecond\rthird\r") + + result = _read(str(path), offset=1, limit=1, max_bytes=1_000) + + assert " 2\tsecond\r" in result + + +def test_read_caps_rendered_output_for_many_short_lines(tmp_path): + path = tmp_path / "empty-lines.txt" + path.write_bytes(b"\n" * 10_000) + + result = _read(str(path), max_bytes=10_000, max_output_chars=200) + + assert "Read output capped at 200 characters" in result + assert len(result) <= 200 + + +def test_pdf_rejects_an_oversized_source_before_opening_it(monkeypatch, tmp_path): + path = tmp_path / "large.pdf" + path.write_bytes(b"x" * 2_048) + monkeypatch.setitem( + sys.modules, "fitz", types.SimpleNamespace(open=lambda _p: pytest.fail("opened")), + ) + + result = _read_pdf( + {"file_path": str(path)}, {"pdf_extract_max_file_bytes": 10}, + ) + + assert "larger than the 1,024-byte extraction limit" in result + + +def test_read_cache_cannot_bypass_allowed_root(tmp_path): + from cheetahclaws.tool_registry import clear_tool_cache + from cheetahclaws.tools import execute_tool + + path = tmp_path / "visible.txt" + path.write_text("private content\n", encoding="utf-8") + clear_tool_cache() + first = execute_tool( + "Read", {"file_path": str(path)}, permission_mode="accept-all", + config={"allowed_root": str(tmp_path), "_session_id": "root-change"}, + ) + second = execute_tool( + "Read", {"file_path": str(path)}, permission_mode="accept-all", + config={"allowed_root": str(tmp_path / "other"), "_session_id": "root-change"}, + ) + + assert "private content" in first + assert "Error:" in second + assert "private content" not in second diff --git a/tests/test_prompt_assembly.py b/tests/test_prompt_assembly.py index ccc58335..7130ec67 100644 --- a/tests/test_prompt_assembly.py +++ b/tests/test_prompt_assembly.py @@ -69,7 +69,9 @@ def test_tmux_fragment_absent_when_tmux_unavailable(monkeypatch): def test_tmux_fragment_present_when_tmux_available(monkeypatch): monkeypatch.setattr(_context, "_tmux_available", lambda: True) - prompt = _context.build_system_prompt(_base_config()) + prompt = _context.build_system_prompt(_base_config( + tool_profile="full", _active_tool_names=frozenset({"TmuxNewSession"}), + )) assert "TmuxNewSession" in prompt assert "## Tmux (Terminal Multiplexer)" in prompt @@ -97,7 +99,12 @@ def test_assembly_order_is_base_then_env_then_memory_then_plan(monkeypatch): from cheetahclaws import runtime runtime.get_session_ctx("test-session").plan_file = "/tmp/plan.md" try: - prompt = _context.build_system_prompt(_base_config(permission_mode="plan")) + prompt = _context.build_system_prompt( + _base_config( + permission_mode="plan", tool_profile="full", + _active_tool_names=frozenset({"TmuxNewSession"}), + ) + ) finally: runtime.get_session_ctx("test-session").plan_file = None runtime.release_session_ctx("test-session") @@ -105,7 +112,9 @@ def test_assembly_order_is_base_then_env_then_memory_then_plan(monkeypatch): idx_identity = prompt.index("CheetahClaws") idx_env = prompt.index("# Environment") idx_memory = prompt.index("Your persistent memories:") - idx_tmux = prompt.index("TmuxNewSession") + # The active-surface block may name TmuxNewSession earlier; use the + # fragment header to assert the assembly position of the actual guidance. + idx_tmux = prompt.index("## Tmux (Terminal Multiplexer)") idx_plan = prompt.index("# Plan Mode (ACTIVE)") assert idx_identity < idx_env < idx_memory < idx_tmux < idx_plan @@ -125,3 +134,44 @@ def test_missing_config_falls_back_to_default(): # The base portion of the prompt must match default.md verbatim, so # we can assert by checking the prompt starts with default's opening line. assert prompt.lstrip().startswith(default_body.splitlines()[0]) + + +def test_active_tool_surface_matches_the_selected_profile(monkeypatch): + monkeypatch.setattr(_context, "_tmux_available", lambda: False) + + standard = _context.build_system_prompt(_base_config(tool_profile="standard")) + research = _context.build_system_prompt(_base_config(tool_profile="research")) + + assert "# Active Tool Surface" in standard + assert "`WebFetch`" not in standard + assert "`WebFetch`" in research + + +def test_planning_hint_is_only_shown_when_plan_tools_are_active(monkeypatch): + """The compact default must not rely on slash-command import side effects.""" + monkeypatch.setattr(_context, "_tmux_available", lambda: False) + monkeypatch.setattr(_context, "_render_commands_block", lambda: "") + + standard = _context.build_system_prompt(_base_config(tool_profile="standard")) + orchestration = _context.build_system_prompt( + _base_config(tool_profile="orchestration") + ) + + assert "For complex or multi-file work" not in standard + assert "For complex or multi-file work" in orchestration + + +def test_standard_surface_omits_tmux_fragment_even_when_available(monkeypatch): + monkeypatch.setattr(_context, "_tmux_available", lambda: True) + + prompt = _context.build_system_prompt(_base_config(tool_profile="standard")) + + assert "TmuxNewSession" not in prompt + + +def test_full_surface_omits_tmux_fragment_when_tool_is_not_registered(monkeypatch): + monkeypatch.setattr(_context, "_tmux_available", lambda: True) + + prompt = _context.build_system_prompt(_base_config(tool_profile="full")) + + assert "TmuxNewSession" not in prompt diff --git a/tests/test_read_overflow_redirect.py b/tests/test_read_overflow_redirect.py index 9c482367..2f0ecb7b 100644 --- a/tests/test_read_overflow_redirect.py +++ b/tests/test_read_overflow_redirect.py @@ -180,3 +180,21 @@ def test_read_tool_passes_through_small_file(tmp_path): ) assert "ReadTooLarge" not in out assert "just a few lines" in out + + +def test_standard_profile_redirects_large_read_to_an_available_follow_up(tmp_path): + big = tmp_path / "big-cjk.txt" + big.write_text("English content " * 5_000, encoding="utf-8") + + from cheetahclaws.tools import execute_tool + out = execute_tool( + "Read", {"file_path": str(big)}, permission_mode="accept-all", + config={ + "model": "custom/qwen2.5-72b", "tool_profile": "standard", + "_active_tool_names": frozenset({"Read"}), + }, + ) + + assert "ReadTooLarge" in out + assert "SummarizeLargeFile" not in out + assert "narrower `offset` and `limit`" in out diff --git a/tests/test_summarize_large_file.py b/tests/test_summarize_large_file.py index cf2b4c65..d5061537 100644 --- a/tests/test_summarize_large_file.py +++ b/tests/test_summarize_large_file.py @@ -38,6 +38,10 @@ def test_estimate_text_tokens(text, expected_min, expected_max): assert expected_min <= n <= expected_max +def test_estimate_text_tokens_is_conservative_for_cjk(): + assert _estimate_text_tokens("中" * 10_000) == 10_000 + + # ── Chunk planner: adaptive to file size + model context ───────────────── @@ -95,6 +99,13 @@ def test_plan_chunks_covers_entire_content(): assert text[-1000:] in concat +def test_plan_chunks_keep_cjk_within_a_32k_context_budget(): + chunks = _plan_chunks("中" * 100_000, 32768) + + assert len(chunks) >= 4 + assert max(map(len, chunks)) <= 24_500 + + # ── File reader dispatch ───────────────────────────────────────────────── @@ -106,6 +117,52 @@ def test_read_file_for_summary_text_file(tmp_path): assert "line 2" in content +def test_read_file_for_summary_rejects_input_above_byte_cap(tmp_path): + p = tmp_path / "oversized.txt" + p.write_bytes(b"x" * 2_048) + + out = _read_file_for_summary(str(p), {"summarize_max_input_bytes": 1_024}) + + assert out.startswith("Error") + assert "1,024-byte summary input limit" in out + + +def test_pdf_summary_reader_bypasses_the_recursive_summary_redirect(monkeypatch, tmp_path): + class Rect: + def __init__(self, x0, y0, x1, y1): + self.x0, self.y0, self.x1, self.y1 = x0, y0, x1, y1 + self.height = y1 - y0 + + class Page: + rect = Rect(0, 0, 100, 100) + + def get_text(self, _mode, *, clip): + return "PDF SOURCE " * 2_000 if clip.y0 == 0 else "" + + class Doc: + def __len__(self): + return 1 + + def __getitem__(self, _index): + return Page() + + def close(self): + return None + + monkeypatch.setitem( + sys.modules, "fitz", type("Fitz", (), {"open": lambda _p: Doc(), "Rect": Rect}), + ) + p = tmp_path / "paper.pdf" + p.write_bytes(b"%PDF-fake") + + content = _read_file_for_summary( + str(p), {"model": "custom/qwen2.5-72b", "pdf_extract_max_chars": 30_000}, + ) + + assert "PDF SOURCE" in content + assert "ReadTooLarge" not in content + + def test_read_file_for_summary_missing_file(tmp_path): out = _read_file_for_summary(str(tmp_path / "nope.txt"), {}) assert out.startswith("Error") diff --git a/tests/test_tool_profile_config.py b/tests/test_tool_profile_config.py new file mode 100644 index 00000000..04042e55 --- /dev/null +++ b/tests/test_tool_profile_config.py @@ -0,0 +1,92 @@ +"""Compatibility and validation tests for tool-profile configuration.""" +from __future__ import annotations + +import json +import types +from pathlib import Path + +import pytest + +from cheetahclaws import config as config_module +from cheetahclaws.tool_registry import normalize_tool_profile + + +def test_legacy_saved_config_uses_compact_default_tool_surface(monkeypatch, tmp_path): + 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" + + +def test_saved_full_profile_remains_an_explicit_opt_in(monkeypatch, tmp_path): + config_file = tmp_path / "config.json" + config_file.write_text('{"tool_profile": "full"}', 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"] == "full" + + +def test_fresh_config_uses_compact_standard_profile(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"] == "standard" + + +@pytest.mark.parametrize("value", [1, ["standard"], {"profile": "full"}]) +def test_invalid_tool_profile_value_is_a_clean_validation_error(value): + with pytest.raises(ValueError): + normalize_tool_profile(value) + + +def test_web_session_exposes_and_updates_tool_profile(monkeypatch): + from cheetahclaws.web import api + + persisted = {} + fake_db = types.SimpleNamespace( + repo=types.SimpleNamespace( + upsert_session=lambda *args, **kwargs: persisted.update(kwargs), + ), + ) + import cheetahclaws.web as web_package + monkeypatch.setattr(web_package, "db", fake_db, raising=False) + + session = api.ChatSession.__new__(api.ChatSession) + session.config = {"tool_profile": "standard"} + session.session_id = "profile-test" + session.user_id = 1 + session.title = "Test" + + assert session.update_config({"tool_profile": "research"})["tool_profile"] == "research" + assert session.config["tool_profile"] == "research" + assert persisted["config"]["tool_profile"] == "research" + + with pytest.raises(ValueError): + session.update_config({"tool_profile": "not-a-profile"}) + + +def test_web_settings_expose_and_render_the_tool_profile_selector(): + root = Path(__file__).resolve().parent.parent + markup = (root / "cheetahclaws/web/chat.html").read_text(encoding="utf-8") + script = (root / "cheetahclaws/web/static/js/settings.js").read_text(encoding="utf-8") + + 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 + + +def test_terminal_config_rejects_invalid_tool_profile(monkeypatch): + from cheetahclaws import config as config_module + from cheetahclaws.commands.config_cmd import cmd_config + + monkeypatch.setattr(config_module, "save_config", lambda _config: None) + config = {"tool_profile": "standard"} + + assert cmd_config("tool_profile=not-a-profile", None, config) is False + assert config["tool_profile"] == "standard" diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index abd61041..33858aed 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -1,11 +1,15 @@ from __future__ import annotations +import threading + import pytest from cheetahclaws.tool_registry import ( ToolDef, + clear_tool_cache, clear_registry, execute_tool, + get_active_tool_names, get_all_tools, get_tool, get_tool_schemas, @@ -15,10 +19,15 @@ @pytest.fixture(autouse=True) def _clean_registry(): - """Reset registry before each test.""" + """Isolate registry tests without leaving later integration tests empty.""" + original_tools = get_all_tools() clear_registry() + clear_tool_cache() yield clear_registry() + clear_tool_cache() + for tool in original_tools: + register_tool(tool) def _make_echo_tool(name: str = "echo", read_only: bool = False) -> ToolDef: @@ -89,6 +98,46 @@ def test_get_tool_schemas(): assert schemas[0]["name"] == "echo" +def test_tool_profiles_filter_schemas_and_names(): + register_tool(ToolDef( + name="core", + schema={"name": "core", "input_schema": {}}, + func=lambda _p, _c: "core", + profiles=frozenset({"standard"}), + )) + register_tool(ToolDef( + name="research_only", + schema={"name": "research_only", "input_schema": {}}, + func=lambda _p, _c: "research", + profiles=frozenset({"research"}), + )) + register_tool(ToolDef( + name="full_only", + schema={"name": "full_only", "input_schema": {}}, + func=lambda _p, _c: "full", + profiles=frozenset({"full"}), + )) + + assert [s["name"] for s in get_tool_schemas("standard")] == ["core"] + assert [s["name"] for s in get_tool_schemas("research")] == [ + "core", "research_only", + ] + assert get_active_tool_names("orchestration") == frozenset({"core"}) + assert {s["name"] for s in get_tool_schemas("full")} == { + "core", "research_only", "full_only", + } + + +def test_tool_profiles_honor_disabled_tools(): + register_tool(ToolDef( + name="core", + schema={"name": "core", "input_schema": {}}, + func=lambda _p, _c: "core", + profiles=frozenset({"standard"}), + )) + assert get_tool_schemas("standard", disabled_tools=["core"]) == [] + + # ------------------------------------------------------------------ # execute_tool # ------------------------------------------------------------------ @@ -122,14 +171,27 @@ def big_func(params: dict, config: dict) -> str: register_tool(tool) result = execute_tool("big", {}, config={}, max_output=40) - # first half = 20 chars, last quarter = 10 chars, marker in between. - # The truncation marker now includes a model-context-safety message - # which is ~80-150 chars depending on file_path hint. - assert len(result) < 200 + # Tiny caps may only fit a marker; the hard cap always wins over keeping + # a prefix/suffix. + assert len(result) <= 40 + assert "truncated" in result + + +def test_output_truncation_never_exceeds_cap_with_a_long_file_path(): + register_tool(ToolDef( + name="path_big", + schema={"name": "path_big", "input_schema": {}}, + func=lambda _p, _c: "x" * 5_000, + read_only=True, + )) + + result = execute_tool( + "path_big", {"file_path": "/" + "x" * 2_000}, + {"tool_profile": "research"}, max_output=1_000, + ) + + assert len(result) <= 1_000 assert "truncated" in result - # The kept portion: first 20 + last 10 should be present - assert result.startswith("x" * 20) - assert result.endswith("x" * 10) def test_no_truncation_when_within_limit(): @@ -138,6 +200,155 @@ def test_no_truncation_when_within_limit(): assert result == "short" +def test_cache_stores_bounded_result_and_reapplies_smaller_cap(): + calls = 0 + + def big_func(params: dict, config: dict) -> str: + nonlocal calls + calls += 1 + return "x" * 20_000 + + register_tool(ToolDef( + name="cached_big", + schema={"name": "cached_big", "input_schema": {}}, + func=big_func, + read_only=True, + )) + + first = execute_tool( + "cached_big", {}, {"max_tool_cache_output": 2_000}, max_output=10_000, + ) + second = execute_tool( + "cached_big", {}, {"max_tool_cache_output": 2_000}, max_output=1_500, + ) + + assert calls == 1 + assert "truncated" in first + assert "truncated" in second + assert len(second) < len(first) + + +def test_cache_key_includes_input_bound_settings(): + calls = 0 + + def config_echo(_params: dict, config: dict) -> str: + nonlocal calls + calls += 1 + return str(config["tool_read_max_bytes"]) + + register_tool(ToolDef( + name="config_sensitive", + schema={"name": "config_sensitive", "input_schema": {}}, + func=config_echo, + read_only=True, + )) + + assert execute_tool("config_sensitive", {}, {"tool_read_max_bytes": 10}) == "10" + assert execute_tool("config_sensitive", {}, {"tool_read_max_bytes": 20}) == "20" + assert calls == 2 + + +def test_write_invalidation_cannot_recache_an_inflight_stale_read(): + started = threading.Event() + release = threading.Event() + calls = 0 + + def slow_read(_params: dict, _config: dict) -> str: + nonlocal calls + calls += 1 + value = "old" if calls == 1 else "new" + if calls == 1: + started.set() + assert release.wait(timeout=2) + return value + + register_tool(ToolDef( + name="slow_read", + schema={"name": "slow_read", "input_schema": {}}, + func=slow_read, + read_only=True, + )) + thread = threading.Thread( + target=lambda: execute_tool("slow_read", {}, {"_session_id": "race"}), + ) + thread.start() + assert started.wait(timeout=2) + clear_tool_cache() # Equivalent to a Write/Edit/Bash invalidation. + release.set() + thread.join(timeout=2) + assert not thread.is_alive() + + assert execute_tool("slow_read", {}, {"_session_id": "race"}) == "new" + assert calls == 2 + + +def test_post_write_invalidation_clears_a_read_cached_during_mutation(): + write_started = threading.Event() + release_write = threading.Event() + state = {"value": "old"} + reads = 0 + + def read_value(_params: dict, _config: dict) -> str: + nonlocal reads + reads += 1 + return state["value"] + + def slow_write(_params: dict, _config: dict) -> str: + write_started.set() + assert release_write.wait(timeout=2) + state["value"] = "new" + return "written" + + register_tool(ToolDef( + name="cached_read", schema={"name": "cached_read", "input_schema": {}}, + func=read_value, read_only=True, + )) + register_tool(ToolDef( + name="Write", schema={"name": "Write", "input_schema": {}}, + func=slow_write, + )) + thread = threading.Thread(target=lambda: execute_tool("Write", {}, {})) + thread.start() + assert write_started.wait(timeout=2) + assert execute_tool("cached_read", {}, {"_session_id": "mid-write"}) == "old" + release_write.set() + thread.join(timeout=2) + assert not thread.is_alive() + + assert execute_tool("cached_read", {}, {"_session_id": "mid-write"}) == "new" + assert reads == 2 + + +def test_cache_varies_by_active_tool_surface_for_profile_aware_hints(): + calls = 0 + + def large_result(_params: dict, _config: dict) -> str: + nonlocal calls + calls += 1 + return "x" * 20_000 + + register_tool(ToolDef( + name="profile_read", schema={"name": "profile_read", "input_schema": {}}, + func=large_result, read_only=True, + )) + params = {"file_path": "/tmp/large.txt"} + research = { + "_session_id": "surface", "tool_profile": "research", + "_active_tool_names": frozenset({"profile_read", "SummarizeLargeFile"}), + } + standard = { + "_session_id": "surface", "tool_profile": "standard", + "_active_tool_names": frozenset({"profile_read"}), + } + first = execute_tool("profile_read", params, research, max_output=10_000) + second = execute_tool("profile_read", params, standard, max_output=10_000) + + assert "SummarizeLargeFile" in first + assert "SummarizeLargeFile" not in second + assert "narrower offset and limit" in second + assert calls == 2 + + # ------------------------------------------------------------------ # duplicate register overwrites # ------------------------------------------------------------------ diff --git a/tests/test_web_api.py b/tests/test_web_api.py index da5be73c..d8536bc9 100644 --- a/tests/test_web_api.py +++ b/tests/test_web_api.py @@ -231,6 +231,28 @@ def test_create_session_via_prompt(server_url): assert ls[0]["id"] == sid +def test_web_config_gets_and_updates_tool_profile(server_url): + with _client(server_url) as c: + _register(c, "profile-user") + sid = c.post("/api/prompt", json={"prompt": "", "session_id": ""}).json()["session_id"] + + before = c.get(f"/api/config?sid={sid}") + assert before.status_code == 200 + assert before.json()["tool_profile"] == "standard" + + updated = c.patch( + "/api/config", json={"session_id": sid, "config": {"tool_profile": "research"}}, + ) + assert updated.status_code == 200 + assert updated.json()["tool_profile"] == "research" + assert c.get(f"/api/config?sid={sid}").json()["tool_profile"] == "research" + + invalid = c.patch( + "/api/config", json={"session_id": sid, "config": {"tool_profile": "invalid"}}, + ) + assert invalid.status_code == 400 + + def test_rename_session(server_url): with _client(server_url) as c: _register(c, "alice")