feat(agent): core agent loop with asyncio, Claude API, tool dispatch, background thread

This commit is contained in:
2026-03-01 22:03:55 -05:00
parent b6927568a9
commit 5c74e6c2e6
7 changed files with 131 additions and 5 deletions

View File

@@ -1 +1 @@
80236 57728

View File

@@ -1 +1 @@
1772417621 1772420411

View File

@@ -84,7 +84,7 @@ git notes add -m "{task_id} — {summary of changes} — {files changed}" $sha
### 8. Mark Done ### 8. Mark Done
```powershell ```powershell
cd C:\projects\rook cd C:\projects\rook
bd update <id> --status done bd update <id> --status closed
``` ```
### 9. Next Task or Phase Verification ### 9. Next Task or Phase Verification

8
.gitignore vendored
View File

@@ -2,3 +2,11 @@
# Dolt database files (added by bd init) # Dolt database files (added by bd init)
.dolt/ .dolt/
*.db *.db
# Python
__pycache__/
*.pyc
.coverage
.venv/
*.egg-info/
dist/

View File

@@ -68,7 +68,7 @@ Beads replaces `plan.md`. Same discipline, different storage:
cd C:\projects\rook cd C:\projects\rook
bd ready --json # find unblocked tasks (start here) bd ready --json # find unblocked tasks (start here)
bd update <id> --claim # mark in-progress before starting bd update <id> --claim # mark in-progress before starting
bd update <id> --status done # mark complete after commit + git note bd update <id> --status closed # mark complete after commit + git note
bd create --title "..." --description "..." # create a new task bd create --title "..." --description "..." # create a new task
bd dep add <id> <depends-on-id> # link dependency bd dep add <id> <depends-on-id> # link dependency
bd list --json # list all tasks bd list --json # list all tasks
@@ -117,7 +117,7 @@ All work follows this strict lifecycle. **MMA tiered delegation is mandatory —
git notes add -m "<task id> — <summary of changes and why>" <sha> git notes add -m "<task id> — <summary of changes and why>" <sha>
``` ```
8. **Mark done**: `bd update <id> --status done` 8. **Mark done**: `bd update <id> --status closed`
### Worker Prompt Rules ### Worker Prompt Rules

54
src/rook/agent.py Normal file
View File

@@ -0,0 +1,54 @@
import asyncio
import threading
from typing import Any, Callable, Optional
import anthropic
PRIMARY_MODEL: str = 'claude-haiku-4-5-20251001'
FALLBACK_MODEL: str = 'claude-sonnet-4-6'
class AgentLoop:
def __init__(self, system: str, model: str = PRIMARY_MODEL) -> None:
self.model = model
self.system = system
self.history: list[dict] = []
self.tools: dict[str, Any] = {}
self._client = anthropic.Anthropic()
def register_tool(self, name: str, fn: Callable[..., Any]) -> None:
self.tools[name] = fn
async def send(self, user_message: str) -> str:
self.history.append({'role': 'user', 'content': user_message})
response = self._client.messages.create(
model=self.model,
system=self.system,
messages=self.history,
max_tokens=4096,
)
while any(block.type == 'tool_use' for block in response.content):
for block in response.content:
if block.type == 'tool_use':
result = self.tools[block.name](**block.input)
self.history.append({'role': 'assistant', 'content': response.content})
self.history.append({
'role': 'user',
'content': [{'type': 'tool_result', 'tool_use_id': block.id, 'content': str(result)}],
})
response = self._client.messages.create(
model=self.model,
system=self.system,
messages=self.history,
max_tokens=4096,
)
break
for block in response.content:
if block.type == 'text':
return block.text
return ''
def run_in_thread(self) -> threading.Thread:
self._loop = asyncio.new_event_loop()
t = threading.Thread(target=self._loop.run_forever, daemon=True)
t.start()
return t

64
tests/test_agent.py Normal file
View File

@@ -0,0 +1,64 @@
import asyncio
import pytest
from unittest.mock import MagicMock, patch
from rook.agent import AgentLoop, PRIMARY_MODEL, FALLBACK_MODEL
def test_agent_loop_init():
loop = AgentLoop(system='You are Rook.')
assert loop.model == PRIMARY_MODEL
assert loop.history == []
assert isinstance(loop.tools, dict)
def test_register_tool():
loop = AgentLoop(system='You are Rook.')
loop.register_tool('ping', lambda: 'pong')
assert 'ping' in loop.tools
def test_send_returns_string():
mock_text_block = MagicMock()
mock_text_block.type = 'text'
mock_text_block.text = 'Hello'
mock_response = MagicMock()
mock_response.content = [mock_text_block]
mock_client = MagicMock()
mock_client.messages.create.return_value = mock_response
with patch('anthropic.Anthropic', return_value=mock_client):
loop = AgentLoop(system='You are Rook.')
result = asyncio.run(loop.send('hi'))
assert result == 'Hello'
def test_tool_dispatch_called():
tool_use_block = MagicMock()
tool_use_block.type = 'tool_use'
tool_use_block.name = 'ping'
tool_use_block.id = 'tu_1'
tool_use_block.input = {}
first_response = MagicMock()
first_response.content = [tool_use_block]
text_block = MagicMock()
text_block.type = 'text'
text_block.text = 'done'
second_response = MagicMock()
second_response.content = [text_block]
mock_client = MagicMock()
mock_client.messages.create.side_effect = [first_response, second_response]
ping_fn = MagicMock(return_value='pong')
with patch('anthropic.Anthropic', return_value=mock_client):
loop = AgentLoop(system='You are Rook.')
loop.register_tool('ping', ping_fn)
result = asyncio.run(loop.send('call ping'))
ping_fn.assert_called_once()
assert result == 'done'
def test_run_in_thread():
loop = AgentLoop(system='You are Rook.')
t = loop.run_in_thread()
assert t.is_alive()
assert t.daemon == True
t.join(timeout=0.1)