diff --git a/.beads/dolt-monitor.pid b/.beads/dolt-monitor.pid index 1cfe9a3..35a9cc2 100644 --- a/.beads/dolt-monitor.pid +++ b/.beads/dolt-monitor.pid @@ -1 +1 @@ -80236 \ No newline at end of file +57728 \ No newline at end of file diff --git a/.beads/dolt-server.activity b/.beads/dolt-server.activity index 9522481..cb907c4 100644 --- a/.beads/dolt-server.activity +++ b/.beads/dolt-server.activity @@ -1 +1 @@ -1772417621 \ No newline at end of file +1772420411 \ No newline at end of file diff --git a/.claude/commands/conductor-implement.md b/.claude/commands/conductor-implement.md index 226f1c1..e97056d 100644 --- a/.claude/commands/conductor-implement.md +++ b/.claude/commands/conductor-implement.md @@ -84,7 +84,7 @@ git notes add -m "{task_id} — {summary of changes} — {files changed}" $sha ### 8. Mark Done ```powershell cd C:\projects\rook -bd update --status done +bd update --status closed ``` ### 9. Next Task or Phase Verification diff --git a/.gitignore b/.gitignore index dc7a30c..9f32929 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,11 @@ # Dolt database files (added by bd init) .dolt/ *.db + +# Python +__pycache__/ +*.pyc +.coverage +.venv/ +*.egg-info/ +dist/ diff --git a/CLAUDE.md b/CLAUDE.md index ade3335..fbd7ab6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ Beads replaces `plan.md`. Same discipline, different storage: cd C:\projects\rook bd ready --json # find unblocked tasks (start here) bd update --claim # mark in-progress before starting -bd update --status done # mark complete after commit + git note +bd update --status closed # mark complete after commit + git note bd create --title "..." --description "..." # create a new task bd dep add # link dependency 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 "" ``` -8. **Mark done**: `bd update --status done` +8. **Mark done**: `bd update --status closed` ### Worker Prompt Rules diff --git a/src/rook/agent.py b/src/rook/agent.py new file mode 100644 index 0000000..15c77b5 --- /dev/null +++ b/src/rook/agent.py @@ -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 diff --git a/tests/test_agent.py b/tests/test_agent.py new file mode 100644 index 0000000..32bd09c --- /dev/null +++ b/tests/test_agent.py @@ -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)