feat: qualify native Hermes research and private conversation exports

This commit is contained in:
snowspeeder committed 2026-10-03 08:56:18 -04:00
1 parent fb3178d34b
commit e048793b02
5 files changed
+206 -6

No files matched your search

+53
View File
@@ -0,0 +1,53 @@
"""Private bounded conversation exports; no public sharing or provider execution."""
import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from .app import current_user
MAX_EXPORT_BYTES = 16 * 1024 * 1024
MAX_EXPORT_MESSAGES = 10_000
def export_conversation(store, owner, identifier, format='json', byte_limit=MAX_EXPORT_BYTES):
if format not in ('json', 'markdown'):
raise HTTPException(422, 'Export format must be json or markdown')
with store.lock:
try:
conversation = store._conversation(store._row('chat_conversations', owner, identifier))
except (KeyError, ValueError):
raise HTTPException(404, 'Conversation not found') from None
count, content_bytes = store.db.execute('SELECT COUNT(*),COALESCE(SUM(length(CAST(content AS BLOB))),0) FROM chat_messages WHERE owner=? AND conversation_id=?', (owner, identifier)).fetchone()
if count > MAX_EXPORT_MESSAGES or content_bytes > byte_limit:
raise HTTPException(413, 'Conversation export limit exceeded')
messages = [store._message(row) for row in store.db.execute('SELECT * FROM chat_messages WHERE owner=? AND conversation_id=? ORDER BY position', (owner, identifier))]
if format == 'json':
# Only public projection; no Hermes IDs, API URLs, credentials or tool events.
encoded = json.dumps({'version': 1, 'conversation': conversation, 'messages': messages}, ensure_ascii=False, indent=2).encode('utf-8')
mime, suffix = 'application/json', '.json'
else:
# Preserve original message text/citations. Always attachment+plain text:
# user markdown/HTML is never executed or rendered by this API.
sections = ['# ' + conversation['title'], '']
for message in messages:
sections += ['## ' + message['role'].capitalize() + ' — ' + message['status'], '', message['content'], '']
if message['attachments']:
sections += ['Attachments (IDs; private bytes not embedded): ' + ', '.join(message['attachments']), '']
encoded = '\n'.join(sections).encode('utf-8')
mime, suffix = 'text/plain', '.md'
if len(encoded) > byte_limit:
raise HTTPException(413, 'Conversation export limit exceeded')
return encoded, mime, 'conversation-' + conversation['id'] + suffix
def create_export_router():
router = APIRouter()
@router.get('/api/conversations/{identifier}/export')
async def conversation_export(identifier: str, request: Request, format: str = 'json', user=Depends(current_user)):
data, mime, filename = await asyncio.to_thread(export_conversation, request.app.state.chat_store, user['id'], identifier, format)
await current_user(request) # Revocation during export denies final response.
return Response(data, media_type=mime, headers={'Content-Disposition': 'attachment; filename="' + filename + '"',
'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'})
return router
+49 -3
View File
@@ -361,6 +361,36 @@ class FeatureProvider:
raise HTTPException(503, 'Hermes not ready')
return user
async def toolset_status(self, owner):
user = await self._account(owner)
try:
response = await self.http.get(user['hermes_url'].rstrip('/') + '/v1/toolsets', headers={'Authorization': 'Bearer ' + user['hermes_api_key']})
if response.status_code != 200:
raise ValueError()
body = response.json()
if not isinstance(body, dict) or not isinstance(body.get('data'), list):
raise ValueError()
return [{'name': row['name'], 'enabled': row.get('enabled') is True, 'configured': row.get('configured') is True,
'tools': [tool for tool in row.get('tools', []) if isinstance(tool, str)][:200]}
for row in body['data'][:100] if isinstance(row, dict) and isinstance(row.get('name'), str) and isinstance(row.get('tools'), list)]
except Exception:
raise HTTPException(503, 'Hermes tool capability status unavailable') from None
async def research_status(self, owner):
toolsets = await self.toolset_status(owner)
available = any(row['name'] == 'web' and row['enabled'] and row['configured'] and
{'web_search', 'web_extract'} <= set(row['tools']) for row in toolsets)
return {'available': available, 'mode': 'hermes_tools' if available else None,
'reason': None if available else 'Hermes web search and extraction must be enabled and configured'}
async def research_instruction(self, owner):
status = await self.research_status(owner)
if not status['available']:
raise HTTPException(409, 'Research tools are unavailable for this account')
return ('Research mode requested by the user. Use your Hermes web search and extraction tools to investigate the question. '
'Prefer primary sources, corroborate material claims, distinguish evidence from uncertainty, and cite source URLs in the final answer. '
'Do not claim sources were checked if tools failed or access was unavailable. Complete this research in this same Hermes run.')
async def feature_capabilities(self, owner):
await self._account(owner)
settings = await asyncio.to_thread(self.accounts.get_model_settings, owner)
@@ -372,8 +402,12 @@ class FeatureProvider:
return True
except HTTPException:
return False
try:
research = (await self.research_status(owner))['available']
except HTTPException:
research = False
return {'scheduled_tasks': True, 'memory_controls': await asyncio.to_thread(memory_available),
'image_input': settings['model'] == 'auto', 'connectors': False, 'research': False}
'image_input': settings['model'] == 'auto', 'connectors': False, 'research': research}
async def feature_request(self, owner, method, path, *, json=None, params=None):
user = await self._account(owner)
@@ -712,12 +746,24 @@ def create_feature_router(store, provider=None):
@router.get('/api/connectors')
async def connectors(user=Depends(current_user)):
# The pinned gateway has no connector management API. Do not invent one.
return {'connectors': [], 'available': False, 'reason': 'Hermes connector management API is not qualified'}
statuses = []
if provider is not None and hasattr(provider, 'toolset_status'):
try:
statuses = await provider.toolset_status(user['id'])
except HTTPException:
pass
return {'connectors': [], 'available': False, 'reason': 'Pinned Hermes has no connector credential/settings write API',
'toolsets': [{'name': row['name'], 'enabled': row['enabled'], 'configured': row['configured']} for row in statuses]}
@router.get('/api/research')
async def research(user=Depends(current_user)):
# Research remains normal Hermes execution, never a Hope reasoning loop.
return {'available': False, 'reason': 'Dedicated research mode is not qualified; normal Hermes tool use remains available'}
if provider is not None and hasattr(provider, 'research_status'):
try:
return await provider.research_status(user['id'])
except HTTPException:
pass
return {'available': False, 'mode': None, 'reason': 'Hermes research tool availability could not be qualified'}
return router