fix(agent): preserve agent-owned state in project workspaces (#4945)
This commit is contained in:
@@ -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.
|
||||
|
||||
+18
-1
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
+22
-9
@@ -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
|
||||
|
||||
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)
|
||||
if available:
|
||||
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
|
||||
else:
|
||||
suffix = ""
|
||||
if not available:
|
||||
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)
|
||||
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."""
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 `<history-log-path>` with the absolute path from the system prompt):
|
||||
- `grep(pattern="keyword", path="<history-log-path>", case_insensitive=true)`
|
||||
- `grep(pattern="2026-04-02 10:00", path="<history-log-path>", fixed_strings=true)`
|
||||
- `grep(pattern="keyword", path="<history-log-path>", output_mode="count", case_insensitive=true)`
|
||||
- `grep(pattern="oauth|token", path="<history-log-path>", output_mode="content", case_insensitive=true)`
|
||||
|
||||
## Important
|
||||
|
||||
|
||||
@@ -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 `<agent-workspace>/skills/update/SKILL.md` path, where
|
||||
`<agent-workspace>` 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 `<agent-workspace>/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 `<agent-workspace>/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 `<agent-workspace>/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."
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 "<history-log-path>" in memory
|
||||
assert 'path="memory/history.jsonl"' not in memory
|
||||
assert update_setup is not None
|
||||
assert "<agent-workspace>/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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user