diff --git a/README.md b/README.md index 3ac2e20..ace2080 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,12 @@ nanobot onboard "model": "anthropic/claude-opus-4-5" } }, - "webSearch": { - "apiKey": "BSA-xxx" + "tools": { + "web": { + "search": { + "apiKey": "BSA-xxx" + } + } } } ``` diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 4a96b84..bfe6e89 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -40,14 +40,17 @@ class AgentLoop: workspace: Path, model: str | None = None, max_iterations: int = 20, - brave_api_key: str | None = None + brave_api_key: str | None = None, + exec_config: "ExecToolConfig | None" = None, ): + from nanobot.config.schema import ExecToolConfig self.bus = bus self.provider = provider self.workspace = workspace self.model = model or provider.get_default_model() self.max_iterations = max_iterations self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() self.context = ContextBuilder(workspace) self.sessions = SessionManager(workspace) @@ -58,6 +61,7 @@ class AgentLoop: bus=bus, model=self.model, brave_api_key=brave_api_key, + exec_config=self.exec_config, ) self._running = False @@ -72,7 +76,11 @@ class AgentLoop: self.tools.register(ListDirTool()) # Shell tool - self.tools.register(ExecTool(working_dir=str(self.workspace))) + self.tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.exec_config.restrict_to_workspace, + )) # Web tools self.tools.register(WebSearchTool(api_key=self.brave_api_key)) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index d3b320c..05ffbb8 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -33,12 +33,15 @@ class SubagentManager: bus: MessageBus, model: str | None = None, brave_api_key: str | None = None, + exec_config: "ExecToolConfig | None" = None, ): + from nanobot.config.schema import ExecToolConfig self.provider = provider self.workspace = workspace self.bus = bus self.model = model or provider.get_default_model() self.brave_api_key = brave_api_key + self.exec_config = exec_config or ExecToolConfig() self._running_tasks: dict[str, asyncio.Task[None]] = {} async def spawn( @@ -96,7 +99,11 @@ class SubagentManager: tools.register(ReadFileTool()) tools.register(WriteFileTool()) tools.register(ListDirTool()) - tools.register(ExecTool(working_dir=str(self.workspace))) + tools.register(ExecTool( + working_dir=str(self.workspace), + timeout=self.exec_config.timeout, + restrict_to_workspace=self.exec_config.restrict_to_workspace, + )) tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebFetchTool()) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 6fcfec6..ca9bcc2 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -12,6 +12,15 @@ class Tool(ABC): the environment, such as reading files, executing commands, etc. """ + _TYPE_MAP = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, + } + @property @abstractmethod def name(self) -> str: @@ -42,6 +51,44 @@ class Tool(ABC): String result of the tool execution. """ pass + + def validate_params(self, params: dict[str, Any]) -> list[str]: + """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + schema = self.parameters or {} + if schema.get("type", "object") != "object": + raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") + return self._validate(params, {**schema, "type": "object"}, "") + + def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]: + t, label = schema.get("type"), path or "parameter" + if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]): + return [f"{label} should be {t}"] + + errors = [] + if "enum" in schema and val not in schema["enum"]: + errors.append(f"{label} must be one of {schema['enum']}") + if t in ("integer", "number"): + if "minimum" in schema and val < schema["minimum"]: + errors.append(f"{label} must be >= {schema['minimum']}") + if "maximum" in schema and val > schema["maximum"]: + errors.append(f"{label} must be <= {schema['maximum']}") + if t == "string": + if "minLength" in schema and len(val) < schema["minLength"]: + errors.append(f"{label} must be at least {schema['minLength']} chars") + if "maxLength" in schema and len(val) > schema["maxLength"]: + errors.append(f"{label} must be at most {schema['maxLength']} chars") + if t == "object": + props = schema.get("properties", {}) + for k in schema.get("required", []): + if k not in val: + errors.append(f"missing required {path + '.' + k if path else k}") + for k, v in val.items(): + if k in props: + errors.extend(self._validate(v, props[k], path + '.' + k if path else k)) + if t == "array" and "items" in schema: + for i, item in enumerate(val): + errors.extend(self._validate(item, schema["items"], f"{path}[{i}]" if path else f"[{i}]")) + return errors def to_schema(self) -> dict[str, Any]: """Convert tool to OpenAI function schema format.""" diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 1e8f56d..d9b33ff 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -52,8 +52,11 @@ class ToolRegistry: tool = self._tools.get(name) if not tool: return f"Error: Tool '{name}' not found" - + try: + errors = tool.validate_params(params) + if errors: + return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) return await tool.execute(**params) except Exception as e: return f"Error executing {name}: {str(e)}" diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 5319c57..805d36c 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -34,9 +34,28 @@ def validate_command_safety(command: str) -> tuple[bool, str | None]: class ExecTool(Tool): """Tool to execute shell commands.""" - def __init__(self, timeout: int = 60, working_dir: str | None = None): + def __init__( + self, + timeout: int = 60, + working_dir: str | None = None, + deny_patterns: list[str] | None = None, + allow_patterns: list[str] | None = None, + restrict_to_workspace: bool = False, + ): self.timeout = timeout self.working_dir = working_dir + self.deny_patterns = deny_patterns or [ + r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr + r"\bdel\s+/[fq]\b", # del /f, del /q + r"\brmdir\s+/s\b", # rmdir /s + r"\b(format|mkfs|diskpart)\b", # disk operations + r"\bdd\s+if=", # dd + r">\s*/dev/sd", # write to disk + r"\b(shutdown|reboot|poweroff)\b", # system power + r":\(\)\s*\{.*\};\s*:", # fork bomb + ] + self.allow_patterns = allow_patterns or [] + self.restrict_to_workspace = restrict_to_workspace @property def name(self) -> str: @@ -70,6 +89,9 @@ class ExecTool(Tool): return f"Error: Refusing to execute dangerous command. {warning}" cwd = working_dir or self.working_dir or os.getcwd() + guard_error = self._guard_command(command, cwd) + if guard_error: + return guard_error try: process = await asyncio.create_subprocess_shell( @@ -112,3 +134,35 @@ class ExecTool(Tool): except Exception as e: return f"Error executing command: {str(e)}" + + def _guard_command(self, command: str, cwd: str) -> str | None: + """Best-effort safety guard for potentially destructive commands.""" + cmd = command.strip() + lower = cmd.lower() + + for pattern in self.deny_patterns: + if re.search(pattern, lower): + return "Error: Command blocked by safety guard (dangerous pattern detected)" + + if self.allow_patterns: + if not any(re.search(p, lower) for p in self.allow_patterns): + return "Error: Command blocked by safety guard (not in allowlist)" + + if self.restrict_to_workspace: + if "..\\" in cmd or "../" in cmd: + return "Error: Command blocked by safety guard (path traversal detected)" + + cwd_path = Path(cwd).resolve() + + win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd) + posix_paths = re.findall(r"/[^\s\"']+", cmd) + + for raw in win_paths + posix_paths: + try: + p = Path(raw).resolve() + except Exception: + continue + if cwd_path not in p.parents and p != cwd_path: + return "Error: Command blocked by safety guard (path outside working dir)" + + return None diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5ecc31b..c2241fb 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -202,7 +202,8 @@ def gateway( workspace=config.workspace_path, model=config.agents.defaults.model, max_iterations=config.agents.defaults.max_tool_iterations, - brave_api_key=config.tools.web.search.api_key or None + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, ) # Create cron service @@ -309,7 +310,8 @@ def agent( bus=bus, provider=provider, workspace=config.workspace_path, - brave_api_key=config.tools.web.search.api_key or None + brave_api_key=config.tools.web.search.api_key or None, + exec_config=config.tools.exec, ) if message: @@ -398,7 +400,7 @@ def _get_bridge_dir() -> Path: raise typer.Exit(1) # Find source bridge: first check package data, then source dir - pkg_bridge = Path(__file__).parent / "bridge" # nanobot/bridge (installed) + pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) source = None diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 71e3361..4c34834 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -73,9 +73,16 @@ class WebToolsConfig(BaseModel): search: WebSearchConfig = Field(default_factory=WebSearchConfig) +class ExecToolConfig(BaseModel): + """Shell exec tool configuration.""" + timeout: int = 60 + restrict_to_workspace: bool = False # If true, block commands accessing paths outside workspace + + class ToolsConfig(BaseModel): """Tools configuration.""" web: WebToolsConfig = Field(default_factory=WebToolsConfig) + exec: ExecToolConfig = Field(default_factory=ExecToolConfig) class Config(BaseSettings): diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 547626d..8945412 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -88,13 +88,13 @@ class LiteLLMProvider(LLMProvider): model = f"openrouter/{model}" # For Zhipu/Z.ai, ensure prefix is present - # Handle cases like "glm-4.7-flash" -> "zhipu/glm-4.7-flash" + # Handle cases like "glm-4.7-flash" -> "zai/glm-4.7-flash" if ("glm" in model.lower() or "zhipu" in model.lower()) and not ( model.startswith("zhipu/") or model.startswith("zai/") or model.startswith("openrouter/") ): - model = f"zhipu/{model}" + model = f"zai/{model}" # For vLLM, use hosted_vllm/ prefix per LiteLLM docs # Convert openai/ prefix to hosted_vllm/ if user specified it diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index 4680d5e..9b5eb6f 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -9,9 +9,9 @@ This skill provides guidance for creating effective skills. ## About Skills -Skills are modular, self-contained packages that extend Codex's capabilities by providing +Skills are modular, self-contained packages that extend the agent's capabilities by providing specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific -domains or tasks—they transform Codex from a general-purpose agent into a specialized agent +domains or tasks—they transform the agent from a general-purpose agent into a specialized agent equipped with procedural knowledge that no model can fully possess. ### What Skills Provide @@ -25,9 +25,9 @@ equipped with procedural knowledge that no model can fully possess. ### Concise is Key -The context window is a public good. Skills share the context window with everything else Codex needs: system prompt, conversation history, other Skills' metadata, and the actual user request. +The context window is a public good. Skills share the context window with everything else the agent needs: system prompt, conversation history, other Skills' metadata, and the actual user request. -**Default assumption: Codex is already very smart.** Only add context Codex doesn't already have. Challenge each piece of information: "Does Codex really need this explanation?" and "Does this paragraph justify its token cost?" +**Default assumption: the agent is already very smart.** Only add context the agent doesn't already have. Challenge each piece of information: "Does the agent really need this explanation?" and "Does this paragraph justify its token cost?" Prefer concise examples over verbose explanations. @@ -41,7 +41,7 @@ Match the level of specificity to the task's fragility and variability: **Low freedom (specific scripts, few parameters)**: Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed. -Think of Codex as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). +Think of the agent as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom). ### Anatomy of a Skill @@ -64,7 +64,7 @@ skill-name/ Every SKILL.md consists of: -- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that Codex reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. +- **Frontmatter** (YAML): Contains `name` and `description` fields. These are the only fields that the agent reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. - **Body** (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all). #### Bundled Resources (optional) @@ -76,27 +76,27 @@ Executable code (Python/Bash/etc.) for tasks that require deterministic reliabil - **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed - **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks - **Benefits**: Token efficient, deterministic, may be executed without loading into context -- **Note**: Scripts may still need to be read by Codex for patching or environment-specific adjustments +- **Note**: Scripts may still need to be read by the agent for patching or environment-specific adjustments ##### References (`references/`) -Documentation and reference material intended to be loaded as needed into context to inform Codex's process and thinking. +Documentation and reference material intended to be loaded as needed into context to inform the agent's process and thinking. -- **When to include**: For documentation that Codex should reference while working +- **When to include**: For documentation that the agent should reference while working - **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications - **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides -- **Benefits**: Keeps SKILL.md lean, loaded only when Codex determines it's needed +- **Benefits**: Keeps SKILL.md lean, loaded only when the agent determines it's needed - **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md - **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. ##### Assets (`assets/`) -Files not intended to be loaded into context, but rather used within the output Codex produces. +Files not intended to be loaded into context, but rather used within the output the agent produces. - **When to include**: When the skill needs files that will be used in the final output - **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography - **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified -- **Benefits**: Separates output resources from documentation, enables Codex to use files without loading them into context +- **Benefits**: Separates output resources from documentation, enables the agent to use files without loading them into context #### What to Not Include in a Skill @@ -116,7 +116,7 @@ Skills use a three-level loading system to manage context efficiently: 1. **Metadata (name + description)** - Always in context (~100 words) 2. **SKILL.md body** - When skill triggers (<5k words) -3. **Bundled resources** - As needed by Codex (Unlimited because scripts can be executed without reading into context window) +3. **Bundled resources** - As needed by the agent (Unlimited because scripts can be executed without reading into context window) #### Progressive Disclosure Patterns @@ -141,7 +141,7 @@ Extract text with pdfplumber: - **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns ``` -Codex loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. +the agent loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed. **Pattern 2: Domain-specific organization** @@ -157,7 +157,7 @@ bigquery-skill/ └── marketing.md (campaigns, attribution) ``` -When a user asks about sales metrics, Codex only reads sales.md. +When a user asks about sales metrics, the agent only reads sales.md. Similarly, for skills supporting multiple frameworks or variants, organize by variant: @@ -170,7 +170,7 @@ cloud-deploy/ └── azure.md (Azure deployment patterns) ``` -When the user chooses AWS, Codex only reads aws.md. +When the user chooses AWS, the agent only reads aws.md. **Pattern 3: Conditional details** @@ -191,12 +191,12 @@ For simple edits, modify the XML directly. **For OOXML details**: See [OOXML.md](OOXML.md) ``` -Codex reads REDLINING.md or OOXML.md only when the user needs those features. +the agent reads REDLINING.md or OOXML.md only when the user needs those features. **Important guidelines:** - **Avoid deeply nested references** - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md. -- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so Codex can see the full scope when previewing. +- **Structure longer reference files** - For files longer than 100 lines, include a table of contents at the top so the agent can see the full scope when previewing. ## Skill Creation Process @@ -293,7 +293,7 @@ After initialization, customize the SKILL.md and add resources as needed. If you ### Step 4: Edit the Skill -When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Codex to use. Include information that would be beneficial and non-obvious to Codex. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Codex instance execute these tasks more effectively. +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of the agent to use. Include information that would be beneficial and non-obvious to the agent. Consider what procedural knowledge, domain-specific details, or reusable assets would help another the agent instance execute these tasks more effectively. #### Learn Proven Design Patterns @@ -321,10 +321,10 @@ If you used `--examples`, delete any placeholder files that are not needed for t Write the YAML frontmatter with `name` and `description`: - `name`: The skill name -- `description`: This is the primary triggering mechanism for your skill, and helps Codex understand when to use the skill. +- `description`: This is the primary triggering mechanism for your skill, and helps the agent understand when to use the skill. - Include both what the Skill does and specific triggers/contexts for when to use it. - - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Codex. - - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Codex needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" + - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent. + - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" Do not include any other fields in YAML frontmatter. diff --git a/pyproject.toml b/pyproject.toml index 3d8fe69..7d589b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post3" +version = "0.1.3.post4" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py new file mode 100644 index 0000000..f11c667 --- /dev/null +++ b/tests/test_tool_validation.py @@ -0,0 +1,88 @@ +from typing import Any + +from nanobot.agent.tools.base import Tool +from nanobot.agent.tools.registry import ToolRegistry + + +class SampleTool(Tool): + @property + def name(self) -> str: + return "sample" + + @property + def description(self) -> str: + return "sample tool" + + @property + def parameters(self) -> dict[str, Any]: + return { + "type": "object", + "properties": { + "query": {"type": "string", "minLength": 2}, + "count": {"type": "integer", "minimum": 1, "maximum": 10}, + "mode": {"type": "string", "enum": ["fast", "full"]}, + "meta": { + "type": "object", + "properties": { + "tag": {"type": "string"}, + "flags": { + "type": "array", + "items": {"type": "string"}, + }, + }, + "required": ["tag"], + }, + }, + "required": ["query", "count"], + } + + async def execute(self, **kwargs: Any) -> str: + return "ok" + + +def test_validate_params_missing_required() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "hi"}) + assert "missing required count" in "; ".join(errors) + + +def test_validate_params_type_and_range() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "hi", "count": 0}) + assert any("count must be >= 1" in e for e in errors) + + errors = tool.validate_params({"query": "hi", "count": "2"}) + assert any("count should be integer" in e for e in errors) + + +def test_validate_params_enum_and_min_length() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "h", "count": 2, "mode": "slow"}) + assert any("query must be at least 2 chars" in e for e in errors) + assert any("mode must be one of" in e for e in errors) + + +def test_validate_params_nested_object_and_array() -> None: + tool = SampleTool() + errors = tool.validate_params( + { + "query": "hi", + "count": 2, + "meta": {"flags": [1, "ok"]}, + } + ) + assert any("missing required meta.tag" in e for e in errors) + assert any("meta.flags[0] should be string" in e for e in errors) + + +def test_validate_params_ignores_unknown_fields() -> None: + tool = SampleTool() + errors = tool.validate_params({"query": "hi", "count": 2, "extra": "x"}) + assert errors == [] + + +async def test_registry_returns_validation_error() -> None: + reg = ToolRegistry() + reg.register(SampleTool()) + result = await reg.execute("sample", {"query": "hi"}) + assert "Invalid parameters" in result diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md index a99a7b4..b4e5b5f 100644 --- a/workspace/AGENTS.md +++ b/workspace/AGENTS.md @@ -16,6 +16,7 @@ You have access to: - Shell commands (exec) - Web access (search, fetch) - Messaging (message) +- Background tasks (spawn) ## Memory diff --git a/workspace/TOOLS.md b/workspace/TOOLS.md index 9915561..0134a64 100644 --- a/workspace/TOOLS.md +++ b/workspace/TOOLS.md @@ -37,29 +37,31 @@ exec(command: str, working_dir: str = None) -> str ``` **Safety Notes:** -- Commands have a 60-second timeout +- Commands have a configurable timeout (default 60s) +- Dangerous commands are blocked (rm -rf, format, dd, shutdown, etc.) - Output is truncated at 10,000 characters -- Use with caution for destructive operations +- Optional `restrictToWorkspace` config to limit paths ## Web Access ### web_search -Search the web using DuckDuckGo. +Search the web using Brave Search API. ``` -web_search(query: str) -> str +web_search(query: str, count: int = 5) -> str ``` -Returns top 5 search results with titles, URLs, and snippets. +Returns search results with titles, URLs, and snippets. Requires `tools.web.search.apiKey` in config. ### web_fetch Fetch and extract main content from a URL. ``` -web_fetch(url: str) -> str +web_fetch(url: str, extractMode: str = "markdown", maxChars: int = 50000) -> str ``` **Notes:** -- Content is extracted using trafilatura -- Output is truncated at 8,000 characters +- Content is extracted using readability +- Supports markdown or plain text extraction +- Output is truncated at 50,000 characters by default ## Communication @@ -69,6 +71,16 @@ Send a message to the user (used internally). message(content: str, channel: str = None, chat_id: str = None) -> str ``` +## Background Tasks + +### spawn +Spawn a subagent to handle a task in the background. +``` +spawn(task: str, label: str = None) -> str +``` + +Use for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done. + ## Scheduled Reminders (Cron) Use the `exec` tool to create scheduled reminders with `nanobot cron add`: