500 lines
28 KiB
Python
500 lines
28 KiB
Python
"""Single account execution coordinator and trusted Hermes runs transport.
|
|
|
|
Restart recovery reattaches by upstream run ID; admission retries use the exact
|
|
persisted payload and stable idempotency key. No transcript/session replay occurs.
|
|
Revocation cancels the initiating session's execution (including room grants).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import time
|
|
from collections import defaultdict
|
|
from urllib.parse import urlsplit
|
|
|
|
import httpx
|
|
from fastapi import HTTPException
|
|
|
|
from .accounts import token_hash
|
|
|
|
TERMINAL = {'completed', 'failed', 'cancelled'}
|
|
|
|
|
|
class HermesRuns:
|
|
"""Credentials obtained exclusively from trusted Accounts rows."""
|
|
available = True
|
|
approvals = True
|
|
|
|
def __init__(self, accounts, client=None):
|
|
self.accounts = accounts
|
|
self.client = client or httpx.AsyncClient(timeout=httpx.Timeout(30, read=60), follow_redirects=False)
|
|
self.owns_client = client is None
|
|
|
|
async def connection(self, owner):
|
|
user = await asyncio.to_thread(self.accounts.get, owner)
|
|
if not user or user['desired'] == 'deleted' or user['status'] != 'ready':
|
|
raise RuntimeError('Account unavailable')
|
|
root, key = user.get('hermes_url'), user.get('hermes_api_key')
|
|
parsed = urlsplit(root or '')
|
|
if parsed.scheme not in ('http', 'https') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or not key or '\n' in key or '\r' in key:
|
|
raise RuntimeError('Invalid trusted connection')
|
|
root = root.rstrip('/')
|
|
if not root.endswith('/v1'):
|
|
root += '/v1'
|
|
return root, {'Authorization': f'Bearer {key}', 'X-Hermes-Session-Key': f'hope:{owner}'}
|
|
|
|
async def request(self, owner, method, path, **kwargs):
|
|
root, headers = await self.connection(owner)
|
|
headers.update(kwargs.pop('headers', {}))
|
|
response = await self.client.request(method, root + path, headers=headers, **kwargs)
|
|
response.raise_for_status()
|
|
if len(response.content) > 2 * 1024 * 1024:
|
|
raise RuntimeError('Response too large')
|
|
return response.json()
|
|
|
|
async def start(self, owner, rid, payload):
|
|
return await self.request(owner, 'POST', '/runs', json=payload, headers={'Idempotency-Key': rid})
|
|
|
|
async def status(self, owner, upstream):
|
|
return await self.request(owner, 'GET', f'/runs/{upstream}')
|
|
|
|
async def stop(self, owner, upstream):
|
|
return await self.request(owner, 'POST', f'/runs/{upstream}/stop')
|
|
|
|
async def approve(self, owner, upstream, approval_id, decision):
|
|
return await self.request(owner, 'POST', f'/runs/{upstream}/approval', json={'request_id': approval_id, 'choice': 'once' if decision == 'approve' else 'deny'})
|
|
|
|
async def events(self, owner, upstream):
|
|
root, headers = await self.connection(owner)
|
|
headers['Accept'] = 'text/event-stream'
|
|
async with self.client.stream('GET', root + f'/runs/{upstream}/events', headers=headers) as response:
|
|
response.raise_for_status()
|
|
lines, size = [], 0
|
|
async for line in response.aiter_lines():
|
|
if not line:
|
|
if lines:
|
|
value = json.loads('\n'.join(lines))
|
|
if isinstance(value, dict):
|
|
yield value
|
|
lines, size = [], 0
|
|
elif line.startswith('data:'):
|
|
size += len(line)
|
|
if size > 1024 * 1024:
|
|
raise RuntimeError('Event too large')
|
|
lines.append(line[5:].lstrip())
|
|
if lines:
|
|
value = json.loads('\n'.join(lines))
|
|
if isinstance(value, dict):
|
|
yield value
|
|
|
|
async def aclose(self):
|
|
if self.owns_client:
|
|
await self.client.aclose()
|
|
|
|
|
|
class ChatRuns:
|
|
def __init__(self, accounts, store, adapter=None, feature_store=None):
|
|
self.accounts, self.store = accounts, store
|
|
self.feature_store = feature_store
|
|
self.feature_provider = None
|
|
self.adapter = adapter or HermesRuns(accounts)
|
|
self.locks = defaultdict(asyncio.Lock)
|
|
self.admission_locks = defaultdict(asyncio.Lock)
|
|
self.tasks = {}
|
|
self.confirmed_stops = set()
|
|
self.task_owners = {}
|
|
self.closed = False
|
|
with accounts.lock, accounts.db:
|
|
accounts.db.execute('''CREATE TABLE IF NOT EXISTS chat_transport (
|
|
run_id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
conversation_id TEXT NOT NULL, metadata TEXT NOT NULL)''')
|
|
|
|
def capabilities(self):
|
|
enabled = bool(getattr(self.adapter, 'available', True))
|
|
return {'conversations': True, 'workspaces': True, 'preferences': True,
|
|
'search': True, 'branching': True, 'sync': True, 'text_chat': enabled,
|
|
'durable_runs': enabled, 'cancellation': enabled,
|
|
'approvals': enabled and bool(getattr(self.adapter, 'approvals', False)),
|
|
'attachments': self.feature_store is not None, 'artifacts': False, 'tasks': False,
|
|
'memory': False, 'research': False, 'voice_chat': enabled, 'temporary_chat': False}
|
|
|
|
def metadata(self, owner, rid, value=None):
|
|
with self.accounts.lock, self.accounts.db:
|
|
if value is not None:
|
|
existing = self.accounts.db.execute('SELECT metadata FROM chat_transport WHERE owner=? AND run_id=?', (owner, rid)).fetchone()
|
|
if existing:
|
|
prior = json.loads(existing[0])
|
|
for flag in ('stop_requested', 'storage_failed', 'remote_terminal'):
|
|
if prior.get(flag):
|
|
value[flag] = True
|
|
self.accounts.db.execute('INSERT INTO chat_transport VALUES(?,?,?,?) ON CONFLICT(run_id) DO UPDATE SET metadata=excluded.metadata',
|
|
(rid, owner, value['conversation_id'], json.dumps(value, ensure_ascii=False)))
|
|
row = self.accounts.db.execute('SELECT metadata FROM chat_transport WHERE owner=? AND run_id=?', (owner, rid)).fetchone()
|
|
return json.loads(row[0]) if row else None
|
|
|
|
def authorized(self, owner, metadata):
|
|
with self.accounts.lock:
|
|
if metadata.get('room'):
|
|
user = self.accounts.resolve_room(metadata['room'])
|
|
return bool(user and user['id'] == owner and user.get('conversation_id') == metadata['conversation_id'])
|
|
row = self.accounts.db.execute("SELECT 1 FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.hash=? AND s.user_id=? AND s.expires_at>? AND u.desired!='deleted'", (metadata.get('session_hash'), owner, time.time())).fetchone()
|
|
return row is not None
|
|
|
|
async def call(self, method, *args, **kwargs):
|
|
return await asyncio.to_thread(getattr(self.store, method), *args, **kwargs)
|
|
|
|
async def send(self, owner, cid, session_token, *, content, idempotency_key, attachment_ids=None, room=None, mode='normal'):
|
|
from .chat_store import ChatConflict
|
|
if mode not in ('normal', 'research'):
|
|
raise HTTPException(400, 'Invalid chat mode')
|
|
if mode == 'research':
|
|
if self.feature_provider is None or not (await self.feature_provider.feature_capabilities(owner)).get('research'):
|
|
raise HTTPException(409, 'Research is unavailable')
|
|
parts = None
|
|
if attachment_ids:
|
|
if self.feature_store is None:
|
|
raise HTTPException(400, 'Attachments are not enabled')
|
|
vision = False
|
|
if self.feature_provider is not None:
|
|
qualified = await self.feature_provider.feature_capabilities(owner)
|
|
vision = qualified.get('image_input') is True
|
|
parts = await self.feature_store.provider_content(owner, attachment_ids, vision=vision)
|
|
if not self.capabilities()['text_chat']:
|
|
raise HTTPException(503, 'Chat unavailable')
|
|
try:
|
|
# Coordinate payload admission with execution and recovery metadata.
|
|
async with self.admission_locks[owner]:
|
|
if sum(not task.done() for task in self.tasks.values()) >= 256 or sum(not task.done() and self.task_owners.get(rid) == owner for rid, task in self.tasks.items()) >= 8:
|
|
raise HTTPException(429, 'Chat queue full')
|
|
operation = await self.call('send', owner, cid, content, idempotency_key, attachment_ids, mode=mode)
|
|
rid = operation['run']['id']
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
if metadata is None:
|
|
settings = await asyncio.to_thread(self.accounts.get_model_settings, owner)
|
|
session_state = await self.call('get_session', owner, cid)
|
|
session = session_state['hermes_session_id'] or 'hope-' + cid
|
|
seed_history = None
|
|
if not session_state['hermes_seeded']:
|
|
def copied_history():
|
|
with self.accounts.lock:
|
|
return [{'role': row['role'], 'content': row['content']} for row in self.accounts.db.execute("SELECT role,content FROM chat_messages WHERE owner=? AND conversation_id=? AND run_id IS NULL AND status='completed' ORDER BY position", (owner, cid))]
|
|
seed_history = await asyncio.to_thread(copied_history)
|
|
if len(seed_history) > 200 or len(json.dumps(seed_history).encode()) > 256000:
|
|
raise HTTPException(409, 'Branch context exceeds the supported history limit')
|
|
await self.call('set_session', owner, cid, session, False)
|
|
user_input = content if not parts else [{'role': 'user', 'content': [{'type': 'text', 'text': content}, *parts]}]
|
|
payload = {'input': user_input, 'session_id': session, 'model': settings['model'],
|
|
'provider': 'custom:hope-upstream',
|
|
'model_options': {'reasoning': {'enabled': False} if settings['thinking_level'] == 'none' else {'enabled': True, 'effort': settings['thinking_level']}},
|
|
'instructions': 'Respond concisely for spoken conversation. Avoid markdown.' if room else 'Respond helpfully to a text chat. Use readable markdown when useful.'}
|
|
if mode == 'research':
|
|
payload['instructions'] += '\n' + await self.feature_provider.research_instruction(owner)
|
|
conversation = (await self.call('get_conversation', owner, cid))['conversation']
|
|
if conversation['workspace_id']:
|
|
workspace = await self.call('get_workspace', owner, conversation['workspace_id'])
|
|
payload['instructions'] += '\nWorkspace instructions:\n' + workspace['instructions']
|
|
if seed_history:
|
|
payload['conversation_history'] = seed_history
|
|
metadata = {'conversation_id': cid, 'payload': payload,
|
|
'session_hash': token_hash(session_token) if session_token else None,
|
|
'room': room, 'upstream_id': None, 'assistant_id': None}
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
if operation['run']['status'] not in TERMINAL:
|
|
self.schedule(owner, rid)
|
|
return operation
|
|
except ChatConflict:
|
|
raise HTTPException(409, 'Idempotency or operation conflict') from None
|
|
except KeyError:
|
|
raise HTTPException(404, 'Chat resource not found') from None
|
|
except ValueError:
|
|
raise HTTPException(400, 'Invalid chat request') from None
|
|
|
|
def schedule(self, owner, rid):
|
|
if not self.closed and (rid not in self.tasks or self.tasks[rid].done()):
|
|
self.task_owners[rid] = owner
|
|
self.tasks[rid] = asyncio.create_task(self.execute(owner, rid))
|
|
|
|
async def start(self):
|
|
def rows():
|
|
with self.accounts.lock:
|
|
return [(r['owner'], r['run_id']) for r in self.accounts.db.execute('SELECT owner,run_id FROM chat_transport')]
|
|
known = await asyncio.to_thread(rows)
|
|
known_ids = {rid for _, rid in known}
|
|
for run in await self.call('active_runs'):
|
|
if run['id'] not in known_ids:
|
|
await self.set_status(run['owner'], run['id'], 'failed', 'Chat admission interrupted. Start a new request.')
|
|
await self.emit(run['owner'], run['id'], 'done', {'run': (await self.call('get_run', run['owner'], run['id']))['run']})
|
|
for owner, rid in known:
|
|
try:
|
|
detail = await self.call('get_run', owner, rid)
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
if detail['run']['status'] not in TERMINAL or (metadata.get('stop_requested') and metadata.get('upstream_id') and not metadata.get('remote_terminal')):
|
|
self.schedule(owner, rid)
|
|
except KeyError:
|
|
continue
|
|
|
|
async def emit(self, owner, rid, event, data):
|
|
try:
|
|
await self.call('append_event', owner, rid, event, data)
|
|
except ValueError:
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
if metadata:
|
|
metadata['storage_failed'] = True
|
|
metadata['stop_requested'] = True
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
raise
|
|
|
|
async def set_status(self, owner, rid, status, error=None):
|
|
current = (await self.call('get_run', owner, rid))['run']
|
|
if current['status'] in TERMINAL:
|
|
return current
|
|
run = await self.call('update_run', owner, rid, status, error=error)
|
|
await self.emit(owner, rid, 'run', {'run': run})
|
|
return run
|
|
|
|
async def finish(self, owner, rid, metadata, status, content=None, error=None):
|
|
detail = await self.call('get_run', owner, rid)
|
|
if detail['run']['status'] in TERMINAL:
|
|
return detail['run']
|
|
if metadata.get('assistant_id'):
|
|
message = await self.call('update_message', owner, metadata['assistant_id'], content=content, status=status)
|
|
await self.emit(owner, rid, 'message', {'message': message})
|
|
elif content is not None and status == 'completed':
|
|
message = await self.call('add_message', owner, metadata['conversation_id'], 'assistant', content, status='completed', run_id=rid)
|
|
await self.emit(owner, rid, 'message', {'message': message})
|
|
run = await self.set_status(owner, rid, status, error)
|
|
await self.emit(owner, rid, 'done', {'run': run})
|
|
return run
|
|
|
|
async def monitor_authorization(self, owner, rid, metadata):
|
|
while True:
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
if not await asyncio.to_thread(self.authorized, owner, metadata):
|
|
await self.cancel(owner, rid)
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
if metadata.get('stop_requested') and metadata.get('upstream_id'):
|
|
try:
|
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
|
state = await self.adapter.status(owner, metadata['upstream_id'])
|
|
if await self.consume_status(owner, rid, metadata, state):
|
|
self.confirmed_stops.add(rid)
|
|
task = self.tasks.get(rid)
|
|
if task and task is not asyncio.current_task():
|
|
task.cancel()
|
|
return
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(.25)
|
|
|
|
async def execute(self, owner, rid):
|
|
watcher = None
|
|
try:
|
|
async with self.locks[owner]:
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
detail = await self.call('get_run', owner, rid)
|
|
if not metadata or (detail['run']['status'] in TERMINAL and not (metadata.get('stop_requested') and metadata.get('upstream_id') and not metadata.get('remote_terminal'))):
|
|
return
|
|
if not await asyncio.to_thread(self.authorized, owner, metadata):
|
|
if not metadata.get('upstream_id'):
|
|
await self.finish(owner, rid, metadata, 'cancelled')
|
|
return
|
|
metadata['stop_requested'] = True
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
watcher = asyncio.create_task(self.monitor_authorization(owner, rid, metadata))
|
|
if not metadata['upstream_id']:
|
|
while True:
|
|
try:
|
|
started = await self.adapter.start(owner, rid, metadata['payload'])
|
|
upstream = started.get('run_id')
|
|
if not isinstance(upstream, str) or not upstream or len(upstream) > 200 or not all(c.isalnum() or c in '-_' for c in upstream):
|
|
raise RuntimeError('Invalid upstream run')
|
|
metadata['upstream_id'] = upstream
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
session_id = metadata['payload'].get('session_id')
|
|
if session_id:
|
|
await self.call('set_session', owner, metadata['conversation_id'], session_id, True)
|
|
break
|
|
except httpx.HTTPStatusError as exc:
|
|
if exc.response.status_code in (400, 401, 403, 404, 409, 422):
|
|
raise RuntimeError('Chat admission rejected') from None
|
|
await asyncio.sleep(1)
|
|
except (httpx.HTTPError, RuntimeError):
|
|
# Unknown outcome: retain account slot, retry exact
|
|
# durable payload/key, never create a second action.
|
|
await asyncio.sleep(1)
|
|
current = await self.call('get_run', owner, rid)
|
|
if current['run']['status'] in TERMINAL or not await asyncio.to_thread(self.authorized, owner, metadata):
|
|
metadata['stop_requested'] = True
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
try:
|
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
|
except Exception:
|
|
pass
|
|
# Keep ownership of the execution slot until remote terminal.
|
|
else:
|
|
await self.set_status(owner, rid, 'running')
|
|
# Reconnect only this single queue-consuming relay; never expose upstream SSE.
|
|
while True:
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
try:
|
|
if metadata.get('stop_requested'):
|
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
|
state = await self.adapter.status(owner, metadata['upstream_id'])
|
|
if await self.consume_status(owner, rid, metadata, state):
|
|
return
|
|
except Exception:
|
|
await asyncio.sleep(1)
|
|
continue
|
|
try:
|
|
async for event in self.adapter.events(owner, metadata['upstream_id']):
|
|
current = await self.call('get_run', owner, rid)
|
|
if current['run']['status'] in TERMINAL:
|
|
if await self.consume_status(owner, rid, metadata, {'status': event.get('event', '').removeprefix('run.'), 'output': event.get('output')}):
|
|
return
|
|
continue
|
|
latest = await asyncio.to_thread(self.metadata, owner, rid)
|
|
if latest.get('storage_failed'):
|
|
metadata = latest
|
|
continue
|
|
if await self.consume_event(owner, rid, metadata, event):
|
|
return
|
|
state = await self.adapter.status(owner, metadata['upstream_id'])
|
|
if await self.consume_status(owner, rid, metadata, state):
|
|
return
|
|
except (httpx.HTTPError, ValueError):
|
|
pass
|
|
await asyncio.sleep(1)
|
|
except asyncio.CancelledError:
|
|
if rid in self.confirmed_stops:
|
|
self.confirmed_stops.discard(rid)
|
|
return
|
|
# Shutdown detaches; durable metadata is recovered on startup.
|
|
raise
|
|
except Exception:
|
|
try:
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
await self.finish(owner, rid, metadata or {}, 'failed', error='Chat execution unavailable. Try again.')
|
|
except KeyError:
|
|
pass
|
|
finally:
|
|
if watcher:
|
|
watcher.cancel()
|
|
await asyncio.gather(watcher, return_exceptions=True)
|
|
|
|
async def consume_status(self, owner, rid, metadata, state):
|
|
status = state.get('status')
|
|
if status in ('completed', 'failed', 'cancelled', 'interrupted'):
|
|
metadata['remote_terminal'] = True
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
if (await self.call('get_run', owner, rid))['run']['status'] in TERMINAL:
|
|
return True
|
|
if status in ('completed', 'failed', 'cancelled', 'interrupted') and metadata.get('storage_failed'):
|
|
if metadata.get('assistant_id'):
|
|
await self.call('update_message', owner, metadata['assistant_id'], status='failed')
|
|
run = await self.set_status(owner, rid, 'failed', 'Chat storage limit reached.')
|
|
await self.emit(owner, rid, 'done', {'run': run})
|
|
return True
|
|
if status == 'waiting_for_approval':
|
|
approval = state.get('approval')
|
|
if isinstance(approval, dict) and isinstance(approval.get('request_id'), str):
|
|
await self.consume_event(owner, rid, metadata, {'event': 'approval.request', 'request_id': approval['request_id']})
|
|
if status == 'completed':
|
|
output = state.get('output')
|
|
if not isinstance(output, str) or not output.strip():
|
|
raise RuntimeError('Invalid final output')
|
|
await self.finish(owner, rid, metadata, 'completed', content=output[:32000])
|
|
return True
|
|
if status in ('failed', 'cancelled', 'interrupted'):
|
|
await self.finish(owner, rid, metadata, 'cancelled' if status == 'cancelled' else 'failed', error=None if status == 'cancelled' else 'Chat execution failed.')
|
|
return True
|
|
return False
|
|
|
|
async def consume_event(self, owner, rid, metadata, event):
|
|
name = event.get('event')
|
|
if name == 'message.delta':
|
|
delta = event.get('delta')
|
|
if isinstance(delta, str) and delta:
|
|
if not metadata.get('assistant_id'):
|
|
message = await self.call('add_message', owner, metadata['conversation_id'], 'assistant', '', status='streaming', run_id=rid)
|
|
metadata['assistant_id'] = message['id']
|
|
metadata['content'] = ''
|
|
metadata['content'] = (metadata.get('content', '') + delta)[:32000]
|
|
now = time.monotonic()
|
|
if len(metadata['content']) - metadata.get('emitted_length', 0) >= 256 or now - metadata.get('emitted_at', 0) >= .1:
|
|
metadata['emitted_at'] = now
|
|
metadata['emitted_length'] = len(metadata['content'])
|
|
message = await self.call('update_message', owner, metadata['assistant_id'], content=metadata['content'], status='streaming')
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
await self.emit(owner, rid, 'message', {'message': message})
|
|
elif name == 'approval.request':
|
|
approval_id = event.get('request_id')
|
|
if isinstance(approval_id, str) and 0 < len(approval_id) <= 200:
|
|
metadata['approval_id'] = approval_id
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
await self.set_status(owner, rid, 'awaiting_approval')
|
|
await self.emit(owner, rid, 'approval', {'approval_id': approval_id, 'prompt': 'Hermes requests permission to execute an action.'})
|
|
elif name in ('tool.started', 'tool.completed', 'run.started'):
|
|
await self.emit(owner, rid, 'progress', {'status': 'working', 'message': 'Working on your request.'})
|
|
elif name in ('run.completed', 'run.failed', 'run.cancelled'):
|
|
return await self.consume_status(owner, rid, metadata, {'status': name.split('.')[1], 'output': event.get('output')})
|
|
# Unknown/reasoning/raw tools deliberately never enter the product stream.
|
|
return False
|
|
|
|
async def cancel(self, owner, rid):
|
|
try:
|
|
detail = await self.call('get_run', owner, rid)
|
|
if detail['run']['status'] in TERMINAL:
|
|
return detail['run']
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
if metadata:
|
|
metadata['stop_requested'] = True
|
|
await asyncio.to_thread(self.metadata, owner, rid, metadata)
|
|
if metadata.get('upstream_id'):
|
|
try:
|
|
await self.adapter.stop(owner, metadata['upstream_id'])
|
|
except Exception:
|
|
pass # Durable intent remains; never claim remote success.
|
|
return detail['run']
|
|
# Queued/not-yet-admitted runs are safe to cancel locally. An admission
|
|
# in flight rechecks this terminal projection and stops its returned ID.
|
|
return await self.finish(owner, rid, metadata or {}, 'cancelled')
|
|
except KeyError:
|
|
raise HTTPException(404, 'Run not found') from None
|
|
|
|
async def cancel_conversation(self, owner, cid):
|
|
from .chat_store import ChatConflict
|
|
detail = await self.call('get_conversation', owner, cid)
|
|
pending = False
|
|
for run in detail['runs']:
|
|
if run['status'] not in TERMINAL:
|
|
result = await self.cancel(owner, run['id'])
|
|
pending |= result['status'] not in TERMINAL
|
|
if pending:
|
|
raise ChatConflict('Remote cancellation is pending')
|
|
|
|
async def approval(self, owner, rid, approval_id, decision):
|
|
if decision not in ('approve', 'deny') or not isinstance(approval_id, str) or not approval_id or len(approval_id) > 200:
|
|
raise HTTPException(400, 'Invalid approval')
|
|
if not self.capabilities()['approvals']:
|
|
raise HTTPException(409, 'Approvals unavailable')
|
|
metadata = await asyncio.to_thread(self.metadata, owner, rid)
|
|
try:
|
|
detail = await self.call('get_run', owner, rid)
|
|
if not metadata or detail['run']['status'] != 'awaiting_approval' or metadata.get('approval_id') != approval_id:
|
|
raise HTTPException(409, 'Approval no longer pending')
|
|
await self.adapter.approve(owner, metadata['upstream_id'], approval_id, decision)
|
|
return await self.set_status(owner, rid, 'running')
|
|
except KeyError:
|
|
raise HTTPException(404, 'Run not found') from None
|
|
except httpx.HTTPError:
|
|
raise HTTPException(503, 'Approval unavailable') from None
|
|
|
|
async def stop(self):
|
|
self.closed = True
|
|
tasks = list(self.tasks.values())
|
|
for task in tasks:
|
|
task.cancel()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
if hasattr(self.adapter, 'aclose'):
|
|
await self.adapter.aclose()
|