feat: reset bot session context while retaining monitoring history

This commit is contained in:
Hyu
2026-10-10 22:35:27 +08:00
parent b8a7649ac2
commit 1c70218383
20 changed files with 408 additions and 5 deletions
@@ -0,0 +1,46 @@
"""Scoped context reset markers, independent of retained monitoring history."""
import hashlib
import json
import sqlalchemy as sa
from ...entity.persistence.runner_state import RunnerState
HOST_RESET_RUNNER = '__langbot_context_reset__'
def reset_scope(workspace, bot, conversation):
return 'host-reset:' + hashlib.sha256(json.dumps([workspace, bot, conversation]).encode()).hexdigest()
async def get_reset_generation(engine, workspace, bot, conversation):
if not all((workspace, bot, conversation)):
return None
async with engine.connect() as connection:
value = (
await connection.execute(
sa.select(RunnerState.value_json).where(
RunnerState.scope_key == reset_scope(workspace, bot, conversation),
RunnerState.state_key == 'generation',
RunnerState.runner_id == HOST_RESET_RUNNER,
)
)
).scalar_one_or_none()
return json.loads(value) if value else None
def active_history_condition(transcript):
cutoff = (
sa.select(sa.func.max(RunnerState.created_at))
.where(
RunnerState.runner_id == HOST_RESET_RUNNER,
RunnerState.state_key == 'generation',
RunnerState.workspace_id == transcript.workspace_id,
RunnerState.bot_id == transcript.bot_id,
RunnerState.conversation_id == transcript.conversation_id,
)
.correlate(transcript)
.scalar_subquery()
)
return sa.or_(cutoff.is_(None), transcript.created_at > cutoff)
@@ -191,7 +191,9 @@ class PersistentStateStore:
# Seed external.conversation_id from event.conversation_id if not set
if self._check_scope_enabled('conversation', binding) and event.conversation_id:
if 'external.conversation_id' not in snapshot['conversation']:
snapshot['conversation']['external.conversation_id'] = event.conversation_id
from .context_reset import get_reset_generation
generation = await get_reset_generation(self._db_engine, event.workspace_id, event.bot_id, event.conversation_id)
snapshot['conversation']['external.conversation_id'] = generation or event.conversation_id
return snapshot
@@ -335,6 +335,8 @@ class TranscriptStore:
thread_id: str | None,
strict_thread: bool,
) -> typing.Any:
from .context_reset import active_history_condition
query = query.where(active_history_condition(Transcript))
if bot_id is not None:
query = query.where(Transcript.bot_id == bot_id)
if workspace_id is not None:
@@ -545,6 +545,24 @@ class MonitoringRouterGroup(group.RouterGroup):
}
)
@self.route('/sessions/reset-context', methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, permission=Permission.RESOURCE_MANAGE)
async def reset_session_context(request_context: RequestContext):
body = await quart.request.get_json()
if not isinstance(body, dict):
return self.http_status(400, 'invalid_request', 'Expected a JSON object')
try:
result = await self.ap.monitoring_service.reset_session_context(
request_context, body.get('bot_id'), body.get('session_id'),
)
except ValueError as exc:
return self.http_status(400, 'invalid_request', str(exc))
except LookupError as exc:
return self.http_status(404, 'resource_not_found', str(exc))
except RuntimeError as exc:
return self.http_status(409, 'session_busy', str(exc))
return self.success(data=result)
@self.route(
'/sessions/<session_id>/analysis',
methods=['GET'],
@@ -2193,6 +2193,60 @@ class MonitoringService(ExecutionDetailsMixin):
total,
)
@_workspace_transaction
async def reset_session_context(self, context: TenantContext, bot_id: str, session_id: str) -> dict:
"""Reset model context without deleting monitoring history or other sessions."""
from ....agent.runner.context_reset import HOST_RESET_RUNNER, reset_scope
from ....entity.persistence.runner_state import RunnerState
if not isinstance(bot_id, str) or not bot_id or not isinstance(session_id, str) or not session_id:
raise ValueError('bot_id and session_id are required')
workspace = require_workspace_uuid(context)
execute = self.ap.persistence_mgr.execute_async
source = self._session_message_source(workspace, [bot_id])
exists = (await execute(sqlalchemy.select(source.c.session_id).where(
source.c.session_id == session_id,
).limit(1))).first()
if exists is None:
session = persistence_monitoring.MonitoringSession
exists = (await execute(sqlalchemy.select(session.session_id).where(
session.workspace_uuid == workspace, session.bot_id == bot_id, session.session_id == session_id,
).limit(1))).first()
if exists is None:
raise LookupError('Session not found')
# Legacy sessions use uppercase PERSON/GROUP; envelope IDs use lowercase.
conversation = re.sub(r'^(PERSON|GROUP)_', lambda match: match[1].lower() + '_', session_id)
run = persistence_agent_run.AgentRun
busy = (await execute(sqlalchemy.select(run.run_id).where(
run.workspace_id == workspace, run.bot_id == bot_id, run.conversation_id == conversation,
run.status.in_(['created', 'queued', 'claimed', 'running']),
).limit(1))).first()
message = persistence_monitoring.MonitoringMessage
pending = (await execute(sqlalchemy.select(message.id).where(
message.workspace_uuid == workspace, message.bot_id == bot_id,
message.session_id.in_([session_id, conversation]), message.status == 'pending',
).limit(1))).first()
interaction = persistence_agent_interaction.AgentInteraction
waiting = (await execute(sqlalchemy.select(interaction.id).where(
interaction.workspace_id == workspace, interaction.bot_id == bot_id,
interaction.conversation_id == conversation, interaction.status.in_(['pending', 'submitted']),
sqlalchemy.or_(interaction.expires_at.is_(None), interaction.expires_at > datetime.datetime.utcnow()),
).limit(1))).first()
if busy or pending or waiting:
raise RuntimeError('Session is processing a task; wait until it finishes')
await execute(sqlalchemy.delete(RunnerState).where(
RunnerState.workspace_id == workspace, RunnerState.bot_id == bot_id,
RunnerState.conversation_id == conversation, RunnerState.scope == 'conversation',
))
generation = str(uuid.uuid4())
await execute(sqlalchemy.insert(RunnerState).values(
runner_id=HOST_RESET_RUNNER, binding_identity=HOST_RESET_RUNNER, scope='conversation',
scope_key=reset_scope(workspace, bot_id, conversation), state_key='generation',
value_json=json.dumps(generation), workspace_id=workspace, bot_id=bot_id,
conversation_id=conversation, created_at=datetime.datetime.utcnow(),
))
return {'reset': True, 'session_id': session_id}
async def get_sessions(
self,
context: TenantContext,
+6
View File
@@ -211,6 +211,12 @@ class LangBotMCPServer:
async def get_inflight_executions() -> str:
return _dump(await ap.monitoring_service.get_inflight_snapshot(_authorized(Permission.RESOURCE_VIEW)))
@mcp.tool(description='Reset a bot session model context, preserving monitoring history. Refuses busy sessions.')
async def reset_session_context(bot_id: str, session_id: str) -> str:
return _dump(await ap.monitoring_service.reset_session_context(
_authorized(Permission.RESOURCE_MANAGE), bot_id, session_id,
))
@mcp.tool(
description='List Workspace executions, including events that did not start a processor. The legacy pipeline_ids filter accepts Agent, Pipeline and event processor IDs. Returns bounded rows and summary metrics.'
)
@@ -271,8 +271,19 @@ class SessionManager:
async def get_session(self, query: pipeline_query.Query) -> provider_session.Session:
"""获取会话"""
session_key, execution_context = _query_session_key(query)
from ...agent.runner.context_reset import get_reset_generation
generation = None
persistence = getattr(self.ap, 'persistence_mgr', None)
if persistence is not None:
generation = await get_reset_generation(
persistence.get_db_engine(), execution_context.workspace_uuid, query.bot_uuid,
f'{str(query.launcher_type.value).lower()}_{query.launcher_id}',
)
now = time.monotonic()
session = self._session_index.get(session_key)
if session is not None and getattr(session, '_context_generation', None) != generation:
self._remove_session(session)
session = None
if session is not None:
self._touch_session(session, session_key, now)
return session
@@ -319,6 +330,7 @@ class SessionManager:
object.__setattr__(session, 'bot_uuid', query.bot_uuid)
object.__setattr__(session, '_execution_context', session_context)
object.__setattr__(session, '_langbot_session_key', session_key)
object.__setattr__(session, '_context_generation', generation)
object.__setattr__(session, '_langbot_session_concurrency', session_concurrency)
session._semaphore = asyncio.Semaphore(session_concurrency)
self._session_index[session_key] = session