Files
Timothy Jaeryang Baek 121bc0ec50 refac
2026-11-14 00:14:28 +09:00

145 lines
6.8 KiB
Python

"""Claude's native stream-json/control protocol; message handling adapted from Computer."""
import asyncio
import contextlib
import json
from uuid import uuid4
from . import reasoning_effort
async def run(transport, output, prompt, images, options):
async def send(message):
await transport.write(json.dumps(message) + '\n')
messages = transport.read_messages()
async def request(control):
request_id = uuid4().hex
await send({'type': 'control_request', 'request_id': request_id, 'request': control})
async with asyncio.timeout(60):
async for message in messages:
if (
message.get('type') == 'control_response'
and message.get('response', {}).get('request_id') == request_id
):
if message['response'].get('subtype') == 'error':
raise ValueError(message['response'].get('error', 'Claude request failed'))
return
raise ValueError('Claude ended before replying')
await request({'subtype': 'initialize'})
effort = reasoning_effort(options)
if effort is not None:
await request({'subtype': 'apply_flag_settings', 'settings': {'effortLevel': effort}})
content = [{'type': 'text', 'text': prompt}]
for image in images:
prefix, data = image.split(',', 1)
content.append(
{'type': 'image', 'source': {'type': 'base64', 'media_type': prefix[5:].split(';')[0], 'data': data}}
)
await send(
{
'type': 'user',
'message': {'role': 'user', 'content': content},
'parent_tool_use_id': None,
'session_id': options.get('resume') or 'default',
}
)
streamed_text = streamed_thinking = False
try:
async for message in messages:
kind = message.get('type')
if kind == 'system' and message.get('subtype') == 'init':
output.state['session_id'] = message['session_id']
await output.flush(force=True, persist=True)
elif kind == 'control_request':
request = message['request']
if request.get('subtype') != 'can_use_tool':
await send(
{
'type': 'control_response',
'response': {
'subtype': 'error',
'request_id': message['request_id'],
'error': 'Unsupported control request',
},
}
)
continue
name, arguments = request['tool_name'], request['input']
question = name == 'AskUserQuestion'
presented = arguments
if question:
presented = {
'questions': [
{**q, 'id': q.get('question', str(i))} for i, q in enumerate(arguments.get('questions', []))
]
}
resolution = await output.approve('ask_user' if question else name, presented, message)
if resolution['action'] == 'reject' or resolution.get('timed_out'):
response = {'behavior': 'deny', 'message': 'Rejected by the user'}
else:
answers = {
key: (value.get('text', '') if value.get('type') == 'other' else value.get('label', ''))
if isinstance(value, dict)
else value
for key, value in (resolution.get('answers') or {}).items()
}
updated = {**arguments, 'answers': answers} if question else arguments
response = {'behavior': 'allow', 'updatedInput': updated}
await send(
{
'type': 'control_response',
'response': {'subtype': 'success', 'request_id': message['request_id'], 'response': response},
}
)
elif kind == 'stream_event':
event = message.get('event') or {}
if event.get('type') == 'message_start':
streamed_text = streamed_thinking = False
if event.get('type') == 'content_block_delta':
delta = event.get('delta') or {}
if delta.get('type') == 'text_delta':
streamed_text = True
await output.text(delta.get('text', ''))
elif delta.get('type') == 'thinking_delta':
streamed_thinking = True
await output.text(delta.get('thinking', ''), reasoning=True)
elif kind == 'assistant':
for block in message.get('message', {}).get('content', []):
if block['type'] == 'text' and not streamed_text:
await output.text(block['text'])
elif block['type'] == 'thinking' and not streamed_thinking:
await output.text(block['thinking'], reasoning=True)
elif block['type'] == 'tool_use':
await output.tool(block['id'], block['name'], block['input'], native=block)
elif kind == 'user':
blocks = message.get('message', {}).get('content', [])
for block in blocks if isinstance(blocks, list) else []:
if block.get('type') == 'tool_result':
previous = next((i for i in output.items if i.get('call_id') == block['tool_use_id']), {})
await output.tool(
block['tool_use_id'],
previous.get('name', 'agent_tool'),
json.loads(previous.get('arguments', '{}')),
native=block,
result=block.get('content'),
done=True,
)
elif kind == 'result':
output.state['session_id'] = message['session_id']
output.usage = message.get('usage')
if message.get('is_error'):
raise ValueError(
message.get('result') or '; '.join(message.get('errors') or []) or 'Claude Code turn failed'
)
return
raise ValueError('Claude Code ended without a turn result')
except asyncio.CancelledError:
with contextlib.suppress(Exception):
await asyncio.wait_for(
send({'type': 'control_request', 'request_id': uuid4().hex, 'request': {'subtype': 'interrupt'}}), 5
)
raise