Compare commits

...
Author SHA1 Message Date
nanobot fa66e00547 feat: wire ara-research-manager into memory consolidation
Build Nanobot OAuth / build (pull_request) Successful in 6m12s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
After _consolidate_memory completes (on /new or auto-compact), spawn
a background ara-pm subagent that runs the ara-research-manager skill
on /workspace/ara-projects/nanobot/. Fire-and-forget via asyncio.create_task.
Silently skips if ara-projects/nanobot/ doesn't exist.
2026-05-05 23:07:39 +02:00
nanobot 59b4abaa14 chore: bump model defaults to Opus 4.7, Sonnet 4.6
Build Nanobot OAuth / build (pull_request) Successful in 7m4s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
Build Nanobot OAuth / build (push) Successful in 2m48s
Build Nanobot OAuth / cleanup (push) Successful in 1s
- Default model: anthropic/claude-opus-4-5 → anthropic/claude-opus-4-7
- Quota switcher: claude-opus-4-6 → claude-opus-4-7
- Update all provider defaults and test fixtures
- Update comments/docstrings referencing old model names
- Claude Opus 4.7 released 2026-04-16, same pricing as 4.6
2026-04-17 02:53:08 +02:00
code-serverandClaude Opus 4.6 71e65052d1 fix: use correct build_messages signature after emergency trim
Build Nanobot OAuth / build (push) Successful in 1m33s
Build Nanobot OAuth / cleanup (push) Successful in 0s
Used nonexistent 'system_prompt' variable. Match the keyword-arg call
pattern used at the top of _process_message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:22:06 +01:00
code-serverandClaude Opus 4.6 7b0714c5c5 fix(oauth): re-raise LongContextError past chat() blanket except
Build Nanobot OAuth / cleanup (push) Has been cancelled
Build Nanobot OAuth / build (push) Has been cancelled
chat() had a blanket `except Exception` that swallowed LongContextError,
preventing the agent loop from catching it for auto-consolidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:17:57 +01:00
code-serverandClaude Opus 4.6 4bdcd0b568 feat: auto-consolidate session on long context 429
Build Nanobot OAuth / build (push) Successful in 5m1s
Build Nanobot OAuth / cleanup (push) Successful in 0s
When Anthropic returns 429 "Extra usage is required for long context
requests", the agent now automatically runs memory consolidation and
trims the session, then retries the LLM call with shorter context.

- Add LongContextError exception in providers/base.py
- Provider detects long-context 429 and raises immediately (no retry)
- Agent loop catches it in both _process_message and _process_system_message
- Consolidates facts, trims session, rebuilds messages, retries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 14:07:08 +01:00
code-serverandClaude Opus 4.6 fdecb76035 fix(mem0): handle dict facts from LLM and fix slice error on dicts
Build Nanobot OAuth / build (push) Successful in 55s
Build Nanobot OAuth / cleanup (push) Successful in 1s
The extraction LLM returns facts as {"fact": "...", "date": "..."} dicts
instead of plain strings. store_facts now normalizes these to strings
before passing to mem0.add(). Also fixes KeyError when slicing dicts
in the error handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:48:31 +01:00
code-serverandClaude Opus 4.6 b7d451ec5d fix(mem0): increase max_tokens for fact extraction from 2000 to 16384
Build Nanobot OAuth / cleanup (push) Has been cancelled
Build Nanobot OAuth / build (push) Has been cancelled
2000 tokens is insufficient for large sessions (700+ messages), causing
JSON truncation and parse failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:45:38 +01:00
code-serverandClaude Opus 4.6 86fe3a4749 test: add tests for identity block, fact extraction, and audit log
Build Nanobot OAuth / build (push) Successful in 47s
Build Nanobot OAuth / cleanup (push) Successful in 0s
- test_oauth_identity_block: verify identity block is included in API
  requests even when system=None (covers fix in 3f2684d)
- test_mem0_extract_facts: verify extract_facts passes thinking_budget=0
  to provider.chat() (covers fix in 76d5a73)
- test_session_audit_log: verify save() creates append-only audit log
  with markers and message preservation (covers feat in 2ab6494)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:49:23 +01:00
code-serverandClaude Opus 4.6 76d5a73cc7 fix(mem0): disable thinking for fact extraction calls
Build Nanobot OAuth / build (push) Successful in 5m40s
Build Nanobot OAuth / cleanup (push) Successful in 0s
Fact extraction inherited the instance thinking_budget (10000), causing
the model to spend tokens on thinking instead of outputting JSON. The
response content was empty, failing JSON parse every time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:36:21 +01:00
code-serverandClaude Opus 4.6 2ab6494ec9 feat(session): add append-only audit log
Build Nanobot OAuth / build (push) Successful in 5m59s
Build Nanobot OAuth / cleanup (push) Successful in 0s
Every SessionManager.save() now also appends the full session state
to a parallel audit file (*.audit.YYYY-MM.jsonl). This survives
session trims and memory consolidation — when something wipes the
session, the audit file retains the complete history.

Rotated monthly by filename. Never truncated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 06:31:53 +01:00
code-serverandClaude Opus 4.6 3f2684dcfe fix(oauth): include identity block in all API calls
Build Nanobot OAuth / build (push) Successful in 48s
Build Nanobot OAuth / cleanup (push) Successful in 0s
Anthropic requires the identity prefix for OAuth tokens on every
request, but it was only included when a system prompt was present.
Calls without a system prompt (e.g. fact extraction during memory
consolidation) got 400 invalid_request_error every time, silently
breaking memory consolidation while the session trim still ran.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 17:03:01 +01:00
code-serverandClaude Opus 4.6 266458528e fix(oauth): restore identity block required by Anthropic API
Build Nanobot OAuth / build (push) Successful in 23m54s
Build Nanobot OAuth / cleanup (push) Successful in 1s
Anthropic now requires OAuth requests to include an approved identity
string as a separate first content block in the system prompt array.
Without it, Sonnet/Opus models return 400 invalid_request_error.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:14:32 +01:00
code-server 35eb35cdc2 Merge pull request 'Sign intermediate messages for model visibility' (#31) from feat/message-visibility-signing into main
Build Nanobot OAuth / build (push) Successful in 1m1s
Build Nanobot OAuth / cleanup (push) Successful in 1s
2026-03-09 18:08:37 +01:00
code-server 8cb5d93005 docs: add PR testing workflow guide
Build Nanobot OAuth / cleanup (push) Has been cancelled
Build Nanobot OAuth / build (push) Has been cancelled
Comprehensive guide for using the staging environment:
- Quick start with test-pr.sh script
- Manual testing methods
- Cache verification procedures
- Session management
- Troubleshooting tips

Includes examples for multi-turn testing and cache validation.
2026-03-09 18:07:57 +01:00
code-serverandClaude Opus 4.6 5569c99b8e feat: sign intermediate messages so model knows what user didn't see
Build Nanobot OAuth / build (pull_request) Successful in 6m14s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
Intermediate assistant messages (with tool_calls) and tool result messages
are never sent to the user but remain in the model's context. This causes
the model to refer to content the user never saw.

Add _hidden_sig field at message creation time (context.py), then apply
[HIDDEN:sig] prefix at read time (session get_history) so the model sees
which messages were hidden. Storing the signature separately from content
preserves Anthropic prompt caching — the same prefixed string is produced
every turn.

Changes:
- visibility.py: add compute_signature(), refactor sign_content/verify to
  use it, fix Tuple -> tuple (PEP 585)
- context.py: add_assistant_message() and add_tool_result() store _hidden_sig
- session/manager.py: get_history() applies [HIDDEN:sig] prefix at read time
- tests/test_message_visibility.py: 14 tests covering compute_signature,
  _hidden_sig creation, get_history prefix, JSONL round-trip, idempotency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:23:46 +01:00
code-serverandClaude Opus 4.6 d90c3b4a24 feat: sign intermediate messages so model knows what user didn't see
Build Nanobot OAuth / build (pull_request) Failing after 7m24s
Build Nanobot OAuth / cleanup (pull_request) Has been skipped
Intermediate assistant messages (with tool_calls) and tool result messages
are never sent to the user but remain in the model's context. This causes
the model to refer to content the user never saw.

Add _hidden_sig field at message creation time (context.py), then apply
[HIDDEN:sig] prefix at read time (session get_history) so the model sees
which messages were hidden. Storing the signature separately from content
preserves Anthropic prompt caching — the same prefixed string is produced
every turn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 15:17:28 +01:00
code-server ee0b25e29a Merge pull request #32 'Add local staging environment for PR testing' from staging-setup into main
Build Nanobot OAuth / build (push) Failing after 8m39s
Build Nanobot OAuth / cleanup (push) Has been skipped
2026-03-09 15:16:32 +01:00
22 changed files with 1107 additions and 122 deletions
+1 -1
View File
@@ -143,7 +143,7 @@ Add or merge these **two parts** into your config (other options have defaults).
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5",
"model": "anthropic/claude-opus-4-7",
"provider": "openrouter"
}
}
+214
View File
@@ -0,0 +1,214 @@
# PR Testing Workflow
Guide for testing Pull Requests using the local staging environment.
## Quick Start
```bash
./test-pr.sh <pr-number> "test message"
```
## Staging Environment
**Location:** `/config/workspace/.nanobot-staging/`
**Components:**
- `config.json` — Staging configuration (channels disabled, shared OAuth)
- `workspace/` — Isolated workspace for tool operations
- `workspace/sessions/` — Session storage (separate from production)
**Key differences from production:**
- No external channels (Telegram disabled)
- Uses `NANOBOT_CONFIG` environment variable
- Gateway runs on localhost:18791 (vs production's 18790)
- `restrictToWorkspace: true` for safety
## Testing a PR
### Method 1: Helper Script (Recommended)
```bash
# Test PR with default message
./test-pr.sh 31
# Test with custom message
./test-pr.sh 31 "test the hidden message feature"
```
**What it does:**
1. Fetches PR from `wylab` remote (force updates if branch exists)
2. Checks out PR branch locally
3. Installs in editable mode with `uv pip install -e .`
4. Runs test with staging config via `NANOBOT_CONFIG` env var
5. Leaves branch checked out for further testing
**After testing:**
```bash
git checkout main # Return to main branch
```
### Method 2: Manual Testing
```bash
# 1. Fetch and checkout PR
cd /config/workspace/nanobot-oauth-port/nanobot-fork
git fetch wylab pull/<N>/head:pr-<N>
git checkout pr-<N>
# 2. Install in editable mode
uv pip install -e .
# 3. Test with staging config
NANOBOT_CONFIG=/config/workspace/.nanobot-staging/config.json \
.venv/bin/nanobot agent -m "test message"
# 4. For multi-turn testing
NANOBOT_CONFIG=/config/workspace/.nanobot-staging/config.json \
.venv/bin/nanobot agent # Interactive mode
# 5. Return to main
git checkout main
```
### Method 3: Gateway Validation
Test that gateway starts without errors:
```bash
NANOBOT_CONFIG=/config/workspace/.nanobot-staging/config.json \
.venv/bin/nanobot gateway
# Kill with Ctrl+C when validated
```
## Verifying Cache Behavior
To verify prompt caching works correctly (important for performance):
```bash
# Enable logs to see cache metrics
NANOBOT_CONFIG=/config/workspace/.nanobot-staging/config.json \
.venv/bin/nanobot agent --logs -m "Turn 1: list files"
# Look for cache metrics in output:
# - cache_write: New cache entries created
# - cache_read: Tokens read from cache
```
**What to look for:**
- Turn 1: High `cache_write`, moderate `cache_read`
- Turn 2+: Low `cache_write`, high `cache_read` (reusing cache)
- `cache_read` should increase across turns as context grows
**Example healthy pattern:**
```
Turn 1: cache_write=354 cache_read=3563
Turn 2: cache_write=255 cache_read=3917 ← Same as Turn 1 end
Turn 3: cache_write=113 cache_read=4172 ← Growing with context
```
## Session Management
### Clear session for fresh test
```bash
rm -f /config/workspace/.nanobot-staging/workspace/sessions/cli_direct.jsonl
```
### View session contents
```bash
cat /config/workspace/.nanobot-staging/workspace/sessions/cli_direct.jsonl | jq
```
### Check for specific features (e.g., hidden signatures)
```bash
cat /config/workspace/.nanobot-staging/workspace/sessions/cli_direct.jsonl | grep "_hidden_sig"
```
## Common Testing Scenarios
### Test tool execution
```bash
./test-pr.sh 31 "List all Python files in the current directory"
```
### Test multi-turn conversation
```bash
NANOBOT_CONFIG=/config/workspace/.nanobot-staging/config.json \
.venv/bin/nanobot agent
# Then interact naturally:
> list files in current directory
> how many python files are there?
> what's the total size?
```
### Test error handling
```bash
./test-pr.sh 31 "Try to read a file that doesn't exist: /nonexistent.txt"
```
### Test with thinking mode
The staging config has `thinking_budget: 10000` enabled by default, so all tests use extended thinking.
## Troubleshooting
### "No API key configured" error
- **Cause:** `NANOBOT_CONFIG` env var not set
- **Fix:** Ensure you're using `NANOBOT_CONFIG=/config/workspace/.nanobot-staging/config.json`
### "Module not found" after checkout
- **Cause:** Need to reinstall after switching branches
- **Fix:** Run `uv pip install -e .` after checkout
### Changes not applying
- **Cause:** Using cached `.pyc` files
- **Fix:** Clear pycache: `find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true`
### Session has stale data
- **Cause:** Previous test left session data
- **Fix:** `rm /config/workspace/.nanobot-staging/workspace/sessions/cli_direct.jsonl`
## Best Practices
1. **Clear session between PR tests** to avoid cross-contamination
2. **Test with tool use** to trigger agentic behavior (not just simple Q&A)
3. **Check cache metrics** for performance-sensitive PRs
4. **Run with `--logs`** to see detailed behavior during development
5. **Return to main** after testing to avoid accidental commits on PR branches
## Integration with CI/CD
The staging environment is currently manual-only. Future enhancements:
- [ ] Automated PR testing via Gitea Actions
- [ ] Cache validation in CI pipeline
- [ ] Multi-PR parallel testing using git worktrees
- [ ] Regression test suite against production behavior
## File Locations Reference
| Path | Purpose |
|------|---------|
| `/config/workspace/nanobot-oauth-port/nanobot-fork/` | Local nanobot repository |
| `/config/workspace/.nanobot-staging/` | Staging environment root |
| `/config/workspace/.nanobot-staging/config.json` | Staging configuration |
| `/config/workspace/.nanobot-staging/workspace/` | Staging workspace |
| `/config/workspace/.nanobot-staging/workspace/sessions/` | Session storage |
| `/config/workspace/nanobot-oauth-port/nanobot-fork/test-pr.sh` | Helper script |
## Related Documentation
- [nanobot README](../README.md) - Main project documentation
- [CLAUDE.md](../CLAUDE.md) - Development guide for Claude Code
- [config/schema.py](../nanobot/config/schema.py) - Configuration schema
+10 -6
View File
@@ -11,6 +11,7 @@ from loguru import logger
from nanobot.agent.memory import MemoryStore
from nanobot.agent.memory_mem0 import Mem0MemoryStore, HAS_MEM0
from nanobot.agent.skills import SkillsLoader
from nanobot.agent.visibility import compute_signature
class ContextBuilder:
@@ -226,12 +227,14 @@ visibility markers will be rejected."""
Returns:
Updated message list.
"""
messages.append({
msg: dict[str, Any] = {
"role": "tool",
"tool_call_id": tool_call_id,
"name": tool_name,
"content": result
})
"content": result,
"_hidden_sig": compute_signature(result if isinstance(result, str) else ""),
}
messages.append(msg)
return messages
def add_assistant_message(
@@ -254,13 +257,14 @@ visibility markers will be rejected."""
Updated message list.
"""
msg: dict[str, Any] = {"role": "assistant", "content": content or ""}
if tool_calls:
msg["tool_calls"] = tool_calls
msg["_hidden_sig"] = compute_signature(content or "")
# Thinking models reject history without this
if reasoning_content:
msg["reasoning_content"] = reasoning_content
messages.append(msg)
return messages
+81 -14
View File
@@ -11,7 +11,7 @@ from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMProvider
from nanobot.providers.base import LLMProvider, LongContextError
from nanobot.agent.context import ContextBuilder
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.agent.tools.filesystem import ReadFileTool, WriteFileTool, EditFileTool, ListDirTool
@@ -221,7 +221,7 @@ class AgentLoop:
return self._quota_cache["model"]
# Default models
OPUS = "claude-opus-4-6"
OPUS = "claude-opus-4-7"
SONNET = "claude-sonnet-4-6"
TOLERANCE = 1.17 # 17% overage triggers downgrade
@@ -417,12 +417,35 @@ class AgentLoop:
# Call LLM
logger.debug(f"Calling LLM with model={selected_model}, provider.thinking_budget={self.provider.thinking_budget}")
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
model=selected_model,
context_management=self.CONTEXT_MANAGEMENT,
)
try:
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
model=selected_model,
context_management=self.CONTEXT_MANAGEMENT,
)
except LongContextError:
logger.warning("Long context 429 — auto-consolidating session")
await self._consolidate_memory(session, archive_all=False)
# Apply trim immediately (normally deferred to end of turn)
checkpoint = getattr(session, '_trim_checkpoint', None)
if checkpoint is not None:
old_size = len(session.messages)
session.messages = session.messages[checkpoint:]
session._trim_checkpoint = None
self.sessions.save(session)
logger.info(f"Emergency trim: {old_size} -> {len(session.messages)} messages")
# Rebuild messages from trimmed session
messages = self.context.build_messages(
history=session.get_history(),
current_message=current_message,
media=msg.media if msg.media else None,
channel=msg.channel,
chat_id=msg.chat_id,
)
turn_start = len(messages)
continue # Retry LLM call with shorter context
raise # No trim happened — can't recover
# Handle tool calls
if response.has_tool_calls:
@@ -672,12 +695,32 @@ class AgentLoop:
while iteration < self.max_iterations:
iteration += 1
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
model=selected_model,
context_management=self.CONTEXT_MANAGEMENT,
)
try:
response = await self.provider.chat(
messages=messages,
tools=self.tools.get_tools(), # Pass tool objects for beta flag extraction
model=selected_model,
context_management=self.CONTEXT_MANAGEMENT,
)
except LongContextError:
logger.warning("Long context 429 in system handler — auto-consolidating")
await self._consolidate_memory(session, archive_all=False)
checkpoint = getattr(session, '_trim_checkpoint', None)
if checkpoint is not None:
old_size = len(session.messages)
session.messages = session.messages[checkpoint:]
session._trim_checkpoint = None
self.sessions.save(session)
logger.info(f"Emergency trim: {old_size} -> {len(session.messages)} messages")
messages = self.context.build_messages(
history=session.get_history(),
current_message=msg.content,
channel=origin_channel,
chat_id=origin_chat_id,
)
turn_start = len(messages)
continue
raise
if response.has_tool_calls:
tool_call_dicts = [
@@ -1008,9 +1051,33 @@ Respond with ONLY valid JSON, no markdown fences."""
session.messages = self._trim_to_clean_boundary(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")
self._spawn_ara_research_manager()
except Exception as e:
logger.error(f"Memory consolidation failed: {e}")
def _spawn_ara_research_manager(self) -> None:
"""Fire-and-forget: update the nanobot ARA trace after memory consolidation.
Uses the SpawnTool which already has channel/chat_id context from the
last message. Silently skips if ARA path doesn't exist or spawn fails.
"""
import os
ara_path = os.path.join(self.workspace, "ara-projects", "nanobot")
if not os.path.isdir(ara_path):
return
spawn_tool = self.tools.get("spawn")
if not isinstance(spawn_tool, SpawnTool):
return
task = (
f"Read /root/.nanobot/workspace/skills/ara-research-manager/SKILL.md "
f"and run the per-turn procedure on the nanobot ARA at {ara_path}/. "
f"Capture any research events from this session."
)
asyncio.create_task(
spawn_tool.execute(task=task, label="ara-pm", model="claude-haiku-4-5")
)
logger.debug("ARA research-manager task spawned")
async def process_direct(
self,
content: str,
+11 -3
View File
@@ -175,8 +175,9 @@ class Mem0MemoryStore:
response = await provider.chat(
messages=extraction_messages,
model=model,
max_tokens=2000,
max_tokens=16384,
temperature=0.3,
thinking_budget=0,
)
text = (response.content or "").strip()
if text.startswith("```"):
@@ -211,16 +212,23 @@ class Mem0MemoryStore:
stored = 0
for fact in facts:
# Normalize: LLM may return dicts like {"fact": "...", "date": "..."} or plain strings
if isinstance(fact, dict):
fact_text = fact.get("fact", fact.get("text", str(fact)))
else:
fact_text = str(fact)
if not fact_text.strip():
continue
try:
self.memory.add(
fact,
fact_text,
user_id=user_id,
infer=False,
metadata=metadata if metadata else None,
)
stored += 1
except Exception as e:
logger.error(f"Failed to store fact '{fact[:50]}...': {e}")
logger.error(f"Failed to store fact '{str(fact_text)[:50]}...': {e}")
logger.info(f"Stored {stored}/{len(facts)} facts for user {user_id}")
+12 -13
View File
@@ -4,11 +4,19 @@
import hmac
import hashlib
import re
from typing import Tuple
SECRET_KEY = "nanobot_visibility_secret_key_v1"
def compute_signature(content: str) -> str:
"""Compute HMAC signature for content (hex string, no prefix)."""
return hmac.new(
SECRET_KEY.encode(),
content.encode(),
hashlib.sha256
).hexdigest()[:8]
def sign_content(content: str) -> str:
"""
Sign content with HMAC and prepend marker.
@@ -19,15 +27,11 @@ def sign_content(content: str) -> str:
Returns:
Content with signed visibility marker: "[HIDDEN:{sig}] {content}"
"""
sig = hmac.new(
SECRET_KEY.encode(),
content.encode(),
hashlib.sha256
).hexdigest()[:8]
sig = compute_signature(content)
return f"[HIDDEN:{sig}] {content}"
def verify_signature(marked_content: str) -> Tuple[bool, str]:
def verify_signature(marked_content: str) -> tuple[bool, str]:
"""
Verify HMAC signature and extract clean content.
@@ -44,12 +48,7 @@ def verify_signature(marked_content: str) -> Tuple[bool, str]:
return False, marked_content
claimed_sig, content = match.groups()
expected_sig = hmac.new(
SECRET_KEY.encode(),
content.encode(),
hashlib.sha256
).hexdigest()[:8]
expected_sig = compute_signature(content)
is_valid = hmac.compare_digest(claimed_sig, expected_sig)
return is_valid, content
+1 -1
View File
@@ -220,7 +220,7 @@ class AgentDefaults(Base):
"""Default agent configuration."""
workspace: str = "~/.nanobot/workspace"
model: str = "anthropic/claude-opus-4-5"
model: str = "anthropic/claude-opus-4-7"
provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
max_tokens: int = 8192
temperature: float = 0.1
+1 -1
View File
@@ -1,6 +1,6 @@
"""Provider module exports."""
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.providers.base import LLMProvider, LLMResponse, LongContextError, ToolCallRequest
from nanobot.providers.litellm_provider import LiteLLMProvider
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
+121 -68
View File
@@ -10,8 +10,8 @@ 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
from nanobot.providers.base import LLMProvider, LLMResponse, LongContextError, ToolCallRequest
from nanobot.providers.oauth_utils import get_auth_headers, get_claude_code_system_prefix
class AnthropicOAuthProvider(LLMProvider):
@@ -27,7 +27,7 @@ class AnthropicOAuthProvider(LLMProvider):
def __init__(
self,
oauth_token: str,
default_model: str = "claude-opus-4-5",
default_model: str = "claude-opus-4-7",
api_base: str | None = None,
thinking_budget: int = 0,
):
@@ -51,8 +51,8 @@ class AnthropicOAuthProvider(LLMProvider):
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.
Anthropic model IDs use hyphens (claude-sonnet-4-6), but users often
write dots (claude-sonnet-4.6). Normalize so both work.
"""
return model.replace(".", "-")
@@ -377,7 +377,14 @@ class AnthropicOAuthProvider(LLMProvider):
payload["temperature"] = temperature
if system:
payload["system"] = [{"type": "text", "text": system, "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
payload["system"] = [
{"type": "text", "text": get_claude_code_system_prefix()},
{"type": "text", "text": system, "cache_control": {"type": "ephemeral", "ttl": "1h"}},
]
else:
payload["system"] = [
{"type": "text", "text": get_claude_code_system_prefix()},
]
if tools:
cached_tools = list(tools)
@@ -424,70 +431,114 @@ class AnthropicOAuthProvider(LLMProvider):
import asyncio
import time as _time
_t0 = _time.monotonic()
try:
response = await client.post(
self._get_api_url(),
headers=headers,
json=payload,
)
except httpx.ConnectTimeout:
elapsed = _time.monotonic() - _t0
logger.error(f"ConnectTimeout after {elapsed:.1f}s — running diagnostics")
await self._diagnose_connectivity()
await self._reset_client()
raise
except httpx.PoolTimeout:
elapsed = _time.monotonic() - _t0
logger.error(f"PoolTimeout after {elapsed:.1f}s — resetting client")
await self._reset_client()
raise
except (httpx.ConnectError, httpx.TimeoutException) as e:
elapsed = _time.monotonic() - _t0
logger.error(f"{type(e).__name__} after {elapsed:.1f}s")
raise
elapsed = _time.monotonic() - _t0
if elapsed > 30:
logger.warning(f"Anthropic API slow response: {elapsed:.1f}s")
# Dump rate limit headers for analysis
try:
import datetime
import os
header_dump = {
"timestamp": datetime.datetime.now(datetime.UTC).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)
max_retries = 3
base_delay = 2.0 # seconds
if response.status_code != 200:
error_text = response.text
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")
for attempt in range(max_retries + 1):
_t0 = _time.monotonic()
try:
response = await client.post(
self._get_api_url(),
headers=headers,
json=payload,
)
except httpx.ConnectTimeout:
elapsed = _time.monotonic() - _t0
logger.error(f"ConnectTimeout after {elapsed:.1f}s (attempt {attempt+1}/{max_retries+1})")
if attempt == 0:
await self._diagnose_connectivity()
await self._reset_client()
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
logger.info(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
continue
raise
except httpx.PoolTimeout:
elapsed = _time.monotonic() - _t0
logger.error(f"PoolTimeout after {elapsed:.1f}s (attempt {attempt+1}/{max_retries+1})")
await self._reset_client()
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
logger.info(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
continue
raise
except (httpx.ConnectError, httpx.TimeoutException) as e:
elapsed = _time.monotonic() - _t0
logger.error(f"{type(e).__name__} after {elapsed:.1f}s (attempt {attempt+1}/{max_retries+1})")
if attempt < max_retries:
delay = base_delay * (2 ** attempt)
logger.info(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
continue
raise
elapsed = _time.monotonic() - _t0
if elapsed > 30:
logger.warning(f"Anthropic API slow response: {elapsed:.1f}s")
return response.json()
# Dump rate limit headers for analysis
try:
import datetime
import os
header_dump = {
"timestamp": datetime.datetime.now(datetime.UTC).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)
# Retry on 5xx server errors and 429 rate limits
if response.status_code >= 500 or response.status_code == 429:
error_text = response.text
logger.warning(f"Anthropic API {response.status_code} (attempt {attempt+1}/{max_retries+1}): {error_text[:200]}")
# Long context 429 — retrying won't help, need to trim context
if response.status_code == 429 and "long context" in error_text.lower():
raise LongContextError(f"Context too long for current plan: {error_text[:200]}")
if attempt < max_retries:
if response.status_code == 429:
retry_after = response.headers.get("retry-after")
delay = float(retry_after) if retry_after else base_delay * (2 ** attempt)
else:
delay = base_delay * (2 ** attempt)
logger.info(f"Retrying in {delay:.1f}s...")
await asyncio.sleep(delay)
continue
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")
if response.status_code != 200:
error_text = response.text
raise Exception(f"Anthropic API error {response.status_code}: {error_text}")
return response.json()
# Should not reach here, but just in case
raise Exception("Exhausted all retry attempts")
async def chat(
self,
@@ -506,7 +557,7 @@ class AnthropicOAuthProvider(LLMProvider):
if "/" in model:
model = model.split("/")[-1]
# Normalize dots to hyphens (claude-sonnet-4.5 -> claude-sonnet-4-5)
# Normalize dots to hyphens (claude-sonnet-4.6 -> claude-sonnet-4-6)
model = self._normalize_model(model)
system, prepared_messages = self._prepare_messages(messages)
@@ -539,6 +590,8 @@ class AnthropicOAuthProvider(LLMProvider):
beta_flags=beta_flags,
)
return self._parse_response(response)
except LongContextError:
raise # Let caller handle context trimming
except Exception as e:
logger.exception("Exception in chat():")
error_msg = f"{type(e).__name__}: {str(e)}" if str(e) else f"{type(e).__name__} (no message)"
+5
View File
@@ -28,6 +28,11 @@ class LLMResponse:
return len(self.tool_calls) > 0
class LongContextError(Exception):
"""Raised when the API rejects a request due to long context limits."""
pass
class LLMProvider(ABC):
"""
Abstract base class for LLM providers.
+2 -2
View File
@@ -37,7 +37,7 @@ class LiteLLMProvider(LLMProvider):
self,
api_key: str | None = None,
api_base: str | None = None,
default_model: str = "anthropic/claude-opus-4-5",
default_model: str = "anthropic/claude-opus-4-7",
extra_headers: dict[str, str] | None = None,
provider_name: str | None = None,
):
@@ -187,7 +187,7 @@ class LiteLLMProvider(LLMProvider):
Args:
messages: List of message dicts with 'role' and 'content'.
tools: Optional list of tool definitions in OpenAI format.
model: Model identifier (e.g., 'anthropic/claude-sonnet-4-5').
model: Model identifier (e.g., 'anthropic/claude-sonnet-4-6').
max_tokens: Maximum tokens in response.
temperature: Sampling temperature.
+8
View File
@@ -36,3 +36,11 @@ 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 a Claude agent, built on Anthropic's Claude Agent SDK."
+32 -4
View File
@@ -59,13 +59,22 @@ class Session:
trimming old tool chains safely at token thresholds, so we send the full
history and let the server decide what to drop.
Messages with ``_hidden_sig`` get a ``[HIDDEN:{sig}]`` prefix applied to
their content so the model knows the user never saw them. The prefix is
applied at read time (not stored in content) to preserve prompt-cache
stability: the same prefixed string is produced every turn.
Returns:
List of messages in LLM format (API-relevant fields only).
"""
return [
{k: v for k, v in m.items() if k in self._API_FIELDS and v is not None}
for m in self.messages
]
out: list[dict[str, Any]] = []
for m in self.messages:
msg = {k: v for k, v in m.items() if k in self._API_FIELDS and v is not None}
sig = m.get("_hidden_sig")
if sig and isinstance(msg.get("content"), str):
msg["content"] = f"[HIDDEN:{sig}] {msg['content']}"
out.append(msg)
return out
def clear(self) -> None:
"""Clear all messages and reset session to initial state."""
@@ -178,6 +187,25 @@ class SessionManager:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
self._cache[session.key] = session
self._append_audit(session)
def _append_audit(self, session: Session) -> None:
"""Append session state to an audit log (append-only, rotated monthly)."""
now = datetime.now()
safe_key = safe_filename(session.key.replace(":", "_"))
audit_path = self.sessions_dir / f"{safe_key}.audit.{now:%Y-%m}.jsonl"
try:
with open(audit_path, "a", encoding="utf-8") as f:
marker = {
"_type": "save_marker",
"timestamp": now.isoformat(),
"message_count": len(session.messages),
}
f.write(json.dumps(marker, ensure_ascii=False) + "\n")
for msg in session.messages:
f.write(json.dumps(msg, ensure_ascii=False) + "\n")
except Exception as e:
logger.warning("Audit log write failed for {}: {}", session.key, e)
def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache."""
+2 -2
View File
@@ -10,14 +10,14 @@ def provider():
"""Create provider with test OAuth token."""
return AnthropicOAuthProvider(
oauth_token="sk-ant-oat01-test-token",
default_model="claude-opus-4-5"
default_model="claude-opus-4-7"
)
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"
assert provider.default_model == "claude-opus-4-7"
def test_provider_uses_bearer_auth(provider):
+1 -1
View File
@@ -13,7 +13,7 @@ def test_oauth_token_injected_into_config(tmp_path, monkeypatch):
# 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"}},
"agents": {"defaults": {"model": "anthropic/claude-opus-4-7"}},
"providers": {"anthropic": {"apiKey": ""}}
}))
+78
View File
@@ -0,0 +1,78 @@
"""Test auto-consolidation on long context 429 errors."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from nanobot.providers.base import LongContextError, LLMResponse
def test_long_context_error_is_exception():
"""LongContextError should be a distinct exception class."""
err = LongContextError("too long")
assert isinstance(err, Exception)
assert str(err) == "too long"
@pytest.mark.asyncio
async def test_provider_raises_long_context_error_on_long_context_429():
"""Provider should raise LongContextError immediately for long-context 429s."""
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
provider = AnthropicOAuthProvider(
oauth_token="sk-ant-oat01-test-token",
default_model="claude-sonnet-4-6",
)
mock_response = MagicMock()
mock_response.status_code = 429
mock_response.text = '{"type":"error","error":{"type":"rate_limit_error","message":"Extra usage is required for long context requests."}}'
mock_response.headers = {}
mock_client = AsyncMock()
mock_client.post.return_value = mock_response
provider._client = mock_client
with pytest.raises(LongContextError, match="Context too long"):
await provider._make_request(
messages=[{"role": "user", "content": "hello"}],
)
# Should NOT retry — only one call
assert mock_client.post.call_count == 1
@pytest.mark.asyncio
async def test_provider_retries_normal_429():
"""Provider should still retry normal 429s (not long-context)."""
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
provider = AnthropicOAuthProvider(
oauth_token="sk-ant-oat01-test-token",
default_model="claude-sonnet-4-6",
)
rate_limit_response = MagicMock()
rate_limit_response.status_code = 429
rate_limit_response.text = '{"type":"error","error":{"type":"rate_limit_error","message":"Rate limit exceeded"}}'
rate_limit_response.headers = {}
success_response = MagicMock()
success_response.status_code = 200
success_response.headers = {}
success_response.json.return_value = {
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1},
}
mock_client = AsyncMock()
mock_client.post.side_effect = [rate_limit_response, success_response]
provider._client = mock_client
result = await provider._make_request(
messages=[{"role": "user", "content": "hello"}],
)
# Should have retried and succeeded
assert mock_client.post.call_count == 2
assert result["stop_reason"] == "end_turn"
+129
View File
@@ -0,0 +1,129 @@
"""Test mem0 fact extraction calls provider with thinking disabled."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from pathlib import Path
from nanobot.providers.base import LLMResponse
@pytest.fixture
def mock_provider():
provider = AsyncMock()
provider.chat = AsyncMock(return_value=LLMResponse(
content='{"facts": ["user likes Python", "user works on nanobot"]}',
finish_reason="end_turn",
))
return provider
@pytest.fixture
def mem0_store(tmp_path):
"""Create a Mem0MemoryStore with mocked mem0 dependency."""
# We can't import Mem0MemoryStore at module level because it requires
# the mem0 package. Instead, we test extract_facts as a standalone method
# by constructing a minimal instance.
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
store = Mem0MemoryStore(workspace=tmp_path)
return store
except ImportError:
pytest.skip("mem0 not installed")
@pytest.mark.asyncio
async def test_extract_facts_passes_thinking_budget_zero(mock_provider):
"""extract_facts must pass thinking_budget=0 to provider.chat().
Without this, the provider inherits its instance default (e.g. 10000),
causing the model to spend tokens on thinking instead of outputting JSON.
"""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
# Create a minimal instance without full mem0 init
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [
{"role": "user", "content": "I like Python programming"},
{"role": "assistant", "content": "That's great! Python is versatile."},
]
facts = await store.extract_facts(messages, mock_provider, "claude-sonnet-4-6")
# Verify provider.chat was called with thinking_budget=0
mock_provider.chat.assert_called_once()
call_kwargs = mock_provider.chat.call_args.kwargs
assert call_kwargs["thinking_budget"] == 0
@pytest.mark.asyncio
async def test_extract_facts_returns_parsed_facts(mock_provider):
"""extract_facts should parse JSON response into a list of fact strings."""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [
{"role": "user", "content": "I like Python programming"},
]
facts = await store.extract_facts(messages, mock_provider, "claude-sonnet-4-6")
assert facts == ["user likes Python", "user works on nanobot"]
@pytest.mark.asyncio
async def test_extract_facts_handles_empty_response():
"""extract_facts should return empty list when provider returns no content."""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
provider = AsyncMock()
provider.chat = AsyncMock(return_value=LLMResponse(
content="",
finish_reason="end_turn",
))
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [{"role": "user", "content": "Hello there"}]
facts = await store.extract_facts(messages, provider, "claude-sonnet-4-6")
assert facts == []
@pytest.mark.asyncio
async def test_extract_facts_skips_empty_messages():
"""extract_facts should return empty list when all messages have empty content."""
try:
from nanobot.agent.memory_mem0 import Mem0MemoryStore
except ImportError:
pytest.skip("mem0 not installed")
provider = AsyncMock()
store = object.__new__(Mem0MemoryStore)
store.custom_prompt = "Extract facts as JSON: "
messages = [
{"role": "user", "content": ""},
{"role": "assistant", "content": ""},
]
facts = await store.extract_facts(messages, provider, "claude-sonnet-4-6")
assert facts == []
# Provider should not be called when there's no content
provider.chat.assert_not_called()
+153
View File
@@ -0,0 +1,153 @@
"""Tests for message visibility signing (hidden intermediate messages)."""
import json
from pathlib import Path
from nanobot.agent.context import ContextBuilder
from nanobot.agent.visibility import compute_signature, sign_content
from nanobot.session.manager import Session
class TestComputeSignature:
"""Tests for compute_signature()."""
def test_returns_8_char_hex(self):
sig = compute_signature("hello")
assert len(sig) == 8
assert all(c in "0123456789abcdef" for c in sig)
def test_deterministic(self):
assert compute_signature("hello") == compute_signature("hello")
def test_different_content_different_sig(self):
assert compute_signature("hello") != compute_signature("world")
def test_sign_content_uses_compute_signature(self):
"""sign_content should produce [HIDDEN:{compute_signature(content)}] prefix."""
content = "test message"
sig = compute_signature(content)
assert sign_content(content) == f"[HIDDEN:{sig}] {content}"
class TestAddAssistantMessage:
"""Tests for _hidden_sig in add_assistant_message()."""
def setup_method(self):
self.ctx = ContextBuilder(Path("/tmp"))
def test_intermediate_message_gets_hidden_sig(self):
msgs: list = []
tool_calls = [{"id": "tc1", "type": "function", "function": {"name": "test", "arguments": "{}"}}]
self.ctx.add_assistant_message(msgs, "thinking...", tool_calls)
assert msgs[0].get("_hidden_sig") is not None
assert msgs[0]["_hidden_sig"] == compute_signature("thinking...")
def test_final_message_no_hidden_sig(self):
msgs: list = []
self.ctx.add_assistant_message(msgs, "Here is the answer", None)
assert "_hidden_sig" not in msgs[0]
def test_empty_content_signed(self):
msgs: list = []
tool_calls = [{"id": "tc1", "type": "function", "function": {"name": "test", "arguments": "{}"}}]
self.ctx.add_assistant_message(msgs, None, tool_calls)
assert msgs[0]["_hidden_sig"] == compute_signature("")
class TestAddToolResult:
"""Tests for _hidden_sig in add_tool_result()."""
def setup_method(self):
self.ctx = ContextBuilder(Path("/tmp"))
def test_tool_result_gets_hidden_sig(self):
msgs: list = []
self.ctx.add_tool_result(msgs, "tc1", "read_file", "file contents here")
assert msgs[0]["_hidden_sig"] == compute_signature("file contents here")
def test_tool_result_non_string_content(self):
msgs: list = []
# Multipart content (e.g. image) is a list, not a string
self.ctx.add_tool_result(msgs, "tc1", "screenshot", [{"type": "text", "text": "ok"}])
assert msgs[0]["_hidden_sig"] == compute_signature("")
class TestGetHistoryPrefix:
"""Tests for get_history() applying [HIDDEN:sig] prefix."""
def test_hidden_sig_applied_at_read_time(self):
session = Session(key="test")
sig = compute_signature("thinking...")
session.messages = [
{"role": "assistant", "content": "thinking...", "tool_calls": [{}], "_hidden_sig": sig},
]
history = session.get_history()
assert history[0]["content"] == f"[HIDDEN:{sig}] thinking..."
assert "_hidden_sig" not in history[0]
def test_no_prefix_without_hidden_sig(self):
session = Session(key="test")
session.messages = [
{"role": "assistant", "content": "Here is the answer"},
]
history = session.get_history()
assert history[0]["content"] == "Here is the answer"
def test_tool_result_gets_prefix(self):
session = Session(key="test")
sig = compute_signature("file contents")
session.messages = [
{"role": "tool", "tool_call_id": "tc1", "name": "read", "content": "file contents", "_hidden_sig": sig},
]
history = session.get_history()
assert history[0]["content"] == f"[HIDDEN:{sig}] file contents"
def test_roundtrip_jsonl(self, tmp_path):
"""Write to session JSONL, reload, verify get_history() produces correct prefix."""
from nanobot.session.manager import SessionManager
workspace = tmp_path / "workspace"
workspace.mkdir()
mgr = SessionManager(workspace)
session = mgr.get_or_create("test:roundtrip")
sig = compute_signature("intermediate")
session.add_raw_message({
"role": "assistant",
"content": "intermediate",
"tool_calls": [{"id": "tc1", "type": "function", "function": {"name": "x", "arguments": "{}"}}],
"_hidden_sig": sig,
})
session.add_raw_message({
"role": "assistant",
"content": "final answer",
})
mgr.save(session)
# Reload from disk
mgr.invalidate("test:roundtrip")
reloaded = mgr.get_or_create("test:roundtrip")
history = reloaded.get_history()
assert history[0]["content"] == f"[HIDDEN:{sig}] intermediate"
assert history[1]["content"] == "final answer"
def test_idempotent_across_calls(self):
"""Same prefix produced every call (cache stability)."""
session = Session(key="test")
sig = compute_signature("msg")
session.messages = [
{"role": "assistant", "content": "msg", "_hidden_sig": sig},
]
h1 = session.get_history()
h2 = session.get_history()
assert h1[0]["content"] == h2[0]["content"]
+102
View File
@@ -0,0 +1,102 @@
"""Test that the Anthropic OAuth identity block is always included in API requests."""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from nanobot.providers.anthropic_oauth import AnthropicOAuthProvider
from nanobot.providers.oauth_utils import get_claude_code_system_prefix
IDENTITY_TEXT = get_claude_code_system_prefix()
@pytest.fixture
def provider():
return AnthropicOAuthProvider(
oauth_token="sk-ant-oat01-test-token",
default_model="claude-opus-4-7",
)
def _mock_response(status_code=200, json_data=None):
"""Create a mock httpx.Response."""
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
resp.headers = {}
resp.text = ""
resp.json.return_value = json_data or {
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 1, "output_tokens": 1},
}
return resp
@pytest.mark.asyncio
async def test_identity_block_present_with_system_prompt(provider):
"""When a system prompt is provided, identity block is the first system block."""
mock_client = AsyncMock()
mock_client.post.return_value = _mock_response()
provider._client = mock_client
await provider._make_request(
messages=[{"role": "user", "content": "hello"}],
system="You are a helpful assistant.",
)
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs["json"] if "json" in call_kwargs.kwargs else call_kwargs[1]["json"]
system_blocks = payload["system"]
assert len(system_blocks) == 2
assert system_blocks[0]["type"] == "text"
assert system_blocks[0]["text"] == IDENTITY_TEXT
assert system_blocks[1]["text"] == "You are a helpful assistant."
@pytest.mark.asyncio
async def test_identity_block_present_without_system_prompt(provider):
"""When no system prompt is provided, identity block is still included.
This is the critical fix: extract_facts and similar calls pass system=None,
but Anthropic requires the identity block for OAuth tokens.
"""
mock_client = AsyncMock()
mock_client.post.return_value = _mock_response()
provider._client = mock_client
await provider._make_request(
messages=[{"role": "user", "content": "extract facts"}],
system=None,
)
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs["json"] if "json" in call_kwargs.kwargs else call_kwargs[1]["json"]
system_blocks = payload["system"]
assert len(system_blocks) == 1
assert system_blocks[0]["type"] == "text"
assert system_blocks[0]["text"] == IDENTITY_TEXT
@pytest.mark.asyncio
async def test_identity_block_present_with_empty_string_system(provider):
"""Empty string system prompt should still include the identity block."""
mock_client = AsyncMock()
mock_client.post.return_value = _mock_response()
provider._client = mock_client
await provider._make_request(
messages=[{"role": "user", "content": "hello"}],
system="",
)
call_kwargs = mock_client.post.call_args
payload = call_kwargs.kwargs["json"] if "json" in call_kwargs.kwargs else call_kwargs[1]["json"]
system_blocks = payload["system"]
# Empty string is falsy, so should go through the else branch
assert len(system_blocks) == 1
assert system_blocks[0]["text"] == IDENTITY_TEXT
+3 -3
View File
@@ -9,7 +9,7 @@ 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"
model="anthropic/claude-opus-4-7"
)
assert isinstance(provider, AnthropicOAuthProvider)
@@ -18,7 +18,7 @@ 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"
model="anthropic/claude-opus-4-7"
)
assert isinstance(provider, LiteLLMProvider)
@@ -27,6 +27,6 @@ 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"
model="anthropic/claude-opus-4-7"
)
assert isinstance(provider, LiteLLMProvider)
+3 -3
View File
@@ -5,14 +5,14 @@ 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", "anthropic/claude-opus-4-7") 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
assert should_use_oauth_provider("sk-ant-api03-xxx", "claude-opus-4-7") is False
assert should_use_oauth_provider("sk-or-v1-xxx", "anthropic/claude-opus-4-7") is False
def test_should_not_use_oauth_for_non_anthropic():
+137
View File
@@ -0,0 +1,137 @@
"""Test SessionManager audit log functionality."""
import json
import pytest
from nanobot.session.manager import Session, SessionManager
@pytest.fixture
def session_manager(tmp_path):
return SessionManager(workspace=tmp_path)
@pytest.fixture
def session():
s = Session(key="telegram:12345")
s.add_message("user", "Hello")
s.add_message("assistant", "Hi there!")
return s
def test_save_creates_audit_file(session_manager, session):
"""SessionManager.save() should create a monthly audit log file."""
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
assert len(audit_files) == 1
assert "telegram_12345.audit." in audit_files[0].name
def test_audit_file_contains_save_marker(session_manager, session):
"""Audit log should start with a save_marker line containing metadata."""
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
lines = audit_files[0].read_text().strip().split("\n")
marker = json.loads(lines[0])
assert marker["_type"] == "save_marker"
assert marker["message_count"] == 2
assert "timestamp" in marker
def test_audit_file_contains_all_messages(session_manager, session):
"""Audit log should contain all session messages after the save marker."""
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
lines = audit_files[0].read_text().strip().split("\n")
# Line 0 = save_marker, lines 1-2 = messages
assert len(lines) == 3
msg1 = json.loads(lines[1])
msg2 = json.loads(lines[2])
assert msg1["role"] == "user"
assert msg1["content"] == "Hello"
assert msg2["role"] == "assistant"
assert msg2["content"] == "Hi there!"
def test_audit_file_is_append_only(session_manager, session):
"""Multiple saves should append to the same audit file, not overwrite."""
session_manager.save(session)
# Add another message and save again
session.add_message("user", "How are you?")
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
assert len(audit_files) == 1 # Same file
lines = audit_files[0].read_text().strip().split("\n")
# First save: 1 marker + 2 messages = 3 lines
# Second save: 1 marker + 3 messages = 4 lines
# Total: 7 lines
assert len(lines) == 7
# Both save markers present
markers = [json.loads(l) for l in lines if json.loads(l).get("_type") == "save_marker"]
assert len(markers) == 2
assert markers[0]["message_count"] == 2
assert markers[1]["message_count"] == 3
def test_audit_preserves_message_fields(session_manager):
"""Audit log should preserve all message fields including reasoning_content."""
session = Session(key="test:preserve")
session.add_raw_message({
"role": "assistant",
"content": "thinking response",
"reasoning_content": [{"type": "thinking", "thinking": "deep thoughts"}],
"timestamp": "2026-03-22T12:00:00",
})
session_manager.save(session)
audit_files = list(session_manager.sessions_dir.glob("*.audit.*.jsonl"))
lines = audit_files[0].read_text().strip().split("\n")
msg = json.loads(lines[1])
assert msg["reasoning_content"] == [{"type": "thinking", "thinking": "deep thoughts"}]
def test_audit_failure_does_not_break_save(session_manager, session, tmp_path):
"""If audit logging fails, the main session save should still succeed.
_append_audit has its own try/except, so internal failures are caught.
We simulate a realistic failure by making the sessions dir read-only
for audit file creation.
"""
# First save works (creates both session file and audit file)
session_manager.save(session)
path = session_manager._get_session_path(session.key)
assert path.exists()
# Remove audit files and make a blocking file at the audit path
# so the next audit open("a") fails
for af in session_manager.sessions_dir.glob("*.audit.*.jsonl"):
af.unlink()
# Create a directory where the audit file should be — open() will fail
from datetime import datetime
now = datetime.now()
bad_path = session_manager.sessions_dir / f"telegram_12345.audit.{now:%Y-%m}.jsonl"
bad_path.mkdir()
# Second save should succeed despite audit failure
session.add_message("user", "another message")
session_manager.save(session)
# Session file should still be written correctly
with open(path) as f:
first_line = json.loads(f.readline())
assert first_line["_type"] == "metadata"