fix(agent): preserve agent-owned state in project workspaces (#4945)

This commit is contained in:
chengyongru
2026-07-22 17:25:22 +08:00
committed by GitHub
parent 4cd6eb6c38
commit b189a37648
23 changed files with 560 additions and 89 deletions
+18
View File
@@ -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
View File
@@ -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.
+10
View File
@@ -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. |
+10
View File
@@ -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.
+22
View File
@@ -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.
+17 -3
View File
@@ -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 ""
+27 -14
View File
@@ -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."""
+8 -4
View File
@@ -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 "",
)
+17 -6
View File
@@ -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:
+9 -3
View File
@@ -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):
+13 -11
View File
@@ -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
+12 -6
View File
@@ -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."
-2
View File
@@ -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).
+9 -12
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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 %}
+7 -7
View File
@@ -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
View File
@@ -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)
+22 -14
View File
@@ -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
+39
View File
@@ -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"
+66
View File
@@ -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)
+118 -2
View File
@@ -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"
+33
View File
@@ -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"