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.
|
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
|
## Memory and Sessions
|
||||||
|
|
||||||
Session history is the near-term conversation replay. Memory is the longer-term workspace state.
|
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.
|
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 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`.
|
`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:
|
A normal turn follows this flow:
|
||||||
|
|
||||||
1. A channel receives a user message and publishes it to the message bus.
|
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.
|
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.
|
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.
|
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`.
|
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 |
|
| 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. |
|
| `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
|
## 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
|
```text
|
||||||
workspace/
|
workspace/
|
||||||
├── SOUL.md # The bot's long-term voice and communication style
|
├── SOUL.md # The bot's long-term voice and communication style
|
||||||
@@ -79,6 +84,11 @@ workspace/
|
|||||||
└── .git/ # Version history for long-term memory files
|
└── .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:
|
These files play different roles:
|
||||||
|
|
||||||
- `SOUL.md` remembers how nanobot should sound.
|
- `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
|
agent the right project context for file paths, shell commands, and session
|
||||||
metadata.
|
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
|
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
|
chat. It does not bypass your gateway, provider, shell sandbox, or operating
|
||||||
system configuration; it only selects among the capabilities that are already
|
system configuration; it only selects among the capabilities that are already
|
||||||
available to this WebUI session.
|
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
|
Remote WebUI sessions may reduce access for the current workspace. Selecting a
|
||||||
different workspace or enabling Full Access remains limited to local and native
|
different workspace or enabling Full Access remains limited to local and native
|
||||||
clients.
|
clients.
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class ContextBuilder:
|
|||||||
"""Builds the context (system prompt + messages) for the agent."""
|
"""Builds the context (system prompt + messages) for the agent."""
|
||||||
|
|
||||||
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
|
||||||
|
_SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
|
||||||
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
|
||||||
_MAX_RECENT_HISTORY = 50
|
_MAX_RECENT_HISTORY = 50
|
||||||
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
|
||||||
@@ -116,12 +117,14 @@ class ContextBuilder:
|
|||||||
"""Get the core identity section."""
|
"""Get the core identity section."""
|
||||||
root = workspace or self.workspace
|
root = workspace or self.workspace
|
||||||
workspace_path = str(root.expanduser().resolve())
|
workspace_path = str(root.expanduser().resolve())
|
||||||
|
agent_workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
system = platform.system()
|
system = platform.system()
|
||||||
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/identity.md",
|
"agent/identity.md",
|
||||||
workspace_path=workspace_path,
|
workspace_path=workspace_path,
|
||||||
|
agent_workspace_path=agent_workspace_path,
|
||||||
runtime=runtime,
|
runtime=runtime,
|
||||||
platform_policy=render_template("agent/platform_policy.md", system=system),
|
platform_policy=render_template("agent/platform_policy.md", system=system),
|
||||||
channel=channel or "",
|
channel=channel or "",
|
||||||
@@ -146,14 +149,25 @@ class ContextBuilder:
|
|||||||
return _to_blocks(left) + _to_blocks(right)
|
return _to_blocks(left) + _to_blocks(right)
|
||||||
|
|
||||||
def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
|
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 = []
|
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
|
file_path = root / filename
|
||||||
if file_path.exists():
|
if file_path.exists():
|
||||||
content = file_path.read_text(encoding="utf-8")
|
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}")
|
parts.append(f"## {filename}\n\n{content}")
|
||||||
|
|
||||||
return "\n\n".join(parts) if parts else ""
|
return "\n\n".join(parts) if parts else ""
|
||||||
|
|||||||
+27
-14
@@ -125,21 +125,34 @@ class SkillsLoader:
|
|||||||
if not all_skills:
|
if not all_skills:
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
lines: list[str] = []
|
sections: list[str] = []
|
||||||
for entry in all_skills:
|
groups = (
|
||||||
skill_name = entry["name"]
|
("Workspace skills", "workspace", self.workspace_skills),
|
||||||
if exclude and skill_name in exclude:
|
("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
|
continue
|
||||||
meta = self._get_skill_meta(skill_name)
|
|
||||||
available = self._check_requirements(meta)
|
lines = [f"### {label} (`{root.expanduser().resolve()}`)"]
|
||||||
desc = self._get_skill_description(skill_name)
|
for entry in entries:
|
||||||
if available:
|
skill_name = entry["name"]
|
||||||
lines.append(f"- **{skill_name}** — {desc} `{entry['path']}`")
|
meta = self._get_skill_meta(skill_name)
|
||||||
else:
|
available = self._check_requirements(meta)
|
||||||
missing = self._get_missing_requirements(meta)
|
desc = self._get_skill_description(skill_name)
|
||||||
suffix = f" (unavailable: {missing})" if missing else " (unavailable)"
|
suffix = ""
|
||||||
lines.append(f"- **{skill_name}** — {desc}{suffix} `{entry['path']}`")
|
if not available:
|
||||||
return "\n".join(lines)
|
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:
|
def _get_missing_requirements(self, skill_meta: dict) -> str:
|
||||||
"""Get a description of missing requirements."""
|
"""Get a description of missing requirements."""
|
||||||
|
|||||||
@@ -299,7 +299,8 @@ class SubagentManager:
|
|||||||
if workspace_scope is not None:
|
if workspace_scope is not None:
|
||||||
cfg = self._subagent_tools_config()
|
cfg = self._subagent_tools_config()
|
||||||
cfg.restrict_to_workspace = workspace_scope.restrict_to_workspace
|
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)
|
system_prompt = self._build_subagent_prompt(workspace=root)
|
||||||
messages: list[dict[str, Any]] = [
|
messages: list[dict[str, Any]] = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
@@ -438,14 +439,17 @@ class SubagentManager:
|
|||||||
"""Build a focused system prompt for the subagent."""
|
"""Build a focused system prompt for the subagent."""
|
||||||
from nanobot.agent.skills import SkillsLoader
|
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(
|
skills_summary = SkillsLoader(
|
||||||
root,
|
self.workspace,
|
||||||
disabled_skills=self.disabled_skills,
|
disabled_skills=self.disabled_skills,
|
||||||
).build_skills_summary()
|
).build_skills_summary()
|
||||||
return render_template(
|
return render_template(
|
||||||
"agent/subagent_system.md",
|
"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 "",
|
skills_summary=skills_summary or "",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class _FsTool(Tool):
|
|||||||
file_states: FileStates | None = None,
|
file_states: FileStates | None = None,
|
||||||
restrict_to_workspace: bool | None = None,
|
restrict_to_workspace: bool | None = None,
|
||||||
sandbox_restricts_workspace: bool = False,
|
sandbox_restricts_workspace: bool = False,
|
||||||
|
extra_read_allowed_files: list[Path] | None = None,
|
||||||
):
|
):
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
self._allowed_dir = allowed_dir
|
self._allowed_dir = allowed_dir
|
||||||
@@ -60,6 +61,7 @@ class _FsTool(Tool):
|
|||||||
*(extra_allowed_dirs or []),
|
*(extra_allowed_dirs or []),
|
||||||
*(extra_read_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_dirs = list(extra_write_allowed_dirs or [])
|
||||||
self._extra_write_allowed_files = list(extra_write_allowed_files or [])
|
self._extra_write_allowed_files = list(extra_write_allowed_files or [])
|
||||||
self._restrict_to_workspace = (
|
self._restrict_to_workspace = (
|
||||||
@@ -78,17 +80,21 @@ class _FsTool(Tool):
|
|||||||
def create(cls, ctx: Any) -> Tool:
|
def create(cls, ctx: Any) -> Tool:
|
||||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||||
|
|
||||||
|
agent_workspace = Path(ctx.workspace)
|
||||||
|
resolved_agent_workspace = agent_workspace.expanduser().resolve(strict=False)
|
||||||
restrict = (
|
restrict = (
|
||||||
ctx.config.restrict_to_workspace
|
ctx.config.restrict_to_workspace
|
||||||
or ctx.config.exec.sandbox
|
or ctx.config.exec.sandbox
|
||||||
)
|
)
|
||||||
sandbox_restricts = bool(ctx.config.exec.sandbox)
|
sandbox_restricts = bool(ctx.config.exec.sandbox)
|
||||||
allowed_dir = Path(ctx.workspace) if restrict else None
|
allowed_dir = agent_workspace if restrict else None
|
||||||
extra_read = [BUILTIN_SKILLS_DIR]
|
# 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(
|
return cls(
|
||||||
workspace=Path(ctx.workspace),
|
workspace=agent_workspace,
|
||||||
allowed_dir=allowed_dir,
|
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,
|
file_states=ctx.file_state_store,
|
||||||
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
restrict_to_workspace=ctx.config.restrict_to_workspace,
|
||||||
sandbox_restricts_workspace=sandbox_restricts,
|
sandbox_restricts_workspace=sandbox_restricts,
|
||||||
@@ -119,16 +125,20 @@ class _FsTool(Tool):
|
|||||||
extra_allowed_files: list[Path] | None,
|
extra_allowed_files: list[Path] | None,
|
||||||
*,
|
*,
|
||||||
include_media_dir: bool,
|
include_media_dir: bool,
|
||||||
|
extra_files_require_allowed_root: bool = False,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
access = current_tool_workspace(
|
access = current_tool_workspace(
|
||||||
self._workspace,
|
self._workspace,
|
||||||
restrict_to_workspace=self._restrict_to_workspace,
|
restrict_to_workspace=self._restrict_to_workspace,
|
||||||
sandbox_restricts_workspace=self._sandbox_restricts_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(
|
return resolve_workspace_path(
|
||||||
path,
|
path,
|
||||||
access.project_path,
|
access.project_path,
|
||||||
self._effective_allowed_root(access.allowed_root),
|
allowed_root,
|
||||||
extra_allowed_dirs,
|
extra_allowed_dirs,
|
||||||
extra_allowed_files,
|
extra_allowed_files,
|
||||||
include_media_dir=include_media_dir,
|
include_media_dir=include_media_dir,
|
||||||
@@ -138,8 +148,9 @@ class _FsTool(Tool):
|
|||||||
return self._resolve_with_extra(
|
return self._resolve_with_extra(
|
||||||
path,
|
path,
|
||||||
self._extra_read_allowed_dirs,
|
self._extra_read_allowed_dirs,
|
||||||
None,
|
self._extra_read_allowed_files,
|
||||||
include_media_dir=True,
|
include_media_dir=True,
|
||||||
|
extra_files_require_allowed_root=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _resolve_write(self, path: str) -> Path:
|
def _resolve_write(self, path: str) -> Path:
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ class GrepTool(_SearchTool):
|
|||||||
|
|
||||||
_MAX_RESULT_CHARS = 128_000
|
_MAX_RESULT_CHARS = 128_000
|
||||||
_MAX_FILE_BYTES = 2_000_000
|
_MAX_FILE_BYTES = 2_000_000
|
||||||
|
_MAX_EXPLICIT_FILE_BYTES = 100_000_000
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
@@ -295,7 +296,8 @@ class GrepTool(_SearchTool):
|
|||||||
"Default output_mode is files_with_matches (file paths only); "
|
"Default output_mode is files_with_matches (file paths only); "
|
||||||
"use content mode for matching lines with context. Prefer this "
|
"use content mode for matching lines with context. Prefer this "
|
||||||
"over shell grep for ordinary workspace searches. "
|
"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
|
@property
|
||||||
@@ -456,6 +458,9 @@ class GrepTool(_SearchTool):
|
|||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
file_mtimes: dict[str, float] = {}
|
file_mtimes: dict[str, float] = {}
|
||||||
root = target if target.is_dir() else target.parent
|
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):
|
for file_path in self._iter_files(target):
|
||||||
rel_path = file_path.relative_to(root).as_posix()
|
rel_path = file_path.relative_to(root).as_posix()
|
||||||
@@ -464,8 +469,9 @@ class GrepTool(_SearchTool):
|
|||||||
if not _matches_type(file_path.name, type):
|
if not _matches_type(file_path.name, type):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
raw = file_path.read_bytes()
|
with file_path.open("rb") as file:
|
||||||
if len(raw) > self._MAX_FILE_BYTES:
|
raw = file.read(max_file_bytes + 1)
|
||||||
|
if len(raw) > max_file_bytes:
|
||||||
skipped_large += 1
|
skipped_large += 1
|
||||||
continue
|
continue
|
||||||
if _is_binary(raw):
|
if _is_binary(raw):
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: memory
|
name: memory
|
||||||
description: Two-layer memory system with Dream-managed knowledge files.
|
description: Search conversation history and understand Dream-managed profile and memory files.
|
||||||
always: true
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Memory
|
# Memory
|
||||||
@@ -11,23 +10,26 @@ always: true
|
|||||||
- `SOUL.md` — Bot personality and communication style. **Managed by Dream.** Do NOT edit.
|
- `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.
|
- `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/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
|
## 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 `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 `fixed_strings=true` for literal timestamps or JSON fragments
|
||||||
- Use `head_limit` / `offset` to page through long histories
|
- 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`):
|
Examples (replace `<history-log-path>` with the absolute path from the system prompt):
|
||||||
- `grep(pattern="keyword", path="memory/history.jsonl", case_insensitive=true)`
|
- `grep(pattern="keyword", path="<history-log-path>", case_insensitive=true)`
|
||||||
- `grep(pattern="2026-04-02 10:00", path="memory/history.jsonl", fixed_strings=true)`
|
- `grep(pattern="2026-04-02 10:00", path="<history-log-path>", fixed_strings=true)`
|
||||||
- `grep(pattern="keyword", path="memory", glob="*.jsonl", output_mode="count", case_insensitive=true)`
|
- `grep(pattern="keyword", path="<history-log-path>", output_mode="count", case_insensitive=true)`
|
||||||
- `grep(pattern="oauth|token", path="memory", glob="*.jsonl", output_mode="content", case_insensitive=true)`
|
- `grep(pattern="oauth|token", path="<history-log-path>", output_mode="content", case_insensitive=true)`
|
||||||
|
|
||||||
## Important
|
## Important
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
---
|
---
|
||||||
name: update-setup
|
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
|
# 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
|
## 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.
|
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
|
## 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
|
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 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
|
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.
|
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
|
## 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.
|
- 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.
|
- 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.
|
- 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.
|
- 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 }}
|
{{ runtime }}
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: {{ workspace_path }}
|
Your current project workspace is at: {{ workspace_path }}
|
||||||
- Long-term memory: {{ workspace_path }}/memory/MEMORY.md (automatically managed by Dream — do not edit directly)
|
{% if agent_workspace_path != workspace_path %}
|
||||||
- History log: {{ workspace_path }}/memory/history.jsonl (append-only JSONL; prefer built-in `grep` for search).
|
Nanobot's agent workspace is at: {{ agent_workspace_path }}
|
||||||
- Custom skills: {{ workspace_path }}/skills/{% raw %}{skill-name}{% endraw %}/SKILL.md
|
{% 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 }}
|
{{ platform_policy }}
|
||||||
{% if channel == 'telegram' or channel == 'qq' or channel == 'discord' %}
|
{% 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.
|
Output is rendered in a terminal. Avoid markdown headings and tables. Use plain text with minimal formatting.
|
||||||
{% endif %}
|
{% 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' %}
|
{% 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
|
# Skills
|
||||||
|
|
||||||
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
|
The following skills extend your capabilities. Each group lists one absolute root and relative SKILL.md paths; join them when using `read_file`.
|
||||||
Unavailable skills need dependencies installed first — you can try installing them with apt/brew.
|
|
||||||
|
|
||||||
{{ skills_summary }}
|
{{ 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' %}
|
{% include 'agent/_snippets/untrusted_content.md' %}
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
{{ workspace }}
|
Current project workspace: {{ workspace }}
|
||||||
|
{% if agent_workspace != workspace %}
|
||||||
|
Nanobot's agent workspace: {{ agent_workspace }}
|
||||||
|
{% endif %}
|
||||||
|
History log: {{ history_log }}
|
||||||
{% if skills_summary %}
|
{% if skills_summary %}
|
||||||
|
|
||||||
## Skills
|
## 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 }}
|
{{ skills_summary }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
# Tool Usage Notes
|
# 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
|
## General Tool Contract
|
||||||
|
|
||||||
- Use the narrowest structured tool that directly matches the task.
|
- Use the narrowest structured tool that directly matches the task.
|
||||||
- Use read-only discovery before writes when state is uncertain.
|
- 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.
|
- 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.
|
- 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.
|
- Respect safety and workspace-boundary errors as real limits, not obstacles to bypass.
|
||||||
|
|
||||||
## Discovery and Reading
|
## 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 `fixed_strings=true` for literal keywords containing regex characters.
|
||||||
- Use `output_mode="count"` to size a broad search before reading full matches.
|
- Use `output_mode="count"` to size a broad search before reading full matches.
|
||||||
- Use `head_limit` and `offset` to page across large result sets.
|
- 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
|
## File and Coding Workflows
|
||||||
|
|
||||||
@@ -55,9 +54,10 @@ Tool signatures are provided automatically via function calling. This section do
|
|||||||
|
|
||||||
## Messaging and Media
|
## Messaging and Media
|
||||||
|
|
||||||
- Use `message` to send content or local media to the user/channel.
|
- Reply directly with text for the current conversation. Do not use the 'message' tool for normal replies in the current chat.
|
||||||
- `read_file` only reads content for your analysis; it does not deliver a file to the user.
|
- Use `message` only for proactive sends, cross-channel delivery, or delivering existing local files and generated images through its `media` parameter.
|
||||||
- When sending an existing local file, attach it through the message/media mechanism instead of pasting file contents unless the user asked for text.
|
- `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
|
## Scheduling and Background Work
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ class TestLoadBootstrapFiles:
|
|||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
assert builder._load_bootstrap_files() == ""
|
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):
|
def test_agents_md(self, tmp_path):
|
||||||
(tmp_path / "AGENTS.md").write_text("Be helpful.", encoding="utf-8")
|
(tmp_path / "AGENTS.md").write_text("Be helpful.", encoding="utf-8")
|
||||||
builder = _builder(tmp_path)
|
builder = _builder(tmp_path)
|
||||||
@@ -116,6 +121,66 @@ class TestLoadBootstrapFiles:
|
|||||||
result = builder._load_bootstrap_files()
|
result = builder._load_bootstrap_files()
|
||||||
assert "用中文回复" in result
|
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)
|
# _is_template_content (static)
|
||||||
@@ -239,6 +304,19 @@ class TestBuildSystemPrompt:
|
|||||||
result = builder.build_system_prompt()
|
result = builder.build_system_prompt()
|
||||||
assert "workspace" in result.lower() or "python" in result.lower()
|
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):
|
def test_includes_bootstrap_files(self, tmp_path):
|
||||||
(tmp_path / "AGENTS.md").write_text("Be helpful and concise.", encoding="utf-8")
|
(tmp_path / "AGENTS.md").write_text("Be helpful and concise.", encoding="utf-8")
|
||||||
builder = _builder(tmp_path)
|
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")
|
assert not (left.get("role") == right.get("role") == "assistant")
|
||||||
|
|
||||||
|
|
||||||
def test_always_skills_excluded_from_skills_index(tmp_path) -> None:
|
def test_memory_skill_is_lazy_loaded_from_skills_index(tmp_path) -> None:
|
||||||
"""Always skills should appear in Active Skills but NOT in the skills index."""
|
"""Memory search guidance should be discoverable without loading its full body."""
|
||||||
workspace = _make_workspace(tmp_path)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
builder = ContextBuilder(workspace)
|
||||||
|
|
||||||
prompt = builder.build_system_prompt()
|
prompt = builder.build_system_prompt()
|
||||||
|
|
||||||
# memory skill should be in Active Skills section
|
assert "### Skill: memory" not in prompt
|
||||||
assert "# Active Skills" in prompt
|
assert "**memory**" in prompt
|
||||||
assert "### Skill: 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)
|
def test_fresh_workspace_omits_default_prompt_scaffolding(tmp_path) -> None:
|
||||||
if len(skills_section) > 1:
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
index_text = skills_section[1].split("\n\n---")[0]
|
|
||||||
assert "**memory**" not in index_text
|
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:
|
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)
|
builder = ContextBuilder(workspace)
|
||||||
prompt = builder.build_system_prompt()
|
prompt = builder.build_system_prompt()
|
||||||
|
|
||||||
# The "# Memory\n\n## Long-term Memory" block is produced only by
|
# This block is produced only when populated long-term memory is injected.
|
||||||
# build_system_prompt() when MEMORY.md is injected. The memory skill
|
|
||||||
# also contains "# Memory" but is followed by "## Structure", not
|
|
||||||
# "## Long-term Memory".
|
|
||||||
assert "# Memory\n\n## Long-term Memory" not in prompt
|
assert "# Memory\n\n## Long-term Memory" not in prompt
|
||||||
assert "This file is automatically updated by nanobot" 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
|
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:
|
def test_disabled_skills_excluded_from_get_always_skills(tmp_path: Path) -> None:
|
||||||
workspace = tmp_path / "ws"
|
workspace = tmp_path / "ws"
|
||||||
ws_skills = workspace / "skills"
|
ws_skills = workspace / "skills"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from nanobot.agent.tools.filesystem import FileToolsConfig
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.config.schema import ToolsConfig
|
from nanobot.config.schema import ToolsConfig
|
||||||
from nanobot.providers.base import GenerationSettings, LLMProvider
|
from nanobot.providers.base import GenerationSettings, LLMProvider
|
||||||
|
from nanobot.security.workspace_access import build_workspace_scope
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_subagent_forwards_fail_on_tool_error_to_runner(tmp_path):
|
async def test_subagent_forwards_fail_on_tool_error_to_runner(tmp_path):
|
||||||
provider = MagicMock(spec=LLMProvider)
|
provider = MagicMock(spec=LLMProvider)
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
@@ -7,14 +9,15 @@ from unittest.mock import MagicMock
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.agent.tools.cli_apps import CliAppsTool
|
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.filesystem import ReadFileTool, WriteFileTool
|
||||||
from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool
|
from nanobot.agent.tools.image_generation import ImageGenerationError, ImageGenerationTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
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.shell import ExecTool
|
||||||
from nanobot.agent.tools.spawn import SpawnTool
|
from nanobot.agent.tools.spawn import SpawnTool
|
||||||
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
|
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 (
|
from nanobot.security.workspace_access import (
|
||||||
WORKSPACE_SCOPE_METADATA_KEY,
|
WORKSPACE_SCOPE_METADATA_KEY,
|
||||||
WorkspaceScopeError,
|
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:
|
def test_workspace_scope_defaults_match_legacy_config(tmp_path: Path) -> None:
|
||||||
unrestricted = default_workspace_scope(tmp_path, restrict_to_workspace=False)
|
unrestricted = default_workspace_scope(tmp_path, restrict_to_workspace=False)
|
||||||
restricted = default_workspace_scope(tmp_path, restrict_to_workspace=True)
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_filesystem_write_tool_full_scope_allows_outside_project(tmp_path: Path) -> None:
|
async def test_filesystem_write_tool_full_scope_allows_outside_project(tmp_path: Path) -> None:
|
||||||
project = tmp_path / "project"
|
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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_search_tools_reject_paths_outside_workspace(tmp_path: Path) -> None:
|
async def test_search_tools_reject_paths_outside_workspace(tmp_path: Path) -> None:
|
||||||
outside = tmp_path.parent / "outside-search.txt"
|
outside = tmp_path.parent / "outside-search.txt"
|
||||||
|
|||||||
Reference in New Issue
Block a user