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
wylabandClaude Opus 4.6 0c65efee06 ci: remove deploy workflow, replaced by Watchtower
Build Nanobot OAuth / build (push) Successful in 42s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Auto-deploy is now handled by Watchtower on Unraid, which polls
for new images every 5 minutes for labeled containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:49:26 +01:00
wylabandClaude Opus 4.6 c12d234ee8 ci: add self-deploy workflow via workflow_dispatch
Build Nanobot OAuth / build (push) Successful in 41s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Allows triggering a deploy via Gitea API. SSHes to Unraid to pull
latest image and restart the nanobot container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:40:43 +01:00
wylabandClaude Opus 4.6 2954933a55 fix(ci): add https:// to cleanup API URLs
Build Nanobot OAuth / build (push) Successful in 40s
Build Nanobot OAuth / cleanup (push) Successful in 1s
REGISTRY env var is just the hostname without scheme. Docker actions
handle this automatically, but curl needs the full URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:25:43 +01:00
wylabandClaude Opus 4.6 a71cce08c1 ci: auto-cleanup SHA-tagged images older than 24h
Build Nanobot OAuth / build (push) Successful in 5m30s
Build Nanobot OAuth / cleanup (push) Failing after 0s
Runs after push builds and daily at 03:00 UTC. Keeps :latest and
:buildcache, deletes old SHA-tagged images via Gitea packages API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:18:26 +01:00
wylabandClaude Opus 4.6 c10544fc19 ci: let PR builds write to registry cache
Build Nanobot OAuth / build (push) Has been cancelled
Makes merge builds near-instant since PR already cached all layers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 03:17:05 +01:00
nanobotandcode-server 7c659bc0fe feat: add optional model override for spawn subagents (#1)
Build Nanobot OAuth / build (push) Has been cancelled
Co-authored-by: Nanobot Agent <nanobot@wylab.me>
Co-committed-by: Nanobot Agent <nanobot@wylab.me>
2026-02-14 03:13:47 +01:00
wylabandClaude Opus 4.6 ea5bf4cf5d ci: require build pass before PR merge
Build Nanobot OAuth / build (push) Successful in 50s
- Add pull_request trigger to build workflow
- Skip push and cache-to on PRs (build-only validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 02:59:01 +01:00
wylabandClaude Opus 4.6 9f3e4089c2 Translate OpenAI image_url blocks to Anthropic image format
Build Nanobot OAuth / build (push) Successful in 5m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 01:40:47 +01:00
wylabandClaude Opus 4.6 c9880d4267 Add summarize to Docker image
Build Nanobot OAuth / build (push) Successful in 15m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:41:39 +01:00
wylabandClaude Opus 4.6 af436f5e6c Remove hardcoded identity strings from system prompt
Build Nanobot OAuth / build (push) Successful in 6m13s
- Remove "# nanobot" branding and "You are nanobot" from context.py
- Remove "You are a helpful AI assistant" personality line
- Remove fake "required" Claude Code system prefix from OAuth provider
- Identity is now fully customizable via IDENTITY.md in workspace

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:10:54 +01:00
wylabandClaude Opus 4.6 2d3c94e609 fix: replace Homebrew with direct installs in Dockerfile
Build Nanobot OAuth / build (push) Successful in 25m40s
Homebrew refuses to run as root in Docker containers.
Replace all brew installs with:
- GitHub release binaries (gogcli, goplaces, himalaya, obsidian-cli)
- go install (songsee)
- npm (gemini-cli)
- uv tool (openai-whisper)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:23:11 +01:00
wylabandClaude Opus 4.6 88d2abc6c5 Port OpenClaw skills: add clawdbot metadata support + deps
Build Nanobot OAuth / build (push) Failing after 6m51s
- skills.py: recognize "clawdbot" metadata key alongside "nanobot"
  so OpenClaw SKILL.md files work without rewriting
- Dockerfile.oauth: add skill binary dependencies
  - APT: ffmpeg, jq, tmux, gh
  - Go: blogwatcher, blucli, gifgrep, sonoscli, wacli
  - Brew: gogcli, goplaces, songsee, gemini-cli, obsidian-cli,
    himalaya, openai-whisper
  - npm: @steipete/oracle
  - uv: nano-pdf

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 17:01:19 +01:00
wylabandClaude Opus 4.6 112212d3cd fix: use loguru for provider logging
Build Nanobot OAuth / build (push) Successful in 1m58s
Nanobot uses loguru, not stdlib logging. Switch to loguru so
thinking/usage logs actually appear in container output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 16:12:55 +01:00
wylabandClaude Opus 4.6 2ab51cb80b Add debug logging to Anthropic OAuth provider
Build Nanobot OAuth / build (push) Successful in 1m59s
Logs thinking block presence, character count, and token usage
in API responses. Also logs request parameters including thinking
budget configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:59:32 +01:00
wylabandClaude Opus 4.6 f71b3b3fea Preserve thinking block signatures for multi-turn conversations
Build Nanobot OAuth / build (push) Successful in 1m59s
The Anthropic API returns a signature field in thinking blocks that
must be replayed in subsequent turns. Store full thinking blocks
(including signatures) instead of just the text content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:51:15 +01:00
wylabandClaude Opus 4.6 9990e80d61 Replace hardcoded model aliases with dot-to-hyphen normalization
Build Nanobot OAuth / build (push) Successful in 1m52s
Instead of maintaining a brittle alias dict mapping model names to
dated API IDs, simply normalize dots to hyphens. The Anthropic API
accepts both claude-sonnet-4-5 and dated variants like
claude-sonnet-4-5-20250929, so no alias table is needed. This lets
users write "claude-sonnet-4.5" or "claude-sonnet-4-5" interchangeably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:46:10 +01:00
wylabandClaude Opus 4.6 9a131cb0ed Add extended thinking support for Anthropic API
Build Nanobot OAuth / build (push) Successful in 1m57s
Adds configurable thinking_budget in agent defaults. When >0, sends
the thinking parameter to the API with the specified token budget.
Handles API constraints: forces temperature=1, auto-bumps max_tokens
if it's below the thinking budget, preserves thinking blocks in
message history for multi-turn conversations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:38:58 +01:00
wylabandClaude Opus 4.6 c5ab4098ca Fix tool_use message format for Anthropic API
Build Nanobot OAuth / build (push) Successful in 1m59s
The agent loop produces messages in OpenAI format (role:tool, tool_calls
array) but the Anthropic API expects its own format (tool_use content
blocks in assistant messages, tool_result blocks in user messages).

This caused 400 errors whenever the bot tried to use tools like
web_search, because the follow-up message with tool results was
malformed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:29:55 +01:00
wylabandClaude Opus 4.6 5a8f3f772c Support wildcard "*" in allowFrom channel config
Build Nanobot OAuth / build (push) Successful in 1m57s
Allow "*" in the allowFrom list to explicitly permit all senders,
as an alternative to the empty-list-means-allow-all behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 15:11:13 +01:00
wylabandClaude Opus 4.6 e1a98d68ff ci: add Docker build workflow and fix gateway CMD
Build Nanobot OAuth / build (push) Successful in 2m45s
- Add .github/workflows/build.yml to auto-build and push to Gitea registry
- Change Dockerfile.oauth CMD from "status" to "gateway" for persistent container

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:25:32 +01:00
wylab 92065dbb74 Merge remote-tracking branch 'origin/main' 2026-02-13 14:16:24 +01:00
wylabandClaude Opus 4.6 55ad41265f feat(oauth): add model alias resolution and Dockerfile.oauth
Add MODEL_ALIASES dict to resolve short model names (e.g. claude-sonnet-4)
to dated API IDs (e.g. claude-sonnet-4-20250514). Includes claude-opus-4-6.

Add Dockerfile.oauth overlay extending birdxs/nanobot:latest for fast builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 14:12:00 +01:00
wylabandClaude Opus 4.6 6d0d995b1b feat(config): integrate OAuth store with config loading
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:19:51 +01:00
wylabandClaude Opus 4.6 f444e94ff7 feat(cli): add OAuth login/status/logout commands
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:16:23 +01:00
wylabandClaude Opus 4.6 5f9af317c4 feat(config): add OAuth credential storage
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:10:17 +01:00
wylabandClaude Opus 4.6 4b3bc89d06 refactor(agent): use provider factory for OAuth support
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:07:38 +01:00
wylabandClaude Opus 4.6 3323b9d909 feat(providers): add create_provider factory with OAuth detection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:05:51 +01:00
wylabandClaude Opus 4.6 96a7abcda4 feat(registry): add OAuth provider detection logic
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 13:03:37 +01:00
wylabandClaude Opus 4.6 534a8344bd feat(providers): add AnthropicOAuthProvider with Bearer auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:58:36 +01:00
wylabandClaude Opus 4.6 7b710116a4 feat(providers): add OAuth token detection and header utilities
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:57:21 +01:00
wylabandClaude Opus 4.6 fc9545c36a feat(config): add OAuthCredentials model for subscription auth
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 12:56:48 +01:00
29 changed files with 1523 additions and 46 deletions
+83
View File
@@ -0,0 +1,83 @@
name: Build Nanobot OAuth
on:
push:
branches: ['main']
pull_request:
branches: ['main']
schedule:
- cron: '0 3 * * *'
workflow_dispatch:
env:
REGISTRY: git.wylab.me
IMAGE_NAME: wylab/nanobot
BUILDKIT_PROGRESS: plain
jobs:
build:
runs-on: [self-hosted, linux-amd64]
timeout-minutes: 15
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME || github.actor }}
password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: Dockerfile.oauth
provenance: false
platforms: linux/amd64
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
push: ${{ github.event_name != 'pull_request' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cleanup:
if: github.event_name == 'push' || github.event_name == 'schedule'
runs-on: [self-hosted, linux-amd64]
needs: build
steps:
- name: Delete images older than 24h
env:
TOKEN: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
run: |
cutoff=$(date -u -d '24 hours ago' +%s)
page=1
while true; do
versions=$(curl -sf -H "Authorization: token $TOKEN" \
"https://${{ env.REGISTRY }}/api/v1/packages/wylab?type=container&q=nanobot&limit=50&page=$page")
count=$(echo "$versions" | jq length)
[ "$count" = "0" ] && break
echo "$versions" | jq -c '.[]' | while read -r pkg; do
ver=$(echo "$pkg" | jq -r '.version')
# Keep latest and buildcache, only delete SHA tags
case "$ver" in latest|buildcache) continue ;; esac
created=$(echo "$pkg" | jq -r '.created_at')
ts=$(date -u -d "$created" +%s 2>/dev/null || echo 0)
if [ "$ts" -lt "$cutoff" ]; then
id=$(echo "$pkg" | jq -r '.id')
echo "Deleting nanobot:$ver (id=$id, created=$created)"
curl -sf -X DELETE -H "Authorization: token $TOKEN" \
"https://${{ env.REGISTRY }}/api/v1/packages/wylab/container/nanobot/$ver" || true
fi
done
[ "$count" -lt 50 ] && break
page=$((page + 1))
done
+62
View File
@@ -0,0 +1,62 @@
FROM birdxs/nanobot:latest
# ── Skill dependencies ──────────────────────────────────────────────
# APT: ffmpeg (video-frames, whisper), jq, tmux, build-essential (for go)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg jq tmux build-essential procps && rm -rf /var/lib/apt/lists/*
# gh CLI via GitHub official apt repo
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list && \
apt-get update && apt-get install -y gh && rm -rf /var/lib/apt/lists/*
# Go toolchain
RUN curl -fsSL https://go.dev/dl/go1.23.6.linux-amd64.tar.gz | tar -C /usr/local -xzf -
ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}"
# Go tools: blogwatcher, blu (blucli), gifgrep, sonos (sonoscli), wacli, songsee
RUN go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest && \
go install github.com/steipete/blucli/cmd/blu@latest && \
go install github.com/steipete/gifgrep/cmd/gifgrep@latest && \
go install github.com/steipete/sonoscli/cmd/sonos@latest && \
go install github.com/steipete/wacli/cmd/wacli@latest && \
go install github.com/steipete/songsee/cmd/songsee@latest
# Pre-built binaries from GitHub releases
# gogcli (gog)
RUN curl -fsSL https://github.com/steipete/gogcli/releases/download/v0.9.0/gogcli_0.9.0_linux_amd64.tar.gz \
| tar -xzf - -C /usr/local/bin gog
# goplaces
RUN curl -fsSL https://github.com/steipete/goplaces/releases/download/v0.2.1/goplaces_0.2.1_linux_amd64.tar.gz \
| tar -xzf - -C /usr/local/bin goplaces
# himalaya (email CLI)
RUN curl -fsSL https://github.com/pimalaya/himalaya/releases/download/v1.1.0/himalaya.x86_64-linux.tgz \
| tar -xzf - -C /usr/local/bin himalaya
# obsidian-cli (release binary is named notesmd-cli, skill expects obsidian-cli)
RUN curl -fsSL -o /tmp/obsidian.tar.gz https://github.com/yakitrak/obsidian-cli/releases/download/v0.3.0/notesmd-cli_0.3.0_linux_amd64.tar.gz && \
tar -xzf /tmp/obsidian.tar.gz -C /tmp notesmd-cli && \
mv /tmp/notesmd-cli /usr/local/bin/obsidian-cli && \
rm /tmp/obsidian.tar.gz
# Node tools: oracle, gemini-cli, summarize
RUN npm install -g @steipete/oracle @google/gemini-cli @steipete/summarize
# Python tools: nano-pdf, openai-whisper
RUN uv tool install nano-pdf && \
uv tool install openai-whisper
ENV PATH="/root/.local/bin:${PATH}"
# ── Nanobot source ──────────────────────────────────────────────────
COPY pyproject.toml README.md LICENSE /app/
COPY nanobot/ /app/nanobot/
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
ENTRYPOINT ["nanobot"]
CMD ["gateway"]
+2 -4
View File
@@ -71,7 +71,7 @@ Skills with available="false" need dependencies installed first - you can try in
return "\n\n---\n\n".join(parts) return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str: def _get_identity(self) -> str:
"""Get the core identity section.""" """Get the core identity section with runtime context."""
from datetime import datetime from datetime import datetime
import time as _time import time as _time
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
@@ -79,10 +79,8 @@ Skills with available="false" need dependencies installed first - you can try in
workspace_path = str(self.workspace.expanduser().resolve()) 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 f"""# nanobot 🐈
You are nanobot, a helpful AI assistant. You have access to tools that allow you to: return f"""You have access to tools that allow you to:
- Read, write, and edit files - Read, write, and edit files
- Execute shell commands - Execute shell commands
- Search the web and fetch web pages - Search the web and fetch web pages
+142 -13
View File
@@ -2,6 +2,8 @@
import asyncio import asyncio
import json import json
import time
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any 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.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.message import MessageTool from nanobot.agent.tools.message import MessageTool
from nanobot.agent.tools.spawn import SpawnTool from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.wait import WaitForSubagentsTool
from nanobot.agent.tools.cron import CronTool from nanobot.agent.tools.cron import CronTool
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
@@ -74,8 +77,10 @@ class AgentLoop:
exec_config=self.exec_config, exec_config=self.exec_config,
restrict_to_workspace=restrict_to_workspace, restrict_to_workspace=restrict_to_workspace,
) )
self._running = False 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() self._register_default_tools()
def _register_default_tools(self) -> None: def _register_default_tools(self) -> None:
@@ -105,6 +110,7 @@ class AgentLoop:
# Spawn tool (for subagents) # Spawn tool (for subagents)
spawn_tool = SpawnTool(manager=self.subagents) spawn_tool = SpawnTool(manager=self.subagents)
self.tools.register(spawn_tool) self.tools.register(spawn_tool)
self.tools.register(WaitForSubagentsTool(manager=self.subagents))
# Cron tool (for scheduling) # Cron tool (for scheduling)
if self.cron_service: if self.cron_service:
@@ -143,7 +149,95 @@ class AgentLoop:
"""Stop the agent loop.""" """Stop the agent loop."""
self._running = False self._running = False
logger.info("Agent loop stopping") 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: async def _process_message(self, msg: InboundMessage, session_key: str | None = None) -> OutboundMessage | None:
""" """
Process a single inbound message. Process a single inbound message.
@@ -177,8 +271,11 @@ class AgentLoop:
content="🐈 New session started. Memory consolidated.") content="🐈 New session started. Memory consolidated.")
if cmd == "/help": if cmd == "/help":
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, 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 # Consolidate memory before processing if session is too large
if len(session.messages) > self.memory_window: if len(session.messages) > self.memory_window:
await self._consolidate_memory(session) await self._consolidate_memory(session)
@@ -196,28 +293,55 @@ class AgentLoop:
if isinstance(cron_tool, CronTool): if isinstance(cron_tool, CronTool):
cron_tool.set_context(msg.channel, msg.chat_id) 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) # Build initial messages (use get_history for LLM-formatted messages)
messages = self.context.build_messages( messages = self.context.build_messages(
history=session.get_history(), history=session.get_history(),
current_message=msg.content, current_message=current_message,
media=msg.media if msg.media else None, media=msg.media if msg.media else None,
channel=msg.channel, channel=msg.channel,
chat_id=msg.chat_id, chat_id=msg.chat_id,
) )
# Select model based on quota
selected_model = self._select_model_based_on_quota()
# Agent loop # Agent loop
iteration = 0 iteration = 0
final_content = None final_content = None
tools_used: list[str] = [] tools_used: list[str] = []
while iteration < self.max_iterations: while iteration < self.max_iterations:
iteration += 1 iteration += 1
# Call LLM # Call LLM
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
response = await self.provider.chat( response = await self.provider.chat(
messages=messages, messages=messages,
tools=self.tools.get_definitions(), tools=self.tools.get_definitions(),
model=self.model model=selected_model
) )
# Handle tool calls # Handle tool calls
@@ -325,14 +449,17 @@ class AgentLoop:
# Agent loop (limited for announce handling) # Agent loop (limited for announce handling)
iteration = 0 iteration = 0
final_content = None final_content = None
# Select model based on quota
selected_model = self._select_model_based_on_quota()
while iteration < self.max_iterations: while iteration < self.max_iterations:
iteration += 1 iteration += 1
response = await self.provider.chat( response = await self.provider.chat(
messages=messages, messages=messages,
tools=self.tools.get_definitions(), tools=self.tools.get_definitions(),
model=self.model model=selected_model
) )
if response.has_tool_calls: 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": "system", "content": "You are a memory consolidation agent. Respond only with valid JSON."},
{"role": "user", "content": prompt}, {"role": "user", "content": prompt},
], ],
model=self.model, model="claude-haiku-4-5",
thinking_budget=0,
max_tokens=16384,
) )
text = (response.content or "").strip() text = (response.content or "").strip()
if text.startswith("```"): if text.startswith("```"):
+1 -1
View File
@@ -170,7 +170,7 @@ class SkillsLoader:
"""Parse nanobot metadata JSON from frontmatter.""" """Parse nanobot metadata JSON from frontmatter."""
try: try:
data = json.loads(raw) data = json.loads(raw)
return data.get("nanobot", {}) if isinstance(data, dict) else {} return (data.get("nanobot") or data.get("clawdbot") or {}) if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return {} return {}
+48 -11
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.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
from nanobot.agent.tools.shell import ExecTool from nanobot.agent.tools.shell import ExecTool
from nanobot.agent.tools.web import WebSearchTool, WebFetchTool from nanobot.agent.tools.web import WebSearchTool, WebFetchTool
from nanobot.agent.tools.spawn import SpawnTool
from nanobot.agent.tools.wait import WaitForSubagentsTool
class SubagentManager: class SubagentManager:
@@ -40,16 +42,21 @@ class SubagentManager:
self.provider = provider self.provider = provider
self.workspace = workspace self.workspace = workspace
self.bus = bus 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.brave_api_key = brave_api_key
self.exec_config = exec_config or ExecToolConfig() self.exec_config = exec_config or ExecToolConfig()
self.restrict_to_workspace = restrict_to_workspace self.restrict_to_workspace = restrict_to_workspace
self._running_tasks: dict[str, asyncio.Task[None]] = {} self._running_tasks: dict[str, asyncio.Task[None]] = {}
self._task_results: dict[str, str] = {}
async def spawn( async def spawn(
self, self,
task: str, task: str,
label: str | None = None, label: str | None = None,
model: str | None = None,
origin_channel: str = "cli", origin_channel: str = "cli",
origin_chat_id: str = "direct", origin_chat_id: str = "direct",
) -> str: ) -> str:
@@ -75,7 +82,7 @@ class SubagentManager:
# Create background task # Create background task
bg_task = asyncio.create_task( bg_task = asyncio.create_task(
self._run_subagent(task_id, task, display_label, origin) self._run_subagent(task_id, task, display_label, origin, model=model)
) )
self._running_tasks[task_id] = bg_task self._running_tasks[task_id] = bg_task
@@ -83,7 +90,7 @@ class SubagentManager:
bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None)) bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None))
logger.info(f"Spawned subagent [{task_id}]: {display_label}") 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( async def _run_subagent(
self, self,
@@ -91,12 +98,13 @@ class SubagentManager:
task: str, task: str,
label: str, label: str,
origin: dict[str, str], origin: dict[str, str],
model: str | None = None,
) -> None: ) -> None:
"""Execute the subagent task and announce the result.""" """Execute the subagent task and announce the result."""
logger.info(f"Subagent [{task_id}] starting task: {label}") logger.info(f"Subagent [{task_id}] starting task: {label}")
try: try:
# Build subagent tools (no message tool, no spawn tool) # Build subagent tools (no message tool)
tools = ToolRegistry() tools = ToolRegistry()
allowed_dir = self.workspace if self.restrict_to_workspace else None allowed_dir = self.workspace if self.restrict_to_workspace else None
tools.register(ReadFileTool(allowed_dir=allowed_dir)) tools.register(ReadFileTool(allowed_dir=allowed_dir))
@@ -110,7 +118,11 @@ class SubagentManager:
)) ))
tools.register(WebSearchTool(api_key=self.brave_api_key)) tools.register(WebSearchTool(api_key=self.brave_api_key))
tools.register(WebFetchTool()) 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 # Build messages with subagent-specific prompt
system_prompt = self._build_subagent_prompt(task) system_prompt = self._build_subagent_prompt(task)
messages: list[dict[str, Any]] = [ messages: list[dict[str, Any]] = [
@@ -119,7 +131,7 @@ class SubagentManager:
] ]
# Run agent loop (limited iterations) # Run agent loop (limited iterations)
max_iterations = 15 max_iterations = 50
iteration = 0 iteration = 0
final_result: str | None = None final_result: str | None = None
@@ -129,7 +141,7 @@ class SubagentManager:
response = await self.provider.chat( response = await self.provider.chat(
messages=messages, messages=messages,
tools=tools.get_definitions(), tools=tools.get_definitions(),
model=self.model, model=model or self.model,
) )
if response.has_tool_calls: if response.has_tool_calls:
@@ -188,7 +200,14 @@ class SubagentManager:
) -> None: ) -> None:
"""Announce the subagent result to the main agent via the message bus.""" """Announce the subagent result to the main agent via the message bus."""
status_text = "completed successfully" if status == "ok" else "failed" 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}] announce_content = f"""[Subagent '{label}' {status_text}]
Task: {task} Task: {task}
@@ -197,7 +216,7 @@ Result:
{result} {result}
Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not mention technical details like "subagent" or task IDs.""" 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 # Inject as system message to trigger main agent
msg = InboundMessage( msg = InboundMessage(
channel="system", channel="system",
@@ -205,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']}", chat_id=f"{origin['channel']}:{origin['chat_id']}",
content=announce_content, content=announce_content,
) )
await self.bus.publish_inbound(msg) await self.bus.publish_inbound(msg)
logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}") logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}")
@@ -237,7 +256,6 @@ You are a subagent spawned by the main agent to complete a specific task.
## What You Cannot Do ## What You Cannot Do
- Send messages directly to users (no message tool available) - Send messages directly to users (no message tool available)
- Spawn other subagents
- Access the main agent's conversation history - Access the main agent's conversation history
## Workspace ## Workspace
@@ -246,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.""" 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: def get_running_count(self) -> int:
"""Return the number of currently running subagents.""" """Return the number of currently running subagents."""
return len(self._running_tasks) return len(self._running_tasks)
+6 -1
View File
@@ -51,15 +51,20 @@ class SpawnTool(Tool):
"type": "string", "type": "string",
"description": "Optional short label for the task (for display)", "description": "Optional short label for the task (for display)",
}, },
"model": {
"type": "string",
"description": "Optional model override for the subagent (e.g. 'claude-haiku-4-5'). Defaults to the main agent's model.",
},
}, },
"required": ["task"], "required": ["task"],
} }
async def execute(self, task: str, label: str | None = None, **kwargs: Any) -> str: async def execute(self, task: str, label: str | None = None, model: str | None = None, **kwargs: Any) -> str:
"""Spawn a subagent to execute the given task.""" """Spawn a subagent to execute the given task."""
return await self._manager.spawn( return await self._manager.spawn(
task=task, task=task,
label=label, label=label,
model=model,
origin_channel=self._origin_channel, origin_channel=self._origin_channel,
origin_chat_id=self._origin_chat_id, origin_chat_id=self._origin_chat_id,
) )
+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)
+6 -2
View File
@@ -69,11 +69,15 @@ class BaseChannel(ABC):
True if allowed, False otherwise. True if allowed, False otherwise.
""" """
allow_list = getattr(self.config, "allow_from", []) allow_list = getattr(self.config, "allow_from", [])
# If no allow list, allow everyone # If no allow list, allow everyone
if not allow_list: if not allow_list:
return True return True
# Wildcard allows everyone
if "*" in allow_list:
return True
sender_str = str(sender_id) sender_str = str(sender_id)
if sender_str in allow_list: if sender_str in allow_list:
return True return True
+2
View File
@@ -92,6 +92,7 @@ class TelegramChannel(BaseChannel):
BotCommand("start", "Start the bot"), BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"), BotCommand("new", "Start a new conversation"),
BotCommand("help", "Show available commands"), BotCommand("help", "Show available commands"),
BotCommand("quota", "Show current quota status"),
] ]
def __init__( def __init__(
@@ -127,6 +128,7 @@ class TelegramChannel(BaseChannel):
self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("new", self._forward_command))
self._app.add_handler(CommandHandler("help", 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 # Add message handler for text, photos, voice, documents
self._app.add_handler( self._app.add_handler(
+8 -4
View File
@@ -270,20 +270,21 @@ This file stores important information that should persist across sessions.
def _make_provider(config): def _make_provider(config):
"""Create LiteLLMProvider from config. Exits if no API key found.""" """Create LLM provider from config. Uses OAuth for subscription tokens."""
from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers import create_provider
p = config.get_provider() p = config.get_provider()
model = config.agents.defaults.model model = config.agents.defaults.model
if not (p and p.api_key) and not model.startswith("bedrock/"): if not (p and p.api_key) and not model.startswith("bedrock/"):
console.print("[red]Error: No API key configured.[/red]") console.print("[red]Error: No API key configured.[/red]")
console.print("Set one in ~/.nanobot/config.json under providers section") console.print("Set one in ~/.nanobot/config.json under providers section")
raise typer.Exit(1) raise typer.Exit(1)
return LiteLLMProvider( return create_provider(
api_key=p.api_key if p else None, api_key=p.api_key if p else None,
model=model,
api_base=config.get_api_base(), api_base=config.get_api_base(),
default_model=model,
extra_headers=p.extra_headers if p else None, extra_headers=p.extra_headers if p else None,
provider_name=config.get_provider_name(), provider_name=config.get_provider_name(),
thinking_budget=config.agents.defaults.thinking_budget,
) )
@@ -507,6 +508,9 @@ def agent(
channels_app = typer.Typer(help="Manage channels") channels_app = typer.Typer(help="Manage channels")
app.add_typer(channels_app, name="channels") app.add_typer(channels_app, name="channels")
from nanobot.cli.oauth import oauth_app
app.add_typer(oauth_app, name="oauth")
@channels_app.command("status") @channels_app.command("status")
def channels_status(): def channels_status():
+93
View File
@@ -0,0 +1,93 @@
"""OAuth CLI commands for subscription authentication."""
from pathlib import Path
from typing import Optional
import typer
from rich.console import Console
oauth_app = typer.Typer(help="Manage OAuth authentication for subscription-based providers")
console = Console()
@oauth_app.command("login")
def login(
provider: str = typer.Argument("anthropic", help="Provider name"),
token: Optional[str] = typer.Option(None, "--token", "-t", help="OAuth token (from claude setup-token)"),
):
"""Login to a provider using OAuth.
For Anthropic Claude Max/Pro, run 'claude setup-token' and paste the token here.
Example:
nanobot oauth login anthropic --token sk-ant-oat01-xxx
"""
from nanobot.config.oauth_store import OAuthStore
from nanobot.config.schema import OAuthCredentials
if provider != "anthropic":
console.print(f"[red]OAuth login for {provider} not yet supported[/red]")
return
if not token:
console.print("Please provide your OAuth token:")
console.print(" 1. Run: claude setup-token")
console.print(" 2. Copy the sk-ant-oat01-... token")
console.print(" 3. Run: nanobot oauth login anthropic --token <your-token>")
console.print()
token = typer.prompt("Token", hide_input=True)
if not token or "sk-ant-oat" not in token:
console.print("[red]Invalid token. Must contain sk-ant-oat[/red]")
return
store = OAuthStore(Path.home() / ".nanobot")
creds = OAuthCredentials(
access_token=token,
token_type="token" # setup-token doesn't expire
)
store.save(provider, creds)
console.print(f"[green]Successfully saved {provider} OAuth credentials![/green]")
@oauth_app.command("status")
def status():
"""Show OAuth credential status."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(Path.home() / ".nanobot")
providers = ["anthropic"]
found_any = False
for provider in providers:
creds = store.load(provider)
if creds:
found_any = True
st = "valid"
if creds.is_expired:
st = "EXPIRED"
elif creds.expires_soon:
st = "expires soon"
token_preview = creds.access_token[:20] + "..."
console.print(f" {provider}: {token_preview} ({st})")
if not found_any:
console.print("No OAuth credentials configured.")
console.print("Run: nanobot oauth login anthropic --token <token>")
@oauth_app.command("logout")
def logout(
provider: str = typer.Argument("anthropic", help="Provider name"),
):
"""Remove OAuth credentials for a provider."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(Path.home() / ".nanobot")
if store.delete(provider):
console.print(f"[green]Removed {provider} OAuth credentials[/green]")
else:
console.print(f"No credentials found for {provider}")
+24 -6
View File
@@ -12,35 +12,53 @@ def get_config_path() -> Path:
return Path.home() / ".nanobot" / "config.json" return Path.home() / ".nanobot" / "config.json"
def _get_oauth_store_dir() -> Path:
"""Get the OAuth store directory."""
return Path.home() / ".nanobot"
def get_data_dir() -> Path: def get_data_dir() -> Path:
"""Get the nanobot data directory.""" """Get the nanobot data directory."""
from nanobot.utils.helpers import get_data_path from nanobot.utils.helpers import get_data_path
return get_data_path() return get_data_path()
def _inject_oauth_credentials(config: Config) -> Config:
"""Inject OAuth credentials from store into config if available."""
from nanobot.config.oauth_store import OAuthStore
store = OAuthStore(_get_oauth_store_dir())
creds = store.load("anthropic")
if creds and creds.access_token and not creds.is_expired:
config.providers.anthropic.api_key = creds.access_token
return config
def load_config(config_path: Path | None = None) -> Config: def load_config(config_path: Path | None = None) -> Config:
""" """
Load configuration from file or create default. Load configuration from file or create default.
Args: Args:
config_path: Optional path to config file. Uses default if not provided. config_path: Optional path to config file. Uses default if not provided.
Returns: Returns:
Loaded configuration object. Loaded configuration object.
""" """
path = config_path or get_config_path() path = config_path or get_config_path()
if path.exists(): if path.exists():
try: try:
with open(path) as f: with open(path) as f:
data = json.load(f) data = json.load(f)
data = _migrate_config(data) data = _migrate_config(data)
return Config.model_validate(convert_keys(data)) config = Config.model_validate(convert_keys(data))
return _inject_oauth_credentials(config)
except (json.JSONDecodeError, ValueError) as e: except (json.JSONDecodeError, ValueError) as e:
print(f"Warning: Failed to load config from {path}: {e}") print(f"Warning: Failed to load config from {path}: {e}")
print("Using default configuration.") print("Using default configuration.")
return Config() return _inject_oauth_credentials(Config())
def save_config(config: Config, config_path: Path | None = None) -> None: def save_config(config: Config, config_path: Path | None = None) -> None:
+59
View File
@@ -0,0 +1,59 @@
"""OAuth credential storage."""
import json
from pathlib import Path
from typing import Any
from nanobot.config.schema import OAuthCredentials
class OAuthStore:
"""Stores OAuth credentials in a JSON file."""
FILENAME = "oauth-credentials.json"
def __init__(self, config_dir: Path):
self.config_dir = config_dir
self.file_path = config_dir / self.FILENAME
def _load_all(self) -> dict[str, Any]:
"""Load all credentials from file."""
if not self.file_path.exists():
return {}
with open(self.file_path, "r") as f:
return json.load(f)
def _save_all(self, data: dict[str, Any]) -> None:
"""Save all credentials to file."""
self.config_dir.mkdir(parents=True, exist_ok=True)
with open(self.file_path, "w") as f:
json.dump(data, f, indent=2)
# Secure permissions
self.file_path.chmod(0o600)
def save(self, provider: str, credentials: OAuthCredentials) -> None:
"""Save credentials for a provider."""
data = self._load_all()
data[provider] = credentials.model_dump()
self._save_all(data)
def load(self, provider: str) -> OAuthCredentials | None:
"""Load credentials for a provider."""
data = self._load_all()
if provider not in data:
return None
return OAuthCredentials(**data[provider])
def delete(self, provider: str) -> bool:
"""Delete credentials for a provider."""
data = self._load_all()
if provider not in data:
return False
del data[provider]
self._save_all(data)
return True
+31
View File
@@ -163,6 +163,7 @@ class AgentDefaults(BaseModel):
temperature: float = 0.7 temperature: float = 0.7
max_tool_iterations: int = 20 max_tool_iterations: int = 20
memory_window: int = 50 memory_window: int = 50
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
class AgentsConfig(BaseModel): class AgentsConfig(BaseModel):
@@ -170,11 +171,41 @@ class AgentsConfig(BaseModel):
defaults: AgentDefaults = Field(default_factory=AgentDefaults) defaults: AgentDefaults = Field(default_factory=AgentDefaults)
class OAuthCredentials(BaseModel):
"""OAuth token credentials for subscription-based auth."""
access_token: str = ""
refresh_token: str = ""
expires_at: int = 0 # Unix timestamp
token_type: str = "oauth" # "oauth" or "token" (setup-token)
@property
def is_oauth_token(self) -> bool:
"""Check if this is an OAuth token (vs regular API key)."""
return "sk-ant-oat" in self.access_token
@property
def is_expired(self) -> bool:
"""Check if token has expired."""
import time
if self.expires_at == 0:
return False # No expiry set (setup-token)
return time.time() > self.expires_at
@property
def expires_soon(self) -> bool:
"""Check if token expires within 10 minutes."""
import time
if self.expires_at == 0:
return False
return time.time() > (self.expires_at - 600)
class ProviderConfig(BaseModel): class ProviderConfig(BaseModel):
"""LLM provider configuration.""" """LLM provider configuration."""
api_key: str = "" api_key: str = ""
api_base: str | None = None api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
oauth_credentials: OAuthCredentials | None = None
class ProvidersConfig(BaseModel): class ProvidersConfig(BaseModel):
+42 -3
View File
@@ -1,6 +1,45 @@
"""LLM provider abstraction module.""" """Provider module exports."""
from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.registry import should_use_oauth_provider
__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider"] __all__ = [
"LLMProvider",
"LLMResponse",
"ToolCallRequest",
"LiteLLMProvider",
"AnthropicOAuthProvider",
"create_provider",
]
def create_provider(
api_key: str,
model: str,
api_base: str | None = None,
extra_headers: dict[str, str] | None = None,
provider_name: str | None = None,
thinking_budget: int = 0,
) -> LLMProvider:
"""Factory function to create appropriate provider.
Automatically selects AnthropicOAuthProvider for OAuth tokens,
LiteLLMProvider for everything else.
"""
if should_use_oauth_provider(api_key, model):
return AnthropicOAuthProvider(
oauth_token=api_key,
default_model=model,
api_base=api_base,
thinking_budget=thinking_budget,
)
return LiteLLMProvider(
api_key=api_key,
api_base=api_base,
default_model=model,
extra_headers=extra_headers,
provider_name=provider_name,
)
+410
View File
@@ -0,0 +1,410 @@
"""Anthropic OAuth provider - direct API calls with Bearer auth.
This provider bypasses litellm to properly handle OAuth tokens
which require Authorization: Bearer header instead of x-api-key.
"""
import json
from typing import Any
import httpx
from loguru import logger
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.oauth_utils import get_auth_headers
class AnthropicOAuthProvider(LLMProvider):
"""
Anthropic provider using OAuth token authentication.
Unlike the LiteLLM provider, this calls the Anthropic API directly
with proper Bearer token authentication for Claude Max/Pro subscriptions.
"""
ANTHROPIC_API_URL = "https://api.anthropic.com/v1/messages"
def __init__(
self,
oauth_token: str,
default_model: str = "claude-opus-4-5",
api_base: str | None = None,
thinking_budget: int = 0,
):
super().__init__(api_key=None, api_base=api_base)
self.oauth_token = oauth_token
self.default_model = default_model
self.thinking_budget = thinking_budget
self._client: httpx.AsyncClient | None = None
def _get_headers(self) -> dict[str, str]:
"""Get request headers with Bearer auth."""
return get_auth_headers(self.oauth_token, is_oauth=True)
def _get_api_url(self) -> str:
"""Get API endpoint URL."""
if self.api_base:
return f"{self.api_base.rstrip('/')}/v1/messages"
return self.ANTHROPIC_API_URL
@staticmethod
def _normalize_model(model: str) -> str:
"""Normalize model name for the Anthropic API.
Anthropic model IDs use hyphens (claude-sonnet-4-5), but users often
write dots (claude-sonnet-4.5). Normalize so both work.
"""
return model.replace(".", "-")
async def _get_client(self) -> httpx.AsyncClient:
"""Get or create async HTTP client."""
if self._client is None:
self._client = httpx.AsyncClient(timeout=300.0)
return self._client
def _prepare_messages(
self,
messages: list[dict[str, Any]]
) -> tuple[str | None, list[dict[str, Any]]]:
"""Prepare messages: extract system prompt and convert OpenAI format to Anthropic.
The agent loop produces messages in OpenAI format:
- assistant msgs with tool_calls [{type:"function", function:{name, arguments}}]
- tool role msgs with tool_call_id, name, content
Anthropic API expects:
- assistant msgs with content blocks [{type:"tool_use", id, name, input}]
- user msgs with content blocks [{type:"tool_result", tool_use_id, content}]
Returns (system_prompt, anthropic_messages)
"""
system_parts = []
converted: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
if role == "system":
system_parts.append(msg.get("content", ""))
continue
if role == "assistant" and msg.get("tool_calls"):
# Convert OpenAI tool_calls to Anthropic content blocks
content_blocks: list[dict[str, Any]] = []
# Preserve thinking blocks (list=raw API blocks with signatures, str=legacy)
rc = msg.get("reasoning_content")
if isinstance(rc, list):
content_blocks.extend(rc)
elif isinstance(rc, str) and rc:
content_blocks.append({"type": "thinking", "thinking": rc})
text = msg.get("content")
if text:
content_blocks.append({"type": "text", "text": text})
for tc in msg["tool_calls"]:
func = tc.get("function", {})
args = func.get("arguments", "{}")
if isinstance(args, str):
try:
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {}
content_blocks.append({
"type": "tool_use",
"id": tc.get("id", ""),
"name": func.get("name", ""),
"input": args,
})
converted.append({"role": "assistant", "content": content_blocks})
continue
if role == "assistant" and msg.get("reasoning_content"):
# Plain assistant message with thinking (no tool calls)
rc = msg["reasoning_content"]
if isinstance(rc, list):
content_blocks = list(rc)
else:
content_blocks = [{"type": "thinking", "thinking": rc}]
text = msg.get("content")
if text:
content_blocks.append({"type": "text", "text": text})
converted.append({"role": "assistant", "content": content_blocks})
continue
if role == "tool":
# Convert tool result to Anthropic user message with tool_result block
tool_result_block = {
"type": "tool_result",
"tool_use_id": msg.get("tool_call_id", ""),
"content": msg.get("content", ""),
}
# Merge into previous user message if it already has tool_result blocks
if converted and converted[-1].get("role") == "user":
prev_content = converted[-1].get("content")
if isinstance(prev_content, list):
prev_content.append(tool_result_block)
continue
converted.append({"role": "user", "content": [tool_result_block]})
continue
if role == "user":
content = msg.get("content", "")
# Convert OpenAI image_url blocks to Anthropic image blocks
if isinstance(content, list):
content = self._convert_image_blocks(content)
# Merge text into previous user message if it has tool_result blocks
# (handles the "Reflect on the results" interleaved message)
if converted and converted[-1].get("role") == "user":
prev_content = converted[-1].get("content")
if isinstance(prev_content, list):
if isinstance(content, str):
prev_content.append({"type": "text", "text": content})
elif isinstance(content, list):
prev_content.extend(content)
continue
converted.append({"role": role, "content": content})
continue
# Pass through other messages (assistant without tool_calls, etc.)
converted.append(msg)
system_prompt = "\n\n".join(system_parts)
return system_prompt, converted
@staticmethod
def _convert_image_blocks(content: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Convert OpenAI image_url blocks to Anthropic image blocks.
OpenAI format: {"type": "image_url", "image_url": {"url": "data:mime;base64,DATA"}}
Anthropic format: {"type": "image", "source": {"type": "base64", "media_type": "mime", "data": "DATA"}}
"""
converted = []
for block in content:
if block.get("type") == "image_url":
url = block.get("image_url", {}).get("url", "")
if url.startswith("data:") and ";base64," in url:
header, data = url.split(";base64,", 1)
media_type = header.removeprefix("data:")
converted.append({
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data},
})
else:
converted.append({
"type": "image",
"source": {"type": "url", "url": url},
})
else:
converted.append(block)
return converted
def _convert_tools_to_anthropic(
self,
tools: list[dict[str, Any]] | None
) -> list[dict[str, Any]] | None:
"""Convert OpenAI-format tools to Anthropic format."""
if not tools:
return None
anthropic_tools = []
for tool in tools:
if tool.get("type") == "function":
func = tool["function"]
anthropic_tools.append({
"name": func["name"],
"description": func.get("description", ""),
"input_schema": func.get("parameters", {"type": "object", "properties": {}})
})
return anthropic_tools if anthropic_tools else None
async def _make_request(
self,
messages: list[dict[str, Any]],
system: str | None = None,
model: str = "claude-opus-4-5",
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()
payload: dict[str, Any] = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
}
# Extended thinking: temperature must be 1 when enabled
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 <= effective_thinking:
payload["max_tokens"] = effective_thinking + 4096
payload["thinking"] = {
"type": "enabled",
"budget_tokens": effective_thinking,
}
else:
payload["temperature"] = temperature
if system:
payload["system"] = system
if tools:
payload["tools"] = tools
logger.info(
"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(
self._get_api_url(),
headers=self._get_headers(),
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}")
return response.json()
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
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
# Strip provider prefix if present (e.g. "anthropic/claude-opus-4-5" -> "claude-opus-4-5")
if "/" in model:
model = model.split("/")[-1]
# Normalize dots to hyphens (claude-sonnet-4.5 -> claude-sonnet-4-5)
model = self._normalize_model(model)
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,
system=system,
model=model,
max_tokens=max_tokens,
temperature=temperature,
tools=anthropic_tools,
thinking_budget_override=effective_thinking,
)
return self._parse_response(response)
except Exception as e:
return LLMResponse(
content=f"Error calling LLM: {str(e)}",
finish_reason="error",
)
def _parse_response(self, response: dict[str, Any]) -> LLMResponse:
"""Parse Anthropic API response."""
content_blocks = response.get("content", [])
text_content = ""
thinking_blocks: list[dict[str, Any]] = []
tool_calls = []
for block in content_blocks:
if block.get("type") == "thinking":
# Preserve full block including signature for multi-turn replay
thinking_blocks.append(block)
elif block.get("type") == "text":
text_content += block.get("text", "")
elif block.get("type") == "tool_use":
tool_calls.append(ToolCallRequest(
id=block.get("id", ""),
name=block.get("name", ""),
arguments=block.get("input", {}),
))
usage = {}
if "usage" in response:
usage = {
"prompt_tokens": response["usage"].get("input_tokens", 0),
"completion_tokens": response["usage"].get("output_tokens", 0),
"total_tokens": (
response["usage"].get("input_tokens", 0) +
response["usage"].get("output_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=stop_reason,
usage=usage,
reasoning_content=thinking_blocks or None,
)
def get_default_model(self) -> str:
"""Get the default model."""
return self.default_model
async def close(self):
"""Close the HTTP client."""
if self._client:
await self._client.aclose()
self._client = None
+2 -1
View File
@@ -20,7 +20,7 @@ class LLMResponse:
tool_calls: list[ToolCallRequest] = field(default_factory=list) tool_calls: list[ToolCallRequest] = field(default_factory=list)
finish_reason: str = "stop" finish_reason: str = "stop"
usage: dict[str, int] = field(default_factory=dict) usage: dict[str, int] = field(default_factory=dict)
reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. reasoning_content: Any = None # str for Kimi/DeepSeek-R1; list[dict] for Anthropic thinking blocks
@property @property
def has_tool_calls(self) -> bool: def has_tool_calls(self) -> bool:
@@ -48,6 +48,7 @@ class LLMProvider(ABC):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_budget: int | None = None,
) -> LLMResponse: ) -> LLMResponse:
""" """
Send a chat completion request. Send a chat completion request.
+1
View File
@@ -106,6 +106,7 @@ class LiteLLMProvider(LLMProvider):
model: str | None = None, model: str | None = None,
max_tokens: int = 4096, max_tokens: int = 4096,
temperature: float = 0.7, temperature: float = 0.7,
thinking_budget: int | None = None,
) -> LLMResponse: ) -> LLMResponse:
""" """
Send a chat completion request via LiteLLM. Send a chat completion request via LiteLLM.
+38
View File
@@ -0,0 +1,38 @@
"""OAuth utility functions for Anthropic subscription auth."""
from typing import Any
def is_oauth_token(token: str | None) -> bool:
"""Check if token is an OAuth token (vs regular API key).
OAuth tokens from Claude Max/Pro contain 'sk-ant-oat' prefix.
Regular API keys use 'sk-ant-api03' or similar.
"""
if not token:
return False
return "sk-ant-oat" in token
def get_auth_headers(token: str, is_oauth: bool = False) -> dict[str, str]:
"""Get authentication headers for Anthropic API.
OAuth tokens require Authorization: Bearer header.
Regular API keys use x-api-key header.
"""
headers: dict[str, str] = {
"anthropic-version": "2023-06-01",
"content-type": "application/json",
}
if is_oauth:
headers["Authorization"] = f"Bearer {token}"
# Required headers to mimic Claude Code client
headers["anthropic-beta"] = "claude-code-20250219,oauth-2025-04-20"
headers["anthropic-dangerous-direct-browser-access"] = "true"
headers["user-agent"] = "claude-cli/2.1.2 (external, cli)"
headers["x-app"] = "cli"
else:
headers["x-api-key"] = token
return headers
+21
View File
@@ -357,3 +357,24 @@ def find_by_name(name: str) -> ProviderSpec | None:
if spec.name == name: if spec.name == name:
return spec return spec
return None return None
def should_use_oauth_provider(api_key: str | None, model: str) -> bool:
"""Determine if OAuth provider should be used.
OAuth provider is used when:
1. API key is an OAuth token (contains 'sk-ant-oat')
2. Model is an Anthropic model (contains 'claude' or 'anthropic')
"""
if not api_key:
return False
if "sk-ant-oat" not in api_key:
return False
model_lower = model.lower()
anthropic_spec = find_by_name("anthropic")
if anthropic_spec:
return any(kw in model_lower for kw in anthropic_spec.keywords)
return False
+88
View File
@@ -0,0 +1,88 @@
"""Test Anthropic OAuth provider."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.base import LLMResponse
@pytest.fixture
def provider():
"""Create provider with test OAuth token."""
return AnthropicOAuthProvider(
oauth_token="sk-ant-oat01-test-token",
default_model="claude-opus-4-5"
)
def test_provider_init(provider):
"""Provider should initialize with OAuth token."""
assert provider.oauth_token == "sk-ant-oat01-test-token"
assert provider.default_model == "claude-opus-4-5"
def test_provider_uses_bearer_auth(provider):
"""Provider should use Bearer auth, not x-api-key."""
headers = provider._get_headers()
assert "Authorization" in headers
assert headers["Authorization"].startswith("Bearer ")
assert "x-api-key" not in headers
@pytest.mark.asyncio
async def test_chat_prepends_system_prompt(provider):
"""Chat should prepend Claude Code identity to system prompt."""
messages = [{"role": "user", "content": "Hello"}]
with patch.object(provider, "_make_request", new_callable=AsyncMock) as mock:
mock.return_value = {"content": [{"type": "text", "text": "Hi"}], "stop_reason": "end_turn"}
await provider.chat(messages)
call_args = mock.call_args
system = call_args[1]["system"]
assert "Claude Code" in system
def test_parse_response_text(provider):
"""Should parse text response correctly."""
response = {
"content": [{"type": "text", "text": "Hello world"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
result = provider._parse_response(response)
assert result.content == "Hello world"
assert result.finish_reason == "end_turn"
assert result.usage["prompt_tokens"] == 10
def test_parse_response_tool_calls(provider):
"""Should parse tool call response correctly."""
response = {
"content": [
{"type": "tool_use", "id": "call_1", "name": "read_file", "input": {"path": "/tmp/test"}}
],
"stop_reason": "tool_use",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
result = provider._parse_response(response)
assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "read_file"
assert result.tool_calls[0].arguments == {"path": "/tmp/test"}
def test_convert_tools_to_anthropic(provider):
"""Should convert OpenAI-format tools to Anthropic format."""
openai_tools = [
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}}
}
}
]
anthropic_tools = provider._convert_tools_to_anthropic(openai_tools)
assert len(anthropic_tools) == 1
assert anthropic_tools[0]["name"] == "read_file"
assert "input_schema" in anthropic_tools[0]
+55
View File
@@ -0,0 +1,55 @@
"""Test OAuth CLI commands."""
import pytest
import tempfile
from pathlib import Path
from typer.testing import CliRunner
from nanobot.cli.oauth import oauth_app
@pytest.fixture
def runner():
return CliRunner()
def test_oauth_login_help(runner):
"""Login command should have help text."""
result = runner.invoke(oauth_app, ["login", "--help"])
assert result.exit_code == 0
assert "token" in result.output.lower()
def test_oauth_status_no_credentials(runner, tmp_path, monkeypatch):
"""Status should show no credentials when none exist."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["status"])
assert result.exit_code == 0
assert "No OAuth credentials" in result.output
def test_oauth_login_and_status(runner, tmp_path, monkeypatch):
"""Login should save credentials, status should show them."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
assert result.exit_code == 0
assert "Successfully saved" in result.output
result = runner.invoke(oauth_app, ["status"])
assert result.exit_code == 0
assert "sk-ant-oat01-test-x" in result.output
def test_oauth_logout(runner, tmp_path, monkeypatch):
"""Logout should remove credentials."""
monkeypatch.setenv("HOME", str(tmp_path))
runner.invoke(oauth_app, ["login", "--token", "sk-ant-oat01-test-xxx"])
result = runner.invoke(oauth_app, ["logout"])
assert result.exit_code == 0
assert "Removed" in result.output
def test_oauth_login_invalid_token(runner, tmp_path, monkeypatch):
"""Login should reject non-OAuth tokens."""
monkeypatch.setenv("HOME", str(tmp_path))
result = runner.invoke(oauth_app, ["login", "--token", "sk-ant-api03-regular"])
assert result.exit_code == 0
assert "Invalid token" in result.output
+65
View File
@@ -0,0 +1,65 @@
"""Test OAuth store integration with config loading."""
import json
import pytest
import tempfile
from pathlib import Path
from nanobot.config.loader import load_config
from nanobot.config.oauth_store import OAuthStore
from nanobot.config.schema import OAuthCredentials
def test_oauth_token_injected_into_config(tmp_path, monkeypatch):
"""OAuth token from store should be injected into provider api_key."""
# Create a minimal config file (no api key set)
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"agents": {"defaults": {"model": "anthropic/claude-opus-4-5"}},
"providers": {"anthropic": {"apiKey": ""}}
}))
# Save OAuth credentials
store = OAuthStore(tmp_path)
creds = OAuthCredentials(access_token="sk-ant-oat01-test-inject")
store.save("anthropic", creds)
# Monkeypatch get_config_path to use our tmp dir
monkeypatch.setattr("nanobot.config.loader.get_config_path", lambda: config_path)
# Monkeypatch the OAuth store path
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path)
config = load_config(config_path)
assert config.providers.anthropic.api_key == "sk-ant-oat01-test-inject"
def test_config_without_oauth_unchanged(tmp_path, monkeypatch):
"""Config without OAuth store should load normally."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"providers": {"anthropic": {"apiKey": "sk-ant-api03-regular"}}
}))
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path / "nonexistent")
config = load_config(config_path)
assert config.providers.anthropic.api_key == "sk-ant-api03-regular"
def test_oauth_does_not_overwrite_existing_key(tmp_path, monkeypatch):
"""If user already has an API key, OAuth should still override (OAuth takes priority)."""
config_path = tmp_path / "config.json"
config_path.write_text(json.dumps({
"providers": {"anthropic": {"apiKey": "sk-ant-api03-existing"}}
}))
store = OAuthStore(tmp_path)
creds = OAuthCredentials(access_token="sk-ant-oat01-oauth-wins")
store.save("anthropic", creds)
monkeypatch.setattr("nanobot.config.loader._get_oauth_store_dir", lambda: tmp_path)
config = load_config(config_path)
# OAuth token takes priority over existing API key
assert config.providers.anthropic.api_key == "sk-ant-oat01-oauth-wins"
+48
View File
@@ -0,0 +1,48 @@
"""Test OAuth configuration schema."""
import pytest
from nanobot.config.schema import ProviderConfig, OAuthCredentials
def test_provider_config_has_oauth_fields():
"""ProviderConfig should have oauth_credentials field."""
config = ProviderConfig(api_key="test")
assert hasattr(config, "oauth_credentials")
assert config.oauth_credentials is None
def test_oauth_credentials_model():
"""OAuthCredentials should store token, refresh, expiry."""
creds = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
refresh_token="rt_xxx",
expires_at=1234567890,
token_type="oauth"
)
assert creds.access_token.startswith("sk-ant-oat")
assert creds.is_oauth_token is True
def test_oauth_credentials_expiry_check():
"""OAuthCredentials should detect expired tokens."""
import time
expired = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
expires_at=int(time.time()) - 3600 # 1 hour ago
)
assert expired.is_expired is True
valid = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
expires_at=int(time.time()) + 3600 # 1 hour from now
)
assert valid.is_expired is False
def test_oauth_credentials_no_expiry():
"""Setup tokens with expires_at=0 should never be expired."""
creds = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
expires_at=0 # No expiry (setup-token)
)
assert creds.is_expired is False
assert creds.expires_soon is False
+55
View File
@@ -0,0 +1,55 @@
"""Test OAuth credential storage."""
import pytest
import tempfile
from pathlib import Path
from nanobot.config.oauth_store import OAuthStore
from nanobot.config.schema import OAuthCredentials
@pytest.fixture
def temp_store():
"""Create store with temp directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield OAuthStore(Path(tmpdir) / ".nanobot")
def test_save_and_load_credentials(temp_store):
"""Should save and load OAuth credentials."""
creds = OAuthCredentials(
access_token="sk-ant-oat01-xxx",
refresh_token="rt_xxx",
expires_at=1234567890
)
temp_store.save("anthropic", creds)
loaded = temp_store.load("anthropic")
assert loaded is not None
assert loaded.access_token == creds.access_token
assert loaded.refresh_token == creds.refresh_token
def test_load_nonexistent_returns_none(temp_store):
"""Should return None for missing credentials."""
assert temp_store.load("nonexistent") is None
def test_delete_credentials(temp_store):
"""Should delete saved credentials."""
creds = OAuthCredentials(access_token="sk-ant-oat01-xxx")
temp_store.save("anthropic", creds)
assert temp_store.delete("anthropic") is True
assert temp_store.load("anthropic") is None
def test_delete_nonexistent_returns_false(temp_store):
"""Should return False when deleting missing credentials."""
assert temp_store.delete("nonexistent") is False
def test_file_permissions(temp_store):
"""Credentials file should have restricted permissions."""
creds = OAuthCredentials(access_token="sk-ant-oat01-xxx")
temp_store.save("anthropic", creds)
perms = oct(temp_store.file_path.stat().st_mode)[-3:]
assert perms == "600"
+28
View File
@@ -0,0 +1,28 @@
"""Test OAuth utility functions."""
import pytest
from nanobot.providers.oauth_utils import is_oauth_token, get_auth_headers
def test_is_oauth_token_detects_oat():
"""Should detect sk-ant-oat tokens as OAuth."""
assert is_oauth_token("sk-ant-oat01-buSdhCH2XEkebW7ZQZTvGqH5EwAFh4u52LrdJhAP") is True
assert is_oauth_token("sk-ant-api03-regularkey") is False
assert is_oauth_token("") is False
assert is_oauth_token(None) is False
def test_get_auth_headers_oauth():
"""OAuth tokens should use Authorization: Bearer."""
headers = get_auth_headers("sk-ant-oat01-xxx", is_oauth=True)
assert "Authorization" in headers
assert headers["Authorization"] == "Bearer sk-ant-oat01-xxx"
assert "x-api-key" not in headers
assert headers["anthropic-beta"] == "claude-code-20250219,oauth-2025-04-20"
def test_get_auth_headers_api_key():
"""Regular API keys should use x-api-key."""
headers = get_auth_headers("sk-ant-api03-xxx", is_oauth=False)
assert "x-api-key" in headers
assert headers["x-api-key"] == "sk-ant-api03-xxx"
assert "Authorization" not in headers
+32
View File
@@ -0,0 +1,32 @@
"""Test provider factory with OAuth support."""
import pytest
from nanobot.providers import create_provider
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.litellm_provider import LiteLLMProvider
def test_create_provider_oauth_token():
"""OAuth tokens should create AnthropicOAuthProvider."""
provider = create_provider(
api_key="sk-ant-oat01-test-token",
model="anthropic/claude-opus-4-5"
)
assert isinstance(provider, AnthropicOAuthProvider)
def test_create_provider_regular_key():
"""Regular API keys should create LiteLLMProvider."""
provider = create_provider(
api_key="sk-ant-api03-regular-key",
model="anthropic/claude-opus-4-5"
)
assert isinstance(provider, LiteLLMProvider)
def test_create_provider_openrouter():
"""OpenRouter keys should create LiteLLMProvider."""
provider = create_provider(
api_key="sk-or-v1-xxx",
model="anthropic/claude-opus-4-5"
)
assert isinstance(provider, LiteLLMProvider)
+21
View File
@@ -0,0 +1,21 @@
"""Test OAuth detection in provider registry."""
import pytest
from nanobot.providers.registry import should_use_oauth_provider
def test_should_use_oauth_for_oat_token():
"""OAuth provider should be used for sk-ant-oat tokens."""
assert should_use_oauth_provider("sk-ant-oat01-xxx", "anthropic/claude-opus-4-5") is True
assert should_use_oauth_provider("sk-ant-oat01-xxx", "claude-sonnet-4") is True
def test_should_not_use_oauth_for_regular_key():
"""Regular API keys should not use OAuth provider."""
assert should_use_oauth_provider("sk-ant-api03-xxx", "claude-opus-4-5") is False
assert should_use_oauth_provider("sk-or-v1-xxx", "anthropic/claude-opus-4-5") is False
def test_should_not_use_oauth_for_non_anthropic():
"""Non-Anthropic models should not use OAuth provider."""
assert should_use_oauth_provider("sk-ant-oat01-xxx", "gpt-4") is False
assert should_use_oauth_provider("sk-ant-oat01-xxx", "deepseek/deepseek-chat") is False