mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 00:15:20 +08:00
feat: reset bot session context while retaining monitoring history
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user