Compare commits
39 Commits
55ad41265f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84268edf01 | ||
|
|
9136cca1ff | ||
|
|
6035b70ae5 | ||
| e4c300bcfd | |||
|
|
0c65efee06 | ||
|
|
c12d234ee8 | ||
|
|
2954933a55 | ||
|
|
a71cce08c1 | ||
|
|
c10544fc19 | ||
| 7c659bc0fe | |||
|
|
ea5bf4cf5d | ||
|
|
9f3e4089c2 | ||
|
|
c9880d4267 | ||
|
|
af436f5e6c | ||
|
|
2d3c94e609 | ||
|
|
88d2abc6c5 | ||
|
|
112212d3cd | ||
|
|
2ab51cb80b | ||
|
|
f71b3b3fea | ||
|
|
9990e80d61 | ||
|
|
9a131cb0ed | ||
|
|
c5ab4098ca | ||
|
|
5a8f3f772c | ||
|
|
e1a98d68ff | ||
|
|
92065dbb74 | ||
|
|
3f59a8e234 | ||
|
|
ccf9a6c146 | ||
|
|
43e2f2605b | ||
|
|
202f0a3144 | ||
|
|
92191ad2a9 | ||
|
|
fd7e477b18 | ||
|
|
5c398c5faf | ||
|
|
e1c359a198 | ||
|
|
32c9431191 | ||
|
|
64feec6656 | ||
|
|
903caaa642 | ||
|
|
0fc4f109bf | ||
|
|
24a90af6d3 | ||
|
|
dbbbecb25c |
83
.github/workflows/build.yml
vendored
Normal file
83
.github/workflows/build.yml
vendored
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,6 +14,7 @@ docs/
|
||||
*.pywz
|
||||
*.pyzz
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
poetry.lock
|
||||
.pytest_cache/
|
||||
|
||||
@@ -1,11 +1,62 @@
|
||||
FROM birdxs/nanobot:latest
|
||||
|
||||
# Copy full project (pyproject.toml + source)
|
||||
# ── 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/
|
||||
|
||||
# Install with all dependencies
|
||||
RUN uv pip install --system --no-cache --reinstall /app
|
||||
RUN uv pip install --system --no-cache --reinstall /app psycopg2-binary
|
||||
|
||||
ENTRYPOINT ["nanobot"]
|
||||
CMD ["status"]
|
||||
CMD ["gateway"]
|
||||
|
||||
@@ -16,10 +16,11 @@
|
||||
|
||||
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
||||
|
||||
📏 Real-time line count: **3,562 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||
📏 Real-time line count: **3,582 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||
|
||||
## 📢 News
|
||||
|
||||
- **2026-02-13** 🎉 Released v0.1.3.post7 — includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
|
||||
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
|
||||
- **2026-02-10** 🎉 Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
|
||||
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
|
||||
|
||||
@@ -95,8 +95,8 @@ File operations have path traversal protection, but:
|
||||
- Consider using a firewall to restrict outbound connections if needed
|
||||
|
||||
**WhatsApp Bridge:**
|
||||
- The bridge runs on `localhost:3001` by default
|
||||
- If exposing to network, use proper authentication and TLS
|
||||
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network)
|
||||
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js
|
||||
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
|
||||
|
||||
### 6. Dependency Security
|
||||
@@ -224,7 +224,7 @@ If you suspect a security breach:
|
||||
✅ **Secure Communication**
|
||||
- HTTPS for all external API calls
|
||||
- TLS for Telegram API
|
||||
- WebSocket security for WhatsApp bridge
|
||||
- WhatsApp bridge: localhost-only binding + optional token auth
|
||||
|
||||
## Known Limitations
|
||||
|
||||
|
||||
@@ -25,11 +25,12 @@ import { join } from 'path';
|
||||
|
||||
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
|
||||
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
|
||||
const TOKEN = process.env.BRIDGE_TOKEN || undefined;
|
||||
|
||||
console.log('🐈 nanobot WhatsApp Bridge');
|
||||
console.log('========================\n');
|
||||
|
||||
const server = new BridgeServer(PORT, AUTH_DIR);
|
||||
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* WebSocket server for Python-Node.js bridge communication.
|
||||
* Security: binds to 127.0.0.1 only; optional BRIDGE_TOKEN auth.
|
||||
*/
|
||||
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
@@ -21,12 +22,13 @@ export class BridgeServer {
|
||||
private wa: WhatsAppClient | null = null;
|
||||
private clients: Set<WebSocket> = new Set();
|
||||
|
||||
constructor(private port: number, private authDir: string) {}
|
||||
constructor(private port: number, private authDir: string, private token?: string) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Create WebSocket server
|
||||
this.wss = new WebSocketServer({ port: this.port });
|
||||
console.log(`🌉 Bridge server listening on ws://localhost:${this.port}`);
|
||||
// Bind to localhost only — never expose to external network
|
||||
this.wss = new WebSocketServer({ host: '127.0.0.1', port: this.port });
|
||||
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
|
||||
if (this.token) console.log('🔒 Token authentication enabled');
|
||||
|
||||
// Initialize WhatsApp client
|
||||
this.wa = new WhatsAppClient({
|
||||
@@ -38,35 +40,58 @@ export class BridgeServer {
|
||||
|
||||
// Handle WebSocket connections
|
||||
this.wss.on('connection', (ws) => {
|
||||
console.log('🔗 Python client connected');
|
||||
this.clients.add(ws);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const cmd = JSON.parse(data.toString()) as SendCommand;
|
||||
await this.handleCommand(cmd);
|
||||
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
|
||||
} catch (error) {
|
||||
console.error('Error handling command:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Python client disconnected');
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
if (this.token) {
|
||||
// Require auth handshake as first message
|
||||
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
|
||||
ws.once('message', (data) => {
|
||||
clearTimeout(timeout);
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
if (msg.type === 'auth' && msg.token === this.token) {
|
||||
console.log('🔗 Python client authenticated');
|
||||
this.setupClient(ws);
|
||||
} else {
|
||||
ws.close(4003, 'Invalid token');
|
||||
}
|
||||
} catch {
|
||||
ws.close(4003, 'Invalid auth message');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('🔗 Python client connected');
|
||||
this.setupClient(ws);
|
||||
}
|
||||
});
|
||||
|
||||
// Connect to WhatsApp
|
||||
await this.wa.connect();
|
||||
}
|
||||
|
||||
private setupClient(ws: WebSocket): void {
|
||||
this.clients.add(ws);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
const cmd = JSON.parse(data.toString()) as SendCommand;
|
||||
await this.handleCommand(cmd);
|
||||
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
|
||||
} catch (error) {
|
||||
console.error('Error handling command:', error);
|
||||
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('🔌 Python client disconnected');
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
this.clients.delete(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleCommand(cmd: SendCommand): Promise<void> {
|
||||
if (cmd.type === 'send' && this.wa) {
|
||||
await this.wa.sendMessage(cmd.to, cmd.text);
|
||||
|
||||
@@ -71,7 +71,7 @@ Skills with available="false" need dependencies installed first - you can try in
|
||||
return "\n\n---\n\n".join(parts)
|
||||
|
||||
def _get_identity(self) -> str:
|
||||
"""Get the core identity section."""
|
||||
"""Get the core identity section with runtime context."""
|
||||
from datetime import datetime
|
||||
import time as _time
|
||||
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())
|
||||
system = platform.system()
|
||||
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
|
||||
- Execute shell commands
|
||||
- Search the web and fetch web pages
|
||||
|
||||
@@ -164,7 +164,20 @@ class AgentLoop:
|
||||
logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}")
|
||||
|
||||
# Get or create session
|
||||
session = self.sessions.get_or_create(session_key or msg.session_key)
|
||||
key = session_key or msg.session_key
|
||||
session = self.sessions.get_or_create(key)
|
||||
|
||||
# Handle slash commands
|
||||
cmd = msg.content.strip().lower()
|
||||
if cmd == "/new":
|
||||
await self._consolidate_memory(session, archive_all=True)
|
||||
session.clear()
|
||||
self.sessions.save(session)
|
||||
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
|
||||
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")
|
||||
|
||||
# Consolidate memory before processing if session is too large
|
||||
if len(session.messages) > self.memory_window:
|
||||
@@ -243,7 +256,10 @@ class AgentLoop:
|
||||
break
|
||||
|
||||
if final_content is None:
|
||||
final_content = "I've completed processing but have no response to give."
|
||||
if iteration >= self.max_iterations:
|
||||
final_content = f"Reached {self.max_iterations} iterations without completion."
|
||||
else:
|
||||
final_content = "I've completed processing but have no response to give."
|
||||
|
||||
# Log response preview
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
@@ -363,11 +379,17 @@ class AgentLoop:
|
||||
content=final_content
|
||||
)
|
||||
|
||||
async def _consolidate_memory(self, session) -> None:
|
||||
async def _consolidate_memory(self, session, archive_all: bool = False) -> None:
|
||||
"""Consolidate old messages into MEMORY.md + HISTORY.md, then trim session."""
|
||||
if not session.messages:
|
||||
return
|
||||
memory = MemoryStore(self.workspace)
|
||||
keep_count = min(10, max(2, self.memory_window // 2))
|
||||
old_messages = session.messages[:-keep_count] # Everything except recent ones
|
||||
if archive_all:
|
||||
old_messages = session.messages
|
||||
keep_count = 0
|
||||
else:
|
||||
keep_count = min(10, max(2, self.memory_window // 2))
|
||||
old_messages = session.messages[:-keep_count]
|
||||
if not old_messages:
|
||||
return
|
||||
logger.info(f"Memory consolidation started: {len(session.messages)} messages, archiving {len(old_messages)}, keeping {keep_count}")
|
||||
@@ -402,14 +424,14 @@ 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,
|
||||
)
|
||||
import json as _json
|
||||
text = (response.content or "").strip()
|
||||
# Strip markdown fences that LLMs often add despite instructions
|
||||
if text.startswith("```"):
|
||||
text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
|
||||
result = _json.loads(text)
|
||||
result = json.loads(text)
|
||||
|
||||
if entry := result.get("history_entry"):
|
||||
memory.append_history(entry)
|
||||
@@ -417,8 +439,7 @@ Respond with ONLY valid JSON, no markdown fences."""
|
||||
if update != current_memory:
|
||||
memory.write_long_term(update)
|
||||
|
||||
# Trim session to recent messages
|
||||
session.messages = session.messages[-keep_count:]
|
||||
session.messages = session.messages[-keep_count:] if keep_count else []
|
||||
self.sessions.save(session)
|
||||
logger.info(f"Memory consolidation done, session trimmed to {len(session.messages)} messages")
|
||||
except Exception as e:
|
||||
|
||||
@@ -170,7 +170,7 @@ class SkillsLoader:
|
||||
"""Parse nanobot metadata JSON from frontmatter."""
|
||||
try:
|
||||
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):
|
||||
return {}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ class SubagentManager:
|
||||
self,
|
||||
task: str,
|
||||
label: str | None = None,
|
||||
model: str | None = None,
|
||||
origin_channel: str = "cli",
|
||||
origin_chat_id: str = "direct",
|
||||
) -> str:
|
||||
@@ -75,7 +76,7 @@ class SubagentManager:
|
||||
|
||||
# Create background 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
|
||||
|
||||
@@ -91,6 +92,7 @@ class SubagentManager:
|
||||
task: str,
|
||||
label: str,
|
||||
origin: dict[str, str],
|
||||
model: str | None = None,
|
||||
) -> None:
|
||||
"""Execute the subagent task and announce the result."""
|
||||
logger.info(f"Subagent [{task_id}] starting task: {label}")
|
||||
@@ -119,7 +121,7 @@ class SubagentManager:
|
||||
]
|
||||
|
||||
# Run agent loop (limited iterations)
|
||||
max_iterations = 15
|
||||
max_iterations = 50
|
||||
iteration = 0
|
||||
final_result: str | None = None
|
||||
|
||||
@@ -129,7 +131,7 @@ class SubagentManager:
|
||||
response = await self.provider.chat(
|
||||
messages=messages,
|
||||
tools=tools.get_definitions(),
|
||||
model=self.model,
|
||||
model=model or self.model,
|
||||
)
|
||||
|
||||
if response.has_tool_calls:
|
||||
|
||||
@@ -51,15 +51,20 @@ class SpawnTool(Tool):
|
||||
"type": "string",
|
||||
"description": "Optional short label for the task (for display)",
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model override for the subagent (e.g. 'claude-sonnet-4-20250514'). Defaults to the main agent's model.",
|
||||
},
|
||||
},
|
||||
"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."""
|
||||
return await self._manager.spawn(
|
||||
task=task,
|
||||
label=label,
|
||||
model=model,
|
||||
origin_channel=self._origin_channel,
|
||||
origin_chat_id=self._origin_chat_id,
|
||||
)
|
||||
|
||||
@@ -69,11 +69,15 @@ class BaseChannel(ABC):
|
||||
True if allowed, False otherwise.
|
||||
"""
|
||||
allow_list = getattr(self.config, "allow_from", [])
|
||||
|
||||
|
||||
# If no allow list, allow everyone
|
||||
if not allow_list:
|
||||
return True
|
||||
|
||||
|
||||
# Wildcard allows everyone
|
||||
if "*" in allow_list:
|
||||
return True
|
||||
|
||||
sender_str = str(sender_id)
|
||||
if sender_str in allow_list:
|
||||
return True
|
||||
|
||||
@@ -166,6 +166,10 @@ class FeishuChannel(BaseChannel):
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
||||
|
||||
_CODE_BLOCK_RE = re.compile(r"(```[\s\S]*?```)", re.MULTILINE)
|
||||
|
||||
@staticmethod
|
||||
def _parse_md_table(table_text: str) -> dict | None:
|
||||
"""Parse a markdown table into a Feishu table element."""
|
||||
@@ -185,17 +189,52 @@ class FeishuChannel(BaseChannel):
|
||||
}
|
||||
|
||||
def _build_card_elements(self, content: str) -> list[dict]:
|
||||
"""Split content into markdown + table elements for Feishu card."""
|
||||
"""Split content into div/markdown + table elements for Feishu card."""
|
||||
elements, last_end = [], 0
|
||||
for m in self._TABLE_RE.finditer(content):
|
||||
before = content[last_end:m.start()].strip()
|
||||
if before:
|
||||
elements.append({"tag": "markdown", "content": before})
|
||||
before = content[last_end:m.start()]
|
||||
if before.strip():
|
||||
elements.extend(self._split_headings(before))
|
||||
elements.append(self._parse_md_table(m.group(1)) or {"tag": "markdown", "content": m.group(1)})
|
||||
last_end = m.end()
|
||||
remaining = content[last_end:].strip()
|
||||
remaining = content[last_end:]
|
||||
if remaining.strip():
|
||||
elements.extend(self._split_headings(remaining))
|
||||
return elements or [{"tag": "markdown", "content": content}]
|
||||
|
||||
def _split_headings(self, content: str) -> list[dict]:
|
||||
"""Split content by headings, converting headings to div elements."""
|
||||
protected = content
|
||||
code_blocks = []
|
||||
for m in self._CODE_BLOCK_RE.finditer(content):
|
||||
code_blocks.append(m.group(1))
|
||||
protected = protected.replace(m.group(1), f"\x00CODE{len(code_blocks)-1}\x00", 1)
|
||||
|
||||
elements = []
|
||||
last_end = 0
|
||||
for m in self._HEADING_RE.finditer(protected):
|
||||
before = protected[last_end:m.start()].strip()
|
||||
if before:
|
||||
elements.append({"tag": "markdown", "content": before})
|
||||
level = len(m.group(1))
|
||||
text = m.group(2).strip()
|
||||
elements.append({
|
||||
"tag": "div",
|
||||
"text": {
|
||||
"tag": "lark_md",
|
||||
"content": f"**{text}**",
|
||||
},
|
||||
})
|
||||
last_end = m.end()
|
||||
remaining = protected[last_end:].strip()
|
||||
if remaining:
|
||||
elements.append({"tag": "markdown", "content": remaining})
|
||||
|
||||
for i, cb in enumerate(code_blocks):
|
||||
for el in elements:
|
||||
if el.get("tag") == "markdown":
|
||||
el["content"] = el["content"].replace(f"\x00CODE{i}\x00", cb)
|
||||
|
||||
return elements or [{"tag": "markdown", "content": content}]
|
||||
|
||||
async def send(self, msg: OutboundMessage) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
@@ -12,9 +12,6 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
"""
|
||||
@@ -26,10 +23,9 @@ class ChannelManager:
|
||||
- Route outbound messages
|
||||
"""
|
||||
|
||||
def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None):
|
||||
def __init__(self, config: Config, bus: MessageBus):
|
||||
self.config = config
|
||||
self.bus = bus
|
||||
self.session_manager = session_manager
|
||||
self.channels: dict[str, BaseChannel] = {}
|
||||
self._dispatch_task: asyncio.Task | None = None
|
||||
|
||||
@@ -46,7 +42,6 @@ class ChannelManager:
|
||||
self.config.channels.telegram,
|
||||
self.bus,
|
||||
groq_api_key=self.config.providers.groq.api_key,
|
||||
session_manager=self.session_manager,
|
||||
)
|
||||
logger.info("Telegram channel enabled")
|
||||
except ImportError as e:
|
||||
|
||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from loguru import logger
|
||||
from telegram import BotCommand, Update
|
||||
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes
|
||||
@@ -16,9 +14,6 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.schema import TelegramConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
def _markdown_to_telegram_html(text: str) -> str:
|
||||
"""
|
||||
@@ -95,7 +90,7 @@ class TelegramChannel(BaseChannel):
|
||||
# Commands registered with Telegram's command menu
|
||||
BOT_COMMANDS = [
|
||||
BotCommand("start", "Start the bot"),
|
||||
BotCommand("reset", "Reset conversation history"),
|
||||
BotCommand("new", "Start a new conversation"),
|
||||
BotCommand("help", "Show available commands"),
|
||||
]
|
||||
|
||||
@@ -104,12 +99,10 @@ class TelegramChannel(BaseChannel):
|
||||
config: TelegramConfig,
|
||||
bus: MessageBus,
|
||||
groq_api_key: str = "",
|
||||
session_manager: SessionManager | None = None,
|
||||
):
|
||||
super().__init__(config, bus)
|
||||
self.config: TelegramConfig = config
|
||||
self.groq_api_key = groq_api_key
|
||||
self.session_manager = session_manager
|
||||
self._app: Application | None = None
|
||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
||||
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
|
||||
@@ -132,8 +125,8 @@ class TelegramChannel(BaseChannel):
|
||||
|
||||
# Add command handlers
|
||||
self._app.add_handler(CommandHandler("start", self._on_start))
|
||||
self._app.add_handler(CommandHandler("reset", self._on_reset))
|
||||
self._app.add_handler(CommandHandler("help", self._on_help))
|
||||
self._app.add_handler(CommandHandler("new", self._forward_command))
|
||||
self._app.add_handler(CommandHandler("help", self._forward_command))
|
||||
|
||||
# Add message handler for text, photos, voice, documents
|
||||
self._app.add_handler(
|
||||
@@ -229,40 +222,15 @@ class TelegramChannel(BaseChannel):
|
||||
"Type /help to see available commands."
|
||||
)
|
||||
|
||||
async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /reset command — clear conversation history."""
|
||||
async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Forward slash commands to the bus for unified handling in AgentLoop."""
|
||||
if not update.message or not update.effective_user:
|
||||
return
|
||||
|
||||
chat_id = str(update.message.chat_id)
|
||||
session_key = f"{self.name}:{chat_id}"
|
||||
|
||||
if self.session_manager is None:
|
||||
logger.warning("/reset called but session_manager is not available")
|
||||
await update.message.reply_text("⚠️ Session management is not available.")
|
||||
return
|
||||
|
||||
session = self.session_manager.get_or_create(session_key)
|
||||
msg_count = len(session.messages)
|
||||
session.clear()
|
||||
self.session_manager.save(session)
|
||||
|
||||
logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)")
|
||||
await update.message.reply_text("🔄 Conversation history cleared. Let's start fresh!")
|
||||
|
||||
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle /help command — show available commands."""
|
||||
if not update.message:
|
||||
return
|
||||
|
||||
help_text = (
|
||||
"🐈 <b>nanobot commands</b>\n\n"
|
||||
"/start — Start the bot\n"
|
||||
"/reset — Reset conversation history\n"
|
||||
"/help — Show this help message\n\n"
|
||||
"Just send me a text message to chat!"
|
||||
await self._handle_message(
|
||||
sender_id=str(update.effective_user.id),
|
||||
chat_id=str(update.message.chat_id),
|
||||
content=update.message.text,
|
||||
)
|
||||
await update.message.reply_text(help_text, parse_mode="HTML")
|
||||
|
||||
async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Handle incoming messages (text, photos, voice, documents)."""
|
||||
|
||||
@@ -42,6 +42,9 @@ class WhatsAppChannel(BaseChannel):
|
||||
try:
|
||||
async with websockets.connect(bridge_url) as ws:
|
||||
self._ws = ws
|
||||
# Send auth token if configured
|
||||
if self.config.bridge_token:
|
||||
await ws.send(json.dumps({"type": "auth", "token": self.config.bridge_token}))
|
||||
self._connected = True
|
||||
logger.info("Connected to WhatsApp bridge")
|
||||
|
||||
|
||||
@@ -284,6 +284,7 @@ def _make_provider(config):
|
||||
api_base=config.get_api_base(),
|
||||
extra_headers=p.extra_headers if p else None,
|
||||
provider_name=config.get_provider_name(),
|
||||
thinking_budget=config.agents.defaults.thinking_budget,
|
||||
)
|
||||
|
||||
|
||||
@@ -369,7 +370,7 @@ def gateway(
|
||||
)
|
||||
|
||||
# Create channel manager
|
||||
channels = ChannelManager(config, bus, session_manager=session_manager)
|
||||
channels = ChannelManager(config, bus)
|
||||
|
||||
if channels.enabled_channels:
|
||||
console.print(f"[green]✓[/green] Channels enabled: {', '.join(channels.enabled_channels)}")
|
||||
@@ -410,7 +411,7 @@ def gateway(
|
||||
@app.command()
|
||||
def agent(
|
||||
message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
|
||||
session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"),
|
||||
session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
|
||||
markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"),
|
||||
logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"),
|
||||
):
|
||||
@@ -639,14 +640,20 @@ def _get_bridge_dir() -> Path:
|
||||
def channels_login():
|
||||
"""Link device via QR code."""
|
||||
import subprocess
|
||||
from nanobot.config.loader import load_config
|
||||
|
||||
config = load_config()
|
||||
bridge_dir = _get_bridge_dir()
|
||||
|
||||
console.print(f"{__logo__} Starting bridge...")
|
||||
console.print("Scan the QR code to connect.\n")
|
||||
|
||||
env = {**os.environ}
|
||||
if config.channels.whatsapp.bridge_token:
|
||||
env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token
|
||||
|
||||
try:
|
||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True)
|
||||
subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env)
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]Bridge failed: {e}[/red]")
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -9,6 +9,7 @@ class WhatsAppConfig(BaseModel):
|
||||
"""WhatsApp channel configuration."""
|
||||
enabled: bool = False
|
||||
bridge_url: str = "ws://localhost:3001"
|
||||
bridge_token: str = "" # Shared token for bridge auth (optional, recommended)
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers
|
||||
|
||||
|
||||
@@ -162,6 +163,7 @@ class AgentDefaults(BaseModel):
|
||||
temperature: float = 0.7
|
||||
max_tool_iterations: int = 20
|
||||
memory_window: int = 50
|
||||
thinking_budget: int = 0 # 0 = disabled; >0 = token budget for extended thinking
|
||||
|
||||
|
||||
class AgentsConfig(BaseModel):
|
||||
|
||||
@@ -21,6 +21,7 @@ def create_provider(
|
||||
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.
|
||||
|
||||
@@ -32,6 +33,7 @@ def create_provider(
|
||||
oauth_token=api_key,
|
||||
default_model=model,
|
||||
api_base=api_base,
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
|
||||
return LiteLLMProvider(
|
||||
|
||||
@@ -8,9 +8,10 @@ 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, get_claude_code_system_prefix
|
||||
from nanobot.providers.oauth_utils import get_auth_headers
|
||||
|
||||
|
||||
class AnthropicOAuthProvider(LLMProvider):
|
||||
@@ -28,10 +29,12 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
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]:
|
||||
@@ -44,19 +47,14 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
return f"{self.api_base.rstrip('/')}/v1/messages"
|
||||
return self.ANTHROPIC_API_URL
|
||||
|
||||
# Short aliases that need dated suffixes for the API
|
||||
MODEL_ALIASES: dict[str, str] = {
|
||||
"claude-sonnet-4": "claude-sonnet-4-20250514",
|
||||
"claude-opus-4": "claude-opus-4-20250514",
|
||||
"claude-haiku-3-5": "claude-haiku-4-5-20241022",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-20250929",
|
||||
"claude-opus-4-5": "claude-opus-4-5-20250929",
|
||||
"claude-opus-4-6": "claude-opus-4-6",
|
||||
}
|
||||
@staticmethod
|
||||
def _normalize_model(model: str) -> str:
|
||||
"""Normalize model name for the Anthropic API.
|
||||
|
||||
def _resolve_model_alias(self, model: str) -> str:
|
||||
"""Resolve short model aliases to full dated IDs."""
|
||||
return self.MODEL_ALIASES.get(model, model)
|
||||
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."""
|
||||
@@ -68,21 +66,136 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
self,
|
||||
messages: list[dict[str, Any]]
|
||||
) -> tuple[str | None, list[dict[str, Any]]]:
|
||||
"""Prepare messages, extracting system prompt and adding Claude Code identity.
|
||||
"""Prepare messages: extract system prompt and convert OpenAI format to Anthropic.
|
||||
|
||||
Returns (system_prompt, messages_without_system)
|
||||
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 = [get_claude_code_system_prefix()]
|
||||
filtered_messages = []
|
||||
system_parts = []
|
||||
converted: list[dict[str, Any]] = []
|
||||
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
role = msg.get("role")
|
||||
|
||||
if role == "system":
|
||||
system_parts.append(msg.get("content", ""))
|
||||
else:
|
||||
filtered_messages.append(msg)
|
||||
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, filtered_messages
|
||||
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,
|
||||
@@ -112,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()
|
||||
@@ -120,15 +234,34 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
# 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=%s max_tokens=%d thinking=%s",
|
||||
payload.get("model"), payload.get("max_tokens"),
|
||||
payload.get("thinking", "disabled"),
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
self._get_api_url(),
|
||||
headers=self._get_headers(),
|
||||
@@ -148,6 +281,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
|
||||
@@ -156,12 +290,15 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
if "/" in model:
|
||||
model = model.split("/")[-1]
|
||||
|
||||
# Resolve short aliases to dated model IDs (API requires dated suffixes)
|
||||
model = self._resolve_model_alias(model)
|
||||
# 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,
|
||||
@@ -170,6 +307,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:
|
||||
@@ -183,10 +321,14 @@ class AnthropicOAuthProvider(LLMProvider):
|
||||
content_blocks = response.get("content", [])
|
||||
|
||||
text_content = ""
|
||||
thinking_blocks: list[dict[str, Any]] = []
|
||||
tool_calls = []
|
||||
|
||||
for block in content_blocks:
|
||||
if block.get("type") == "text":
|
||||
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(
|
||||
@@ -206,11 +348,27 @@ 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),
|
||||
)
|
||||
|
||||
return LLMResponse(
|
||||
content=text_content or None,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=response.get("stop_reason", "end_turn"),
|
||||
usage=usage,
|
||||
reasoning_content=thinking_blocks or None,
|
||||
)
|
||||
|
||||
def get_default_model(self) -> str:
|
||||
|
||||
@@ -20,7 +20,7 @@ class LLMResponse:
|
||||
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
||||
finish_reason: str = "stop"
|
||||
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
|
||||
def has_tool_calls(self) -> bool:
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -36,11 +36,3 @@ def get_auth_headers(token: str, is_oauth: bool = False) -> dict[str, str]:
|
||||
headers["x-api-key"] = token
|
||||
|
||||
return headers
|
||||
|
||||
|
||||
def get_claude_code_system_prefix() -> str:
|
||||
"""Get the required system prompt prefix for OAuth tokens.
|
||||
|
||||
Anthropic requires this identity declaration for OAuth auth.
|
||||
"""
|
||||
return "You are Claude Code, Anthropic's official CLI for Claude."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nanobot-ai"
|
||||
version = "0.1.3.post6"
|
||||
version = "0.1.3.post7"
|
||||
description = "A lightweight personal AI assistant framework"
|
||||
requires-python = ">=3.11"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Reference in New Issue
Block a user