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

255 lines
12 KiB
Python

"""ACP v1 over the terminal's native JSONL carrier."""
import asyncio
import contextlib
import json
from . import reasoning_effort
class Client:
def __init__(self, transport, handle):
self.transport, self.handle = transport, handle
self.messages = transport.read_messages()
self.sequence = 0
async def send(self, message):
await self.transport.write(json.dumps({'jsonrpc': '2.0', **message}) + '\n')
async def request(self, method, params, timeout=60):
self.sequence += 1
request_id = self.sequence
async with asyncio.timeout(timeout):
await self.send({'id': request_id, 'method': method, 'params': params})
async for message in self.messages:
if message.get('id') == request_id and 'method' not in message:
if 'error' in message:
raise ValueError(message['error'].get('message', 'Agent request failed'))
return message.get('result', {})
await self.handle(message)
raise ValueError('Agent ended before replying')
def answer_value(value):
if isinstance(value, dict):
return value.get('text', '') if value.get('type') == 'other' else value.get('label', '')
return value
async def elicit(output, params, native):
"""Present ACP's flat form schema through the existing ask-user card."""
if params.get('mode') != 'form':
return {'action': 'decline'}
properties = params.get('requestedSchema', {}).get('properties', {})
required = params.get('requestedSchema', {}).get('required', [])
questions, choices = [], {}
for key, spec in properties.items():
if key.endswith('_custom') and key[:-7] in properties:
continue
options = spec.get('oneOf') or [{'const': v, 'title': str(v)} for v in spec.get('enum', [])]
if spec.get('type') == 'boolean':
options = [{'const': True, 'title': 'Yes'}, {'const': False, 'title': 'No'}]
choices[key] = {str(o.get('title', o.get('const'))): o.get('const') for o in options}
if key not in required:
choices[key]['(Skip)'] = None
description = spec.get('description') or spec.get('title') or key
if spec.get('type') == 'array':
description += ' (enter a JSON array)'
questions.append(
{
'id': key,
'header': spec.get('title') or key,
'question': f'{params.get("message", "")}\n{description}'.strip(),
'options': [{'label': label, 'description': ''} for label in choices[key]],
}
)
if not questions:
return {'action': 'decline'}
resolution = await output.approve('ask_user', {'questions': questions}, native)
if resolution['action'] != 'answer' or resolution.get('timed_out'):
return {'action': 'decline'}
content = {}
for key, value in (resolution.get('answers') or {}).items():
if key not in properties:
continue
text = answer_value(value)
if text == '(Skip)' and key not in required:
continue
if text in choices.get(key, {}):
content[key] = choices[key][text]
elif key + '_custom' in properties:
content[key + '_custom'] = text
content[key] = next((v for v in choices[key].values() if v is not None), '')
elif properties[key].get('type') in {'integer', 'number', 'boolean', 'array'}:
try:
content[key] = json.loads(text)
except (ValueError, TypeError):
return {'action': 'decline'}
else:
content[key] = text
return {'action': 'accept', 'content': content}
async def run(transport, output, prompt, images, options, *, auth_method=None):
session_id = None
streaming = False
tools = {}
config_options = []
async def handle(message):
nonlocal config_options
method, params = message.get('method'), message.get('params') or {}
if params.get('sessionId') and session_id and params['sessionId'] != session_id:
if 'id' in message:
await client.send({'id': message['id'], 'error': {'code': -32602, 'message': 'Wrong agent session'}})
return
if 'id' in message and method:
if method == 'session/request_permission':
resolution = await output.approve('agent_tool', params.get('toolCall') or {}, message)
accepted = resolution['action'] == 'approve' and not resolution.get('timed_out')
kind = 'allow_once' if accepted else 'reject_once'
choice = next((o for o in params.get('options', []) if o.get('kind') == kind), None)
result = {
'outcome': {'outcome': 'selected', 'optionId': choice['optionId']}
if choice
else {'outcome': 'cancelled'}
}
elif method == 'elicitation/create':
result = await elicit(output, params, message)
else:
await client.send(
{'id': message['id'], 'error': {'code': -32601, 'message': f'Unsupported client method: {method}'}}
)
return
await client.send({'id': message['id'], 'result': result})
elif method == 'session/update':
update = params.get('update') or {}
kind = update.get('sessionUpdate')
if kind == 'config_option_update':
config_options = update.get('configOptions', [])
if not streaming:
return
if kind in {'agent_message_chunk', 'agent_thought_chunk'}:
content = update.get('content') or {}
if content.get('type') == 'text':
await output.text(content.get('text', ''), reasoning=kind == 'agent_thought_chunk')
elif kind in {'tool_call', 'tool_call_update'}:
call_id = update['toolCallId']
tool = tools.setdefault(call_id, {})
tool.update(update)
await output.tool(
call_id,
tool.get('title') or tool.get('kind', 'agent_tool'),
tool.get('rawInput') or {},
native=tool.copy(),
result=tool.get('rawOutput', tool.get('content', [])),
done=tool.get('status') in {'completed', 'failed'},
)
elif kind == 'plan':
await output.tool(
'agent_plan',
'Plan',
{'entries': update.get('entries', [])},
native=update,
result=update.get('entries', []),
done=True,
)
client = Client(transport, handle)
try:
initialized = await client.request(
'initialize',
{
'protocolVersion': 1,
'clientInfo': {'name': 'open_webui', 'version': '1'},
'clientCapabilities': {
'fs': {'readTextFile': False, 'writeTextFile': False},
'terminal': False,
'elicitation': {'form': {}},
},
},
)
if initialized.get('protocolVersion') != 1:
raise ValueError('Agent requires an unsupported ACP version')
if auth_method:
await client.request('authenticate', {'methodId': auth_method})
capabilities = initialized.get('agentCapabilities') or {}
params = {'cwd': options['cwd'], 'mcpServers': []}
if options.get('resume'):
session_id = options['resume']
params['sessionId'] = session_id
if 'resume' in capabilities.get('sessionCapabilities', {}):
session = await client.request('session/resume', params)
elif capabilities.get('loadSession'):
session = await client.request('session/load', params)
else:
raise ValueError('This agent no longer supports resuming this conversation')
else:
session = await client.request('session/new', params)
session_id = session['sessionId']
if capabilities.get('loadSession') or 'resume' in capabilities.get('sessionCapabilities', {}):
output.state['session_id'] = session_id
await output.flush(force=True)
config_options = session.get('configOptions', config_options)
config = next(
(c for c in config_options if c.get('category') == 'model' or c.get('id') == 'model'),
None,
)
if not config and not session.get('models') and options.get('resume_state', {}).get('model_config'):
config = {'id': options['resume_state']['model_config']}
if config:
output.state['model_config'] = config['id']
configured = await client.request(
'session/set_config_option',
{'sessionId': session_id, 'configId': config['id'], 'value': options['model']},
)
config_options = configured.get('configOptions', config_options)
elif session.get('models') or options.get('resume'):
await client.request('session/set_model', {'sessionId': session_id, 'modelId': options['model']})
else:
raise ValueError('Agent did not advertise model selection for this session')
effort = reasoning_effort(options)
if effort is not None:
config = next((c for c in config_options if c.get('category') == 'thought_level'), None)
values = [
value['value']
for group in (config or {}).get('options', [])
for value in (group['options'] if 'group' in group else [group])
]
if not config or effort not in values:
raise ValueError('Agent does not support the selected reasoning effort for this model')
await client.request(
'session/set_config_option',
{'sessionId': session_id, 'configId': config['id'], 'value': effort},
)
await output.flush(force=True, persist=True)
if images and not capabilities.get('promptCapabilities', {}).get('image'):
raise ValueError('This agent does not support image prompts')
content = []
if options.get('instructions'):
content.append(
{'type': 'text', 'text': options['instructions'], 'annotations': {'audience': ['assistant']}}
)
content.append({'type': 'text', 'text': prompt})
for image in images:
prefix, data = image.split(',', 1)
content.append({'type': 'image', 'mimeType': prefix[5:].split(';')[0], 'data': data})
streaming = True
result = await client.request('session/prompt', {'sessionId': session_id, 'prompt': content}, timeout=None)
if result.get('usage'):
usage = result['usage']
output.usage = {
'input_tokens': usage.get('inputTokens', 0),
'output_tokens': usage.get('outputTokens', 0),
'total_tokens': usage.get('totalTokens', 0),
}
if result.get('stopReason') == 'cancelled':
raise ValueError('Agent cancelled the turn')
except asyncio.CancelledError:
if session_id:
with contextlib.suppress(Exception):
await asyncio.wait_for(
client.send({'method': 'session/cancel', 'params': {'sessionId': session_id}}), 5
)
raise