feat(agent): core agent loop with asyncio, Claude API, tool dispatch, background thread
This commit is contained in:
@@ -1 +1 @@
|
|||||||
80236
|
57728
|
||||||
@@ -1 +1 @@
|
|||||||
1772417621
|
1772420411
|
||||||
@@ -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
8
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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
54
src/rook/agent.py
Normal 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
64
tests/test_agent.py
Normal 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)
|
||||||
Reference in New Issue
Block a user