feat: implement durable authenticated chat run coordinator
This commit is contained in:
1 parent
08b7e20be8
commit
550f7c2e91
6 files changed
+905
-3
No files matched your search
+32
-3
@@ -206,13 +206,25 @@ class Lifecycle:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
def create_app(settings=None, provisioner=None, model_catalog=None):
|
||||
def create_app(settings=None, provisioner=None, model_catalog=None, chat_adapter=None, feature_provider=None):
|
||||
settings = settings or Settings()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app):
|
||||
store = Accounts(settings.db_path)
|
||||
app.state.accounts = store
|
||||
from .chat_store import ChatStore
|
||||
from .chat_runs import ChatRuns
|
||||
app.state.chat_store = await asyncio.to_thread(ChatStore, store)
|
||||
from .chat_features import FeatureStore, FeatureProvider
|
||||
from pathlib import Path
|
||||
feature_root = str(Path(settings.db_path).absolute().parent / 'chat-features')
|
||||
app.state.feature_store = await asyncio.to_thread(FeatureStore, store, feature_root)
|
||||
app.state.feature_provider = feature_provider or FeatureProvider(store)
|
||||
from .chat_features import create_feature_router
|
||||
app.include_router(create_feature_router(app.state.feature_store, app.state.feature_provider))
|
||||
app.state.chat_runs = await asyncio.to_thread(ChatRuns, store, app.state.chat_store, chat_adapter, app.state.feature_store)
|
||||
app.state.chat_runs.feature_provider = app.state.feature_provider
|
||||
try:
|
||||
if not store.users():
|
||||
if not settings.admin_password:
|
||||
@@ -231,9 +243,13 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
||||
app.state.lifecycle.schedule(user['id'])
|
||||
app.state.dummy_hash = await asyncio.to_thread(password_hash, 'timing-only-password')
|
||||
loop = asyncio.create_task(app.state.lifecycle.loop(settings.reconcile_interval))
|
||||
await app.state.chat_runs.start()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await app.state.chat_runs.stop()
|
||||
if hasattr(app.state.feature_provider, 'aclose'):
|
||||
await app.state.feature_provider.aclose()
|
||||
loop.cancel()
|
||||
await asyncio.gather(loop, return_exceptions=True)
|
||||
await app.state.lifecycle.stop()
|
||||
@@ -407,6 +423,7 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
||||
if user['status'] != 'ready' or not user['hermes_url'] or not user['hermes_api_key']:
|
||||
raise HTTPException(503, 'User Hermes is not ready')
|
||||
return {'user': public_user(user), 'hermes_url': user['hermes_url'], 'hermes_api_key': user['hermes_api_key'],
|
||||
'conversation_id': user.get('conversation_id'),
|
||||
'model_settings': app.state.accounts.get_model_settings(user['id'])}
|
||||
|
||||
@app.get('/internal/users/{user_id}', dependencies=[Depends(internal_authorized)])
|
||||
@@ -430,13 +447,21 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
||||
raise HTTPException(400, 'Invalid room grant')
|
||||
if type(ttl) is not int or not 1 <= ttl <= 86400:
|
||||
raise HTTPException(400, 'Invalid room expiry')
|
||||
conversation_id = body.get('conversation_id')
|
||||
try:
|
||||
await asyncio.to_thread(app.state.accounts.grant_room, user_id, room, ttl, body.get('session_token'))
|
||||
if conversation_id is None:
|
||||
conversation = await asyncio.to_thread(app.state.chat_store.create_conversation, user_id)
|
||||
conversation_id = conversation['id']
|
||||
else:
|
||||
await asyncio.to_thread(app.state.chat_store.get_conversation, user_id, conversation_id)
|
||||
await asyncio.to_thread(app.state.accounts.grant_room, user_id, room, ttl, body.get('session_token'), conversation_id)
|
||||
except KeyError:
|
||||
raise HTTPException(404, 'Conversation not found') from None
|
||||
except ValueError:
|
||||
raise HTTPException(409, 'Account not ready') from None
|
||||
except sqlite3.IntegrityError:
|
||||
raise HTTPException(409, 'Room already granted') from None
|
||||
return {'room_name': room, 'user_id': user_id}
|
||||
return {'room_name': room, 'user_id': user_id, 'conversation_id': conversation_id}
|
||||
|
||||
@app.get('/internal/rooms/{room_name}', dependencies=[Depends(internal_authorized)])
|
||||
async def resolve_room(room_name: str):
|
||||
@@ -446,4 +471,8 @@ def create_app(settings=None, provisioner=None, model_catalog=None):
|
||||
async def health():
|
||||
return {'status': 'ok'}
|
||||
|
||||
from .chat_api import create_chat_router
|
||||
app.include_router(create_chat_router())
|
||||
from .chat_exports import create_export_router
|
||||
app.include_router(create_export_router())
|
||||
return app
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Authenticated durable chat routes; all SQLite work is offloaded."""
|
||||
import asyncio
|
||||
import json
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from .app import bounded_json, current_user, internal_authorized
|
||||
|
||||
router = APIRouter()
|
||||
TERMINAL = {'completed', 'failed', 'cancelled'}
|
||||
|
||||
async def db(request, method, *args, **kwargs):
|
||||
from .chat_store import ChatConflict
|
||||
try:
|
||||
return await asyncio.to_thread(getattr(request.app.state.chat_store, method), *args, **kwargs)
|
||||
except ChatConflict:
|
||||
raise HTTPException(409, 'Revision 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
|
||||
|
||||
async def body(request, allowed, required=()):
|
||||
value = await bounded_json(request)
|
||||
if not set(value) <= set(allowed) or not set(required) <= set(value):
|
||||
raise HTTPException(400, 'Invalid chat fields')
|
||||
return value
|
||||
|
||||
@router.get('/api/capabilities')
|
||||
async def capabilities(request: Request, user=Depends(current_user)):
|
||||
from .chat_features import local_capabilities
|
||||
features = request.app.state.chat_runs.capabilities()
|
||||
features.update(local_capabilities(request.app.state.feature_store))
|
||||
try:
|
||||
features.update(await request.app.state.feature_provider.feature_capabilities(user['id']))
|
||||
except HTTPException:
|
||||
features.update({'image_input': False, 'scheduled_tasks': False, 'memory_controls': False, 'connectors': False, 'research': False})
|
||||
return {'version': 1, 'features': features, 'limits': {'message_bytes': 6000, 'page_size': 100, 'attachments': request.app.state.feature_store.limits.attachments_per_message}}
|
||||
|
||||
@router.get('/api/conversations')
|
||||
async def conversations(request: Request, q: str = '', workspace_id: str | None = None, archived: bool = False, cursor: str | None = None, limit: int = 50, user=Depends(current_user)):
|
||||
return await db(request, 'list_conversations', user['id'], q=q, workspace_id=workspace_id, archived=archived, cursor=cursor, limit=limit)
|
||||
|
||||
@router.post('/api/conversations')
|
||||
async def create_conversation(request: Request, user=Depends(current_user)):
|
||||
return {'conversation': await db(request, 'create_conversation', user['id'], **await body(request, ('title','workspace_id','temporary')))}
|
||||
|
||||
@router.get('/api/conversations/{cid}')
|
||||
async def conversation(cid: str, request: Request, cursor: str | None = None, limit: int = 50, user=Depends(current_user)):
|
||||
return await db(request, 'get_conversation', user['id'], cid, cursor=cursor, limit=limit)
|
||||
|
||||
@router.patch('/api/conversations/{cid}')
|
||||
async def update_conversation(cid: str, request: Request, user=Depends(current_user)):
|
||||
return {'conversation': await db(request, 'update_conversation', user['id'], cid, **await body(request, ('revision','title','workspace_id','pinned','pin_rank','archived'), ('revision',)))}
|
||||
|
||||
@router.delete('/api/conversations/{cid}')
|
||||
async def delete_conversation(cid: str, request: Request, user=Depends(current_user)):
|
||||
from .chat_store import ChatConflict
|
||||
try:
|
||||
await request.app.state.chat_runs.cancel_conversation(user['id'], cid)
|
||||
except ChatConflict:
|
||||
raise HTTPException(409, 'Cancellation pending; retry deletion after execution stops') from None
|
||||
except KeyError:
|
||||
raise HTTPException(404, 'Chat resource not found') from None
|
||||
detail = await db(request, 'get_conversation', user['id'], cid)
|
||||
if any(run['status'] not in TERMINAL for run in detail['runs']):
|
||||
raise HTTPException(409, 'Cancellation pending; retry deletion after execution stops')
|
||||
await db(request, 'delete_conversation', user['id'], cid)
|
||||
return {'ok': True}
|
||||
|
||||
@router.post('/api/conversations/{cid}/branch')
|
||||
async def branch(cid: str, request: Request, user=Depends(current_user)):
|
||||
return {'conversation': await db(request, 'branch', user['id'], cid, **await body(request, ('message_id','content','title'), ('message_id',)))}
|
||||
|
||||
@router.post('/api/conversations/{cid}/messages', status_code=202)
|
||||
async def send(cid: str, request: Request, user=Depends(current_user)):
|
||||
value = await body(request, ('content','idempotency_key','attachment_ids','mode'), ('content','idempotency_key'))
|
||||
return await request.app.state.chat_runs.send(user['id'], cid, request.state.session_token, **value)
|
||||
|
||||
@router.get('/api/runs/{rid}')
|
||||
async def run(rid: str, request: Request, user=Depends(current_user)):
|
||||
return await db(request, 'get_run', user['id'], rid)
|
||||
|
||||
async def event_response(request, owner, rid, valid, after):
|
||||
await db(request, 'get_run', owner, rid)
|
||||
try:
|
||||
after = max(after, int(request.headers.get('last-event-id', '0')))
|
||||
except ValueError:
|
||||
raise HTTPException(400, 'Invalid event cursor') from None
|
||||
if after < 0:
|
||||
raise HTTPException(400, 'Invalid event cursor')
|
||||
async def stream():
|
||||
sequence = after
|
||||
while not await request.is_disconnected():
|
||||
if not await valid():
|
||||
return
|
||||
try:
|
||||
items = await asyncio.to_thread(request.app.state.chat_store.events, owner, rid, sequence)
|
||||
detail = await asyncio.to_thread(request.app.state.chat_store.get_run, owner, rid)
|
||||
except KeyError:
|
||||
return
|
||||
for item in items:
|
||||
sequence = item['sequence']
|
||||
yield f"id: {sequence}\nevent: {item['event']}\ndata: {json.dumps(item['data'], ensure_ascii=False)}\n\n"
|
||||
if not items and detail['run']['status'] in TERMINAL:
|
||||
return
|
||||
if not items:
|
||||
yield ': keepalive\n\n'
|
||||
await asyncio.sleep(.25)
|
||||
return StreamingResponse(stream(), media_type='text/event-stream', headers={'Cache-Control':'no-store','X-Accel-Buffering':'no'})
|
||||
|
||||
@router.get('/api/runs/{rid}/events')
|
||||
async def events(rid: str, request: Request, after: int = 0, user=Depends(current_user)):
|
||||
async def valid():
|
||||
account = await asyncio.to_thread(request.app.state.accounts.resolve_session, request.state.session_token)
|
||||
return account is not None and account['id'] == user['id']
|
||||
return await event_response(request, user['id'], rid, valid, after)
|
||||
|
||||
@router.post('/api/runs/{rid}/cancel')
|
||||
async def cancel(rid: str, request: Request, user=Depends(current_user)):
|
||||
return {'run': await request.app.state.chat_runs.cancel(user['id'], rid)}
|
||||
|
||||
@router.post('/api/runs/{rid}/approval')
|
||||
async def approval(rid: str, request: Request, user=Depends(current_user)):
|
||||
return {'run': await request.app.state.chat_runs.approval(user['id'], rid, **await body(request, ('approval_id','decision'), ('approval_id','decision')))}
|
||||
|
||||
@router.get('/api/workspaces')
|
||||
async def workspaces(request: Request, user=Depends(current_user)):
|
||||
return {'workspaces': await db(request, 'list_workspaces', user['id'])}
|
||||
|
||||
@router.post('/api/workspaces')
|
||||
async def create_workspace(request: Request, user=Depends(current_user)):
|
||||
return {'workspace': await db(request, 'create_workspace', user['id'], **await body(request, ('name','instructions'), ('name',)))}
|
||||
|
||||
@router.patch('/api/workspaces/{wid}')
|
||||
async def update_workspace(wid: str, request: Request, user=Depends(current_user)):
|
||||
return {'workspace': await db(request, 'update_workspace', user['id'], wid, **await body(request, ('revision','name','instructions'), ('revision',)))}
|
||||
|
||||
@router.delete('/api/workspaces/{wid}')
|
||||
async def delete_workspace(wid: str, request: Request, user=Depends(current_user)):
|
||||
await db(request, 'delete_workspace', user['id'], wid)
|
||||
return {'ok': True}
|
||||
|
||||
@router.get('/api/preferences')
|
||||
async def preferences(request: Request, user=Depends(current_user)):
|
||||
return {'preferences': await db(request, 'get_preferences', user['id'])}
|
||||
|
||||
@router.put('/api/preferences')
|
||||
async def set_preferences(request: Request, user=Depends(current_user)):
|
||||
return {'preferences': await db(request, 'set_preferences', user['id'], **await body(request, ('revision','palette','appearance'), ('revision','palette','appearance')))}
|
||||
|
||||
@router.get('/api/sync')
|
||||
async def sync(request: Request, after: int = 0, user=Depends(current_user)):
|
||||
return await db(request, 'sync', user['id'], after=after)
|
||||
|
||||
async def room_owner(request, room):
|
||||
account = await asyncio.to_thread(request.app.state.accounts.resolve_room, room)
|
||||
if not account or not account.get('conversation_id'):
|
||||
raise HTTPException(404, 'Room chat unavailable')
|
||||
return account
|
||||
|
||||
@router.post('/internal/chat/rooms/{room}/messages', dependencies=[Depends(internal_authorized)], status_code=202)
|
||||
async def voice_send(room: str, request: Request):
|
||||
account = await room_owner(request, room)
|
||||
value = await body(request, ('content','idempotency_key','modality'), ('content','idempotency_key','modality'))
|
||||
if value.pop('modality') != 'voice':
|
||||
raise HTTPException(400, 'Invalid modality')
|
||||
return await request.app.state.chat_runs.send(account['id'], account['conversation_id'], None, room=room, **value)
|
||||
|
||||
@router.get('/internal/chat/rooms/{room}/runs/{rid}/events', dependencies=[Depends(internal_authorized)])
|
||||
async def voice_events(room: str, rid: str, request: Request, after: int = 0):
|
||||
account = await room_owner(request, room)
|
||||
detail = await db(request, 'get_run', account['id'], rid)
|
||||
if detail['run']['conversation_id'] != account['conversation_id']:
|
||||
raise HTTPException(404, 'Run not found')
|
||||
async def valid():
|
||||
return await asyncio.to_thread(request.app.state.accounts.resolve_room, room) is not None
|
||||
return await event_response(request, account['id'], rid, valid, after)
|
||||
|
||||
@router.post('/internal/chat/rooms/{room}/runs/{rid}/cancel', dependencies=[Depends(internal_authorized)])
|
||||
async def voice_cancel(room: str, rid: str, request: Request):
|
||||
account = await room_owner(request, room)
|
||||
detail = await db(request, 'get_run', account['id'], rid)
|
||||
if detail['run']['conversation_id'] != account['conversation_id']:
|
||||
raise HTTPException(404, 'Run not found')
|
||||
return {'run': await request.app.state.chat_runs.cancel(account['id'], rid)}
|
||||
|
||||
|
||||
def create_chat_router():
|
||||
return router
|
||||
@@ -0,0 +1,499 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user