154 lines
7.3 KiB
Python
154 lines
7.3 KiB
Python
"""Native ACP stdio + SQLite probe; local HTTP model fixture, never paid inference.
|
|
|
|
Run with the project Python from the checkout being measured. --output saves the
|
|
wire transcript and database measurements. No production predicates are patched.
|
|
"""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import queue
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument('--output', required=True)
|
|
args = parser.parse_args()
|
|
repo = Path(__file__).resolve().parents[1]
|
|
requests = []
|
|
|
|
class Model(BaseHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
self.reply({'object': 'list', 'data': [{'id': 'fixture-model', 'object': 'model'}]})
|
|
|
|
def do_POST(self):
|
|
body = json.loads(self.rfile.read(int(self.headers['Content-Length'])))
|
|
requests.append({'path': self.path, 'body': body})
|
|
if body.get('stream'):
|
|
chunk = {'id': 'fixture-stream', 'object': 'chat.completion.chunk',
|
|
'created': 1, 'model': 'fixture-model',
|
|
'choices': [{'index': 0, 'delta': {'role': 'assistant',
|
|
'content': 'Local fixture reply.'}, 'finish_reason': 'stop'}]}
|
|
data = ('data: ' + json.dumps(chunk) + '\n\ndata: [DONE]\n\n').encode()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'text/event-stream')
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
return
|
|
self.reply({'id': 'fixture-completion', 'object': 'chat.completion',
|
|
'created': 1, 'model': 'fixture-model',
|
|
'choices': [{'index': 0, 'message': {'role': 'assistant',
|
|
'content': 'Local fixture reply.'}, 'finish_reason': 'stop'}],
|
|
'usage': {'prompt_tokens': 10, 'completion_tokens': 5, 'total_tokens': 15}})
|
|
|
|
def reply(self, body):
|
|
data = json.dumps(body).encode()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
server = ThreadingHTTPServer(('127.0.0.1', 0), Model)
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
home = Path(tempfile.mkdtemp(prefix='hermes-acp-empty-'))
|
|
hermes = home / '.hermes'
|
|
hermes.mkdir()
|
|
(hermes / 'config.yaml').write_text(
|
|
'model:\n provider: custom\n default: fixture-model\n'
|
|
f' base_url: http://127.0.0.1:{server.server_port}/v1\n'
|
|
' api_key: local-fixture-key\n api_mode: chat_completions\n'
|
|
'mcp_servers: {}\nmemory:\n memory_enabled: false\n user_profile_enabled: false\n'
|
|
'agent:\n max_iterations: 1\n disabled_toolsets: [all]\n'
|
|
)
|
|
env = {k: os.environ[k] for k in ('PATH', 'LANG', 'TZ') if k in os.environ}
|
|
env.update(HOME=str(home), HERMES_HOME=str(hermes), PYTHONPATH=os.pathsep.join([str(repo), os.environ.get('PYTHONPATH', '')]),
|
|
HERMES_ACP_SKIP_CONFIGURED_MCP='1', OPENAI_API_KEY='local-fixture-key',
|
|
OPENAI_BASE_URL=f'http://127.0.0.1:{server.server_port}/v1')
|
|
stderr = open(str(args.output) + '.stderr', 'w', encoding='utf-8')
|
|
proc = subprocess.Popen([sys.executable, '-m', 'acp_adapter'], cwd=repo, env=env,
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr, text=True)
|
|
received = queue.Queue()
|
|
wire = []
|
|
|
|
def reader():
|
|
for line in proc.stdout:
|
|
received.put(line)
|
|
threading.Thread(target=reader, daemon=True).start()
|
|
|
|
def rpc(method, params):
|
|
request_id = len(wire) + 1
|
|
request = {'jsonrpc': '2.0', 'id': request_id, 'method': method, 'params': params}
|
|
wire.append({'sent': request})
|
|
proc.stdin.write(json.dumps(request) + '\n')
|
|
proc.stdin.flush()
|
|
while True:
|
|
response = json.loads(received.get(timeout=120))
|
|
wire.append({'received': response})
|
|
if response.get('id') == request_id:
|
|
if 'error' in response:
|
|
raise RuntimeError(response)
|
|
return response['result']
|
|
|
|
def rows():
|
|
with sqlite3.connect(hermes / 'state.db') as db:
|
|
return db.execute('SELECT id, source, message_count FROM sessions ORDER BY id').fetchall()
|
|
|
|
result = {'repo': str(repo), 'home': str(home)}
|
|
try:
|
|
rpc('initialize', {'protocolVersion': 1, 'clientCapabilities': {},
|
|
'clientInfo': {'name': 'local-acp-probe', 'version': '1'}})
|
|
opened = rpc('session/new', {'cwd': str(home), 'mcpServers': []})
|
|
sid = opened['sessionId']
|
|
result['after_open'] = rows()
|
|
result['model_requests_after_open'] = len(requests)
|
|
result['prompt_response'] = rpc('session/prompt', {'sessionId': sid,
|
|
'prompt': [{'type': 'text', 'text': 'Reply with a short greeting; do not use tools.'}]})
|
|
result['after_prompt'] = rows()
|
|
forked = rpc('session/fork', {'sessionId': sid, 'cwd': str(home), 'mcpServers': []})
|
|
result['fork_id'] = forked['sessionId']
|
|
result['after_fork'] = rows()
|
|
with sqlite3.connect(hermes / 'state.db') as db:
|
|
result['messages'] = db.execute('SELECT session_id, role, content FROM messages ORDER BY id').fetchall()
|
|
for session_id in (sid, result['fork_id']):
|
|
assert [(r[1], r[2]) for r in result['messages'] if r[0] == session_id] == [
|
|
('user', 'Reply with a short greeting; do not use tools.'),
|
|
('assistant', 'Local fixture reply.')]
|
|
assert any(r[0] == sid and r[2] > 0 for r in result['after_prompt'])
|
|
assert any(r[0] == result['fork_id'] and r[2] > 0 for r in result['after_fork'])
|
|
legacy = rpc('session/new', {'cwd': str(home), 'mcpServers': []})['sessionId']
|
|
with sqlite3.connect(hermes / 'state.db') as db:
|
|
db.execute("INSERT OR IGNORE INTO sessions (id, source, started_at) VALUES (?, 'acp', 1)",
|
|
(legacy,))
|
|
moved = home / 'moved'
|
|
moved.mkdir()
|
|
rpc('session/load', {'sessionId': legacy, 'cwd': str(moved), 'mcpServers': []})
|
|
with sqlite3.connect(hermes / 'state.db') as db:
|
|
result['existing_empty_metadata'] = db.execute(
|
|
'SELECT model_config, message_count FROM sessions WHERE id = ?', (legacy,)).fetchone()
|
|
assert json.loads(result['existing_empty_metadata'][0])['cwd'] == str(moved)
|
|
assert result['existing_empty_metadata'][1] == 0
|
|
assert result['after_open'] == [], 'session/new created an empty durable row'
|
|
finally:
|
|
proc.terminate()
|
|
proc.wait(timeout=20)
|
|
stderr.close()
|
|
server.shutdown()
|
|
result.update(wire=wire, model_requests=requests)
|
|
Path(args.output).write_text(json.dumps(result, indent=2), encoding='utf-8')
|
|
print(json.dumps({k: v for k, v in result.items() if k not in ('wire', 'model_requests', 'messages')}, indent=2))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|