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
wylabandClaude Opus 4.6 c0a87d77fc fix: loguru format strings and consolidate response logging
Build Nanobot OAuth / build (pull_request) Successful in 5m58s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
- Changed printf-style (%s/%d) to loguru format ({}) in 3 log statements
- Consolidated response logging into a single line showing stop_reason,
  tool_calls count, thinking chars, and token usage
- Added tool count to request logging

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:47:38 +00:00
wylabandClaude Opus 4.6 6e627bc2e0 fix: use quota-selected model in system handler
The system message handler was using self.model instead of the
quota-selected model, bypassing the Opus/Sonnet switching logic.
Also added debug logging for model selection and thinking_budget.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:47:32 +00:00
wylabandClaude Sonnet 4.5 ece660ae69 feat: dynamic Opus/Sonnet model switching based on rolling quota
Build Nanobot OAuth / build (pull_request) Successful in 5m34s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
Implement intelligent model selection to manage 7-day Opus quota burn rate:

- Add _select_model_based_on_quota() method to AgentLoop
  - Reads rate limit data from memory/rate_limits.json
  - Calculates expected vs actual quota usage (100%/168h = 0.595% per hour)
  - If actual > expected × 1.17 (17% overage), downgrades to Sonnet
  - If actual ≤ expected, uses Opus
  - Caches decision for 5 minutes to minimize file I/O

- Add /quota slash command to display real-time quota status
  - Shows current usage vs expected usage
  - Shows hours until weekly reset
  - Shows selected model and burn rate multiplier

- Main agent now calls _select_model_based_on_quota() before each conversation
  - Heartbeat subagent unaffected (explicitly uses claude-sonnet-4-20250514)

This replaces the wrong approach from PR #5 which throttled heartbeat
frequency instead of switching the main agent's model.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-15 00:37:00 +01:00
wylabandClaude Opus 4.6 84268edf01 Fix memory consolidation truncation: set max_tokens=16384
Build Nanobot OAuth / build (push) Successful in 5m52s
Build Nanobot OAuth / cleanup (push) Successful in 10s
Consolidation was failing because max_tokens defaulted to 4096,
causing Haiku's response to be truncated mid-JSON (finish_reason=max_tokens).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:45:35 +01:00
wylabandClaude Opus 4.6 9136cca1ff Fix memory consolidation timeout: use Haiku without thinking
Build Nanobot OAuth / build (push) Successful in 5m51s
Build Nanobot OAuth / cleanup (push) Successful in 3s
Root cause: consolidation was calling Opus 4.6 with 10k thinking budget
on 50-80 message prompts. The 300s httpx timeout killed every request
(all failures were exactly 5 minutes after start). Consolidation is just
summarization — Haiku with no thinking handles it in seconds.

Also adds per-call thinking_budget override to the provider interface
so callers can disable thinking for lightweight tasks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:30:48 +01:00
wylabandClaude Opus 4.6 6035b70ae5 Add psycopg2-binary to Docker image for PostgreSQL access
Build Nanobot OAuth / build (push) Successful in 5m29s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 13:25:02 +01:00
nanobotandcode-server e4c300bcfd Increase subagent max_iterations from 15 to 50 (#3)
Build Nanobot OAuth / build (push) Successful in 47s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Co-authored-by: nanobot <nanobot@wylab.me>
Co-committed-by: nanobot <nanobot@wylab.me>
2026-02-14 11:40:50 +01:00
9 changed files with 297 additions and 44 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ ENV PATH="/root/.local/bin:${PATH}"
COPY pyproject.toml README.md LICENSE /app/
COPY nanobot/ /app/nanobot/
RUN uv pip install --system --no-cache --reinstall /app
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
ENTRYPOINT ["nanobot"]
CMD ["gateway"]
+142 -13
View File
@@ -2,6 +2,8 @@
import asyncio
import json
import time
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -17,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
@@ -74,8 +77,10 @@ class AgentLoop:
exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace,
)
self._running = False
self._quota_cache: dict[str, Any] = {} # {model: str, cached_at: float}
self._quota_cache_ttl: float = 300.0 # 5 minutes
self._register_default_tools()
def _register_default_tools(self) -> None:
@@ -105,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:
@@ -143,7 +149,95 @@ class AgentLoop:
"""Stop the agent loop."""
self._running = False
logger.info("Agent loop stopping")
def _select_model_based_on_quota(self) -> str:
"""Select Opus or Sonnet based on rolling weekly quota burn rate."""
# Check cache
now = time.time()
if self._quota_cache and (now - self._quota_cache.get("cached_at", 0)) < self._quota_cache_ttl:
return self._quota_cache["model"]
# Default models
OPUS = "claude-opus-4-6"
SONNET = "claude-sonnet-4-6"
TOLERANCE = 1.17 # 17% overage triggers downgrade
# Read rate limits
rate_limits_path = self.workspace / "memory" / "rate_limits.json"
if not rate_limits_path.exists():
logger.warning("rate_limits.json not found, defaulting to Sonnet")
return SONNET
try:
with open(rate_limits_path) as f:
limits = json.load(f)
actual_usage = limits.get("weekly_all_models")
weekly_reset = limits.get("weekly_reset")
if actual_usage is None or weekly_reset is None:
logger.warning("Rate limit data incomplete, defaulting to Sonnet")
return SONNET
# Calculate expected usage
actual_pct = actual_usage * 100
week_start = weekly_reset - (168 * 3600)
hours_elapsed = max(0, min((now - week_start) / 3600, 168))
expected_pct = (hours_elapsed / 168) * 100
threshold = expected_pct * TOLERANCE
# Decision logic
if actual_pct > threshold:
model = SONNET
logger.info(
f"Quota: {actual_pct:.1f}% used, expected {expected_pct:.1f}%, "
f"threshold {threshold:.1f}% → Sonnet"
)
else:
model = OPUS
logger.info(
f"Quota: {actual_pct:.1f}% used, expected {expected_pct:.1f}%, "
f"threshold {threshold:.1f}% → Opus"
)
# Cache decision
self._quota_cache = {"model": model, "cached_at": now}
return model
except Exception as e:
logger.error(f"Error checking quota: {e}, defaulting to Sonnet")
return SONNET
def _get_quota_status(self) -> str:
"""Return human-readable quota status."""
rate_limits_path = self.workspace / "memory" / "rate_limits.json"
if not rate_limits_path.exists():
return "⚠️ No quota data available yet."
try:
with open(rate_limits_path) as f:
limits = json.load(f)
actual_pct = limits.get("weekly_all_models", 0) * 100
reset_ts = limits.get("weekly_reset", 0)
now = time.time()
hours_until_reset = (reset_ts - now) / 3600
week_start = reset_ts - (168 * 3600)
hours_elapsed = max(0, (now - week_start) / 3600)
expected_pct = (hours_elapsed / 168) * 100
model = self._select_model_based_on_quota()
return f"""📊 Quota Status:
• Used: {actual_pct:.1f}% (expected {expected_pct:.1f}%)
• Resets in: {hours_until_reset:.1f}h
• Current model: {model}
• Burn rate: {actual_pct / max(expected_pct, 0.01):.2f}x target"""
except Exception as e:
return f"⚠️ Error reading quota: {e}"
async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
"""
Process a single inbound message.
@@ -177,8 +271,11 @@ class AgentLoop:
content="🐈 New session started. Memory consolidated.")
if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands")
content="🐈 nanobot commands:\n/new — Start a new conversation\n/help — Show available commands\n/quota — Show quota status")
if cmd == "/quota":
status = self._get_quota_status()
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content=status)
# Consolidate memory before processing if session is too large
if len(session.messages) > self.memory_window:
await self._consolidate_memory(session)
@@ -196,28 +293,55 @@ 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,
)
# Select model based on quota
selected_model = self._select_model_based_on_quota()
# Agent loop
iteration = 0
final_content = None
tools_used: list[str] = []
while iteration < self.max_iterations:
iteration += 1
# Call LLM
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model
model=selected_model
)
# Handle tool calls
@@ -325,14 +449,17 @@ class AgentLoop:
# Agent loop (limited for announce handling)
iteration = 0
final_content = None
# Select model based on quota
selected_model = self._select_model_based_on_quota()
while iteration < self.max_iterations:
iteration += 1
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_definitions(),
model=self.model
model=selected_model
)
if response.has_tool_calls:
@@ -424,7 +551,9 @@ Respond with ONLY valid JSON, no markdown fences."""
{"role": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
{"role": "user", "content": prompt},
],
model=self.model,
model="claude-haiku-4-5",
thinking_budget=0,
max_tokens=16384,
)
text = (response.content or "").strip()
if text.startswith("```"):
+44 -9
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]] = [
@@ -121,7 +131,7 @@ class SubagentManager:
]
# Run agent loop (limited iterations)
max_iterations = 15
max_iterations = 50
iteration = 0
final_result: str | None = None
@@ -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)
+2
View File
@@ -92,6 +92,7 @@ class TelegramChannel(BaseChannel):
BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"),
BotCommand("help", "Show available commands"),
BotCommand("quota", "Show current quota status"),
]
def __init__(
@@ -127,6 +128,7 @@ class TelegramChannel(BaseChannel):
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command))
self._app.add_handler(CommandHandler("help", self._forward_command))
self._app.add_handler(CommandHandler("quota", self._forward_command))
# Add message handler for text, photos, voice, documents
self._app.add_handler(
+55 -20
View File
@@ -225,6 +225,7 @@ class AnthropicOAuthProvider(LLMProvider):
max_tokens: int = 4096,
temperature: float = 0.7,
tools: list[dict[str, Any]] | None = None,
thinking_budget_override: int | None = None,
) -> dict[str, Any]:
"""Make request to Anthropic API."""
client = await self._get_client()
@@ -236,14 +237,15 @@ class AnthropicOAuthProvider(LLMProvider):
}
# Extended thinking: temperature must be 1 when enabled
if self.thinking_budget > 0:
effective_thinking = thinking_budget_override if thinking_budget_override is not None else self.thinking_budget
if effective_thinking > 0:
payload["temperature"] = 1
# max_tokens must exceed budget_tokens
if max_tokens <= self.thinking_budget:
payload["max_tokens"] = self.thinking_budget + 4096
if max_tokens <= effective_thinking:
payload["max_tokens"] = effective_thinking + 4096
payload["thinking"] = {
"type": "enabled",
"budget_tokens": self.thinking_budget,
"budget_tokens": effective_thinking,
}
else:
payload["temperature"] = temperature
@@ -255,9 +257,10 @@ class AnthropicOAuthProvider(LLMProvider):
payload["tools"] = tools
logger.info(
"Anthropic request: model=%s max_tokens=%d thinking=%s",
"Anthropic request: model={} max_tokens={} thinking={} tools={}",
payload.get("model"), payload.get("max_tokens"),
payload.get("thinking", "disabled"),
len(payload.get("tools", [])),
)
response = await client.post(
@@ -266,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}")
@@ -279,6 +315,7 @@ class AnthropicOAuthProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_budget: int | None = None,
) -> LLMResponse:
"""Send chat completion request to Anthropic API."""
model = model or self.default_model
@@ -293,6 +330,9 @@ class AnthropicOAuthProvider(LLMProvider):
system, prepared_messages = self._prepare_messages(messages)
anthropic_tools = self._convert_tools_to_anthropic(tools)
# Per-call thinking override (None = use instance default)
effective_thinking = self.thinking_budget if thinking_budget is None else thinking_budget
try:
response = await self._make_request(
messages=prepared_messages,
@@ -301,6 +341,7 @@ class AnthropicOAuthProvider(LLMProvider):
max_tokens=max_tokens,
temperature=temperature,
tools=anthropic_tools,
thinking_budget_override=effective_thinking,
)
return self._parse_response(response)
except Exception as e:
@@ -341,25 +382,19 @@ class AnthropicOAuthProvider(LLMProvider):
),
}
# Log usage and thinking info
if thinking_blocks:
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks)
logger.info(
"Anthropic response: %d thinking block(s) (%d chars), "
"input=%d output=%d tokens",
len(thinking_blocks), thinking_chars,
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
)
else:
logger.info(
"Anthropic response: no thinking blocks, input=%d output=%d tokens",
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
)
stop_reason = response.get("stop_reason", "end_turn")
thinking_chars = sum(len(b.get("thinking", "")) for b in thinking_blocks) if thinking_blocks else 0
logger.info(
"Anthropic response: stop={} tool_calls={} thinking={} chars, "
"input={} output={} tokens",
stop_reason, len(tool_calls), thinking_chars,
usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0),
)
return LLMResponse(
content=text_content or None,
tool_calls=tool_calls,
finish_reason=response.get("stop_reason", "end_turn"),
finish_reason=stop_reason,
usage=usage,
reasoning_content=thinking_blocks or None,
)
+1
View File
@@ -48,6 +48,7 @@ class LLMProvider(ABC):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_budget: int | None = None,
) -> LLMResponse:
"""
Send a chat completion request.
+1
View File
@@ -106,6 +106,7 @@ class LiteLLMProvider(LLMProvider):
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
thinking_budget: int | None = None,
) -> LLMResponse:
"""
Send a chat completion request via LiteLLM.