Files
agent-workbench/scripts/appserver.py
T

85 lines
3.4 KiB
Python

"""Small stdio client for the supported Codex App Server protocol."""
import json
import queue
import shutil
import subprocess
import threading
import time
class AppServer:
def __init__(self, executable=None):
executable = executable or shutil.which('codex')
if not executable:
raise RuntimeError('Codex unavailable on this executor')
self.process = subprocess.Popen([executable,'app-server','--stdio'], stdin=subprocess.PIPE,
stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,encoding='utf-8',errors='replace')
self.incoming = queue.Queue()
self.next_id = 0
self.notifications = []
def read():
for line in self.process.stdout:
try:
self.incoming.put(json.loads(line))
except ValueError:
continue
self.incoming.put(None)
threading.Thread(target=read,daemon=True).start()
self.call('initialize', {'clientInfo':{'name':'project_agent_control','version':'1.0'},
'capabilities':{'experimentalApi':True}})
self.send({'method':'initialized','params':{}})
def send(self, value):
self.process.stdin.write(json.dumps(value,ensure_ascii=True)+'\n')
self.process.stdin.flush()
def call(self, method, params, timeout=30):
self.next_id += 1
request_id = self.next_id
self.send({'id':request_id,'method':method,'params':params})
deadline=time.monotonic()+timeout
while True:
try:
event=self.incoming.get(timeout=max(0.01,deadline-time.monotonic()))
except queue.Empty:
raise RuntimeError('App Server request timed out; outcome must be inspected') from None
if event is None:
raise RuntimeError('App Server stopped')
if event.get('id') == request_id and 'method' not in event:
if 'error' in event:
# Surface protocol code only, never arbitrary environment output.
raise RuntimeError('App Server rejected request: '+str(event['error'].get('code')))
return event['result']
if 'id' in event and 'method' in event:
self.send({'id':event['id'],'error':{'code':-32601,'message':'Interactive approval must be handled by the human operator'}})
else:
self.notifications.append(event)
def history(self, thread_id):
meta=self.call('thread/read',{'threadId':thread_id,'includeTurns':False})['thread']
cursor=None
turns=[]
while True:
params={'threadId':thread_id,'limit':100,'sortDirection':'asc','itemsView':'full'}
if cursor:
params['cursor']=cursor
result=self.call('thread/turns/list',params)
turns.extend(result.get('data',[]))
cursor=result.get('nextCursor')
if not cursor:
break
self.notifications.clear()
return meta,turns
def close(self):
if self.process.poll() is None:
self.process.terminate()
try:
self.process.wait(timeout=10)
except subprocess.TimeoutExpired:
self.process.kill()
self.process.wait(timeout=10)
def __enter__(self): return self
def __exit__(self,*args): self.close()