Files
LangBot/src/langbot/pkg/api/http/service/agent.py
T

448 lines
18 KiB
Python

from __future__ import annotations
import datetime
import fnmatch
import time
import uuid
import typing
import sqlalchemy
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.event import (
ActorContext,
RawEventRef,
SubjectContext,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from ....core import app
from ....agent.runner.config_resolver import RunnerConfigResolver
from ....agent.runner.host_models import (
AgentBinding,
AgentEventEnvelope,
BindingScope,
DeliveryPolicy,
StatePolicy,
)
from ....agent.runner.resource_policy import ResourcePolicyProjector
from ....entity.persistence import agent as persistence_agent
from ....workspace.errors import WorkspaceNotFoundError
from ..context import ExecutionContext, RequestContext
from .tenant import TenantContext, require_workspace_uuid, scope_statement
AGENT_KIND_AGENT = 'agent'
AGENT_KIND_PIPELINE = 'pipeline'
PIPELINE_EVENT_PATTERNS = ['message.*']
AGENT_DEFAULT_EVENT_PATTERNS = ['*']
class AgentService:
"""Unified processor facade for the peer Agent and Pipeline types."""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_agent_metadata(self, context: TenantContext) -> dict[str, typing.Any]:
"""Return metadata needed by Agent forms."""
pipeline_metadata = await self.ap.pipeline_service.get_pipeline_metadata(context)
ai_metadata = next((item for item in pipeline_metadata if item.get('name') == 'ai'), None)
return {
'runner_config': ai_metadata,
'kinds': [
{
'name': AGENT_KIND_AGENT,
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
'message_only': False,
},
{
'name': AGENT_KIND_PIPELINE,
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
},
],
}
async def get_agents(
self,
context: TenantContext,
sort_by: str = 'updated_at',
sort_order: str = 'DESC',
) -> list[dict]:
agents = await self._get_agent_rows(context)
pipelines = await self.ap.pipeline_service.get_pipelines(
context,
sort_by='updated_at',
sort_order='DESC',
)
items = [self._agent_to_product_item(agent) for agent in agents]
items.extend(self._pipeline_to_product_item(pipeline) for pipeline in pipelines)
reverse = sort_order == 'DESC'
sort_key = sort_by if sort_by in {'created_at', 'updated_at'} else 'updated_at'
return sorted(items, key=lambda item: self._parse_sort_time(item.get(sort_key)), reverse=reverse)
async def get_agent(self, context: TenantContext, agent_uuid: str) -> dict | None:
agent = await self._get_agent_row(context, agent_uuid)
if agent is not None:
return self._agent_to_product_item(agent, include_config=True)
pipeline = await self.ap.pipeline_service.get_pipeline(context, agent_uuid)
if pipeline is not None:
return self._pipeline_to_product_item(pipeline, include_config=True)
return None
async def debug_agent(
self,
context: RequestContext,
agent_uuid: str,
payload: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Execute one synthetic event against a configured Agent.
The debug surface uses a trusted Workspace execution context, never
delivers outputs to a real platform, and supports both message and
non-message event envelopes.
"""
agent = await self.get_agent(context, agent_uuid)
if agent is None or agent.get('kind') != AGENT_KIND_AGENT:
raise ValueError('Agent not found')
event_type = str(payload.get('event_type') or 'message.received').strip()
if not event_type or len(event_type) > 128:
raise ValueError('Invalid event_type')
if not self._supports_event_type(
agent.get('supported_event_patterns'),
event_type,
):
raise ValueError('Agent does not support this event type')
text = str(payload.get('text') or '').strip()
if len(text) > 20_000:
raise ValueError('Debug input is too long')
event_data = payload.get('data') or {}
if not isinstance(event_data, dict):
raise ValueError('Debug event data must be an object')
config = agent.get('config')
if not isinstance(config, dict):
raise ValueError('Agent configuration is invalid')
_, runner_id, runner_config = RunnerConfigResolver.resolve_agent_runner_config(config)
if not runner_id:
raise ValueError('Agent has no configured runner')
conversation_id = str(payload.get('conversation_id') or f'debug:{agent_uuid}').strip()
if not conversation_id or len(conversation_id) > 256:
raise ValueError('Invalid debug conversation_id')
actor_payload = payload.get('actor') or {
'actor_type': 'user',
'actor_id': 'debug-user',
'actor_name': 'Debug User',
}
subject_payload = payload.get('subject') or {
'subject_type': 'message' if event_type.startswith('message.') else event_type.split('.', 1)[0],
'subject_id': 'debug-subject',
'data': event_data,
}
if not isinstance(actor_payload, dict) or not isinstance(subject_payload, dict):
raise ValueError('Debug actor and subject must be objects')
event_id = f'debug:{agent_uuid}:{uuid.uuid4()}'
event = AgentEventEnvelope(
event_id=event_id,
event_type=event_type,
event_time=int(time.time()),
source='webui',
source_event_type=event_type,
workspace_id=context.workspace_uuid,
conversation_id=conversation_id,
actor=ActorContext.model_validate(actor_payload),
subject=SubjectContext.model_validate(subject_payload),
input=AgentInput.model_validate(
{
'text': text or event_type,
'contents': [
{'type': 'text', 'text': text or event_type},
],
'attachments': [],
}
),
delivery=DeliveryContext(
surface='webui',
reply_target=None,
supports_streaming=False,
supports_edit=False,
supports_reaction=False,
platform_capabilities={
'event_type': event_type,
'debug': True,
},
),
raw_ref=RawEventRef(ref_id=event_id, storage_key=None),
data=event_data,
)
binding = AgentBinding(
binding_id=f'debug:{agent_uuid}:{runner_id}',
scope=BindingScope(scope_type='agent', scope_id=agent_uuid),
event_types=[event_type],
runner_id=runner_id,
runner_config=runner_config,
resource_policy=ResourcePolicyProjector.from_runner_config(runner_config),
state_policy=StatePolicy(
state_scopes=['conversation', 'actor', 'subject', 'runner'],
),
delivery_policy=DeliveryPolicy(
enable_streaming=False,
enable_reply=False,
enable_interactions=False,
),
enabled=True,
agent_id=agent_uuid,
processor_type='agent',
processor_id=agent_uuid,
)
execution_context = ExecutionContext.from_request(
context,
query_uuid=event_id,
)
output_items: list[dict[str, typing.Any]] = []
final_text = ''
async for output in self.ap.agent_run_orchestrator.run(
event,
binding,
adapter_context={'_execution_context': execution_context},
):
output_text = self._provider_output_to_text(output)
if output_text:
final_text = output_text
output_items.append(
{
'kind': output.__class__.__name__,
'role': str(getattr(output, 'role', '') or ''),
'text': output_text,
}
)
return {
'event_id': event_id,
'event_type': event_type,
'conversation_id': conversation_id,
'final_text': final_text,
'outputs': output_items,
}
@staticmethod
def _supports_event_type(patterns: typing.Any, event_type: str) -> bool:
normalized = patterns if isinstance(patterns, list) else AGENT_DEFAULT_EVENT_PATTERNS
return any(isinstance(pattern, str) and fnmatch.fnmatchcase(event_type, pattern) for pattern in normalized)
@staticmethod
def _provider_output_to_text(output: typing.Any) -> str:
all_content = getattr(output, 'all_content', None)
if all_content:
return str(all_content)
content = getattr(output, 'content', None)
if content is None:
return ''
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
item_data = item.model_dump(mode='json') if hasattr(item, 'model_dump') else item
if isinstance(item_data, dict) and item_data.get('text') is not None:
parts.append(str(item_data['text']))
elif item_data is not None and not isinstance(item_data, dict):
parts.append(str(item_data))
return ''.join(parts)
return str(content)
async def create_agent(self, context: TenantContext, agent_data: dict) -> dict[str, str]:
workspace_uuid = require_workspace_uuid(context)
kind = agent_data.get('kind') or AGENT_KIND_AGENT
if kind == AGENT_KIND_PIPELINE:
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
context,
{
'name': agent_data.get('name') or 'New Pipeline',
'description': agent_data.get('description') or '',
'emoji': agent_data.get('emoji') or '⚙️',
'config': {},
},
)
return {'uuid': pipeline_uuid, 'kind': AGENT_KIND_PIPELINE}
if kind != AGENT_KIND_AGENT:
raise ValueError(f'Unsupported agent kind: {kind}')
config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config(context)
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(config)
new_uuid = str(uuid.uuid4())
values = {
'workspace_uuid': workspace_uuid,
'uuid': new_uuid,
'name': agent_data.get('name') or 'New Agent',
'description': agent_data.get('description') or '',
'emoji': agent_data.get('emoji') or '🤖',
'kind': AGENT_KIND_AGENT,
'component_ref': runner_id,
'config': config,
'enabled': agent_data.get('enabled', True),
'supported_event_patterns': agent_data.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
}
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_agent.Agent).values(**values))
return {'uuid': new_uuid, 'kind': AGENT_KIND_AGENT}
async def update_agent(self, context: TenantContext, agent_uuid: str, agent_data: dict) -> None:
existing_agent = await self._get_agent_row(context, agent_uuid)
if existing_agent is None:
pipeline = await self.ap.pipeline_service.get_pipeline(context, agent_uuid)
if pipeline is None:
raise ValueError(f'Agent {agent_uuid} not found')
await self.ap.pipeline_service.update_pipeline(context, agent_uuid, agent_data)
return
update_data = agent_data.copy()
for protected_field in (
'uuid',
'workspace_uuid',
'kind',
'component_ref',
'created_at',
'updated_at',
'capability',
):
update_data.pop(protected_field, None)
if 'config' in update_data:
config, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(update_data['config'])
update_data['config'] = config
else:
_, runner_id, _ = RunnerConfigResolver.resolve_agent_runner_config(existing_agent.config)
update_data['component_ref'] = runner_id
if 'supported_event_patterns' in update_data and not update_data['supported_event_patterns']:
update_data['supported_event_patterns'] = AGENT_DEFAULT_EVENT_PATTERNS
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.update(persistence_agent.Agent)
.where(persistence_agent.Agent.uuid == agent_uuid)
.values(**update_data),
persistence_agent.Agent,
context,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Agent not found')
async def delete_agent(self, context: TenantContext, agent_uuid: str) -> None:
existing_agent = await self._get_agent_row(context, agent_uuid)
if existing_agent is not None:
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.delete(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid),
persistence_agent.Agent,
context,
)
)
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Agent not found')
return
pipeline = await self.ap.pipeline_service.get_pipeline(context, agent_uuid)
if pipeline is None:
raise ValueError(f'Agent {agent_uuid} not found')
await self.ap.pipeline_service.delete_pipeline(context, agent_uuid)
async def _get_agent_rows(self, context: TenantContext) -> list[persistence_agent.Agent]:
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_agent.Agent),
persistence_agent.Agent,
context,
)
)
return list(result.all())
async def _get_agent_row(
self,
context: TenantContext,
agent_uuid: str,
) -> persistence_agent.Agent | None:
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == agent_uuid),
persistence_agent.Agent,
context,
)
)
return result.first()
async def _get_default_agent_config(self, context: TenantContext) -> dict[str, typing.Any]:
runners = []
if getattr(self.ap, 'agent_runner_registry', None) is not None:
try:
runners = await self.ap.agent_runner_registry.list_runners(context, bound_plugins=None)
except Exception as e:
if getattr(self.ap, 'logger', None):
self.ap.logger.warning(f'Failed to load plugin agent runners for default agent config: {e}')
if not runners:
return {'runner': {'id': '', 'expire-time': 0}, 'runner_config': {}}
selected_runner = runners[0]
return {
'runner': {'id': selected_runner.id, 'expire-time': 0},
'runner_config': {
selected_runner.id: self.ap.pipeline_service._get_default_values_from_schema(
selected_runner.config_schema
)
},
}
def _agent_to_product_item(
self,
agent: persistence_agent.Agent,
include_config: bool = False,
) -> dict[str, typing.Any]:
item = self.ap.persistence_mgr.serialize_model(persistence_agent.Agent, agent)
item['kind'] = AGENT_KIND_AGENT
item['capability'] = {
'supported_event_patterns': item.get('supported_event_patterns') or AGENT_DEFAULT_EVENT_PATTERNS,
'message_only': False,
}
if not include_config:
item.pop('config', None)
return item
@staticmethod
def _pipeline_to_product_item(pipeline: dict, include_config: bool = False) -> dict[str, typing.Any]:
item = pipeline.copy()
item['kind'] = AGENT_KIND_PIPELINE
item['component_ref'] = 'pipeline'
item['enabled'] = True
item['supported_event_patterns'] = PIPELINE_EVENT_PATTERNS
item['capability'] = {
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
}
if not include_config:
item.pop('config', None)
return item
@staticmethod
def _parse_sort_time(value: typing.Any) -> datetime.datetime:
if isinstance(value, datetime.datetime):
return value
if isinstance(value, str):
try:
return datetime.datetime.fromisoformat(value)
except ValueError:
return datetime.datetime.min
return datetime.datetime.min