Compare commits

...
Author SHA1 Message Date
code-serverandClaude Sonnet 4.5 71bbefceb1 fix: register WaitForSubagentsTool in main AgentLoop so it's available in live sessions
Build Nanobot OAuth / build (push) Successful in 7m27s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Previously wait_for_subagents was only registered inside _run_subagent
(spawned orchestrators). Main conversation agent had no access to it.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-19 01:17:43 +00:00
wylabandcode-server bdaa1b35e4 feat: prepend time-gap notice to user message when >5 min elapsed (#10)
Build Nanobot OAuth / build (push) Successful in 49s
Build Nanobot OAuth / cleanup (push) Successful in 1s
## Summary

- Adds time-gap awareness to `_process_message` in `nanobot/agent/loop.py`
- When >5 minutes have passed since the last user message in a session, prepends `[SYSTEM ANNOUNCEMENT: X minutes/hours/days have elapsed since last user message]` to the current message content
- Keeps the LLM aware of real time elapsed between conversation turns

## Implementation Details

- Walks `session.messages` in reverse to find the last user message timestamp
- Uses `datetime.fromisoformat()` to parse the stored ISO timestamps
- Threshold: 300s (5 min) → formats as minutes, hours, or days
- Malformed timestamps silently skipped (try/except)
- Note: `session.add_message("user", ...)` runs **after** `build_messages`, so the reversed walk finds the *previous* user message — no off-by-one issue
- Only affects `_process_message`; `_process_system_message` (subagent announces) is unchanged

## Test plan

- [ ] Send two messages with >5 min gap — second message should log with `[SYSTEM ANNOUNCEMENT: X minutes have elapsed...]` prefix
- [ ] Send two messages with <5 min gap — no prefix injected
- [ ] Verify new session (no prior messages) — no prefix injected

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-authored-by: code-server <code-server@wylab.me>
Reviewed-on: #10
2026-02-19 01:13:10 +01:00
wylabandClaude Sonnet 4.5 4c9ae63bcf Default SubagentManager model to Sonnet instead of provider default (Opus)
Build Nanobot OAuth / build (push) Successful in 5m47s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Quota switching updates the main agent's model selection but never updates
SubagentManager.self.model, so any spawn() call without an explicit model
parameter fell back to Opus. Defaulting to Sonnet fixes this — explicit
overrides (e.g. model="claude-haiku-4-5") still take precedence.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 22:46:37 +00:00
wylabandClaude Sonnet 4.5 c606ab9318 Add wait_for_subagents tool and silence child subagent announcements
Build Nanobot OAuth / build (push) Successful in 5m29s
Build Nanobot OAuth / cleanup (push) Successful in 0s
- Child subagents (origin_channel="subagent") no longer announce to Telegram;
  results are stored in _task_results[task_id] instead
- New WaitForSubagentsTool: blocks via asyncio.gather until all specified
  task IDs complete, returns collected results for orchestrator synthesis
- spawn() return message now includes Task ID prominently for collection
- Fixes: orchestrator spawning N workers caused N+1 Telegram messages

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 22:12:33 +00:00
wylabandClaude Sonnet 4.5 e0a1722fa4 Fix SpawnTool model example: use claude-haiku-4-5 not invalid date-suffix format
Build Nanobot OAuth / build (push) Successful in 5m39s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 21:19:03 +00:00
wylabandClaude Sonnet 4.5 9891aea1eb Fix SpawnTool context in subagent: set_context so child subagents report back to correct channel
Build Nanobot OAuth / build (push) Successful in 5m36s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 21:03:49 +00:00
wylabandClaude Sonnet 4.5 c2e70260c4 feat: enable subagents to spawn other subagents
Build Nanobot OAuth / cleanup (push) Has been cancelled
Build Nanobot OAuth / build (push) Has been cancelled
Registers SpawnTool in _run_subagent so subagents can spawn child
subagents. Removes the "cannot spawn" restriction from the system prompt.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 21:00:28 +00:00
wylabandClaude Sonnet 4.5 b4b5de889a feat: capture Anthropic rate limit headers for quota-based model switching
Build Nanobot OAuth / build (push) Successful in 6m3s
Build Nanobot OAuth / cleanup (push) Successful in 1s
- Writes rate_limits.json after every API call with weekly/5h utilization
- Writes api_headers.jsonl with raw headers for analysis
- Upgrades quota fallback model from Sonnet 4.5 to 4.6

Deploy to site-packages (gateway loads from there, not /app/):
  docker cp to /usr/local/lib/python3.12/site-packages/nanobot/providers/

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 20:25:49 +00:00
code-server d0b0284189 Merge pull request 'Quota-based model switching + thinking mode fixes' (#9) from feat/rate-limit-tracking into main
Build Nanobot OAuth / build (push) Successful in 52s
Build Nanobot OAuth / cleanup (push) Successful in 0s
2026-02-15 14:14:44 +01:00
5 changed files with 155 additions and 11 deletions
+28 -2
View File
@@ -3,6 +3,7 @@
import asyncio
import json
import time
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -18,6 +19,7 @@ from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.wait import WaitForSubagentsTool
from nanobot.agent.tools.cron import CronTool
from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager
@@ -108,6 +110,7 @@ class AgentLoop:
# Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool)
self.tools.register(WaitForSubagentsTool(manager=self.subagents))
# Cron tool (for scheduling)
if self.cron_service:
@@ -156,7 +159,7 @@ class AgentLoop:
# Default models
OPUS = "claude-opus-4-6"
SONNET = "claude-sonnet-4-5"
SONNET = "claude-sonnet-4-6"
TOLERANCE = 1.17 # 17% overage triggers downgrade
# Read rate limits
@@ -290,10 +293,33 @@ class AgentLoop:
if isinstance(cron_tool, CronTool):
cron_tool.set_context(msg.channel, msg.chat_id)
# Prepend time-gap notice if >5 minutes since last user message
current_message = msg.content
last_user_ts = None
for m in reversed(session.messages):
if m.get("role") == "user":
last_user_ts = m.get("timestamp")
break
if last_user_ts:
try:
last_dt = datetime.fromisoformat(last_user_ts)
now_dt = datetime.now()
elapsed_seconds = (now_dt - last_dt).total_seconds()
if elapsed_seconds > 300: # 5 minutes
if elapsed_seconds < 3600:
gap_str = f"{int(elapsed_seconds // 60)} minutes"
elif elapsed_seconds < 86400:
gap_str = f"{int(elapsed_seconds // 3600)} hours"
else:
gap_str = f"{int(elapsed_seconds // 86400)} days"
current_message = f"[SYSTEM ANNOUNCEMENT: {gap_str} have elapsed since last user message; take this into account when replying to user]\n\n{msg.content}"
except (ValueError, TypeError):
pass # Malformed timestamp — skip silently
# Build initial messages (use get_history for LLM-formatted messages)
messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content,
current_message=current_message,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
+43 -8
View File
@@ -15,6 +15,8 @@ from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.wait import WaitForSubagentsTool
class SubagentManager:
@@ -40,11 +42,15 @@ class SubagentManager:
self.provider = provider
self.workspace = workspace
self.bus = bus
self.model = model or provider.get_default_model()
# Default to Sonnet, not the provider default (Opus).
# Quota switching only affects the main agent's own requests, not SubagentManager.
# Explicit model overrides (e.g. Haiku workers) still take precedence.
self.model = model or "claude-sonnet-4-6"
self.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace
self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_results: dict[str, str] = {}
async def spawn(
self,
@@ -84,7 +90,7 @@ class SubagentManager:
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
logger.info(f"Spawned subagent [{task_id}]: {display_label}")
return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes."
return f"Subagent [{display_label}] started. Task ID: {task_id}"
async def _run_subagent(
self,
@@ -98,7 +104,7 @@ class SubagentManager:
logger.info(f"Subagent [{task_id}] starting task: {label}")
try:
# Build subagent tools (no message tool, no spawn tool)
# Build subagent tools (no message tool)
tools = ToolRegistry()
allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir))
@@ -112,7 +118,11 @@ class SubagentManager:
))
tools.register(WebSearchTool(api_key=self.brave_api_key))
tools.register(WebFetchTool())
spawn_tool = SpawnTool(manager=self)
spawn_tool.set_context("subagent", origin["chat_id"])
tools.register(spawn_tool)
tools.register(WaitForSubagentsTool(manager=self))
# Build messages with subagent-specific prompt
system_prompt = self._build_subagent_prompt(task)
messages: list[dict[str, Any]] = [
@@ -190,7 +200,14 @@ class SubagentManager:
) -> None:
"""Announce the subagent result to the main agent via the message bus."""
status_text = "completed successfully" if status == "ok" else "failed"
# Child subagents (spawned by other subagents) store results silently.
# The parent orchestrator collects them via wait_for_subagents.
if origin["channel"] == "subagent":
self._task_results[task_id] = result
logger.debug(f"Subagent [{task_id}] stored result silently (child subagent)")
return
announce_content = f"""[Subagent '{label}' {status_text}]
Task: {task}
@@ -199,7 +216,7 @@ Result:
{result}
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs."""
# Inject as system message to trigger main agent
msg = InboundMessage(
channel="system",
@@ -207,7 +224,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men
chat_id=f"{origin['channel']}:{origin['chat_id']}",
content=announce_content,
)
await self.bus.publish_inbound(msg)
logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
@@ -239,7 +256,6 @@ You are a subagent spawned by the main agent to complete a specific task.
## What You Cannot Do
- Send messages directly to users (no message tool available)
- Spawn other subagents
- Access the main agent's conversation history
## Workspace
@@ -248,6 +264,25 @@ Skills are available at: {self.workspace}/skills/ (read SKILL.md files as needed
When you have completed the task, provide a clear summary of your findings or actions."""
async def wait_for(self, task_ids: list[str]) -> str:
"""Wait for specified child subagents to complete and return their results."""
tasks_to_wait = [
self._running_tasks[tid]
for tid in task_ids
if tid in self._running_tasks
]
if tasks_to_wait:
await asyncio.gather(*tasks_to_wait, return_exceptions=True)
results = []
for tid in task_ids:
result = self._task_results.get(tid)
if result is not None:
results.append(f"[{tid}]:\n{result}")
else:
results.append(f"[{tid}]: No result found (invalid ID or task failed before storing)")
return "\n\n---\n\n".join(results)
def get_running_count(self) -> int:
"""Return the number of currently running subagents."""
return len(self._running_tasks)
+1 -1
View File
@@ -53,7 +53,7 @@ class SpawnTool(Tool):
},
"model": {
"type": "string",
"description": "Optional model override for the subagent (e.g. 'claude-sonnet-4-20250514'). Defaults to the main agent's model.",
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
},
},
"required": ["task"],
+50
View File
@@ -0,0 +1,50 @@
"""Wait-for-subagents tool for orchestrator subagents."""
from typing import Any, TYPE_CHECKING
from nanobot.agent.tools.base import Tool
if TYPE_CHECKING:
from nanobot.agent.subagent import SubagentManager
class WaitForSubagentsTool(Tool):
"""
Tool to wait for child subagents to complete and collect their results.
Use this after spawning multiple subagents to wait for all of them
and get their results for synthesis.
"""
def __init__(self, manager: "SubagentManager"):
self._manager = manager
@property
def name(self) -> str:
return "wait_for_subagents"
@property
def description(self) -> str:
return (
"Wait for one or more child subagents to complete and return their results. "
"Use this after spawning subagents to collect all results before synthesizing. "
"Blocks until all specified subagents finish."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"task_ids": {
"type": "array",
"items": {"type": "string"},
"description": "List of task IDs to wait for (from spawn tool responses)",
},
},
"required": ["task_ids"],
}
async def execute(self, task_ids: list[str], **kwargs: Any) -> str:
"""Wait for the specified subagents and return their results."""
return await self._manager.wait_for(task_ids)
+33
View File
@@ -269,6 +269,39 @@ class AnthropicOAuthProvider(LLMProvider):
json=payload,
)
# Dump rate limit headers for analysis
try:
import datetime
import os
header_dump = {
"timestamp": datetime.datetime.utcnow().isoformat(),
"status_code": response.status_code,
"model": payload.get("model"),
"headers": dict(response.headers),
}
dump_path = "/root/.nanobot/workspace/api_headers.jsonl"
with open(dump_path, "a") as f:
f.write(json.dumps(header_dump) + "\n")
# Capture rate limit state for quota-based model switching
hdrs = response.headers
rate_limit_state = {
"updated_at": datetime.datetime.utcnow().isoformat(),
"model": payload.get("model"),
"weekly_all_models": float(hdrs["anthropic-ratelimit-unified-7d-utilization"]) if hdrs.get("anthropic-ratelimit-unified-7d-utilization") else None,
"weekly_sonnet": float(hdrs["anthropic-ratelimit-unified-7d_sonnet-utilization"]) if hdrs.get("anthropic-ratelimit-unified-7d_sonnet-utilization") else None,
"session_5h": float(hdrs["anthropic-ratelimit-unified-5h-utilization"]) if hdrs.get("anthropic-ratelimit-unified-5h-utilization") else None,
"weekly_reset": int(hdrs["anthropic-ratelimit-unified-7d-reset"]) if hdrs.get("anthropic-ratelimit-unified-7d-reset") else None,
"session_reset": int(hdrs["anthropic-ratelimit-unified-5h-reset"]) if hdrs.get("anthropic-ratelimit-unified-5h-reset") else None,
"binding_limit": hdrs.get("anthropic-ratelimit-unified-representative-claim"),
"sonnet_fallback": hdrs.get("anthropic-ratelimit-unified-fallback"),
}
state_path = "/root/.nanobot/workspace/memory/rate_limits.json"
os.makedirs(os.path.dirname(state_path), exist_ok=True)
with open(state_path, "w") as f:
json.dump(rate_limit_state, f, indent=2)
except Exception as e:
logger.warning("Rate limit header capture failed: {}", e)
if response.status_code != 200:
error_text = response.text
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")