From b189a37648e4fa64f662b15de4f78ffd0bab403b Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Wed, 22 Jul 2026 17:25:22 +0800 Subject: [PATCH] fix(agent): preserve agent-owned state in project workspaces (#4945) --- docs/architecture.md | 18 ++++ docs/concepts.md | 19 +++- docs/configuration.md | 10 ++ docs/memory.md | 10 ++ docs/webui.md | 22 ++++ nanobot/agent/context.py | 20 +++- nanobot/agent/skills.py | 41 ++++--- nanobot/agent/subagent.py | 12 ++- nanobot/agent/tools/filesystem.py | 23 ++-- nanobot/agent/tools/search.py | 12 ++- nanobot/skills/memory/SKILL.md | 24 +++-- nanobot/skills/update-setup/SKILL.md | 18 ++-- nanobot/templates/SOUL.md | 2 - nanobot/templates/agent/identity.md | 21 ++-- nanobot/templates/agent/skills_section.md | 3 +- nanobot/templates/agent/subagent_system.md | 8 +- nanobot/templates/agent/tool_contract.md | 14 +-- tests/agent/test_context_builder.py | 78 ++++++++++++++ tests/agent/test_context_prompt_cache.py | 36 ++++--- tests/agent/test_skills_loader.py | 39 +++++++ tests/agent/test_subagent.py | 66 ++++++++++++ tests/agent/test_workspace_scope.py | 120 ++++++++++++++++++++- tests/tools/test_search_tools.py | 33 ++++++ 23 files changed, 560 insertions(+), 89 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 8229b3cb..ddd6deeb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,6 +149,24 @@ Defaults: The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases. +### Agent-Owned State vs Effective Project Context + +Runtime code distinguishes the configured agent workspace from the effective +project workspace carried by a session scope. They are often the same path, but +a WebUI chat may select a separate project: + +| Concern | Path owner | +|---|---| +| Sessions, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace | +| Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace | +| Workspace access mode and project metadata | Session workspace scope | + +`ContextBuilder` combines project instructions with agent-owned profile and +memory. Filesystem and search tools use the project as their ordinary boundary +and receive only capability-specific read access to built-in/agent skills and +the exact agent history file. Keep those cross-root capabilities read-only and +explicit; do not treat the entire agent workspace as an allowed root. + ## Memory and Sessions Session history is the near-term conversation replay. Memory is the longer-term workspace state. diff --git a/docs/concepts.md b/docs/concepts.md index c09ffea3..0e4cdcbf 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -38,6 +38,23 @@ nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance. +### Agent Workspace and Project Workspace + +The configured workspace is the **agent workspace**. A WebUI chat can also select +a different **project workspace** for repository-specific work without moving the +agent's identity or durable state. + +| Resource | Owner when a project is selected | +|---|---| +| Project instructions | `AGENTS.md` from the selected project; there is no fallback to the agent workspace's `AGENTS.md` | +| Agent profile | `SOUL.md` and `USER.md` from the agent workspace; project-local files with those names are ignored | +| Memory and custom skills | `memory/` and `skills/` from the agent workspace | +| Relative file paths and shell working directory | The selected project workspace | + +When no separate project is selected, one directory normally serves both roles. +Selecting a project changes the working context for that chat; it does not create +a second agent or relocate the configured agent workspace. + ## Config Format `config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`. @@ -49,7 +66,7 @@ Most examples are partial snippets. Merge them into the existing file created by A normal turn follows this flow: 1. A channel receives a user message and publishes it to the message bus. -2. The agent loop chooses a session key and builds context from the workspace, skills, memory, recent messages, channel metadata, and runtime settings. +2. The agent loop chooses a session key and builds context from the effective project workspace, agent-owned profile/skills/memory, recent messages, channel metadata, and runtime settings. 3. The provider receives the model request. 4. If the model asks for tools, the runner executes them and feeds results back to the model. 5. The final reply is saved to the session and sent back through the channel. diff --git a/docs/configuration.md b/docs/configuration.md index debe978c..f42ec4ee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1930,6 +1930,16 @@ MCP tools are automatically discovered and registered on startup. The LLM can us For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`. +> [!NOTE] +> When a restricted WebUI chat selects a project outside the configured agent +> workspace, that project becomes the normal file and shell boundary. Nanobot +> adds capability-specific, read-only access for built-in skills, the agent +> workspace's `skills/` directory, and the exact agent +> `memory/history.jsonl` file. Neighboring memory/profile files and all +> cross-workspace writes remain denied. Agent-owned `SOUL.md` and `USER.md` are +> assembled into model context directly; this does not grant file tools broader +> access to the agent workspace. + | Option | Default | Description | |--------|---------|-------------| | `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. | diff --git a/docs/memory.md b/docs/memory.md index c8d899d4..a8d43d81 100644 --- a/docs/memory.md +++ b/docs/memory.md @@ -64,6 +64,11 @@ This is why nanobot's memory is not just archival. It is interpretive. ## The Files +In this page, `workspace` means the configured **agent workspace** (the default +is `~/.nanobot/workspace/`, or the path passed with `--workspace`). Selecting a +different project in the WebUI changes that chat's project context and tool +working directory; it does not relocate the files below. + ```text workspace/ ├── SOUL.md # The bot's long-term voice and communication style @@ -79,6 +84,11 @@ workspace/ └── .git/ # Version history for long-term memory files ``` +A selected project may provide its own `AGENTS.md`, but project-local `SOUL.md`, +`USER.md`, and `memory/` do not replace the agent-owned files above. This keeps +one agent's profile and memory continuous while it works across projects. Use a +separate configured agent workspace when identity or memory must be isolated. + These files play different roles: - `SOUL.md` remembers how nanobot should sound. diff --git a/docs/webui.md b/docs/webui.md index c9b4864a..ef3ce74a 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -106,11 +106,33 @@ Use the workspace picker before starting project-specific work. This gives the agent the right project context for file paths, shell commands, and session metadata. +Selecting a project does not replace the configured agent workspace. The two +paths have different responsibilities: + +| Selected project provides | Agent workspace continues to provide | +|---|---| +| Project `AGENTS.md` | `SOUL.md` and `USER.md` | +| Relative file paths and shell working directory | Long-term memory and history | +| The normal read/write boundary in Restricted mode | Custom skills and instance state | + +Project-local `SOUL.md` and `USER.md` files are ignored, and the agent workspace's +`AGENTS.md` is not inherited by a separately selected project. When the selected +project is the configured agent workspace, both roles naturally use the same +directory. + The access control in the composer controls the local capability level for the chat. It does not bypass your gateway, provider, shell sandbox, or operating system configuration; it only selects among the capabilities that are already available to this WebUI session. +In Restricted mode, ordinary file and shell work stays inside the selected +project. To preserve agent continuity, filesystem/search tools receive narrow, +read-only access to built-in skills, custom skills in the agent workspace, and +the exact agent `memory/history.jsonl` file. This does not grant access to +neighboring memory or profile files, and it does not allow writes outside the +selected project. These tool exceptions do not broaden the browser's file +preview boundary. + Remote WebUI sessions may reduce access for the current workspace. Selecting a different workspace or enabling Full Access remains limited to local and native clients. diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 1adab138..85c90556 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -48,6 +48,7 @@ class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"] + _SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"} _RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG _MAX_RECENT_HISTORY = 50 _MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens) @@ -116,12 +117,14 @@ class ContextBuilder: """Get the core identity section.""" root = workspace or self.workspace workspace_path = str(root.expanduser().resolve()) + agent_workspace_path = str(self.workspace.expanduser().resolve()) system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" return render_template( "agent/identity.md", workspace_path=workspace_path, + agent_workspace_path=agent_workspace_path, runtime=runtime, platform_policy=render_template("agent/platform_policy.md", system=system), channel=channel or "", @@ -146,14 +149,25 @@ class ContextBuilder: return _to_blocks(left) + _to_blocks(right) def _load_bootstrap_files(self, workspace: Path | None = None) -> str: - """Load all bootstrap files from workspace.""" + """Load project instructions plus the agent's global profile files.""" parts = [] - root = workspace or self.workspace + project_root = workspace or self.workspace + sources = [ + ("AGENTS.md", project_root), + ("SOUL.md", self.workspace), + ("USER.md", self.workspace), + ] - for filename in self.BOOTSTRAP_FILES: + for filename, root in sources: file_path = root / filename if file_path.exists(): content = file_path.read_text(encoding="utf-8") + if not content.strip(): + continue + if filename in self._SKIPPABLE_DEFAULTS and self._is_template_content( + content, filename + ): + continue parts.append(f"## {filename}\n\n{content}") return "\n\n".join(parts) if parts else "" diff --git a/nanobot/agent/skills.py b/nanobot/agent/skills.py index b2272434..5d561f58 100644 --- a/nanobot/agent/skills.py +++ b/nanobot/agent/skills.py @@ -125,21 +125,34 @@ class SkillsLoader: if not all_skills: return "" - lines: list[str] = [] - for entry in all_skills: - skill_name = entry["name"] - if exclude and skill_name in exclude: + sections: list[str] = [] + groups = ( + ("Workspace skills", "workspace", self.workspace_skills), + ("Built-in skills", "builtin", self.builtin_skills), + ) + for label, source, root in groups: + entries = [ + entry + for entry in all_skills + if entry["source"] == source and (not exclude or entry["name"] not in exclude) + ] + if not entries: continue - meta = self._get_skill_meta(skill_name) - available = self._check_requirements(meta) - desc = self._get_skill_description(skill_name) - if available: - lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`") - else: - missing = self._get_missing_requirements(meta) - suffix = f" (unavailable: {missing})" if missing else " (unavailable)" - lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`") - return "\n".join(lines) + + lines = [f"### {label} (`{root.expanduser().resolve()}`)"] + for entry in entries: + skill_name = entry["name"] + meta = self._get_skill_meta(skill_name) + available = self._check_requirements(meta) + desc = self._get_skill_description(skill_name) + suffix = "" + if not available: + missing = self._get_missing_requirements(meta) + suffix = f" (unavailable: {missing})" if missing else " (unavailable)" + relative_path = Path(entry["path"]).relative_to(root).as_posix() + lines.append(f"- **{skill_name}** — {desc}{suffix} `{relative_path}`") + sections.append("\n".join(lines)) + return "\n\n".join(sections) def _get_missing_requirements(self, skill_meta: dict) -> str: """Get a description of missing requirements.""" diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 4743e042..e6bd86b2 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -299,7 +299,8 @@ class SubagentManager: if workspace_scope is not None: cfg = self._subagent_tools_config() cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace - tools = self._build_tools(workspace=root, tools_config=cfg) + # Construct from the agent workspace; the bound scope below supplies the project cwd. + tools = self._build_tools(tools_config=cfg) system_prompt = self._build_subagent_prompt(workspace=root) messages: list[dict[str, Any]] = [ {"role": "system", "content": system_prompt}, @@ -438,14 +439,17 @@ class SubagentManager: """Build a focused system prompt for the subagent.""" from nanobot.agent.skills import SkillsLoader - root = workspace or self.workspace + agent_workspace = self.workspace.expanduser().resolve() + project_workspace = workspace.expanduser().resolve() if workspace else agent_workspace skills_summary = SkillsLoader( - root, + self.workspace, disabled_skills=self.disabled_skills, ).build_skills_summary() return render_template( "agent/subagent_system.md", - workspace=str(root), + workspace=str(project_workspace), + agent_workspace=str(agent_workspace), + history_log=str(agent_workspace / "memory" / "history.jsonl"), skills_summary=skills_summary or "", ) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index 2d3eedc2..f143e6d1 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -51,6 +51,7 @@ class _FsTool(Tool): file_states: FileStates | None = None, restrict_to_workspace: bool | None = None, sandbox_restricts_workspace: bool = False, + extra_read_allowed_files: list[Path] | None = None, ): self._workspace = workspace self._allowed_dir = allowed_dir @@ -60,6 +61,7 @@ class _FsTool(Tool): *(extra_allowed_dirs or []), *(extra_read_allowed_dirs or []), ] + self._extra_read_allowed_files = list(extra_read_allowed_files or []) self._extra_write_allowed_dirs = list(extra_write_allowed_dirs or []) self._extra_write_allowed_files = list(extra_write_allowed_files or []) self._restrict_to_workspace = ( @@ -78,17 +80,21 @@ class _FsTool(Tool): def create(cls, ctx: Any) -> Tool: from nanobot.agent.skills import BUILTIN_SKILLS_DIR + agent_workspace = Path(ctx.workspace) + resolved_agent_workspace = agent_workspace.expanduser().resolve(strict=False) restrict = ( ctx.config.restrict_to_workspace or ctx.config.exec.sandbox ) sandbox_restricts = bool(ctx.config.exec.sandbox) - allowed_dir = Path(ctx.workspace) if restrict else None - extra_read = [BUILTIN_SKILLS_DIR] + allowed_dir = agent_workspace if restrict else None + # Agent-owned skills stay available from project scopes. History is a narrower + # capability: expose only the append-only log, not the surrounding memory directory. return cls( - workspace=Path(ctx.workspace), + workspace=agent_workspace, allowed_dir=allowed_dir, - extra_read_allowed_dirs=extra_read, + extra_read_allowed_dirs=[BUILTIN_SKILLS_DIR, resolved_agent_workspace / "skills"], + extra_read_allowed_files=[resolved_agent_workspace / "memory" / "history.jsonl"], file_states=ctx.file_state_store, restrict_to_workspace=ctx.config.restrict_to_workspace, sandbox_restricts_workspace=sandbox_restricts, @@ -119,16 +125,20 @@ class _FsTool(Tool): extra_allowed_files: list[Path] | None, *, include_media_dir: bool, + extra_files_require_allowed_root: bool = False, ) -> Path: access = current_tool_workspace( self._workspace, restrict_to_workspace=self._restrict_to_workspace, sandbox_restricts_workspace=self._sandbox_restricts_workspace, ) + allowed_root = self._effective_allowed_root(access.allowed_root) + if extra_files_require_allowed_root and allowed_root is None: + extra_allowed_files = None return resolve_workspace_path( path, access.project_path, - self._effective_allowed_root(access.allowed_root), + allowed_root, extra_allowed_dirs, extra_allowed_files, include_media_dir=include_media_dir, @@ -138,8 +148,9 @@ class _FsTool(Tool): return self._resolve_with_extra( path, self._extra_read_allowed_dirs, - None, + self._extra_read_allowed_files, include_media_dir=True, + extra_files_require_allowed_root=True, ) def _resolve_write(self, path: str) -> Path: diff --git a/nanobot/agent/tools/search.py b/nanobot/agent/tools/search.py index 718d8e6d..775311fc 100644 --- a/nanobot/agent/tools/search.py +++ b/nanobot/agent/tools/search.py @@ -283,6 +283,7 @@ class GrepTool(_SearchTool): _MAX_RESULT_CHARS = 128_000 _MAX_FILE_BYTES = 2_000_000 + _MAX_EXPLICIT_FILE_BYTES = 100_000_000 @property def name(self) -> str: @@ -295,7 +296,8 @@ class GrepTool(_SearchTool): "Default output_mode is files_with_matches (file paths only); " "use content mode for matching lines with context. Prefer this " "over shell grep for ordinary workspace searches. " - "Skips binary and files >2 MB. Supports glob/type filtering." + "Binary and file-size limits are enforced by the tool; explicit file paths " + "use a larger bounded limit than directory searches. Supports glob/type filtering." ) @property @@ -456,6 +458,9 @@ class GrepTool(_SearchTool): counts: dict[str, int] = {} file_mtimes: dict[str, float] = {} root = target if target.is_dir() else target.parent + max_file_bytes = ( + self._MAX_EXPLICIT_FILE_BYTES if target.is_file() else self._MAX_FILE_BYTES + ) for file_path in self._iter_files(target): rel_path = file_path.relative_to(root).as_posix() @@ -464,8 +469,9 @@ class GrepTool(_SearchTool): if not _matches_type(file_path.name, type): continue - raw = file_path.read_bytes() - if len(raw) > self._MAX_FILE_BYTES: + with file_path.open("rb") as file: + raw = file.read(max_file_bytes + 1) + if len(raw) > max_file_bytes: skipped_large += 1 continue if _is_binary(raw): diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md index 042ef80c..e7a5f2c9 100644 --- a/nanobot/skills/memory/SKILL.md +++ b/nanobot/skills/memory/SKILL.md @@ -1,7 +1,6 @@ --- name: memory -description: Two-layer memory system with Dream-managed knowledge files. -always: true +description: Search conversation history and understand Dream-managed profile and memory files. --- # Memory @@ -11,23 +10,26 @@ always: true - `SOUL.md` — Bot personality and communication style. **Managed by Dream.** Do NOT edit. - `USER.md` — User profile and preferences. **Managed by Dream.** Do NOT edit. - `memory/MEMORY.md` — Long-term facts (project context, important events). **Managed by Dream.** Do NOT edit. -- `memory/history.jsonl` — append-only JSONL, not loaded into context. Prefer the built-in `grep` tool to search it. +- `memory/history.jsonl` — append-only JSONL, not loaded into context. Prefer the + built-in `grep` tool to search it. ## Search Past Events -`memory/history.jsonl` is JSONL format — each line is a JSON object with `cursor`, `timestamp`, `content`. +Use the absolute `History log` path shown in the system prompt. Always pass it to +`grep`; never substitute a project-relative `memory/history.jsonl`, which may belong +to the selected project. Each JSONL line contains `cursor`, `timestamp`, and `content`. -- For broad searches, start with `grep(..., path="memory", glob="*.jsonl", output_mode="count")` or the default `files_with_matches` mode before expanding to full content +- For broad searches, start with `output_mode="count"` or the default + `files_with_matches` mode before expanding to full content - Use `output_mode="content"` plus `context_before` / `context_after` when you need the exact matching lines - Use `fixed_strings=true` for literal timestamps or JSON fragments - Use `head_limit` / `offset` to page through long histories -- Use `exec` only as a last-resort fallback when the built-in search cannot express what you need -Examples (replace `keyword`): -- `grep(pattern="keyword", path="memory/history.jsonl", case_insensitive=true)` -- `grep(pattern="2026-04-02 10:00", path="memory/history.jsonl", fixed_strings=true)` -- `grep(pattern="keyword", path="memory", glob="*.jsonl", output_mode="count", case_insensitive=true)` -- `grep(pattern="oauth|token", path="memory", glob="*.jsonl", output_mode="content", case_insensitive=true)` +Examples (replace `` with the absolute path from the system prompt): +- `grep(pattern="keyword", path="", case_insensitive=true)` +- `grep(pattern="2026-04-02 10:00", path="", fixed_strings=true)` +- `grep(pattern="keyword", path="", output_mode="count", case_insensitive=true)` +- `grep(pattern="oauth|token", path="", output_mode="content", case_insensitive=true)` ## Important diff --git a/nanobot/skills/update-setup/SKILL.md b/nanobot/skills/update-setup/SKILL.md index bc8e5019..d0d7e842 100644 --- a/nanobot/skills/update-setup/SKILL.md +++ b/nanobot/skills/update-setup/SKILL.md @@ -1,15 +1,20 @@ --- name: update-setup -description: One-time setup wizard for the nanobot upgrade skill. Triggers: setup update, configure update, 切设置更新, 初始化更新. +description: "One-time setup wizard for the nanobot upgrade skill. Triggers: setup update, configure update, 设置更新, 初始化更新." --- # Update Setup -Generate a personalized upgrade skill for this workspace. +Generate a personalized upgrade skill in Nanobot's agent workspace. + +Use the absolute `/skills/update/SKILL.md` path, where +`` is shown in the system prompt. Never substitute a project-relative +path. If the write is rejected, ask the user to select the agent workspace or enable +Full Access before rerunning setup. ## Step 1: Check Existing -Use `read_file` to check if `skills/update/SKILL.md` already exists in the workspace. +Use `read_file` to check if `/skills/update/SKILL.md` already exists. If it exists, ask the user: "An upgrade skill already exists. Reconfigure?" Wait for the user's reply. If no, stop here. @@ -32,7 +37,7 @@ likely install method. Do not treat them as confirmation. ## Step 3: Confirm Required Inputs -CRITICAL: Do not write `skills/update/SKILL.md` until the install method is +CRITICAL: Do not write `/skills/update/SKILL.md` until the install method is explicitly confirmed by the user. The install method must come from a user answer or confirmation, not from inference alone. If you cannot get a clear answer, stop and ask the user to rerun this setup when they know how nanobot was @@ -101,7 +106,7 @@ contains spaces. Build the skill content. If proxy is configured, add `export http_proxy=URL` and `export https_proxy=URL` lines before the upgrade command. -Use `write_file` to write `skills/update/SKILL.md` with this content: +Use `write_file` to write `/skills/update/SKILL.md` with this content: ``` --- @@ -120,4 +125,5 @@ description: "Upgrade nanobot to the latest version. Triggers: upgrade nanobot, ## Step 5: Confirm -Tell the user: "Upgrade skill created. Say 'upgrade nanobot' when you want to update." +Only after `write_file` succeeds, tell the user: +"Upgrade skill created. Say 'upgrade nanobot' when you want to update." diff --git a/nanobot/templates/SOUL.md b/nanobot/templates/SOUL.md index 27b60971..f1111121 100644 --- a/nanobot/templates/SOUL.md +++ b/nanobot/templates/SOUL.md @@ -15,6 +15,4 @@ I am nanobot 🐈, a personal AI assistant. - Act immediately on single-step tasks — never end a turn with just a plan or promise. - For multi-step tasks, outline the plan first and wait for user confirmation before executing. - Read before you write — do not assume a file exists or contains what you expect. -- If a tool call fails, diagnose the error and retry with a different approach before reporting failure. - When information is missing, look it up with tools first. Only ask the user when tools cannot answer. -- After multi-step changes, verify the result (re-read the file, run the test, check the output). diff --git a/nanobot/templates/agent/identity.md b/nanobot/templates/agent/identity.md index e6fa5535..dfcfb1b6 100644 --- a/nanobot/templates/agent/identity.md +++ b/nanobot/templates/agent/identity.md @@ -2,10 +2,14 @@ {{ runtime }} ## Workspace -Your workspace is at: {{ workspace_path }} -- Long-term memory: {{ workspace_path }}/memory/MEMORY.md (automatically managed by Dream — do not edit directly) -- History log: {{ workspace_path }}/memory/history.jsonl (append-only JSONL; prefer built-in `grep` for search). -- Custom skills: {{ workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md +Your current project workspace is at: {{ workspace_path }} +{% if agent_workspace_path != workspace_path %} +Nanobot's agent workspace is at: {{ agent_workspace_path }} +{% endif %} +- Agent profile: {{ agent_workspace_path }}/SOUL.md and {{ agent_workspace_path }}/USER.md (automatically managed by Dream — do not edit directly) +- Long-term memory: {{ agent_workspace_path }}/memory/MEMORY.md (automatically managed by Dream — do not edit directly) +- History log: {{ agent_workspace_path }}/memory/history.jsonl (append-only JSONL; prefer built-in `grep` for search). +- Custom skills: {{ agent_workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md {{ platform_policy }} {% if channel == 'telegram' or channel == 'qq' or channel == 'discord' %} @@ -22,13 +26,6 @@ This conversation is via email. Structure with clear sections. Markdown may not Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting. {% endif %} -## Search & Discovery +## External Content -- Prefer built-in `grep` over `exec` for workspace search. -- On broad searches, use `grep(output_mode="count")` to scope before requesting full content. {% include 'agent/_snippets/untrusted_content.md' %} - -Reply directly with text for the current conversation. Do not use the 'message' tool for normal replies in the current chat. -When you need to call tools before answering, do not include the final user-visible answer in the same assistant message as the tool calls. Wait for the tool results, then answer once. -Use the 'message' tool only for proactive sends, cross-channel delivery, or explicitly sending existing local files as attachments. When 'generate_image' creates images, call 'message' with the artifact paths in the 'media' parameter to deliver them to the user. -To send an existing local file that was not automatically attached by another tool, call 'message' with the 'media' parameter. Do NOT use read_file to "send" a file — reading a file only shows its content to you, it does NOT deliver the file to the user. Example: message(content="Here is the document", channel="telegram", chat_id="...", media=["/path/to/file.pdf"]) diff --git a/nanobot/templates/agent/skills_section.md b/nanobot/templates/agent/skills_section.md index 300c5679..02354286 100644 --- a/nanobot/templates/agent/skills_section.md +++ b/nanobot/templates/agent/skills_section.md @@ -1,6 +1,5 @@ # Skills -The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. -Unavailable skills need dependencies installed first — you can try installing them with apt/brew. +The following skills extend your capabilities. Each group lists one absolute root and relative SKILL.md paths; join them when using `read_file`. {{ skills_summary }} diff --git a/nanobot/templates/agent/subagent_system.md b/nanobot/templates/agent/subagent_system.md index d7a3b25a..b07b01cc 100644 --- a/nanobot/templates/agent/subagent_system.md +++ b/nanobot/templates/agent/subagent_system.md @@ -6,12 +6,16 @@ Stay focused on the assigned task. Your final response will be reported back to {% include 'agent/_snippets/untrusted_content.md' %} ## Workspace -{{ workspace }} +Current project workspace: {{ workspace }} +{% if agent_workspace != workspace %} +Nanobot's agent workspace: {{ agent_workspace }} +{% endif %} +History log: {{ history_log }} {% if skills_summary %} ## Skills -Read SKILL.md with read_file to use a skill. +Each group lists one absolute root and relative SKILL.md paths. Join them when using `read_file`. {{ skills_summary }} {% endif %} diff --git a/nanobot/templates/agent/tool_contract.md b/nanobot/templates/agent/tool_contract.md index 8a15e7eb..42b2fd47 100644 --- a/nanobot/templates/agent/tool_contract.md +++ b/nanobot/templates/agent/tool_contract.md @@ -1,14 +1,13 @@ # Tool Usage Notes -Tool signatures are provided automatically via function calling. This section documents the general tool contract and non-obvious usage patterns. - ## General Tool Contract - Use the narrowest structured tool that directly matches the task. - Use read-only discovery before writes when state is uncertain. - Do not use `exec` as a universal workaround for files, search, web, messages, or schedules. - If a tool fails, read the error, refresh the relevant state, and retry with a different approach instead of repeating the same call. -- After meaningful changes, verify with the smallest reliable check: re-read changed state, run targeted tests, or inspect command output. +- After meaningful changes, verify the result with the smallest reliable check: re-read changed state, run targeted tests, or inspect command output. +- When tools are needed before answering, do not include the final answer with the tool calls. Wait for the tool results, then answer once. - Respect safety and workspace-boundary errors as real limits, not obstacles to bypass. ## Discovery and Reading @@ -19,7 +18,7 @@ Tool signatures are provided automatically via function calling. This section do - Use `fixed_strings=true` for literal keywords containing regex characters. - Use `output_mode="count"` to size a broad search before reading full matches. - Use `head_limit` and `offset` to page across large result sets. -- Binary or oversized files may be skipped to keep results readable. +- Search tools enforce binary and file-size limits and report skipped files in the result. ## File and Coding Workflows @@ -55,9 +54,10 @@ Tool signatures are provided automatically via function calling. This section do ## Messaging and Media -- Use `message` to send content or local media to the user/channel. -- `read_file` only reads content for your analysis; it does not deliver a file to the user. -- When sending an existing local file, attach it through the message/media mechanism instead of pasting file contents unless the user asked for text. +- Reply directly with text for the current conversation. Do not use the 'message' tool for normal replies in the current chat. +- Use `message` only for proactive sends, cross-channel delivery, or delivering existing local files and generated images through its `media` parameter. +- `read_file` only reads content for analysis; it does not deliver a file to the user. +- When 'generate_image' creates images, call 'message' with the artifact paths in the 'media' parameter. ## Scheduling and Background Work diff --git a/tests/agent/test_context_builder.py b/tests/agent/test_context_builder.py index 4aa46bd8..87fe8ec1 100644 --- a/tests/agent/test_context_builder.py +++ b/tests/agent/test_context_builder.py @@ -78,6 +78,11 @@ class TestLoadBootstrapFiles: builder = _builder(tmp_path) assert builder._load_bootstrap_files() == "" + def test_empty_bootstrap_files(self, tmp_path): + (tmp_path / "AGENTS.md").write_text("\n", encoding="utf-8") + builder = _builder(tmp_path) + assert builder._load_bootstrap_files() == "" + def test_agents_md(self, tmp_path): (tmp_path / "AGENTS.md").write_text("Be helpful.", encoding="utf-8") builder = _builder(tmp_path) @@ -116,6 +121,66 @@ class TestLoadBootstrapFiles: result = builder._load_bootstrap_files() assert "用中文回复" in result + def test_selected_project_supplies_only_agents_file(self, tmp_path): + agent_home = tmp_path / "agent-home" + project = tmp_path / "project" + agent_home.mkdir() + project.mkdir() + (agent_home / "AGENTS.md").write_text("global project rules", encoding="utf-8") + (agent_home / "SOUL.md").write_text("global soul", encoding="utf-8") + (agent_home / "USER.md").write_text("global user", encoding="utf-8") + (project / "AGENTS.md").write_text("selected project rules", encoding="utf-8") + (project / "SOUL.md").write_text("project soul collision", encoding="utf-8") + (project / "USER.md").write_text("project user collision", encoding="utf-8") + + result = ContextBuilder(agent_home).build_system_prompt( + workspace=project, + include_memory_recent_history=False, + ) + + assert "selected project rules" in result + assert "global project rules" not in result + assert "global soul" in result + assert "global user" in result + assert "project soul collision" not in result + assert "project user collision" not in result + + def test_selected_project_without_agents_does_not_fall_back(self, tmp_path): + agent_home = tmp_path / "agent-home" + project = tmp_path / "project" + agent_home.mkdir() + project.mkdir() + (agent_home / "AGENTS.md").write_text("default workspace rules", encoding="utf-8") + + result = ContextBuilder(agent_home).build_system_prompt( + workspace=project, + include_memory_recent_history=False, + ) + + assert "default workspace rules" not in result + + def test_unmodified_agents_and_user_templates_are_skipped(self, tmp_path): + from nanobot.utils.helpers import sync_workspace_templates + + sync_workspace_templates(tmp_path, silent=True) + + result = ContextBuilder(tmp_path)._load_bootstrap_files() + + assert "## AGENTS.md" not in result + assert "## USER.md" not in result + assert "## SOUL.md" in result + + def test_customized_user_template_is_loaded(self, tmp_path): + from nanobot.utils.helpers import sync_workspace_templates + + sync_workspace_templates(tmp_path, silent=True) + (tmp_path / "USER.md").write_text("User prefers Chinese.", encoding="utf-8") + + result = ContextBuilder(tmp_path)._load_bootstrap_files() + + assert "## USER.md" in result + assert "User prefers Chinese." in result + # --------------------------------------------------------------------------- # _is_template_content (static) @@ -239,6 +304,19 @@ class TestBuildSystemPrompt: result = builder.build_system_prompt() assert "workspace" in result.lower() or "python" in result.lower() + def test_selected_project_identity_keeps_agent_data_in_agent_workspace(self, tmp_path): + agent_home = tmp_path / "agent-home" + project = tmp_path / "project" + agent_home.mkdir() + project.mkdir() + + result = ContextBuilder(agent_home)._get_identity(workspace=project) + + assert f"current project workspace is at: {project.resolve()}" in result + assert f"agent workspace is at: {agent_home.resolve()}" in result + assert f"{agent_home.resolve()}/SOUL.md" in result + assert f"{project.resolve()}/SOUL.md" not in result + def test_includes_bootstrap_files(self, tmp_path): (tmp_path / "AGENTS.md").write_text("Be helpful and concise.", encoding="utf-8") builder = _builder(tmp_path) diff --git a/tests/agent/test_context_prompt_cache.py b/tests/agent/test_context_prompt_cache.py index 8d48b237..143abd4b 100644 --- a/tests/agent/test_context_prompt_cache.py +++ b/tests/agent/test_context_prompt_cache.py @@ -332,22 +332,33 @@ def test_subagent_result_does_not_create_consecutive_assistant_messages(tmp_path assert not (left.get("role") == right.get("role") == "assistant") -def test_always_skills_excluded_from_skills_index(tmp_path) -> None: - """Always skills should appear in Active Skills but NOT in the skills index.""" +def test_memory_skill_is_lazy_loaded_from_skills_index(tmp_path) -> None: + """Memory search guidance should be discoverable without loading its full body.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) prompt = builder.build_system_prompt() - # memory skill should be in Active Skills section - assert "# Active Skills" in prompt - assert "### Skill: memory" in prompt + assert "### Skill: memory" not in prompt + assert "**memory**" in prompt + assert "Search Past Events" not in prompt + assert "Examples (replace `keyword`)" not in prompt - # memory skill should NOT appear in the skills index - skills_section = prompt.split("# Skills\n", 1) - if len(skills_section) > 1: - index_text = skills_section[1].split("\n\n---")[0] - assert "**memory**" not in index_text + +def test_fresh_workspace_omits_default_prompt_scaffolding(tmp_path) -> None: + from nanobot.utils.helpers import sync_workspace_templates + + workspace = _make_workspace(tmp_path) + sync_workspace_templates(workspace, silent=True) + + prompt = ContextBuilder(workspace).build_system_prompt() + + assert "## AGENTS.md" not in prompt + assert "## USER.md" not in prompt + assert "8281248569" not in prompt + assert "(your name)" not in prompt + assert "apt/brew" not in prompt + assert prompt.count("Do not use the 'message' tool for normal replies") == 1 def test_template_memory_md_is_skipped(tmp_path) -> None: @@ -359,10 +370,7 @@ def test_template_memory_md_is_skipped(tmp_path) -> None: builder = ContextBuilder(workspace) prompt = builder.build_system_prompt() - # The "# Memory\n\n## Long-term Memory" block is produced only by - # build_system_prompt() when MEMORY.md is injected. The memory skill - # also contains "# Memory" but is followed by "## Structure", not - # "## Long-term Memory". + # This block is produced only when populated long-term memory is injected. assert "# Memory\n\n## Long-term Memory" not in prompt assert "This file is automatically updated by nanobot" not in prompt diff --git a/tests/agent/test_skills_loader.py b/tests/agent/test_skills_loader.py index d9cfa6d1..6e6869a7 100644 --- a/tests/agent/test_skills_loader.py +++ b/tests/agent/test_skills_loader.py @@ -297,6 +297,45 @@ def test_disabled_skills_excluded_from_build_skills_summary(tmp_path: Path) -> N assert "beta" in summary +def test_build_skills_summary_groups_paths_by_root(tmp_path: Path) -> None: + workspace = tmp_path / "ws" + workspace_skills = workspace / "skills" + workspace_skills.mkdir(parents=True) + workspace_path = _write_skill(workspace_skills, "alpha", body="# Alpha") + builtin = tmp_path / "builtin" + builtin_path = _write_skill(builtin, "beta", body="# Beta") + + summary = SkillsLoader(workspace, builtin_skills_dir=builtin).build_skills_summary() + + assert summary.count(str(workspace_skills)) == 1 + assert summary.count(str(builtin)) == 1 + assert str(workspace_path) not in summary + assert str(builtin_path) not in summary + assert "`alpha/SKILL.md`" in summary + assert "`beta/SKILL.md`" in summary + + +def test_bundled_update_setup_description_is_valid_yaml(tmp_path: Path) -> None: + metadata = SkillsLoader(tmp_path).get_skill_metadata("update-setup") + + assert metadata is not None + assert metadata["description"].startswith("One-time setup wizard") + assert "Triggers:" in metadata["description"] + + +def test_bundled_skills_use_agent_owned_paths(tmp_path: Path) -> None: + loader = SkillsLoader(tmp_path) + memory = loader.load_skill("memory") + update_setup = loader.load_skill("update-setup") + + assert memory is not None + assert "" in memory + assert 'path="memory/history.jsonl"' not in memory + assert update_setup is not None + assert "/skills/update/SKILL.md" in update_setup + assert "Never substitute a project-relative" in update_setup + + def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None: workspace = tmp_path / "ws" ws_skills = workspace / "skills" diff --git a/tests/agent/test_subagent.py b/tests/agent/test_subagent.py index 21b9e08c..551dd986 100644 --- a/tests/agent/test_subagent.py +++ b/tests/agent/test_subagent.py @@ -11,6 +11,7 @@ from nanobot.agent.tools.filesystem import FileToolsConfig from nanobot.bus.queue import MessageBus from nanobot.config.schema import ToolsConfig from nanobot.providers.base import GenerationSettings, LLMProvider +from nanobot.security.workspace_access import build_workspace_scope from nanobot.utils.llm_runtime import LLMRuntime @@ -82,6 +83,71 @@ def test_subagent_respects_file_tool_toggle(tmp_path): assert file_tools.isdisjoint(tools.tool_names) +def test_subagent_prompt_explains_grouped_skill_paths(tmp_path): + agent_workspace = tmp_path / "agent" + project = tmp_path / "project" + global_skill = agent_workspace / "skills" / "global-custom" / "SKILL.md" + project_skill = project / "skills" / "project-custom" / "SKILL.md" + global_skill.parent.mkdir(parents=True) + project_skill.parent.mkdir(parents=True) + global_skill.write_text("---\ndescription: global skill\n---\nGlobal", encoding="utf-8") + project_skill.write_text("---\ndescription: project skill\n---\nProject", encoding="utf-8") + manager = SubagentManager( + workspace=agent_workspace, + bus=MessageBus(), + max_tool_result_chars=16_000, + ) + + prompt = manager._build_subagent_prompt(workspace=project) + + assert "one absolute root and relative SKILL.md paths" in prompt + assert "Join them when using `read_file`" in prompt + assert f"Current project workspace: {project.resolve()}" in prompt + assert f"Nanobot's agent workspace: {agent_workspace.resolve()}" in prompt + assert f"History log: {agent_workspace.resolve() / 'memory' / 'history.jsonl'}" in prompt + assert "global-custom" in prompt + assert "project-custom" not in prompt + + +@pytest.mark.asyncio +async def test_subagent_keeps_project_runtime_scope_with_agent_owned_tools(tmp_path): + agent_workspace = tmp_path / "agent" + project = tmp_path / "project" + agent_workspace.mkdir() + project.mkdir() + provider = MagicMock(spec=LLMProvider) + provider.get_default_model.return_value = "test" + manager = SubagentManager( + workspace=agent_workspace, + bus=MessageBus(), + max_tool_result_chars=16_000, + ) + manager.runner.run = AsyncMock( + return_value=AgentRunResult(final_content="ok", messages=[], stop_reason="completed") + ) + manager._announce_result = AsyncMock() + status = SubagentStatus( + task_id="t1", + label="label", + task_description="task", + started_at=0.0, + ) + + await manager._run_subagent( + "t1", + "task", + "label", + {"channel": "websocket", "chat_id": "direct"}, + status, + _runtime(provider), + workspace_scope=build_workspace_scope(project, "restricted"), + ) + + spec = manager.runner.run.call_args.args[0] + assert spec.workspace == project + assert spec.tools.get("read_file")._workspace == agent_workspace.resolve() + + @pytest.mark.asyncio async def test_subagent_forwards_fail_on_tool_error_to_runner(tmp_path): provider = MagicMock(spec=LLMProvider) diff --git a/tests/agent/test_workspace_scope.py b/tests/agent/test_workspace_scope.py index 9730671e..2c5aefa9 100644 --- a/tests/agent/test_workspace_scope.py +++ b/tests/agent/test_workspace_scope.py @@ -1,4 +1,6 @@ import json +import os +import subprocess import time from pathlib import Path from types import SimpleNamespace @@ -7,14 +9,15 @@ from unittest.mock import MagicMock import pytest from nanobot.agent.tools.cli_apps import CliAppsTool -from nanobot.agent.tools.context import RequestContext, request_context +from nanobot.agent.tools.context import RequestContext, ToolContext, request_context from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool from nanobot.agent.tools.message import MessageTool +from nanobot.agent.tools.search import GrepTool from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.spawn import SpawnTool from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig -from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig +from nanobot.config.schema import ImageGenerationToolConfig, ProviderConfig, ToolsConfig from nanobot.security.workspace_access import ( WORKSPACE_SCOPE_METADATA_KEY, WorkspaceScopeError, @@ -33,6 +36,24 @@ PNG_BYTES = ( ) +def _make_directory_link(link: Path, target: Path) -> None: + if os.name == "nt": + result = subprocess.run( + ["cmd", "/c", "mklink", "/J", str(link), str(target)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip(f"directory junction unavailable: {result.stderr or result.stdout}") + return + + try: + link.symlink_to(target, target_is_directory=True) + except (NotImplementedError, OSError) as exc: + pytest.skip(f"directory symlink unavailable: {exc}") + + def test_workspace_scope_defaults_match_legacy_config(tmp_path: Path) -> None: unrestricted = default_workspace_scope(tmp_path, restrict_to_workspace=False) restricted = default_workspace_scope(tmp_path, restrict_to_workspace=True) @@ -118,6 +139,101 @@ async def test_filesystem_tool_uses_current_restricted_workspace_scope(tmp_path: reset_workspace_scope(token) +@pytest.mark.asyncio +async def test_restricted_project_can_read_agent_skills_and_exact_history(tmp_path: Path) -> None: + agent_workspace = tmp_path / "agent" + project = tmp_path / "project" + skill_file = agent_workspace / "skills" / "custom" / "SKILL.md" + history_file = agent_workspace / "memory" / "history.jsonl" + private_memory_file = agent_workspace / "memory" / "private.txt" + private_file = agent_workspace / "private.txt" + project_file = project / "project.txt" + skill_file.parent.mkdir(parents=True) + history_file.parent.mkdir(parents=True) + project.mkdir() + skill_file.write_text("global skill", encoding="utf-8") + history_file.write_text('{"content":"global history"}\n', encoding="utf-8") + private_memory_file.write_text("private memory", encoding="utf-8") + private_file.write_text("private", encoding="utf-8") + project_file.write_text("project", encoding="utf-8") + + ctx = ToolContext( + config=ToolsConfig(restrict_to_workspace=True), + workspace=str(agent_workspace), + ) + read_tool = ReadFileTool.create(ctx) + grep_tool = GrepTool.create(ctx) + write_tool = WriteFileTool.create(ctx) + scope = validate_workspace_scope_payload( + {"project_path": str(project), "access_mode": "restricted"}, + default_workspace=agent_workspace, + default_restrict_to_workspace=True, + ) + + token = bind_workspace_scope(scope) + try: + project_result = await read_tool.execute(path="project.txt") + skill_result = await read_tool.execute(path=str(skill_file)) + history_result = await grep_tool.execute( + pattern="global history", + path=str(history_file), + output_mode="content", + ) + private_memory_result = await read_tool.execute(path=str(private_memory_file)) + private_result = await read_tool.execute(path=str(private_file)) + write_result = await write_tool.execute(path=str(skill_file), content="changed") + history_write_result = await write_tool.execute(path=str(history_file), content="changed") + finally: + reset_workspace_scope(token) + + assert "project" in project_result + assert "global skill" in skill_result + assert "global history" in history_result + assert "outside allowed directory" in private_memory_result + assert "outside allowed directory" in private_result + assert "outside allowed directory" in write_result + assert "outside allowed directory" in history_write_result + assert skill_file.read_text(encoding="utf-8") == "global skill" + assert history_file.read_text(encoding="utf-8") == '{"content":"global history"}\n' + + +@pytest.mark.asyncio +async def test_restricted_project_reads_history_from_linked_agent_workspace( + tmp_path: Path, +) -> None: + real_agent_workspace = tmp_path / "real-agent" + linked_agent_workspace = tmp_path / "agent-link" + project = tmp_path / "project" + history_file = real_agent_workspace / "memory" / "history.jsonl" + history_file.parent.mkdir(parents=True) + project.mkdir() + history_file.write_text('{"content":"linked history"}\n', encoding="utf-8") + _make_directory_link(linked_agent_workspace, real_agent_workspace) + + ctx = ToolContext( + config=ToolsConfig(restrict_to_workspace=True), + workspace=str(linked_agent_workspace), + ) + grep_tool = GrepTool.create(ctx) + scope = validate_workspace_scope_payload( + {"project_path": str(project), "access_mode": "restricted"}, + default_workspace=linked_agent_workspace, + default_restrict_to_workspace=True, + ) + + token = bind_workspace_scope(scope) + try: + result = await grep_tool.execute( + pattern="linked history", + path=str(history_file.resolve()), + output_mode="content", + ) + finally: + reset_workspace_scope(token) + + assert "linked history" in result + + @pytest.mark.asyncio async def test_filesystem_write_tool_full_scope_allows_outside_project(tmp_path: Path) -> None: project = tmp_path / "project" diff --git a/tests/tools/test_search_tools.py b/tests/tools/test_search_tools.py index 842db417..10d9f9b5 100644 --- a/tests/tools/test_search_tools.py +++ b/tests/tools/test_search_tools.py @@ -294,6 +294,39 @@ async def test_grep_reports_skipped_binary_and_large_files( assert "skipped 1 large files" in result +@pytest.mark.asyncio +async def test_grep_uses_a_larger_bounded_limit_for_an_explicit_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + large_file = tmp_path / "history.jsonl" + large_file.write_text("needle\n" + "x" * 20, encoding="utf-8") + monkeypatch.setattr(GrepTool, "_MAX_FILE_BYTES", 10) + monkeypatch.setattr(GrepTool, "_MAX_EXPLICIT_FILE_BYTES", 100) + tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path) + + explicit_result = await tool.execute( + pattern="needle", + path=str(large_file), + output_mode="content", + ) + directory_result = await tool.execute(pattern="needle", path=".") + monkeypatch.setattr(GrepTool, "_MAX_EXPLICIT_FILE_BYTES", 10) + capped_result = await tool.execute(pattern="needle", path=str(large_file)) + + assert "needle" in explicit_result + assert "skipped 1 large files" in directory_result + assert "skipped 1 large files" in capped_result + + +def test_grep_description_keeps_size_thresholds_implementation_specific(tmp_path: Path) -> None: + tool = GrepTool(workspace=tmp_path, allowed_dir=tmp_path) + + assert "limits are enforced by the tool" in tool.description + assert "2 MB" not in tool.description + assert "100 MB" not in tool.description + + @pytest.mark.asyncio async def test_search_tools_reject_paths_outside_workspace(tmp_path: Path) -> None: outside = tmp_path.parent / "outside-search.txt"