Skip to content

Commit 075e1ff

Browse files
authored
feat: add Claude Code bot (#105)
1 parent 8461d8f commit 075e1ff

File tree

14 files changed

+640
-45
lines changed

14 files changed

+640
-45
lines changed

poetry.lock

Lines changed: 486 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
]
1717

1818
[project.optional-dependencies]
19+
claude = ["claude-code-sdk (==0.0.22)"]
1920
openai = ["openai (>=1.64.0,<2)"]
2021

2122
[project.scripts]

src/git_draft/bots/claude_code.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Claude code bot implementations
2+
3+
Useful links:
4+
5+
* https://github.com/anthropics/claude-code
6+
* https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-python
7+
"""
8+
9+
from collections.abc import Mapping
10+
import dataclasses
11+
import logging
12+
from typing import Any
13+
14+
import claude_code_sdk as sdk
15+
16+
from ..common import UnreachableError, reindent
17+
from .common import ActionSummary, Bot, Goal, UserFeedback, Worktree
18+
19+
20+
_logger = logging.getLogger(__name__)
21+
22+
23+
def new_bot() -> Bot:
24+
return _Bot()
25+
26+
27+
_PROMPT_SUFFIX = reindent("""
28+
ALWAYS use the feedback's MCP server ask_user tool if you need to request
29+
any information from the user. NEVER repeat yourself by also asking your
30+
question to the user in other ways.
31+
""")
32+
33+
34+
class _Bot(Bot):
35+
def __init__(self) -> None:
36+
self._options = sdk.ClaudeCodeOptions(
37+
allowed_tools=["Read", "Write", "mcp__feedback__ask_user"],
38+
permission_mode="bypassPermissions", # TODO: Tighten
39+
append_system_prompt=_PROMPT_SUFFIX,
40+
)
41+
42+
async def act(
43+
self, goal: Goal, tree: Worktree, feedback: UserFeedback
44+
) -> ActionSummary:
45+
summary = ActionSummary()
46+
with tree.edit_files() as tree_path:
47+
options = dataclasses.replace(
48+
self._options,
49+
cwd=tree_path,
50+
mcp_servers={"feedback": _feedback_mcp_server(feedback)},
51+
)
52+
async with sdk.ClaudeSDKClient(options) as client:
53+
await client.query(goal.prompt)
54+
async for msg in client.receive_response():
55+
_logger.debug("SDK message: %s", msg)
56+
match msg:
57+
case sdk.UserMessage(content):
58+
_notify(feedback, content)
59+
case sdk.AssistantMessage(content, _):
60+
_notify(feedback, content)
61+
case sdk.ResultMessage() as message:
62+
# This message's result appears to be identical to
63+
# the last assistant message's content, so we do
64+
# not need to show it.
65+
summary.turn_count = message.num_turns
66+
summary.cost = message.total_cost_usd
67+
if usage := message.usage:
68+
summary.token_count = _token_count(usage)
69+
summary.usage_details = usage
70+
case sdk.SystemMessage():
71+
pass # TODO: Notify on tool usage?
72+
return summary
73+
74+
75+
def _token_count(usage: Mapping[str, Any]) -> int:
76+
return (
77+
usage["input_tokens"]
78+
+ usage["cache_creation_input_tokens"]
79+
+ usage["cache_read_input_tokens"]
80+
+ usage["output_tokens"]
81+
)
82+
83+
84+
def _notify(
85+
feedback: UserFeedback, content: str | list[sdk.ContentBlock]
86+
) -> None:
87+
if isinstance(content, str):
88+
feedback.notify(content)
89+
return
90+
91+
for block in content:
92+
match block:
93+
case sdk.TextBlock(text):
94+
feedback.notify(text)
95+
case sdk.ThinkingBlock(thinking, signature):
96+
feedback.notify(thinking)
97+
feedback.notify(signature)
98+
case sdk.ToolUseBlock() | sdk.ToolResultBlock() as block:
99+
_logger.debug("Using tool: %s", block)
100+
case _:
101+
raise UnreachableError()
102+
103+
104+
def _feedback_mcp_server(feedback: UserFeedback) -> sdk.McpServerConfig:
105+
@sdk.tool("ask_user", "Request feedback from the user", {"question": str})
106+
async def ask_user(args: Any) -> Any:
107+
question = args["question"]
108+
return {"content": [{"type": "text", "text": feedback.ask(question)}]}
109+
110+
return sdk.create_sdk_mcp_server(name="feedback", tools=[ask_user])

src/git_draft/bots/common.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from pathlib import Path, PurePosixPath
99
from typing import Protocol
1010

11-
from ..common import ensure_state_home, qualified_class_name
11+
from ..common import JSONObject, ensure_state_home, qualified_class_name
1212

1313

1414
@dataclasses.dataclass(frozen=True)
@@ -71,11 +71,13 @@ class ActionSummary:
7171
"""
7272

7373
title: str | None = None
74-
request_count: int | None = None
75-
token_count: int | None = None
74+
turn_count: int | None = None
75+
token_count: int | None = None # TODO: Split into input and output.
76+
cost: float | None = None
77+
usage_details: JSONObject | None = None # TODO: Use.
7678

77-
def increment_request_count(self, n: int = 1, init: bool = False) -> None:
78-
self._increment("request_count", n, init)
79+
def increment_turn_count(self, n: int = 1, init: bool = False) -> None:
80+
self._increment("turn_count", n, init)
7981

8082
def increment_token_count(self, n: int, init: bool = False) -> None:
8183
self._increment("token_count", n, init)

src/git_draft/bots/openai_api/__init__.py

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,8 @@
11
"""OpenAI API-backed bots
22
33
They can be used with services other than OpenAI as long as them implement a
4-
sufficient subset of the API. For example the `completions_bot` only requires
5-
tools support.
6-
7-
See the following links for more resources:
8-
9-
* https://platform.openai.com/docs/assistants/tools/function-calling
10-
* https://platform.openai.com/docs/assistants/deep-dive#runs-and-run-steps
11-
* https://platform.openai.com/docs/api-reference/assistants-streaming/events
12-
* https://github.com/openai/openai-python/blob/main/src/openai/resources/beta/threads/runs/runs.py
4+
sufficient subset of the API. For example the `new_completions_bot` only
5+
requires tools support.
136
"""
147

158
from .assistants import new_threads_bot

src/git_draft/bots/openai_api/assistants.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
33
Note that this API is (will soon?) be deprecated in favor of the responses API.
44
It does not support the gpt-5 series of models.
5+
6+
* https://platform.openai.com/docs/assistants/tools/function-calling
7+
* https://platform.openai.com/docs/assistants/deep-dive#runs-and-run-steps
8+
* https://platform.openai.com/docs/api-reference/assistants-streaming/events
9+
* https://github.com/openai/openai-python/blob/main/src/openai/resources/beta/threads/runs/runs.py
510
"""
611

712
from collections.abc import Sequence
@@ -66,7 +71,7 @@ async def act(
6671

6772
# We intentionally do not count the two requests above, to focus on
6873
# "data requests" only.
69-
action = ActionSummary(request_count=0, token_count=0)
74+
action = ActionSummary(turn_count=0, token_count=0)
7075
with self._client.beta.threads.runs.stream(
7176
thread_id=thread.id,
7277
assistant_id=assistant_id,
@@ -89,7 +94,7 @@ def __init__(
8994
self._tree = tree
9095
self._feedback = feedback
9196
self._action = action
92-
self._action.increment_request_count()
97+
self._action.increment_turn_count()
9398

9499
def _clone(self) -> Self:
95100
return self.__class__(

src/git_draft/bots/openai_api/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Shared OpenAPI abstractions"""
1+
"""Shared OpenAI abstractions"""
22

33
from collections.abc import Mapping, Sequence
44
import json

src/git_draft/bots/openai_api/completions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def act(
5959
if done:
6060
break
6161

62-
return ActionSummary(request_count=request_count)
62+
return ActionSummary(turn_count=request_count)
6363

6464

6565
class _CompletionsToolHandler(ToolHandler[str | None]):
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Responses API backed implementation
2+
3+
* https://platform.openai.com/docs/guides/function-calling?api-mode=responses
4+
"""
5+
6+
# TODO: Implement.

src/git_draft/drafter.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,11 @@ async def generate_draft(
157157

158158
# Ensure that we are in a folio.
159159
folio = _maybe_active_folio(self._repo)
160-
if not folio:
160+
if folio:
161+
self._progress.report(
162+
"Reusing active draft branch.", name=folio.branch_name()
163+
)
164+
else:
161165
folio = self._create_folio()
162166
with self._store.cursor() as cursor:
163167
[(prompt_id, seqno)] = cursor.execute(
@@ -183,6 +187,8 @@ async def generate_draft(
183187
"Completed bot run.",
184188
runtime=round(change.walltime.total_seconds(), 1),
185189
tokens=change.action.token_count,
190+
turns=change.action.turn_count,
191+
cost=change.action.cost,
186192
)
187193

188194
# Create git commits, references, and update branches.
@@ -218,7 +224,7 @@ async def generate_draft(
218224
"prompt_id": prompt_id,
219225
"bot_class": qualified_class_name(bot.__class__),
220226
"walltime_seconds": change.walltime.total_seconds(),
221-
"request_count": change.action.request_count,
227+
"turn_count": change.action.turn_count,
222228
"token_count": change.action.token_count,
223229
"pending_question": feedback.pending_question,
224230
},

0 commit comments

Comments
 (0)