Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
f181788
Add notes for personality updates and technical level
JFamo Feb 28, 2026
e38ce2c
Merge branch 'HKUDS:main' into main
JFamo Mar 1, 2026
8cc2fc3
feat: remove web search tool registration
Skyfly2 Feb 28, 2026
f5ac1fe
feat: enable gog
Skyfly2 Mar 1, 2026
39adceb
feat: web skill
Skyfly2 Mar 1, 2026
061a83c
feat: update skills
Skyfly2 Mar 1, 2026
5c2a189
feat: update skill
Skyfly2 Mar 1, 2026
79cff44
Add telegram relay support
JFamo Mar 1, 2026
180022b
git commit -m "feat: update skill"
Skyfly2 Mar 1, 2026
4d6a3d6
feat: update google skill
Skyfly2 Mar 1, 2026
c644561
feat: use package tool defs
Skyfly2 Mar 1, 2026
521f23e
Add additional logging
JFamo Mar 1, 2026
bee2aba
feat: google api tools
Skyfly2 Mar 1, 2026
4500e54
fix: defer agent identity to SOUL.md, remove hardcoded 'You are nanobot'
JFamo Mar 1, 2026
eeddac3
fix: write agent exec logs to /root/.nanobot/agent.log when --logs is…
JFamo Mar 1, 2026
7b03bc9
feat: display format description
Skyfly2 Mar 2, 2026
3ab1bcb
feat: display preview items
Skyfly2 Mar 2, 2026
6f9eeba
feat: expose authenticated HTTP API on gateway port 18790
JFamo Mar 2, 2026
a4200ab
fix: cron jobs fire in user's session with proper delivery framing
JFamo Mar 2, 2026
f864736
fix: move logger import outside verbose branch in gateway command
JFamo Mar 3, 2026
e84dba8
docs: add CLAUDE.md with gateway architecture and crash diagnosis guide
JFamo Mar 3, 2026
0b233aa
feat: tweet tools
Skyfly2 Mar 4, 2026
ca69c80
feat: use twitter display format
Skyfly2 Mar 5, 2026
b27cf74
Handle late cron fire
JFamo Mar 5, 2026
0af2bd6
Merge pull request #1 from JFamo/ahamrick/twitter-int
Skyfly2 Mar 10, 2026
a68d786
fix: call tools immediately without narrating — don't announce before…
JFamo Mar 5, 2026
5efcd0a
feat: send NANOBOT_API_KEY on all outbound coordinator calls
JFamo Mar 11, 2026
464f374
refactor: centralise coordinator auth in nanobot/coordinator/client.py
JFamo Mar 11, 2026
f005d12
feat: add confirmation framework
Skyfly2 Mar 11, 2026
3d8e594
feat: better bot context for pending actions
Skyfly2 Mar 12, 2026
c56d7f5
Merge pull request #2 from JFamo/ahamrick/tool-confirmation
Skyfly2 Mar 12, 2026
cad230c
fix: require NANOBOT_API_KEY at gateway startup
JFamo Mar 12, 2026
a65e4c6
feat: allow bot to send multiple messages mid-turn
JFamo Mar 12, 2026
fc967bb
feat: nudge bot to send multiple messages via message tool (Option A)
JFamo Mar 12, 2026
77f04dc
feat: add scope registry + scope sync endpoint
Skyfly2 Mar 14, 2026
5e163fa
feat: support streaming message progress in loop
Skyfly2 Mar 14, 2026
095c323
feat: reduce redundant data in pending actions + response
Skyfly2 Mar 15, 2026
cf2c93f
feat: add memory save tools
Skyfly2 Mar 16, 2026
08a0824
feat: better preview components
Skyfly2 Mar 16, 2026
09ef718
chore: disable exec command
Skyfly2 Mar 19, 2026
6b23820
feat: fix broken config
Skyfly2 Mar 20, 2026
2d115da
feat: add web relay based on telegram relay
Skyfly2 Mar 21, 2026
590f12c
feat: pass timezone in request
Skyfly2 Mar 22, 2026
d278352
fix: duplicate messages on setup
Skyfly2 Mar 25, 2026
024b025
feat: add track message
Skyfly2 Mar 27, 2026
d35e92a
fix: loop setup followups
Skyfly2 Mar 29, 2026
5a3c8f1
feat: add github tools
Skyfly2 Mar 30, 2026
faa753c
feat: event streaming + name update streaming
Skyfly2 Apr 2, 2026
4c239ec
feat: fix naming
Skyfly2 Apr 3, 2026
3b7964b
chore: add .gitmessage to .gitignore
Apr 3, 2026
d2c605e
feat: harden setup mode
Skyfly2 Apr 5, 2026
4496664
Add Cursor PR rule and document pull request workflow in AGENTS.md
Skyfly2 Apr 5, 2026
08f9b41
fix: resolve setup mode tool conflicts and preserve follow-ups
Skyfly2 Apr 10, 2026
99f903f
Merge pull request #3 from JFamo/fix/setup-mode-issues
Skyfly2 Apr 10, 2026
a9b7647
feat: consistent session id
Skyfly2 Apr 11, 2026
6cf490a
Merge pull request #4 from JFamo/fix/setup-mode-issues
Skyfly2 Apr 11, 2026
74a9ea5
feat: merge upstream HKUDS/nanobot v0.1.5 into fork
Skyfly2 Apr 11, 2026
1dbea0e
fix: emit bot_name_updated SSE event during setup
Skyfly2 Apr 11, 2026
c49b4e1
fix: remove unused Any imports flagged by ruff
Skyfly2 Apr 11, 2026
9511bbc
fix: resolve 5 test failures from upstream merge
Skyfly2 Apr 11, 2026
4821e68
Merge pull request #5 from JFamo/ahamrick/merge
Skyfly2 Apr 11, 2026
e9cba1c
security: comprehensive hardening for external deployment
Skyfly2 Apr 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .cursor/rules/pull-requests.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
description: Open pull requests for code changes; integrate via PR review
alwaysApply: true
---

# Pull requests

- Work on a branch. After substantive changes, open a pull request to the default branch (use `gh pr create` with a clear title and description when available).
- Do not push feature work directly to `main` or the repository default branch unless the user explicitly requests a hotfix or direct push.
- Integrate changes through the PR; that is the normal path for merging.
61 changes: 61 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# nanobot — Claude Instructions

## Auto-commit policy

After making any substantial change, automatically commit without asking. Use conventional prefixes (`fix:`, `feat:`, `chore:`, `refactor:`).

## Quick reference

```bash
# Run a single command
nanobot agent -m "Hello"

# Start the gateway (HTTP API + channels + cron)
nanobot gateway --api-key <key>

# Build Docker image (from ../fairy-bot-e2e/ for e2e testing)
make build
```

## Gateway architecture

`nanobot gateway` starts:
1. An **aiohttp HTTP server** on port 18790 (`POST /agent/run` with bearer auth)
2. The **agent loop** (`AgentLoop.run()`) — connects MCP servers, processes messages
3. **Channel managers** (telegram relay, etc.)
4. **CronService** + **HeartbeatService**

All four run under `asyncio.gather`. If any raises an unhandled exception the entire gateway exits (restart policy: `unless-stopped`).

The log line `"Gateway HTTP API listening on port 18790"` appears immediately after the HTTP server binds, before MCP init. This is the correct health signal — docker status `running` lags behind.

## Cron jobs

- Jobs are persisted to `~/.nanobot/cron/jobs.json`
- `CronService` loads them at startup and runs them in-memory
- When a job fires, `on_cron_job` calls `agent.process_direct()` with:
- **Session key**: `{channel}:{chat_id}` (the user's real session, so the agent has personality and history)
- **Message**: `[SCHEDULED REMINDER] A reminder you scheduled is now due. Deliver this message to the user in your own voice: {original_message}`
- `every_seconds` is for recurring jobs only; use `at` with an ISO datetime for one-time reminders

## Common crash causes in e2e

| Symptom | Root cause | Fix |
|---|---|---|
| Gateway exits ~300ms after start | MCP host unreachable (e.g. `charlotte`) | Set `CHARLOTTE_MCP_URL=` in `.env.test` |
| Gateway exits, telegram relay log shows `nanobot-coordinator` | Wrong coordinator hostname | Set `NANOBOT_COORDINATOR_URL=http://e2e-coordinator:8000` |
| `NameError: cannot access free variable 'logger'` | `logger` imported inside `if verbose:` block but used outside | Import `from loguru import logger` before the `if verbose:` check in `gateway()` |
| ConnectError on chat after container shows "running" | HTTP server not yet bound | Wait for log line `"Gateway HTTP API listening on port"` instead of sleeping |

## E2E testing

Tests live in `../fairy-bot-e2e/`. After nanobot changes:

```bash
cd ../fairy-bot-e2e
make build # rebuilds nanobot:latest
make up # restarts stack
make test # runs Playwright tests
```

Use `bot-helpers.ts` functions (`waitForBotHealthy`, `getContainerLogs`, etc.) — see `fairy-bot-e2e/CLAUDE.md` for the full reference.
14 changes: 8 additions & 6 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ x-common-config: &common-config
- ~/.nanobot:/home/nanobot/.nanobot
cap_drop:
- ALL
cap_add:
- SYS_ADMIN
security_opt:
- apparmor=unconfined
- seccomp=unconfined
# SYS_ADMIN + seccomp/apparmor unconfined only needed if exec sandbox=bwrap.
# Uncomment below lines if you enable tools.exec with sandbox: "bwrap":
# cap_add:
# - SYS_ADMIN
# security_opt:
# - apparmor=unconfined
# - seccomp=unconfined

services:
nanobot-gateway:
Expand All @@ -19,7 +21,7 @@ services:
command: ["gateway"]
restart: unless-stopped
ports:
- 18790:18790
- "127.0.0.1:18790:18790"
deploy:
resources:
limits:
Expand Down
169 changes: 159 additions & 10 deletions nanobot/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import dataclasses
import json
import os
import re
import time
from contextlib import AsyncExitStack, nullcontext
from pathlib import Path
Expand All @@ -29,6 +30,7 @@
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.web import WebFetchTool, WebSearchTool
from nanobot.agent.types import AgentResponse, PendingAction
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.bus.queue import MessageBus
Expand Down Expand Up @@ -200,6 +202,10 @@ def __init__(
)
self._unified_session = unified_session
self._running = False
self._scopes_fetched = False
self._confirmation_supported = False
self._pending_actions: list[PendingAction] = []
self._last_final_content: str = ""
self._mcp_servers = mcp_servers or {}
self._mcp_stacks: dict[str, AsyncExitStack] = {}
self._mcp_connected = False
Expand Down Expand Up @@ -274,11 +280,61 @@ def _register_default_tools(self) -> None:
self.tools.register(WebFetchTool(proxy=self.web_config.proxy))
self.tools.register(MessageTool(send_callback=self.bus.publish_outbound))
self.tools.register(SpawnTool(manager=self.subagents))
from nanobot.agent.tools.memory import SaveMemoryTool, UpdateBotIdentityTool, UpdateUserProfileTool
for cls in (UpdateBotIdentityTool, UpdateUserProfileTool, SaveMemoryTool):
self.tools.register(cls(workspace=self.workspace))
if self.cron_service:
self.tools.register(
CronTool(self.cron_service, default_timezone=self.context.timezone or "UTC")
)

def sync_scoped_tools(
self,
google_scopes: list[str] | None = None,
x_scopes: list[str] | None = None,
github_scopes: list[str] | None = None,
) -> None:
"""Register/deregister Google, X, and GitHub tools based on granted OAuth scopes."""
from nanobot.agent.tools.scope_registry import (
ALL_SCOPED_TOOL_NAMES,
get_tools_for_scopes,
)

desired = get_tools_for_scopes(google_scopes, x_scopes, github_scopes)
current_scoped = {
name for name in self.tools.tool_names if name in ALL_SCOPED_TOOL_NAMES
}

to_add = set(desired.keys()) - current_scoped
to_remove = current_scoped - set(desired.keys())

for name in to_remove:
self.tools.unregister(name)
if to_remove:
logger.info("Deregistered {} scoped tool(s): {}", len(to_remove), ", ".join(sorted(to_remove)))

for name in to_add:
self.tools.register(desired[name]())
if to_add:
logger.info("Registered {} scoped tool(s): {}", len(to_add), ", ".join(sorted(to_add)))

if not to_add and not to_remove:
logger.debug("Scoped tools unchanged ({} active)", len(current_scoped))

async def _fetch_and_sync_scopes(self) -> None:
"""Fetch current OAuth scopes from the coordinator and sync tools (once)."""
if self._scopes_fetched:
return
if not os.environ.get("BOT_ID") or not os.environ.get("COORDINATOR_URL"):
return
try:
from nanobot.coordinator.client import fetch_scopes
google_scopes, x_scopes, github_scopes = await fetch_scopes()
self.sync_scoped_tools(google_scopes, x_scopes, github_scopes)
self._scopes_fetched = True
except Exception as e:
logger.warning("Failed to fetch scopes from coordinator: {}", e)

async def _connect_mcp(self) -> None:
"""Connect to configured MCP servers (one-time, lazy)."""
if self._mcp_connected or self._mcp_connecting or not self._mcp_servers:
Expand Down Expand Up @@ -396,6 +452,21 @@ async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[
items.append({"role": "user", "content": merged})
return items

async def _on_confirmation_needed(
tool_call_id: str, tool_name: str, arguments: dict,
) -> PendingAction | None:
if not self._confirmation_supported:
return None
import uuid as _uuid
pa = PendingAction(
action_id=str(_uuid.uuid4()),
tool_call_id=tool_call_id,
tool_name=tool_name,
arguments=arguments,
)
self._pending_actions.append(pa)
return pa

result = await self.runner.run(AgentRunSpec(
initial_messages=initial_messages,
tools=self.tools,
Expand All @@ -413,6 +484,7 @@ async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[
progress_callback=on_progress,
checkpoint_callback=_checkpoint,
injection_callback=_drain_pending,
confirmation_callback=_on_confirmation_needed,
))
self._last_usage = result.usage
if result.stop_reason == "max_iterations":
Expand All @@ -424,6 +496,7 @@ async def _drain_pending(*, limit: int = _MAX_INJECTIONS_PER_TURN) -> list[dict[
async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
await self._fetch_and_sync_scopes()
await self._connect_mcp()
logger.info("Agent loop started")

Expand Down Expand Up @@ -706,6 +779,8 @@ async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:
if final_content is None or not final_content.strip():
final_content = EMPTY_FINAL_RESPONSE_MESSAGE

self._last_final_content = final_content

self._save_turn(session, all_msgs, 1 + len(history))
self._clear_runtime_checkpoint(session)
self.sessions.save(session)
Expand Down Expand Up @@ -893,6 +968,19 @@ def _restore_runtime_checkpoint(self, session: Session) -> bool:
self._clear_runtime_checkpoint(session)
return True

async def consolidate_and_reset_session(self, session_key: str) -> bool:
"""Consolidate memory for the given session, then clear it."""
session = self.sessions.get_or_create(session_key)
try:
await self.consolidator.maybe_consolidate_by_tokens(session)
except Exception:
logger.exception("Consolidation failed for {}", session_key)
return False
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session_key)
return True

async def process_direct(
self,
content: str,
Expand All @@ -902,14 +990,75 @@ async def process_direct(
on_progress: Callable[[str], Awaitable[None]] | None = None,
on_stream: Callable[[str], Awaitable[None]] | None = None,
on_stream_end: Callable[..., Awaitable[None]] | None = None,
) -> OutboundMessage | None:
"""Process a message directly and return the outbound payload."""
on_message: Callable[[OutboundMessage], Awaitable[None]] | None = None,
on_stream_event: Callable[[dict], Awaitable[None]] | None = None,
confirmation_supported: bool = False,
timezone: str | None = None,
) -> AgentResponse:
"""Process a message directly (for CLI, HTTP gateway, or cron usage).

Args:
on_message: If provided, called for every MessageTool send.
Allows SSE streaming endpoints to capture intermediate bot
messages without registering a fake channel.
confirmation_supported: If True, tools with requires_confirmation
will queue PendingAction instead of executing.
on_stream_event: Callback for structured events (e.g. bot_name_updated).
"""
self._confirmation_supported = confirmation_supported
self._pending_actions = []
await self._fetch_and_sync_scopes()
await self._connect_mcp()
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
return await self._process_message(
msg,
session_key=session_key,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
)

original_cb: Callable[[OutboundMessage], Awaitable[None]] | None = None
mt = self.tools.get("message")
if on_message and isinstance(mt, MessageTool):
original_cb = mt._send_callback

async def _stream_callback(msg: OutboundMessage) -> None:
await on_message(msg)

mt.set_send_callback(_stream_callback)

from nanobot.agent.tools.memory import UpdateBotIdentityTool
identity_tool = self.tools.get("update_bot_identity")
old_stream_event = None
if isinstance(identity_tool, UpdateBotIdentityTool):
old_stream_event = identity_tool._on_stream_event
identity_tool._on_stream_event = on_stream_event

try:
msg = InboundMessage(channel=channel, sender_id="user", chat_id=chat_id, content=content)
response = await self._process_message(
msg,
session_key=session_key,
on_progress=on_progress,
on_stream=on_stream,
on_stream_end=on_stream_end,
)

if response is not None:
resolved_content = response.content
elif isinstance(mt, MessageTool) and mt._sent_in_turn:
if on_message:
suppressed = self._last_final_content or ""
follow_match = re.search(
r"<follow[-_]ups>\s*.*?\s*</follow[-_]ups>",
suppressed,
re.DOTALL | re.IGNORECASE,
)
resolved_content = follow_match.group(0) if follow_match else ""
else:
resolved_content = "\n\n".join(mt._sent_contents)
else:
resolved_content = ""

return AgentResponse(
content=resolved_content,
pending_actions=list(self._pending_actions),
)
finally:
if original_cb is not None and isinstance(mt, MessageTool):
mt.set_send_callback(original_cb)
if isinstance(identity_tool, UpdateBotIdentityTool):
identity_tool._on_stream_event = old_stream_event
24 changes: 24 additions & 0 deletions nanobot/agent/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ class AgentRunSpec:
progress_callback: Any | None = None
checkpoint_callback: Any | None = None
injection_callback: Any | None = None
confirmation_callback: Any | None = None


@dataclass(slots=True)
Expand Down Expand Up @@ -622,6 +623,29 @@ async def _run_tool(
"detail": prep_error.split(": ", 1)[-1][:120],
}
return prep_error + _HINT, event, RuntimeError(prep_error) if spec.fail_on_tool_error else None

resolved_tool = tool if tool is not None else spec.tools.get(tool_call.name)
if (
spec.confirmation_callback is not None
and resolved_tool is not None
and getattr(resolved_tool, "requires_confirmation", False)
):
pending = await spec.confirmation_callback(
tool_call.id, tool_call.name, params
)
if pending is not None:
event = {
"name": tool_call.name,
"status": "pending_confirmation",
"detail": "Queued for user confirmation",
}
return (
"[This action requires user confirmation before it can be executed. "
"The user has been notified and will approve or reject it.]",
event,
None,
)

try:
if tool is not None:
result = await tool.execute(**params)
Expand Down
5 changes: 5 additions & 0 deletions nanobot/agent/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ def parameters(self) -> dict[str, Any]:
"""JSON Schema for tool parameters."""
...

@property
def requires_confirmation(self) -> bool:
"""Whether this tool requires user confirmation before execution."""
return False

@property
def read_only(self) -> bool:
"""Whether this tool is side-effect free and safe to parallelize."""
Expand Down
Loading