mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-26 18:35:38 +08:00
merge: integrate master into lark webhook signature fix
This commit is contained in:
@@ -16,6 +16,65 @@ KB_SELECTOR_TYPES = {'knowledge-base-multi-selector'}
|
||||
PROMPT_EDITOR_TYPES = {'prompt-editor'}
|
||||
NONE_SENTINELS = {'', '__none__', '__none'}
|
||||
|
||||
# Selector field types that pick a Host resource. The Runner config form is
|
||||
# plugin-declared and open-ended (DynamicFormItemSchema allows extra types), so
|
||||
# this is a registry, not a fixed list: a new selector type is one line here and
|
||||
# is then reported by telemetry without touching the extraction call sites.
|
||||
RESOURCE_SELECTOR_TYPES: dict[str, str] = {
|
||||
'knowledge-base-multi-selector': 'knowledge_bases',
|
||||
'tool-multi-selector': 'tools',
|
||||
'mcp-server-multi-selector': 'mcp_servers',
|
||||
'skill-multi-selector': 'skills',
|
||||
}
|
||||
|
||||
|
||||
def declared_resource_selectors(descriptor: RunnerDescriptor | None) -> dict[str, list[str]]:
|
||||
"""Map each selected resource kind to the schema field names that select it.
|
||||
|
||||
Only the descriptor is read, so this reports what a Runner *can* select even
|
||||
when the current configuration leaves the field empty.
|
||||
"""
|
||||
declared: dict[str, list[str]] = {}
|
||||
if descriptor is None:
|
||||
return declared
|
||||
for item in descriptor.config_schema or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
kind = RESOURCE_SELECTOR_TYPES.get(normalize_schema_item_type(item.get('type')))
|
||||
field_name = item.get('name')
|
||||
if not kind or not isinstance(field_name, str) or not field_name:
|
||||
continue
|
||||
declared.setdefault(kind, [])
|
||||
if field_name not in declared[kind]:
|
||||
declared[kind].append(field_name)
|
||||
return declared
|
||||
|
||||
|
||||
def extract_selected_resources(
|
||||
descriptor: RunnerDescriptor | None,
|
||||
runner_config: dict[str, typing.Any] | None,
|
||||
) -> dict[str, list[str]]:
|
||||
"""Extract the resource ids selected through schema-declared selector fields.
|
||||
|
||||
Field names are never hard-coded: they come from the Runner's own config
|
||||
schema, so a form that renames or adds a selector keeps working.
|
||||
"""
|
||||
selected: dict[str, list[str]] = {}
|
||||
if not isinstance(runner_config, dict):
|
||||
return selected
|
||||
for kind, field_names in declared_resource_selectors(descriptor).items():
|
||||
values: list[str] = []
|
||||
for field_name in field_names:
|
||||
value = runner_config.get(field_name)
|
||||
if isinstance(value, str):
|
||||
if value not in NONE_SENTINELS:
|
||||
values.append(value)
|
||||
elif isinstance(value, (list, tuple)):
|
||||
values.extend(item for item in value if isinstance(item, str) and item not in NONE_SENTINELS)
|
||||
if values:
|
||||
selected[kind] = list(dict.fromkeys(values))
|
||||
return selected
|
||||
|
||||
|
||||
def normalize_schema_item_type(item_type: typing.Any) -> typing.Any:
|
||||
"""Normalize legacy/frontend DynamicForm aliases to protocol field types."""
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Scoped context reset markers, independent of retained monitoring history."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import datetime
|
||||
import uuid
|
||||
|
||||
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 reset_runner_context(persistence, workspace, bot, conversation):
|
||||
"""Reset scoped Runner state and history visibility within the caller's transaction."""
|
||||
await persistence.execute_async(
|
||||
sa.delete(RunnerState).where(
|
||||
RunnerState.workspace_id == workspace,
|
||||
RunnerState.bot_id == bot,
|
||||
RunnerState.conversation_id == conversation,
|
||||
RunnerState.scope == 'conversation',
|
||||
)
|
||||
)
|
||||
await persistence.execute_async(
|
||||
sa.insert(RunnerState).values(
|
||||
runner_id=HOST_RESET_RUNNER,
|
||||
binding_identity=HOST_RESET_RUNNER,
|
||||
scope='conversation',
|
||||
scope_key=reset_scope(workspace, bot, conversation),
|
||||
state_key='generation',
|
||||
value_json=json.dumps(str(uuid.uuid4())),
|
||||
workspace_id=workspace,
|
||||
bot_id=bot,
|
||||
conversation_id=conversation,
|
||||
created_at=datetime.datetime.utcnow(),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -128,7 +128,33 @@ class EventLogStore:
|
||||
created_at=as_naive_utc(_utc_now()),
|
||||
)
|
||||
session.add(event)
|
||||
await session.commit()
|
||||
try:
|
||||
await session.commit()
|
||||
except sqlalchemy.exc.IntegrityError:
|
||||
await session.rollback()
|
||||
existing = (
|
||||
(await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)))
|
||||
.scalars()
|
||||
.first()
|
||||
)
|
||||
if existing is None or existing.workspace_id != workspace_id or existing.bot_id != bot_id:
|
||||
raise
|
||||
# Complete the ingress snapshot once a runner claims it. A
|
||||
# second subscriber must not replace another run's identity.
|
||||
for field, value in {
|
||||
'run_id': run_id,
|
||||
'runner_id': runner_id,
|
||||
'conversation_id': conversation_id,
|
||||
'thread_id': thread_id,
|
||||
'actor_id': actor_id,
|
||||
'actor_name': actor_name,
|
||||
}.items():
|
||||
if getattr(existing, field) is None and value is not None:
|
||||
setattr(existing, field, value)
|
||||
if input_json and ('contents' in input_json or 'attachments' in input_json):
|
||||
existing.input_json = json.dumps(input_json)
|
||||
existing.input_summary = input_summary
|
||||
await session.commit()
|
||||
|
||||
return event_id
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@ from __future__ import annotations
|
||||
import time
|
||||
import asyncio
|
||||
import contextlib
|
||||
import copy
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
@@ -18,6 +20,7 @@ from ...pipeline.pool import get_query_execution_context
|
||||
from .binding_resolver import AgentBindingResolver
|
||||
from .context_builder import RunnerContextBuilder, RunnerContextPayload
|
||||
from .descriptor import RunnerDescriptor
|
||||
from .config_schema import extract_prompt_config
|
||||
from .execution_context import (
|
||||
append_mcp_resource_context_to_event,
|
||||
build_mcp_resource_context_addition,
|
||||
@@ -37,15 +40,13 @@ from .run_journal import AgentRunJournal
|
||||
from .session_registry import AgentRunSessionRegistry, get_session_registry
|
||||
from .state_scope import build_state_context
|
||||
from ...provider.tools.loaders import skill as skill_loader
|
||||
from ...telemetry import resources as telemetry_resources
|
||||
from ...telemetry import trace as trace_mod
|
||||
from ...telemetry.execution import bind_trace as bind_execution_trace
|
||||
from ...telemetry.execution import close_trace as close_execution_trace
|
||||
from ...telemetry.execution import record as record_execution
|
||||
|
||||
|
||||
ACTIVATED_SKILL_NAMES_STATE_KEY = 'host.activated_skills'
|
||||
|
||||
|
||||
class AgentRunOrchestrator:
|
||||
"""Coordinate one Runner execution.
|
||||
|
||||
@@ -114,6 +115,12 @@ class AgentRunOrchestrator:
|
||||
|
||||
if execution_query is None:
|
||||
execution_query = build_execution_query(event, [])
|
||||
# Event runs bypass pipeline prompt preparation, just like debug runs.
|
||||
prompt_config = extract_prompt_config(descriptor, binding.runner_config, [])
|
||||
execution_query.prompt = provider_prompt.Prompt(
|
||||
name='default',
|
||||
messages=[provider_message.Message(**item) for item in copy.deepcopy(prompt_config)],
|
||||
)
|
||||
# Synthetic events must expose the same trusted scope as pipeline queries.
|
||||
for field_name in ('instance_uuid', 'workspace_uuid', 'placement_generation', 'query_uuid'):
|
||||
object.__setattr__(execution_query, field_name, getattr(execution_context, field_name))
|
||||
@@ -142,6 +149,22 @@ class AgentRunOrchestrator:
|
||||
binding=binding,
|
||||
descriptor=descriptor,
|
||||
)
|
||||
# Telemetry: report what this run was granted (never what it used). The
|
||||
# materialized resource list is authoritative for tools/skills/models;
|
||||
# the Runner's own config form is authoritative for its selectors.
|
||||
try:
|
||||
from . import config_schema as runner_config_schema
|
||||
|
||||
capabilities = getattr(descriptor, 'capabilities', None)
|
||||
telemetry_resources.note_agent(
|
||||
runner_id=descriptor.id,
|
||||
resources=resources,
|
||||
selectors=runner_config_schema.extract_selected_resources(descriptor, binding.runner_config),
|
||||
capabilities=capabilities.model_dump() if hasattr(capabilities, 'model_dump') else None,
|
||||
tools_all=getattr(binding.resource_policy, 'allow_all_tools', None),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context = await self.context_builder.build_context_from_event(
|
||||
event=execution_event,
|
||||
@@ -150,24 +173,21 @@ class AgentRunOrchestrator:
|
||||
resources=resources,
|
||||
)
|
||||
|
||||
authorized_skill_names = [
|
||||
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
|
||||
]
|
||||
# Every invocation owns a new run; conversation state is not a recovery snapshot.
|
||||
prepare_execution_query(execution_query, event, authorized_skill_names)
|
||||
execution_query.variables.pop(skill_loader.ACTIVATED_SKILLS_KEY, None)
|
||||
|
||||
session_query_id = None
|
||||
if adapter_context:
|
||||
if execution_query is not None:
|
||||
skill_loader.restore_activated_skills_from_state(
|
||||
self.ap,
|
||||
execution_query,
|
||||
context.get('state', {}),
|
||||
)
|
||||
session_query_id = adapter_context.get('query_id')
|
||||
if execution_query is not None or session_query_id is not None:
|
||||
context['context']['available_apis']['prompt_get'] = True
|
||||
if 'params' in adapter_context:
|
||||
context['adapter']['extra']['params'] = adapter_context['params']
|
||||
|
||||
authorized_skill_names = [
|
||||
str(skill['skill_name']) for skill in resources.get('skills', []) if skill.get('skill_name')
|
||||
]
|
||||
prepare_execution_query(execution_query, event, authorized_skill_names)
|
||||
context['variables'] = {
|
||||
key: value
|
||||
for key, value in (execution_query.variables or {}).items()
|
||||
@@ -177,6 +197,21 @@ class AgentRunOrchestrator:
|
||||
|
||||
state_context = build_state_context(event, binding, descriptor)
|
||||
run_id = context['run_id']
|
||||
|
||||
# Monitoring attributes every call to the processor that ran it and to the
|
||||
# record that owns it; a Runner has to claim both itself, since no
|
||||
# Pipeline did. Runs before the Runner is invoked and before the run
|
||||
# context is handed to it, so its own variables stay private.
|
||||
from ...pipeline import monitoring_helper
|
||||
|
||||
await monitoring_helper.prepare_monitoring_identity(
|
||||
self.ap,
|
||||
execution_query,
|
||||
processor_type=binding.processor_type,
|
||||
processor_id=binding.processor_id,
|
||||
runner_id=binding.runner_id,
|
||||
execution_record_id=run_id,
|
||||
)
|
||||
context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any(
|
||||
tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform'
|
||||
for tool in resources.get('tools', [])
|
||||
@@ -232,6 +267,11 @@ class AgentRunOrchestrator:
|
||||
context=context,
|
||||
authorization=run_authorization,
|
||||
)
|
||||
monitoring_id = (execution_query.variables or {}).get('_monitoring_message_id')
|
||||
if monitoring_id and monitoring_id != run_id:
|
||||
await self.ap.monitoring_service.link_execution_message(
|
||||
get_query_execution_context(execution_query), monitoring_id, run_id, event.event_id
|
||||
)
|
||||
await self._session_registry.register(
|
||||
run_id=run_id,
|
||||
runner_id=descriptor.id,
|
||||
@@ -314,16 +354,25 @@ class AgentRunOrchestrator:
|
||||
):
|
||||
continue
|
||||
|
||||
journal_started = time.monotonic()
|
||||
await self.journal.append_run_result(
|
||||
result_dict=result_dict,
|
||||
run_id=run_id,
|
||||
sequence=sequence_int,
|
||||
)
|
||||
journal_ms = (time.monotonic() - journal_started) * 1000
|
||||
|
||||
# Trusted Host observers receive validated events before message-only normalization.
|
||||
result_observer = (adapter_context or {}).get('_result_observer')
|
||||
observer_started = time.monotonic()
|
||||
if result_observer is not None:
|
||||
await result_observer(result_dict)
|
||||
observer_ms = (time.monotonic() - observer_started) * 1000
|
||||
if journal_ms + observer_ms >= 100:
|
||||
self.ap.logger.debug(
|
||||
f'Slow runner event consumer run={run_id} type={result_type} '
|
||||
f'journal_ms={journal_ms:.1f} observer_ms={observer_ms:.1f}'
|
||||
)
|
||||
|
||||
if result_type == 'state.updated':
|
||||
await self.journal.handle_state_updated_event(
|
||||
@@ -409,9 +458,15 @@ class AgentRunOrchestrator:
|
||||
terminal_status or '', 'unknown'
|
||||
)
|
||||
execution_error = '' if execution_outcome == 'success' else (terminal_reason or execution_outcome)
|
||||
except asyncio.CancelledError:
|
||||
except (asyncio.CancelledError, GeneratorExit):
|
||||
execution_outcome = 'cancelled'
|
||||
execution_error = 'cancelled'
|
||||
await self.journal.finalize_run(
|
||||
run_id=run_id,
|
||||
status='cancelled',
|
||||
status_reason='consumer_cancelled',
|
||||
usage=terminal_usage,
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
execution_outcome = 'timeout' if self._is_deadline_exhausted(context) else 'failed'
|
||||
|
||||
@@ -191,7 +191,12 @@ 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
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
|
||||
'Call this tool to send a message; Agent output text is not sent automatically.',
|
||||
},
|
||||
_object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']),
|
||||
('message.*', 'friend.*', 'group.*', 'feedback.*'),
|
||||
('message.*', 'friend.*', 'group.*', 'feedback.*', 'interaction.submitted'),
|
||||
'reply_target',
|
||||
),
|
||||
PlatformToolDefinition(
|
||||
@@ -465,7 +465,11 @@ def build_platform_tool_resources(
|
||||
reason = None
|
||||
if definition is None:
|
||||
reason = 'unknown_tool'
|
||||
elif definition.api not in supported_apis:
|
||||
elif definition.api not in supported_apis and not (
|
||||
definition.name == 'event_reply'
|
||||
and {'reply_message', 'get_message'} <= supported_apis
|
||||
and (event.delivery.reply_target or {}).get('message_id')
|
||||
):
|
||||
reason = 'adapter_api_unsupported'
|
||||
elif definition.scope == 'event' and not _event_matches(event.event_type, definition.event_patterns):
|
||||
reason = 'event_incompatible'
|
||||
@@ -727,9 +731,36 @@ async def execute_platform_tool(
|
||||
bot = await ap.platform_mgr.get_bot_by_uuid(execution_context, bot_id)
|
||||
if bot is None:
|
||||
raise ValueError(f'Bot {bot_id} is not running')
|
||||
if definition.api not in set(bot.adapter.get_supported_apis() or []):
|
||||
supported_apis = set(bot.adapter.get_supported_apis() or [])
|
||||
reply_via_source = (
|
||||
definition.name == 'event_reply'
|
||||
and 'send_message' not in supported_apis
|
||||
and {'reply_message', 'get_message'} <= supported_apis
|
||||
and (delivery.get('reply_target') or {}).get('message_id')
|
||||
)
|
||||
if definition.api not in supported_apis and not reply_via_source:
|
||||
raise ValueError(f'Platform API {definition.api} is no longer supported by bot {bot_id}')
|
||||
api_func = getattr(bot.adapter, definition.api, None)
|
||||
if reply_via_source:
|
||||
|
||||
async def api_func(target_type, target_id, message):
|
||||
source = await bot.adapter.get_message(
|
||||
chat_type=target_type,
|
||||
chat_id=target_id,
|
||||
message_id=delivery['reply_target']['message_id'],
|
||||
)
|
||||
return await bot.adapter.reply_message(source, message)
|
||||
|
||||
# Some platforms require a one-time event credential instead of an
|
||||
# ordinary conversation send. Only pass the Host-frozen event context.
|
||||
event_reply_handler = getattr(bot.adapter, 'reply_to_event', None)
|
||||
if definition.name == 'event_reply' and callable(event_reply_handler):
|
||||
|
||||
async def api_func(target_type, target_id, message):
|
||||
return await event_reply_handler(
|
||||
context=context, target_type=target_type, target_id=target_id, message=message
|
||||
)
|
||||
|
||||
if not callable(api_func):
|
||||
raise ValueError(f'Platform API {definition.api} is declared but not implemented')
|
||||
if definition.api == 'send_message':
|
||||
@@ -743,10 +774,45 @@ async def execute_platform_tool(
|
||||
from ...telemetry.platform import processing_mode
|
||||
|
||||
token = processing_mode.set(authorization.get('processor_type', 'none'))
|
||||
delivery_error = None
|
||||
try:
|
||||
return await api_func(**normalized)
|
||||
except Exception as exc:
|
||||
delivery_error = str(exc)
|
||||
raise
|
||||
finally:
|
||||
processing_mode.reset(token)
|
||||
if definition.api == 'send_message' and getattr(ap, 'monitoring_service', None) is not None:
|
||||
try:
|
||||
import json
|
||||
|
||||
chain = normalized.get('message')
|
||||
content = chain.model_dump(mode='json') if hasattr(chain, 'model_dump') else str(chain or '')
|
||||
await ap.monitoring_service.record_message(
|
||||
execution_context,
|
||||
bot_id=bot_id,
|
||||
bot_name=str(getattr(getattr(bot, 'bot_entity', None), 'name', bot_id)),
|
||||
pipeline_id=str(authorization.get('processor_id') or ''),
|
||||
pipeline_name=str(authorization.get('processor_id') or authorization.get('runner_id') or ''),
|
||||
message_content=content
|
||||
if isinstance(content, (list, dict))
|
||||
else json.dumps(content, ensure_ascii=False),
|
||||
session_id=str(authorization.get('conversation_id') or ''),
|
||||
role='assistant',
|
||||
status='error' if delivery_error else 'success',
|
||||
level='error' if delivery_error else 'info',
|
||||
run_id=execution_id,
|
||||
parent_message_id=execution_id,
|
||||
variables=json.dumps(
|
||||
{
|
||||
'delivery_error': delivery_error,
|
||||
'target_type': normalized.get('target_type'),
|
||||
'target_id': normalized.get('target_id'),
|
||||
}
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
ap.logger.warning(f'Failed to record platform delivery: {exc}')
|
||||
finally:
|
||||
reset_execution_id(execution_token)
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@ class QueryRunBridge:
|
||||
"""Build an event-first run plan from a Pipeline Query."""
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(query.pipeline_config)
|
||||
if not runner_id:
|
||||
raise RunnerNotFoundError('no runner configured')
|
||||
raise RunnerNotFoundError(
|
||||
'no runner configured; legacy (pre-4.11) pipelines must be migrated with the pipeline migration assistant'
|
||||
)
|
||||
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(query, runner_id)
|
||||
|
||||
@@ -335,6 +335,8 @@ class QueryEntryAdapter:
|
||||
|
||||
# Handle launcher_id
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
if launcher_id is not None:
|
||||
launcher_id = str(launcher_id)
|
||||
|
||||
# Build session_id from launcher info if available
|
||||
session_id = None
|
||||
|
||||
@@ -106,6 +106,8 @@ class AgentResourceBuilder:
|
||||
descriptor,
|
||||
runner_config,
|
||||
)
|
||||
if getattr(self.ap, 'skill_mgr', None) is not None:
|
||||
await self.ap.skill_mgr.reload_skills(execution_context)
|
||||
skills = self._build_skills_from_binding(
|
||||
execution_context,
|
||||
resource_policy,
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import typing
|
||||
|
||||
from ...core import app
|
||||
from ...storage.media import MediaCache
|
||||
from .descriptor import RunnerDescriptor
|
||||
from .errors import RunnerProtocolError
|
||||
from .host_models import AgentBinding, AgentEventEnvelope
|
||||
@@ -44,10 +45,10 @@ class AgentRunJournal:
|
||||
if not item:
|
||||
return value
|
||||
item_type = item.get('type')
|
||||
if item_type == 'image_base64' and item.get('image_base64'):
|
||||
item['image_base64'] = None
|
||||
if MediaCache.has_inline_images(item):
|
||||
item = MediaCache.redact_inline_images(item)
|
||||
item['content_redacted'] = True
|
||||
elif item_type == 'file_base64' and item.get('file_base64'):
|
||||
if item_type == 'file_base64' and item.get('file_base64'):
|
||||
item['file_base64'] = None
|
||||
item['content_redacted'] = True
|
||||
return item
|
||||
@@ -68,6 +69,28 @@ class AgentRunJournal:
|
||||
def _sanitize_attachments(cls, attachments: typing.Iterable[typing.Any]) -> list[dict[str, typing.Any]]:
|
||||
return [cls._sanitize_attachment_ref(attachment) for attachment in attachments]
|
||||
|
||||
@classmethod
|
||||
def _sanitize_event_data(cls, value: typing.Any) -> typing.Any:
|
||||
"""Retain arbitrary event fields without copying inline binary payloads."""
|
||||
if hasattr(value, 'model_dump'):
|
||||
value = value.model_dump(mode='json')
|
||||
if isinstance(value, list):
|
||||
return [cls._sanitize_event_data(item) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
if value.get('type') in ('Image', 'image', 'image_url', 'image_base64'):
|
||||
value = cls._sanitize_content_item(value)
|
||||
result = {}
|
||||
for key, item in value.items():
|
||||
if key in ('image_base64', 'file_base64', 'base64') and item:
|
||||
result[key] = None
|
||||
result['content_redacted'] = True
|
||||
elif key == 'attachments' and isinstance(item, list):
|
||||
result[key] = [cls._sanitize_event_data(cls._sanitize_attachment_ref(ref)) for ref in item]
|
||||
else:
|
||||
result[key] = cls._sanitize_event_data(item)
|
||||
return result
|
||||
|
||||
async def create_run(
|
||||
self,
|
||||
*,
|
||||
@@ -78,6 +101,9 @@ class AgentRunJournal:
|
||||
authorization: dict[str, typing.Any],
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Create the Host-owned run ledger record."""
|
||||
from ...telemetry import trace
|
||||
|
||||
trace_state = trace.current()
|
||||
runtime = context.get('runtime') if isinstance(context, dict) else {}
|
||||
return await self._get_run_ledger_store().create_run(
|
||||
run_id=context['run_id'],
|
||||
@@ -93,17 +119,21 @@ class AgentRunJournal:
|
||||
authorization=authorization,
|
||||
metadata={
|
||||
'event_type': event.event_type,
|
||||
'ingress_event_id': trace_state.event_id if trace_state else event.event_id,
|
||||
'source': event.source,
|
||||
'processor_id': binding.processor_id,
|
||||
'processor_type': binding.processor_type,
|
||||
**(
|
||||
{'input_event': event.data, 'delivery': event.delivery.model_dump(mode='json')}
|
||||
{
|
||||
'input_event': self._sanitize_event_data(event.data),
|
||||
'delivery': event.delivery.model_dump(mode='json'),
|
||||
}
|
||||
if binding.processor_type == 'event_processor'
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{
|
||||
**({'input_event': event.data} if event.event_type != 'message.received' else {}),
|
||||
'input_event': self._sanitize_event_data(event.data),
|
||||
'input': {
|
||||
'text': context.get('input', {}).get('text', ''),
|
||||
'contents': self._sanitize_contents(context.get('input', {}).get('contents', [])),
|
||||
@@ -130,11 +160,16 @@ class AgentRunJournal:
|
||||
usage = result_dict.get('usage')
|
||||
if hasattr(usage, 'model_dump'):
|
||||
usage = usage.model_dump(mode='json')
|
||||
data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}
|
||||
if MediaCache.has_inline_images(data):
|
||||
data = await self.ap.storage_mgr.media_cache.externalize_chain_dump(data)
|
||||
if MediaCache.has_inline_images(metadata):
|
||||
metadata = MediaCache.redact_inline_images(metadata)
|
||||
return await self._get_run_ledger_store().append_event(
|
||||
run_id=run_id,
|
||||
sequence=sequence,
|
||||
event_type=str(result_dict.get('type') or 'unknown'),
|
||||
data=result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {},
|
||||
data=data,
|
||||
usage=usage if isinstance(usage, dict) else None,
|
||||
source=source,
|
||||
metadata=metadata,
|
||||
@@ -260,7 +295,7 @@ class AgentRunJournal:
|
||||
event_time=(
|
||||
datetime.datetime.fromtimestamp(event.event_time, datetime.timezone.utc) if event.event_time else None
|
||||
),
|
||||
metadata=metadata,
|
||||
metadata={**(metadata or {}), 'input_event': self._sanitize_event_data(event.data)},
|
||||
)
|
||||
|
||||
async def write_user_transcript(
|
||||
|
||||
@@ -12,6 +12,9 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from ...persistence.datetime_utils import as_naive_utc
|
||||
from ...storage.media import MediaCache
|
||||
from ...utils.inflight import inflight_hub
|
||||
from ...utils.execution_lease import host_execution_lease
|
||||
|
||||
from ...entity.persistence.agent_run import AgentRun, AgentRunEvent, AgentRuntime
|
||||
|
||||
@@ -136,6 +139,7 @@ class RunLedgerStore:
|
||||
thread_id=thread_id,
|
||||
workspace_id=workspace_id,
|
||||
bot_id=bot_id,
|
||||
**(host_execution_lease() if status == 'running' and queue_name is None else {}),
|
||||
status=status,
|
||||
queue_name=queue_name,
|
||||
priority=priority,
|
||||
@@ -149,6 +153,7 @@ class RunLedgerStore:
|
||||
)
|
||||
session.add(run)
|
||||
await session.commit()
|
||||
inflight_hub.notify(run.workspace_id)
|
||||
return self._run_to_dict(run)
|
||||
|
||||
async def claim_next_run(
|
||||
@@ -206,6 +211,7 @@ class RunLedgerStore:
|
||||
run.last_claimed_at = as_naive_utc(now)
|
||||
run.updated_at = as_naive_utc(now)
|
||||
await session.commit()
|
||||
inflight_hub.notify(run.workspace_id)
|
||||
return self._run_to_dict(run, include_claim_token=True)
|
||||
|
||||
async def renew_claim(
|
||||
@@ -226,6 +232,7 @@ class RunLedgerStore:
|
||||
run.claim_lease_expires_at = as_naive_utc(now + datetime.timedelta(seconds=max(int(lease_seconds), 1)))
|
||||
run.updated_at = as_naive_utc(now)
|
||||
await session.commit()
|
||||
inflight_hub.notify(run.workspace_id)
|
||||
return self._run_to_dict(run)
|
||||
|
||||
async def release_claim(
|
||||
@@ -254,6 +261,7 @@ class RunLedgerStore:
|
||||
if status in TERMINAL_STATUSES:
|
||||
run.finished_at = run.finished_at or as_naive_utc(now)
|
||||
await session.commit()
|
||||
inflight_hub.notify(run.workspace_id)
|
||||
return self._run_to_dict(run)
|
||||
|
||||
async def release_expired_claims(
|
||||
@@ -332,6 +340,7 @@ class RunLedgerStore:
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
inflight_hub.notify_run(run_id, event_type)
|
||||
return self._event_to_dict(row)
|
||||
|
||||
async def append_audit_event(
|
||||
@@ -366,8 +375,36 @@ class RunLedgerStore:
|
||||
)
|
||||
session.add(row)
|
||||
await session.commit()
|
||||
inflight_hub.notify_run(run_id, event_type)
|
||||
return self._event_to_dict(row)
|
||||
|
||||
async def pin_skill_revision(self, run_id: str, workspace_id: str, name: str, revision: str) -> str:
|
||||
"""Pin a Host-owned revision without overwriting concurrent run metadata."""
|
||||
async with self._session_factory() as session:
|
||||
run = await self._get_run_row(session, run_id)
|
||||
if run is None or run.workspace_id != workspace_id or run.status in TERMINAL_STATUSES:
|
||||
raise ValueError('Skill activation requires an active run in the current Workspace')
|
||||
original = run.metadata_json
|
||||
metadata = MediaCache.redact_inline_images(_json_loads(original, {}))
|
||||
bindings = metadata.setdefault('activated_skills', {})
|
||||
if name in bindings:
|
||||
return str(bindings[name])
|
||||
bindings[name] = revision
|
||||
result = await session.execute(
|
||||
sqlalchemy.update(AgentRun)
|
||||
.where(
|
||||
AgentRun.run_id == run_id,
|
||||
AgentRun.metadata_json == original,
|
||||
AgentRun.status.notin_(TERMINAL_STATUSES),
|
||||
)
|
||||
.values(metadata_json=_json_dumps(metadata))
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
if result.rowcount != 1:
|
||||
raise ValueError('Concurrent run update; retry Skill activation')
|
||||
await session.commit()
|
||||
return revision
|
||||
|
||||
async def finalize_run(
|
||||
self,
|
||||
*,
|
||||
@@ -403,13 +440,15 @@ class RunLedgerStore:
|
||||
if cost is not None:
|
||||
run.cost_json = _json_dumps(cost)
|
||||
if metadata is not None:
|
||||
existing_metadata = _json_loads(run.metadata_json, {})
|
||||
# Discard legacy image bytes before merging; new metadata still uses the write contract.
|
||||
existing_metadata = MediaCache.redact_inline_images(_json_loads(run.metadata_json, {}))
|
||||
if isinstance(existing_metadata, dict):
|
||||
existing_metadata.update(metadata)
|
||||
run.metadata_json = _json_dumps(existing_metadata)
|
||||
else:
|
||||
run.metadata_json = _json_dumps(metadata)
|
||||
await session.commit()
|
||||
inflight_hub.notify(run.workspace_id)
|
||||
return self._run_to_dict(run)
|
||||
|
||||
async def validate_active_claim(
|
||||
@@ -443,6 +482,7 @@ class RunLedgerStore:
|
||||
run.updated_at = as_naive_utc(now)
|
||||
run.status_reason = status_reason or run.status_reason
|
||||
await session.commit()
|
||||
inflight_hub.notify(run.workspace_id)
|
||||
return self._run_to_dict(run)
|
||||
|
||||
async def get_run(self, run_id: str) -> dict[str, typing.Any] | None:
|
||||
|
||||
@@ -335,6 +335,9 @@ 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:
|
||||
|
||||
@@ -17,6 +17,8 @@ from ....workspace.errors import WorkspaceNotFoundError
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
from ....core.errors import TaskCapacityError
|
||||
from ....provider.modelmgr.codex_errors import CodexProviderError
|
||||
from ....i18n import DEFAULT_LANGUAGE, language, language_scope, t, error_text
|
||||
from ....i18n.workspace import get_workspace_language
|
||||
from ..authz import (
|
||||
AuthenticationDeniedError,
|
||||
AuthorizationError,
|
||||
@@ -137,7 +139,7 @@ class RouterGroup(abc.ABC):
|
||||
# receive RequestContext or enforce Workspace permissions.
|
||||
self._inject_handler_context(f, kwargs, user_email, None, account)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
return await self._localized_auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN:
|
||||
# get token from Authorization header
|
||||
@@ -160,7 +162,7 @@ class RouterGroup(abc.ABC):
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, user_email, request_context)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
return await self._localized_auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.API_KEY:
|
||||
# get API key from Authorization header or X-API-Key header
|
||||
@@ -179,7 +181,7 @@ class RouterGroup(abc.ABC):
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, None, request_context)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
return await self._localized_auth_error_response(e)
|
||||
|
||||
elif auth_type == AuthType.USER_TOKEN_OR_API_KEY:
|
||||
token = quart.request.headers.get('Authorization', '').replace('Bearer ', '')
|
||||
@@ -193,7 +195,7 @@ class RouterGroup(abc.ABC):
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, None, request_context)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
return await self._localized_auth_error_response(e)
|
||||
# Try API key first (check X-API-Key header)
|
||||
elif api_key := quart.request.headers.get('X-API-Key', ''):
|
||||
# API key authentication
|
||||
@@ -203,7 +205,7 @@ class RouterGroup(abc.ABC):
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, None, request_context)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
return await self._localized_auth_error_response(e)
|
||||
else:
|
||||
# Try user token authentication (Authorization header)
|
||||
if not token:
|
||||
@@ -223,7 +225,7 @@ class RouterGroup(abc.ABC):
|
||||
# Authentication succeeded and authorization was
|
||||
# evaluated. Do not reinterpret a denied user token
|
||||
# as an API key, which would mask the stable 403/404.
|
||||
return self._auth_error_response(e)
|
||||
return await self._localized_auth_error_response(e)
|
||||
except Exception:
|
||||
# If user token fails, maybe it's an API key in Authorization header
|
||||
try:
|
||||
@@ -232,10 +234,12 @@ class RouterGroup(abc.ABC):
|
||||
require_permission(request_context, permission)
|
||||
self._inject_handler_context(f, kwargs, None, request_context)
|
||||
except Exception as e:
|
||||
return self._auth_error_response(e)
|
||||
return await self._localized_auth_error_response(e)
|
||||
|
||||
observed_at = time.monotonic()
|
||||
try:
|
||||
if request_context is not None:
|
||||
language.set(await get_workspace_language(self.ap, request_context.workspace_uuid))
|
||||
if request_context is not None:
|
||||
with bounded_executor.blocking_work_scope(request_context.workspace_uuid):
|
||||
persistence_mgr = getattr(
|
||||
@@ -292,6 +296,8 @@ class RouterGroup(abc.ABC):
|
||||
)
|
||||
if isinstance(e, CodexProviderError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, EntitlementUnavailableError):
|
||||
return self.http_status(403, 'entitlement_unavailable', str(e))
|
||||
if isinstance(e, AuthorizationError):
|
||||
return self.http_status(e.status_code, e.error_code, str(e))
|
||||
if isinstance(e, WorkspaceNotFoundError):
|
||||
@@ -319,7 +325,11 @@ class RouterGroup(abc.ABC):
|
||||
)
|
||||
return self.internal_error_response(request_id)
|
||||
|
||||
new_f = handler_error
|
||||
async def new_f(*args, **kwargs):
|
||||
# Reset even on errors; child tasks inherit this request's locale.
|
||||
with language_scope(quart.request.headers.get('X-LangBot-Language', DEFAULT_LANGUAGE)):
|
||||
return await handler_error(*args, **kwargs)
|
||||
|
||||
# Quart/Flask requires a unique endpoint name even when the same URL
|
||||
# intentionally has separate handlers for different HTTP methods.
|
||||
# Include the method set so CRUD routes can declare distinct
|
||||
@@ -687,6 +697,14 @@ class RouterGroup(abc.ABC):
|
||||
'Invalid authentication credentials',
|
||||
)
|
||||
|
||||
async def _localized_auth_error_response(self, error: Exception):
|
||||
# A resolved context is trusted even when its permissions deny this
|
||||
# operation. Never look up a language using the unverified selector.
|
||||
context = getattr(quart.g, 'request_context', None)
|
||||
if context is not None:
|
||||
language.set(await get_workspace_language(self.ap, context.workspace_uuid))
|
||||
return self._auth_error_response(error)
|
||||
|
||||
def request_id(self) -> str:
|
||||
"""Return one stable request ID for authentication, logs, and errors."""
|
||||
|
||||
@@ -707,7 +725,7 @@ class RouterGroup(abc.ABC):
|
||||
response = quart.jsonify(
|
||||
{
|
||||
'code': 'internal_error',
|
||||
'msg': 'Internal server error',
|
||||
'msg': t('Internal server error'),
|
||||
'request_id': resolved_request_id,
|
||||
}
|
||||
)
|
||||
@@ -729,7 +747,7 @@ class RouterGroup(abc.ABC):
|
||||
return quart.jsonify(
|
||||
{
|
||||
'code': code,
|
||||
'msg': msg,
|
||||
'msg': error_text(code, msg),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .....i18n import t
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
@@ -45,20 +47,20 @@ def debug_stream_response(service, context, agent_uuid: str, payload: dict) -> q
|
||||
if isinstance(exc, RunnerExecutionError):
|
||||
code, message = exc.error_code or 'runner_execution_failed', exc.message
|
||||
elif isinstance(exc, RunnerNotFoundError):
|
||||
code, message = 'runner_not_found', 'The configured Agent runner is unavailable'
|
||||
code, message = 'runner_not_found', t('Agent runner temporarily unavailable. Please try again.')
|
||||
elif isinstance(exc, RunnerNotAuthorizedError):
|
||||
code, message = 'runner_not_authorized', 'The configured Agent runner is not authorized'
|
||||
code, message = 'runner_not_authorized', t('Agent runner not authorized for this pipeline.')
|
||||
elif isinstance(exc, RunnerProtocolError):
|
||||
code, message = 'runner_protocol_error', 'The Agent runner returned an invalid response'
|
||||
code, message = 'runner_protocol_error', t('The Agent runner returned an invalid response.')
|
||||
elif isinstance(exc, ValueError):
|
||||
code, message = 'invalid_request', str(exc)
|
||||
elif isinstance(exc, RunnerError):
|
||||
code, message = 'runner_error', 'The Agent runner could not complete this test'
|
||||
code, message = 'runner_error', t('Agent runner execution failed.')
|
||||
else:
|
||||
logger = getattr(application, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.exception('Agent debug stream execution failed')
|
||||
code, message = 'runner_error', 'The Agent debug execution failed'
|
||||
code, message = 'runner_error', t('Agent runner execution failed.')
|
||||
await queue.put({'kind': 'error', 'code': code, 'msg': message})
|
||||
|
||||
task = asyncio.create_task(execute())
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Web-session-only assistant endpoints; resource tools use the existing service layer."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import quart
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
@@ -10,6 +13,11 @@ from ...context import RequestContext
|
||||
from ...service.assistant import AssistantError, AssistantService
|
||||
|
||||
|
||||
class StopInput(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
revision: int = Field(ge=0, strict=True)
|
||||
|
||||
|
||||
class TurnInput(BaseModel):
|
||||
model_config = ConfigDict(extra='forbid')
|
||||
revision: int = Field(ge=0, strict=True)
|
||||
@@ -23,6 +31,27 @@ class AssistantRouterGroup(group.RouterGroup):
|
||||
async def initialize(self):
|
||||
service = AssistantService(self.ap)
|
||||
|
||||
@self.route('/conversations', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def list_conversations(request_context: RequestContext):
|
||||
return self.success(data={'conversations': await service.list_conversations(request_context)})
|
||||
|
||||
@self.route('/conversations/<conversation_id>/stop', methods=['POST'], permission=Permission.RUNTIME_OPERATE)
|
||||
async def stop(conversation_id: str, request_context: RequestContext):
|
||||
try:
|
||||
body = StopInput.model_validate(await quart.request.get_json())
|
||||
return self.success(data=await service.stop(request_context, conversation_id, body.revision))
|
||||
except ValidationError:
|
||||
return self.http_status(400, 'invalid_input', 'Invalid stop request')
|
||||
except AssistantError as exc:
|
||||
return self.http_status(exc.status, exc.code, exc.code)
|
||||
|
||||
@self.route('/recommended-model', methods=['GET'], permission=Permission.RUNTIME_OPERATE)
|
||||
async def recommended_model(request_context: RequestContext):
|
||||
try:
|
||||
return self.success(data=await service.recommended_model(request_context))
|
||||
except AssistantError as exc:
|
||||
return self.http_status(exc.status, exc.code, exc.code)
|
||||
|
||||
@self.route('/conversations', methods=['POST'], permission=Permission.RUNTIME_OPERATE)
|
||||
async def create(request_context: RequestContext):
|
||||
try:
|
||||
@@ -33,7 +62,7 @@ class AssistantRouterGroup(group.RouterGroup):
|
||||
@self.route('/conversations/<conversation_id>', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get(conversation_id: str, request_context: RequestContext):
|
||||
try:
|
||||
return self.success(data=service.public_view(await service.get(request_context, conversation_id)))
|
||||
return self.success(data=await service.progress(request_context, conversation_id))
|
||||
except AssistantError as exc:
|
||||
return self.http_status(exc.status, exc.code, exc.code)
|
||||
|
||||
@@ -56,3 +85,43 @@ class AssistantRouterGroup(group.RouterGroup):
|
||||
return self.http_status(400, 'invalid_input', 'Invalid assistant request')
|
||||
except AssistantError as exc:
|
||||
return self.http_status(exc.status, exc.code, exc.code)
|
||||
|
||||
@self.route(
|
||||
'/conversations/<conversation_id>/turn/stream', methods=['POST'], permission=Permission.RUNTIME_OPERATE
|
||||
)
|
||||
async def stream_turn(conversation_id: str, request_context: RequestContext):
|
||||
try:
|
||||
body = TurnInput.model_validate(await quart.request.get_json())
|
||||
if (body.text is None) == (body.approved is None) or (body.text is not None and not body.text.strip()):
|
||||
return self.http_status(400, 'invalid_input', 'Provide text or an approval decision')
|
||||
await service.get(request_context, conversation_id)
|
||||
except ValidationError:
|
||||
return self.http_status(400, 'invalid_input', 'Invalid assistant request')
|
||||
except AssistantError as exc:
|
||||
return self.http_status(exc.status, exc.code, exc.code)
|
||||
try:
|
||||
return assistant_stream_response(service, request_context, conversation_id, body)
|
||||
except AssistantError as exc:
|
||||
return self.http_status(exc.status, exc.code, exc.code)
|
||||
|
||||
|
||||
def assistant_stream_response(service, context, conversation_id, body):
|
||||
"""Detach execution from the subscriber; only an explicit stop cancels a run."""
|
||||
queue = service.start_run(context, conversation_id, body)
|
||||
|
||||
async def stream():
|
||||
while True:
|
||||
try:
|
||||
frame = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
except TimeoutError:
|
||||
yield '\n'
|
||||
continue
|
||||
yield json.dumps(frame, ensure_ascii=False) + '\n'
|
||||
if frame['kind'] in {'completed', 'error'}:
|
||||
break
|
||||
|
||||
response = quart.Response(stream(), content_type='application/x-ndjson; charset=utf-8')
|
||||
response.timeout = None
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
return response
|
||||
|
||||
@@ -24,6 +24,24 @@ def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:
|
||||
return dt
|
||||
|
||||
|
||||
def validate_monitoring_window(
|
||||
start_time: datetime.datetime | None,
|
||||
end_time: datetime.datetime | None,
|
||||
*,
|
||||
allow_point_window: bool = False,
|
||||
) -> None:
|
||||
"""Bound explicit dashboard windows to one year without silently truncating them."""
|
||||
if start_time is None:
|
||||
return
|
||||
end = end_time or datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
if (
|
||||
start_time > end
|
||||
or (start_time == end and not allow_point_window)
|
||||
or end - start_time > datetime.timedelta(days=365)
|
||||
):
|
||||
quart.abort(400, description='Monitoring time range must be positive and no longer than 365 days')
|
||||
|
||||
|
||||
@group.group_class('monitoring', '/api/v1/monitoring')
|
||||
class MonitoringRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@@ -39,6 +57,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
metrics = await self.ap.monitoring_service.get_overview_metrics(
|
||||
request_context,
|
||||
@@ -50,6 +69,80 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=metrics)
|
||||
|
||||
@self.route('/in-flight/stream', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def stream_inflight(request_context: RequestContext):
|
||||
from .....utils.inflight import inflight_hub
|
||||
from ...service.tenant import require_workspace_uuid
|
||||
import json
|
||||
|
||||
workspace = require_workspace_uuid(request_context)
|
||||
previous = []
|
||||
|
||||
async def snapshot():
|
||||
nonlocal previous
|
||||
result = await self.ap.monitoring_service.get_inflight_snapshot(request_context, previous)
|
||||
previous = [row for row in result['items'] if row['status_group'] in {'running', 'queued'}]
|
||||
return result
|
||||
|
||||
async def frames():
|
||||
async for frame in inflight_hub.watch(workspace, snapshot):
|
||||
yield 'data: ' + json.dumps(frame, ensure_ascii=False) + '\n\n'
|
||||
|
||||
response = quart.Response(frames(), content_type='text/event-stream')
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store'
|
||||
response.headers['X-Accel-Buffering'] = 'no'
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
@self.route('/executions', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_executions(request_context: RequestContext) -> str:
|
||||
"""Unified execution list across agent runs and pipeline queries."""
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
agent_ids = quart.request.args.getlist('agentId')
|
||||
statuses = quart.request.args.getlist('status')
|
||||
source = quart.request.args.get('source', 'all')
|
||||
mode = quart.request.args.get('mode', 'all')
|
||||
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
result = await self.ap.monitoring_service.get_executions(
|
||||
request_context,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
agent_ids=agent_ids if agent_ids else None,
|
||||
statuses=statuses if statuses else None,
|
||||
source=source,
|
||||
mode=mode,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=quart.request.args.get('limit', 50),
|
||||
offset=quart.request.args.get('offset', 0),
|
||||
)
|
||||
|
||||
return self.success(data=result)
|
||||
|
||||
@self.route(
|
||||
'/executions/<source>/<execution_id>',
|
||||
methods=['GET'],
|
||||
permission=Permission.RESOURCE_VIEW,
|
||||
)
|
||||
async def get_execution_detail(source: str, execution_id: str, request_context: RequestContext) -> str:
|
||||
"""Read a full trace, its initial overview, or one paginated section."""
|
||||
try:
|
||||
detail = await self.ap.monitoring_service.get_execution_detail(
|
||||
request_context,
|
||||
source,
|
||||
execution_id,
|
||||
section=quart.request.args.get('section'),
|
||||
offset=quart.request.args.get('offset', 0),
|
||||
limit=quart.request.args.get('limit', 100),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return self.fail(404, str(exc))
|
||||
return self.success(data=detail)
|
||||
|
||||
@self.route('/token-statistics', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_token_statistics(request_context: RequestContext) -> str:
|
||||
"""Get detailed token usage statistics (summary, per-model, timeseries)."""
|
||||
@@ -63,9 +156,12 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
stats = await self.ap.monitoring_service.get_token_statistics(
|
||||
request_context,
|
||||
execution_statuses=quart.request.args.getlist('status') or None,
|
||||
mode=quart.request.args.get('mode', 'all'),
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -96,6 +192,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
messages, total = await self.ap.monitoring_service.get_messages(
|
||||
request_context,
|
||||
@@ -137,6 +234,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
llm_calls, total = await self.ap.monitoring_service.get_llm_calls(
|
||||
request_context,
|
||||
@@ -176,6 +274,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
|
||||
request_context,
|
||||
@@ -216,6 +315,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls(
|
||||
request_context,
|
||||
@@ -257,6 +357,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
# Parse is_active
|
||||
is_active = None
|
||||
@@ -304,6 +405,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
errors, total = await self.ap.monitoring_service.get_errors(
|
||||
request_context,
|
||||
@@ -327,6 +429,10 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
@self.route('/data', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_all_data(request_context: RequestContext) -> str:
|
||||
"""Get all monitoring data in a single request"""
|
||||
execution_filters = {
|
||||
'execution_statuses': quart.request.args.getlist('status') or None,
|
||||
'mode': quart.request.args.get('mode', 'all'),
|
||||
}
|
||||
# Parse query parameters
|
||||
bot_ids = quart.request.args.getlist('botId')
|
||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||
@@ -337,10 +443,12 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
# Get overview metrics
|
||||
overview = await self.ap.monitoring_service.get_overview_metrics(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -350,6 +458,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Get messages
|
||||
messages, messages_total = await self.ap.monitoring_service.get_messages(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -361,6 +470,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Get LLM calls
|
||||
llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -372,6 +482,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Get tool calls
|
||||
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -383,6 +494,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Get sessions
|
||||
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -395,6 +507,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Get errors
|
||||
errors, errors_total = await self.ap.monitoring_service.get_errors(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -406,6 +519,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Get embedding calls
|
||||
embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(
|
||||
request_context,
|
||||
**execution_filters,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
limit=limit,
|
||||
@@ -417,6 +531,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
'traffic': await get_traffic_series(
|
||||
self.ap,
|
||||
request_context,
|
||||
**execution_filters,
|
||||
bot_ids=bot_ids or None,
|
||||
pipeline_ids=pipeline_ids or None,
|
||||
start_time=start_time,
|
||||
@@ -440,6 +555,30 @@ 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'],
|
||||
@@ -450,6 +589,8 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
"""Get detailed analysis for a specific session"""
|
||||
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||
# Message pages can contain one timestamp; tool-call bounds are inclusive.
|
||||
validate_monitoring_window(start_time, end_time, allow_point_window=True)
|
||||
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||
request_context,
|
||||
session_id,
|
||||
@@ -491,6 +632,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
# Get data based on export type
|
||||
if export_type == 'messages':
|
||||
@@ -639,7 +781,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
'platform',
|
||||
]
|
||||
else:
|
||||
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
||||
return self.fail(400, f'Invalid export type: {export_type}')
|
||||
|
||||
# Generate CSV content with UTF-8 BOM for Excel compatibility
|
||||
import io
|
||||
@@ -681,9 +823,12 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
stats = await self.ap.monitoring_service.get_feedback_stats(
|
||||
request_context,
|
||||
execution_statuses=quart.request.args.getlist('status') or None,
|
||||
mode=quart.request.args.get('mode', 'all'),
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
start_time=start_time,
|
||||
@@ -707,12 +852,15 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
# Parse datetime
|
||||
start_time = parse_iso_datetime(start_time_str)
|
||||
end_time = parse_iso_datetime(end_time_str)
|
||||
validate_monitoring_window(start_time, end_time)
|
||||
|
||||
# Parse feedback type
|
||||
feedback_type = int(feedback_type_str) if feedback_type_str else None
|
||||
|
||||
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
|
||||
request_context,
|
||||
execution_statuses=quart.request.args.getlist('status') or None,
|
||||
mode=quart.request.args.get('mode', 'all'),
|
||||
bot_ids=bot_ids if bot_ids else None,
|
||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||
feedback_type=feedback_type,
|
||||
|
||||
@@ -21,6 +21,8 @@ import httpx
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ......i18n import language, t
|
||||
from ......i18n.workspace import get_workspace_language
|
||||
from ......utils import httpclient, paths
|
||||
from ......platform.sources.websocket_manager import WebSocketScope, is_valid_session_id, ws_connection_manager
|
||||
from .websocket_chat import create_scoped_duplex_tasks, wait_for_duplex_tasks
|
||||
@@ -73,6 +75,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
|
||||
pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None
|
||||
if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid:
|
||||
language.set(await get_workspace_language(self.ap, bot.execution_context.workspace_uuid))
|
||||
return bot, pipeline_uuid
|
||||
return None, None
|
||||
|
||||
@@ -284,7 +287,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
websocket_adapter.reset_session(pipeline_uuid, session_type, session_id)
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
return self.success(data={'message': t('Session reset successfully')})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
|
||||
@@ -338,7 +341,9 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
|
||||
runtime_bot, pipeline_uuid = await self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': t('Bot not found or not available')})
|
||||
)
|
||||
return
|
||||
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
@@ -357,13 +362,15 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
await self._authenticate_websocket(runtime_bot)
|
||||
await self._assert_execution_active(runtime_bot)
|
||||
except Exception:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Unauthorized')}))
|
||||
return
|
||||
try:
|
||||
proxy_bot = await self.ap.platform_mgr.get_websocket_proxy_bot(runtime_bot.execution_context)
|
||||
websocket_adapter = proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': t('WebSocket adapter not found')})
|
||||
)
|
||||
return
|
||||
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
@@ -428,7 +435,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
except Exception as e:
|
||||
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
||||
try:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Internal server error')}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -451,7 +458,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
current_bot = await self._resolve_connected_bot(owner_bot, pipeline_uuid)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Bot is unavailable'})
|
||||
await connection.send_queue.put({'type': 'error', 'message': t('Bot is unavailable')})
|
||||
break
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=current_bot)
|
||||
elif message_type == 'disconnect':
|
||||
@@ -460,7 +467,7 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
pass
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
await connection.send_queue.put({'type': 'error', 'message': t('Invalid JSON format')})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Embed receive error: {e}', exc_info=True)
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ......i18n import language, t
|
||||
from ......i18n.workspace import get_workspace_language
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
@@ -217,8 +220,9 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
request_context, token = await self._authenticate_websocket()
|
||||
except Exception:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Unauthorized'}))
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Unauthorized')}))
|
||||
return
|
||||
language.set(await get_workspace_language(self.ap, request_context.workspace_uuid))
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
if session_type not in ['person', 'group']:
|
||||
await quart.websocket.send(
|
||||
@@ -229,7 +233,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
websocket_adapter = await self._get_scoped_adapter(request_context, pipeline_uuid)
|
||||
if websocket_adapter is None:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Pipeline not found'}))
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Pipeline not found')}))
|
||||
return
|
||||
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
@@ -294,7 +298,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
except Exception:
|
||||
logger.error('Dashboard WebSocket connection error', exc_info=True)
|
||||
try:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': t('Internal server error')}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -419,7 +423,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
request_context = await self._revalidate_websocket_authorization(request_context, token)
|
||||
except Exception:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Unauthorized'})
|
||||
await connection.send_queue.put({'type': 'error', 'message': t('Unauthorized')})
|
||||
break
|
||||
await websocket_adapter.handle_websocket_message(connection, data)
|
||||
elif message_type == 'disconnect':
|
||||
@@ -427,7 +431,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
else:
|
||||
logger.warning(f'Unknown WebSocket message type: {message_type}')
|
||||
except json.JSONDecodeError:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
await connection.send_queue.put({'type': 'error', 'message': t('Invalid JSON format')})
|
||||
|
||||
except Exception:
|
||||
logger.error('Dashboard WebSocket receive error', exc_info=True)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from langbot.pkg.i18n import t, DEFAULT_LANGUAGE
|
||||
from langbot.pkg.i18n.workspace import get_workspace_language
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import mimetypes
|
||||
@@ -6,6 +8,7 @@ import quart
|
||||
|
||||
from langbot.pkg.api.http.authz import Permission
|
||||
from langbot.pkg.api.http.context import RequestContext
|
||||
from langbot.pkg.cloud.plan_policy import workspace_entitlement, platform_feature_names, require_workspace_platform
|
||||
from langbot.pkg.core.errors import TaskCapacityError
|
||||
from langbot.pkg.utils import httpclient, importutil
|
||||
|
||||
@@ -158,12 +161,98 @@ def _decrypt_qqofficial_secret(encrypted_b64: str, key: bytes) -> str:
|
||||
@group.group_class('adapters', '/api/v1/platform/adapters')
|
||||
class AdaptersRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
from ....service.slack_setup import get_slack_setup, SlackSetupError
|
||||
|
||||
slack_setup = get_slack_setup(self.ap)
|
||||
|
||||
@self.route(
|
||||
'/slack/setup',
|
||||
methods=['POST'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def slack_start(request_context: RequestContext):
|
||||
data = await quart.request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return self.http_status(400, -1, 'invalid_input')
|
||||
try:
|
||||
result = await slack_setup.start(
|
||||
request_context,
|
||||
**{
|
||||
key: data[key]
|
||||
for key in (
|
||||
'bot_uuid',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'webhook_url',
|
||||
'name',
|
||||
'socket_mode',
|
||||
'redirect_url',
|
||||
)
|
||||
if key in data
|
||||
},
|
||||
)
|
||||
return self.success(data=result)
|
||||
except (SlackSetupError, TypeError) as exc:
|
||||
return self.http_status(400, -1, str(exc) if isinstance(exc, SlackSetupError) else 'invalid_input')
|
||||
|
||||
@self.route(
|
||||
'/slack/setup/<session_id>',
|
||||
methods=['GET', 'DELETE'],
|
||||
permission=Permission.RESOURCE_MANAGE,
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def slack_status(session_id: str, request_context: RequestContext):
|
||||
try:
|
||||
slack_setup.owned(request_context, session_id)
|
||||
if quart.request.method == 'DELETE':
|
||||
slack_setup.discard(session_id)
|
||||
return self.success(data={})
|
||||
response = await quart.make_response(self.success(data=slack_setup.status(request_context, session_id)))
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
except SlackSetupError:
|
||||
return self.http_status(404, -1, 'session_not_found')
|
||||
|
||||
@self.route('/slack/setup/callback', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def slack_callback():
|
||||
session = slack_setup.sessions.get(quart.request.args.get('state', ''))
|
||||
locale = DEFAULT_LANGUAGE
|
||||
if session is not None:
|
||||
locale = await get_workspace_language(self.ap, session['context'].workspace_uuid)
|
||||
try:
|
||||
await slack_setup.finish(
|
||||
quart.request.args.get('state', ''),
|
||||
quart.request.args.get('code', ''),
|
||||
quart.request.args.get('error'),
|
||||
)
|
||||
message = t('Slack authorization completed. Return to LangBot to finish setup.', locale=locale)
|
||||
status = 200
|
||||
except SlackSetupError:
|
||||
message = t('Slack authorization failed or expired. Return to LangBot for details.', locale=locale)
|
||||
status = 400
|
||||
return quart.Response(
|
||||
message,
|
||||
status=status,
|
||||
content_type='text/plain; charset=utf-8',
|
||||
headers={'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer'},
|
||||
)
|
||||
|
||||
@self.route('', methods=['GET'])
|
||||
async def _() -> str:
|
||||
return self.success(data={'adapters': self.ap.platform_mgr.get_available_adapters_info()})
|
||||
async def _(request_context: RequestContext) -> str:
|
||||
snapshot = await workspace_entitlement(self.ap, request_context.workspace_uuid)
|
||||
adapters = self.ap.platform_mgr.get_available_adapters_info()
|
||||
if snapshot is not None:
|
||||
adapters = [
|
||||
a
|
||||
for a in adapters
|
||||
if not any(snapshot.features.get(f) is False for f in platform_feature_names(a['name']))
|
||||
]
|
||||
return self.success(data={'adapters': adapters})
|
||||
|
||||
@self.route('/<adapter_name>', methods=['GET'])
|
||||
async def _(adapter_name: str) -> str:
|
||||
async def _(adapter_name: str, request_context: RequestContext) -> str:
|
||||
await require_workspace_platform(self.ap, request_context.workspace_uuid, adapter_name)
|
||||
adapter_info = self.ap.platform_mgr.get_available_adapter_info_by_name(adapter_name)
|
||||
|
||||
if adapter_info is None:
|
||||
@@ -187,6 +276,15 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
|
||||
)
|
||||
|
||||
@self.route('/dingtalk/stream-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> quart.Response:
|
||||
filename = 'dingtalk_stream_card.json'
|
||||
response = quart.Response(
|
||||
importutil.read_resource_file_bytes(f'templates/{filename}'), mimetype='application/json'
|
||||
)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename={filename}'
|
||||
return response
|
||||
|
||||
@self.route('/dingtalk/human-input-card-template', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> quart.Response:
|
||||
filename = 'dingtalk_human_input_card.json'
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .....i18n import t
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import collections.abc
|
||||
@@ -296,14 +298,20 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
async def _check_extensions_limit(self, request_context: RequestContext) -> str | None:
|
||||
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
|
||||
await self.ap.plugin_connector.require_workspace_context(request_context)
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
from ...service.workspace_limits import workspace_limits
|
||||
|
||||
limitation = await workspace_limits(self.ap, request_context.workspace_uuid)
|
||||
max_extensions = limitation.get('max_extensions', -1)
|
||||
if max_extensions >= 0:
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
mcp_servers = await self.ap.mcp_service.get_mcp_servers(request_context)
|
||||
total_extensions = len(plugins) + len(mcp_servers)
|
||||
if total_extensions >= max_extensions:
|
||||
return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached')
|
||||
return self.http_status(
|
||||
400,
|
||||
-1,
|
||||
t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=max_extensions),
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -14,6 +14,26 @@ MODEL_TEST_ERROR_CODE = 'model_test_failed'
|
||||
@group.group_class('models/llm', '/api/v1/provider/models/llm')
|
||||
class LLMModelsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/_/starred', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||
async def get_starred(request_context: RequestContext) -> str:
|
||||
return self.success(data={'uuid': await self.ap.llm_model_service.get_starred_model(request_context)})
|
||||
|
||||
@self.route('/_/starred', methods=['PUT'], permission=Permission.PROVIDER_SECRET_MANAGE)
|
||||
async def set_starred(request_context: RequestContext) -> str:
|
||||
body = await quart.request.json
|
||||
model_uuid = body.get('uuid')
|
||||
if model_uuid is not None and not isinstance(model_uuid, str):
|
||||
return self.http_status(400, -1, 'uuid must be a string or null')
|
||||
try:
|
||||
await self.ap.llm_model_service.set_starred_model(request_context, model_uuid)
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={})
|
||||
|
||||
@self.route('/_/default', methods=['GET'], permission=Permission.RESOURCE_MANAGE)
|
||||
async def get_default(request_context: RequestContext) -> str:
|
||||
return self.success(data={'uuid': await self.ap.llm_model_service.get_default_model(request_context)})
|
||||
|
||||
@self.route(
|
||||
'',
|
||||
methods=['GET'],
|
||||
|
||||
@@ -2,12 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from langbot.pkg.cloud.entitlements import EntitlementFeatureUnavailableError
|
||||
from langbot_plugin.box.errors import BoxError
|
||||
|
||||
from ...authz import Permission
|
||||
from ...context import RequestContext
|
||||
from .....operation_trace import service as settings_service
|
||||
from .....skill.repository import SkillRevisionConflictError
|
||||
from .. import group
|
||||
|
||||
|
||||
@@ -25,12 +23,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def list_skills(request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skills = await self.ap.skill_service.list_skills(request_context)
|
||||
except EntitlementFeatureUnavailableError:
|
||||
# Plans without managed sandbox support have no runnable skills.
|
||||
# Treat that capability absence as an empty collection so the
|
||||
# shared UI can render normally instead of surfacing a 500.
|
||||
return self.success(data={'skills': []})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
return self.success(data={'skills': skills})
|
||||
|
||||
@@ -48,7 +41,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.create_skill(request_context, data)
|
||||
return self.success(data={'skill': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -60,7 +53,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def get_skill(skill_name: str, request_context: RequestContext) -> quart.Response:
|
||||
try:
|
||||
skill = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
if not skill:
|
||||
return self.http_status(404, -1, 'Skill not found')
|
||||
@@ -80,11 +73,13 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
data = await quart.request.json
|
||||
try:
|
||||
previous = await self.ap.skill_service.get_skill(request_context, skill_name)
|
||||
except (ValueError, BoxError):
|
||||
except ValueError:
|
||||
previous = None
|
||||
try:
|
||||
skill = await self.ap.skill_service.update_skill(request_context, skill_name, data)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except SkillRevisionConflictError as exc:
|
||||
return self.http_status(409, -1, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
changes = settings_service.changed_fields(
|
||||
@@ -101,7 +96,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
await self.ap.skill_service.delete_skill(request_context, skill_name)
|
||||
return self.success()
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -123,7 +118,9 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
include_hidden=include_hidden,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except SkillRevisionConflictError as exc:
|
||||
return self.http_status(409, -1, str(exc))
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -136,7 +133,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.read_skill_file(request_context, skill_name, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -148,13 +145,20 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
async def write_skill_file(skill_name: str, path: str, request_context: RequestContext) -> quart.Response:
|
||||
data = await quart.request.json
|
||||
content = data.get('content', '')
|
||||
base_revision = str(data.get('base_revision', '') or '').strip() or None
|
||||
if content is None:
|
||||
return self.http_status(400, -1, 'Missing required field: content')
|
||||
|
||||
try:
|
||||
result = await self.ap.skill_service.write_skill_file(request_context, skill_name, path, content)
|
||||
result = await self.ap.skill_service.write_skill_file(
|
||||
request_context,
|
||||
skill_name,
|
||||
path,
|
||||
content,
|
||||
base_revision=base_revision,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@self.route(
|
||||
@@ -191,7 +195,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
skill = await self.ap.skill_service.install_from_github(request_context, data)
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -215,7 +219,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
preview = await self.ap.skill_service.preview_install_from_github(request_context, data)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -243,7 +247,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
source_paths=form.getlist('source_paths'),
|
||||
)
|
||||
return self.success(data={'skills': skill})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -266,7 +270,7 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
filename=file.filename or '',
|
||||
)
|
||||
return self.success(data={'skills': preview})
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
except Exception:
|
||||
raise
|
||||
@@ -285,5 +289,5 @@ class SkillsRouterGroup(group.RouterGroup):
|
||||
try:
|
||||
result = await self.ap.skill_service.scan_directory_async(request_context, path)
|
||||
return self.success(data=result)
|
||||
except (ValueError, BoxError) as exc:
|
||||
except ValueError as exc:
|
||||
return self.http_status(400, -1, str(exc))
|
||||
|
||||
@@ -111,7 +111,11 @@ class UserRouterGroup(group.RouterGroup):
|
||||
async def _() -> str:
|
||||
if getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud':
|
||||
return self.http_status(403, 'password_login_disabled', 'Password login is disabled on LangBot Cloud')
|
||||
json_data = await quart.request.json
|
||||
json_data = await quart.request.get_json(silent=True)
|
||||
if not isinstance(json_data, dict) or not all(
|
||||
isinstance(json_data.get(field), str) for field in ('user', 'password')
|
||||
):
|
||||
return self.http_status(400, -1, 'Invalid request parameters')
|
||||
|
||||
user_email = json_data['user']
|
||||
password = json_data['password']
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
import quart_cors
|
||||
import traceback
|
||||
|
||||
from .. import group
|
||||
@@ -10,6 +11,35 @@ from .....utils import bounded_executor
|
||||
@group.group_class('webhooks', '/bots')
|
||||
class WebhookRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.quart_app.websocket(self.path + '/<bot_uuid>/onebot/v11/ws')
|
||||
@quart_cors.cors_exempt
|
||||
async def onebot_websocket(bot_uuid: str):
|
||||
# Protocol clients omit Origin; bearer authentication protects this endpoint.
|
||||
from .....platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
|
||||
|
||||
runtime = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
|
||||
if runtime is None or not runtime.enable or not isinstance(runtime.adapter, AiocqhttpAdapter):
|
||||
quart.abort(404)
|
||||
adapter = runtime.adapter
|
||||
socket = quart.websocket._get_current_object()
|
||||
if adapter.config['connection_mode'] != 'reverse_ws':
|
||||
quart.abort(409)
|
||||
if not adapter.authenticate(socket.headers):
|
||||
quart.abort(401)
|
||||
if socket.headers.get('X-Client-Role', '').lower() != 'universal':
|
||||
quart.abort(400)
|
||||
self_id = socket.headers.get('X-Self-ID', '')
|
||||
expected = str(adapter.config.get('self_id', '') or '')
|
||||
if not self_id or (expected and self_id != expected):
|
||||
quart.abort(400)
|
||||
if adapter.bot.connection is not None:
|
||||
quart.abort(409)
|
||||
await socket.accept()
|
||||
try:
|
||||
await adapter.serve_connection(socket.send, socket.receive, socket.close, self_id)
|
||||
except Exception:
|
||||
await socket.close(1011)
|
||||
|
||||
@self.route('/<bot_uuid>', methods=['GET', 'POST'], auth_type=group.AuthType.NONE)
|
||||
async def handle_webhook(bot_uuid: str):
|
||||
"""处理 bot webhook 回调(无子路径)"""
|
||||
@@ -31,6 +61,11 @@ class WebhookRouterGroup(group.RouterGroup):
|
||||
适配器返回的响应
|
||||
"""
|
||||
try:
|
||||
setup = getattr(self.ap, 'slack_setup_service', None)
|
||||
if setup is not None and not path and quart.request.method == 'POST':
|
||||
challenge = await setup.challenge(bot_uuid, quart.request)
|
||||
if challenge is not None:
|
||||
return quart.jsonify(challenge)
|
||||
# Public ingress never accepts X-Workspace-Id. The opaque bot UUID
|
||||
# is resolved against the already-bound runtime resource, which
|
||||
# carries the trusted Workspace and placement generation.
|
||||
|
||||
@@ -14,6 +14,8 @@ from .....workspace.errors import WorkspaceNotFoundError
|
||||
from .....workspace.invitation_delivery import InvitationDeliveryService
|
||||
from .....operation_trace import service as settings_service
|
||||
from .. import group
|
||||
from .....i18n import SUPPORTED_LANGUAGES
|
||||
from .....i18n.workspace import get_workspace_language, set_workspace_language
|
||||
|
||||
|
||||
def _workspace_payload(workspace: Workspace) -> dict[str, typing.Any]:
|
||||
@@ -74,6 +76,34 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
return await operation()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route(
|
||||
'/current/language',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.WORKSPACE_VIEW,
|
||||
)
|
||||
async def get_language(request_context: RequestContext):
|
||||
return self.success(
|
||||
data={
|
||||
'language': await get_workspace_language(self.ap, request_context.workspace_uuid, refresh=True),
|
||||
'supported_languages': list(SUPPORTED_LANGUAGES),
|
||||
}
|
||||
)
|
||||
|
||||
@self.route(
|
||||
'/current/language',
|
||||
methods=['PUT'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
permission=Permission.WORKSPACE_UPDATE,
|
||||
)
|
||||
async def update_language(request_context: RequestContext):
|
||||
body = await quart.request.get_json()
|
||||
locale = body.get('language') if isinstance(body, dict) else None
|
||||
if locale not in SUPPORTED_LANGUAGES:
|
||||
return self.http_status(400, 'invalid_language', 'Unsupported Workspace language')
|
||||
locale = await set_workspace_language(self.ap, request_context.workspace_uuid, locale)
|
||||
return self.success(data={'language': locale})
|
||||
|
||||
@self.route('/bootstrap', methods=['GET'], auth_type=group.AuthType.ACCOUNT_TOKEN)
|
||||
async def _(account) -> typing.Any:
|
||||
"""List the active Workspaces available to an authenticated Account.
|
||||
@@ -129,6 +159,8 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
membership = quart.g.workspace_membership
|
||||
workspace = await self.ap.workspace_service.get_workspace(request_context.workspace_uuid)
|
||||
plan_name: str | None = None
|
||||
limitation = None
|
||||
current_plan = None
|
||||
resolver = getattr(self.ap, 'entitlement_resolver', None)
|
||||
if workspace.source == WorkspaceSource.CLOUD_PROJECTION.value and resolver is not None:
|
||||
entitlement = await resolver.resolve(
|
||||
@@ -136,6 +168,18 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
minimum_revision=request_context.entitlement_revision,
|
||||
)
|
||||
plan_name = entitlement.plan_name
|
||||
if entitlement.plan_revision is not None and entitlement.monthly_bonus_credits is not None:
|
||||
current_plan = {
|
||||
'name': entitlement.plan_name,
|
||||
'subscription_started_at': entitlement.subscription_started_at,
|
||||
'revision': entitlement.plan_revision,
|
||||
'monthly_bonus_credits': entitlement.monthly_bonus_credits,
|
||||
'limits': entitlement.limits,
|
||||
'entitlement_features': entitlement.features,
|
||||
}
|
||||
from ...service.workspace_limits import LIMIT_KEYS
|
||||
|
||||
limitation = {key: entitlement.limit(value) for key, value in LIMIT_KEYS.items()}
|
||||
if request_context.principal.principal_type == PrincipalType.SUPPORT_ADMIN:
|
||||
return self.success(
|
||||
data={
|
||||
@@ -154,6 +198,8 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
'current_plan': current_plan,
|
||||
'limitation': limitation,
|
||||
}
|
||||
)
|
||||
account = await self.ap.user_service.get_user_by_uuid(request_context.account_uuid)
|
||||
@@ -170,6 +216,8 @@ class WorkspacesRouterGroup(group.RouterGroup):
|
||||
'permissions': sorted(request_context.workspace.permissions),
|
||||
'placement_generation': request_context.placement_generation,
|
||||
'plan_name': plan_name,
|
||||
'current_plan': current_plan,
|
||||
'limitation': limitation,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import t
|
||||
|
||||
from contextlib import aclosing
|
||||
|
||||
import datetime
|
||||
import copy
|
||||
import fnmatch
|
||||
@@ -45,7 +49,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
AGENT_KIND_AGENT = 'agent'
|
||||
AGENT_KIND_PIPELINE = 'pipeline'
|
||||
AGENT_KIND_EVENT_PROCESSOR = 'event_processor'
|
||||
PIPELINE_EVENT_PATTERNS = ['message.*']
|
||||
PIPELINE_EVENT_PATTERNS = ['message.received']
|
||||
AGENT_DEFAULT_EVENT_PATTERNS = ['*']
|
||||
|
||||
|
||||
@@ -348,25 +352,45 @@ class AgentService:
|
||||
execution_events.append(visible_result)
|
||||
|
||||
final_text = ''
|
||||
async for output in self.ap.agent_run_orchestrator.run(
|
||||
event,
|
||||
binding,
|
||||
adapter_context={
|
||||
'_query': execution_query,
|
||||
'_execution_context': execution_context,
|
||||
'_result_observer': observe_result,
|
||||
},
|
||||
# A debug run has no platform event: the synthetic event envelope is the
|
||||
# execution's inbound boundary, so the chain is opened here with the real
|
||||
# event id and marked as a debug WebUI/API execution. The Runner inside
|
||||
# then joins this chain instead of becoming a trace origin of its own.
|
||||
from ....telemetry.execution import ingress as execution_ingress
|
||||
|
||||
principal_type = str(getattr(getattr(context, 'principal', None), 'principal_type', '') or '')
|
||||
origin = 'api' if principal_type == 'api_key' else 'webui'
|
||||
with execution_ingress(
|
||||
self.ap,
|
||||
'event_done',
|
||||
execution_context,
|
||||
event_id,
|
||||
debug=True,
|
||||
origin=origin,
|
||||
synthetic_event=event_type,
|
||||
):
|
||||
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,
|
||||
}
|
||||
)
|
||||
async with aclosing(
|
||||
self.ap.agent_run_orchestrator.run(
|
||||
event,
|
||||
binding,
|
||||
adapter_context={
|
||||
'_query': execution_query,
|
||||
'_execution_context': execution_context,
|
||||
'_result_observer': observe_result,
|
||||
},
|
||||
)
|
||||
) as owned_stream:
|
||||
async for output in owned_stream:
|
||||
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,
|
||||
@@ -410,7 +434,7 @@ class AgentService:
|
||||
pipeline_uuid = await self.ap.pipeline_service.create_pipeline(
|
||||
context,
|
||||
{
|
||||
'name': agent_data.get('name') or 'New Pipeline',
|
||||
'name': agent_data.get('name') or t('New Pipeline'),
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or '⚙️',
|
||||
'config': {},
|
||||
@@ -427,12 +451,19 @@ class AgentService:
|
||||
config = agent_data['config'] if 'config' in agent_data else await self._get_default_agent_config(context)
|
||||
config, runner_id, _ = RunnerConfigResolver.resolve_agent_config(config)
|
||||
await self._validate_runner_for_agent(context, runner_id)
|
||||
if runner_id:
|
||||
descriptor = await self.ap.runner_registry.get(context, runner_id)
|
||||
config['runner_config'][runner_id] = await self.ap.llm_model_service.apply_default_model(
|
||||
context,
|
||||
descriptor.config_schema,
|
||||
config['runner_config'][runner_id],
|
||||
)
|
||||
patterns = agent_data.get('supported_event_patterns', AGENT_DEFAULT_EVENT_PATTERNS)
|
||||
new_uuid = str(uuid.uuid4())
|
||||
values = {
|
||||
'workspace_uuid': workspace_uuid,
|
||||
'uuid': new_uuid,
|
||||
'name': agent_data.get('name') or 'New Agent',
|
||||
'name': agent_data.get('name') or t('New Agent'),
|
||||
'description': agent_data.get('description') or '',
|
||||
'emoji': agent_data.get('emoji') or ('🧩' if kind == AGENT_KIND_EVENT_PROCESSOR else '🤖'),
|
||||
'kind': kind,
|
||||
@@ -509,6 +540,7 @@ class AgentService:
|
||||
|
||||
async def _prepare_event_processor(self, context, data, existing=None):
|
||||
"""Resolve an installed component and keep its capability declaration authoritative."""
|
||||
draft = existing is None and 'config' not in data and 'parameters' not in data
|
||||
config = copy.deepcopy(data.get('config', existing.config if existing is not None else {}))
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError('Processor configuration must be an object')
|
||||
@@ -537,13 +569,19 @@ class AgentService:
|
||||
parameters = runner_config.get(component_ref)
|
||||
if parameters is None:
|
||||
parameters = self.ap.pipeline_service._get_default_values_from_schema(descriptor.config_schema)
|
||||
if draft:
|
||||
parameters = await self.ap.llm_model_service.apply_default_model(
|
||||
context, descriptor.config_schema, parameters
|
||||
)
|
||||
if not isinstance(parameters, dict):
|
||||
raise ValueError('Processor parameters must be an object')
|
||||
for field in descriptor.config_schema:
|
||||
if field.get('required') and parameters.get(field['name']) in (None, ''):
|
||||
if not draft and field.get('required') and parameters.get(field['name']) in (None, ''):
|
||||
raise ValueError(f'Required processor parameter: {field["name"]}')
|
||||
config['runner_config'] = {component_ref: parameters}
|
||||
return config, component_ref, descriptor.supported_event_patterns
|
||||
# Component-only creation keeps defaults for the detail form, but does
|
||||
# not subscribe to events until configuration is saved and validated.
|
||||
return config, component_ref, [] if draft else descriptor.supported_event_patterns
|
||||
|
||||
async def get_processor_runs(self, context, processor_id, *, before_id=None):
|
||||
"""Read only this Workspace's Agent or plugin processor runs."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""In-process management assistant using the existing model and resource services."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import uuid
|
||||
|
||||
@@ -44,6 +45,187 @@ class AssistantService:
|
||||
self.ap = ap
|
||||
# ponytail: per-process admission; shared quotas if multiple workers need a global ceiling.
|
||||
self._slots = asyncio.Semaphore(4)
|
||||
self._runs = {}
|
||||
|
||||
async def list_conversations(self, context):
|
||||
self._scope(context, '')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sa.select(Conversation)
|
||||
.where(
|
||||
Conversation.workspace_uuid == context.workspace_uuid,
|
||||
Conversation.account_uuid == context.account_uuid,
|
||||
)
|
||||
.order_by(Conversation.updated_at.desc(), Conversation.uuid)
|
||||
.limit(100)
|
||||
)
|
||||
return [
|
||||
dict(
|
||||
uuid=row['uuid'],
|
||||
status=row['status'],
|
||||
revision=row['revision'],
|
||||
title=next(
|
||||
(
|
||||
m.get('content', '')[:80]
|
||||
for m in row['messages']
|
||||
if m['role'] == 'user' and isinstance(m.get('content'), str)
|
||||
),
|
||||
'',
|
||||
),
|
||||
)
|
||||
for row in result.mappings()
|
||||
]
|
||||
|
||||
async def progress(self, context, conversation_id):
|
||||
conversation = await self.get(context, conversation_id)
|
||||
run = self._runs.get(conversation_id)
|
||||
if run:
|
||||
return {**(run['snapshot'] or self.public_view(conversation)), 'progress': run['progress']}
|
||||
return self.public_view(conversation)
|
||||
|
||||
def start_run(self, context, conversation_id, body):
|
||||
"""Own execution independently of any HTTP subscriber; never block on a slow browser."""
|
||||
if conversation_id in self._runs:
|
||||
raise AssistantError('stale_turn')
|
||||
if len(self._runs) >= 4:
|
||||
raise AssistantError('busy', 429)
|
||||
queue = asyncio.Queue(maxsize=32)
|
||||
run = {
|
||||
'queue': queue,
|
||||
'snapshot': None,
|
||||
'progress': {},
|
||||
'stopping': False,
|
||||
'revision': body.revision + 1,
|
||||
'started': asyncio.Event(),
|
||||
}
|
||||
self._runs[conversation_id] = run
|
||||
|
||||
async def emit(frame):
|
||||
if frame['kind'] == 'snapshot':
|
||||
run['snapshot'] = frame['data']
|
||||
run['progress'] = {}
|
||||
elif frame['kind'] == 'text':
|
||||
run['progress']['text'] = frame['data']['text']
|
||||
elif frame['kind'] == 'phase':
|
||||
run['progress'].update(frame['data'])
|
||||
if queue.full():
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(frame)
|
||||
|
||||
async def execute():
|
||||
run['started'].set()
|
||||
tenant_scope = getattr(self.ap.persistence_mgr, 'tenant_scope', None)
|
||||
scope = tenant_scope(context.workspace_uuid) if callable(tenant_scope) else contextlib.nullcontext()
|
||||
try:
|
||||
async with scope:
|
||||
try:
|
||||
conversation = await self.turn(
|
||||
context,
|
||||
conversation_id,
|
||||
body.revision,
|
||||
body.text,
|
||||
body.approved,
|
||||
str(body.model_uuid) if body.model_uuid else None,
|
||||
on_event=emit,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
conversation = await self.get(context, conversation_id)
|
||||
# Preserve interrupted tool outcomes; stopping cannot undo an external write.
|
||||
if run['stopping'] and (
|
||||
conversation['error'] == 'result_unknown'
|
||||
or (conversation['status'] == 'running' and conversation['revision'] == run['revision'])
|
||||
):
|
||||
await self._save(context, conversation, 'failed', 'stopped')
|
||||
await emit({'kind': 'completed', 'data': self.public_view(conversation)})
|
||||
except AssistantError as exc:
|
||||
await emit({'kind': 'error', 'code': exc.code})
|
||||
except Exception:
|
||||
self.ap.logger.exception('Assistant background run failed')
|
||||
await emit({'kind': 'error', 'code': 'turn_failed'})
|
||||
finally:
|
||||
self._runs.pop(conversation_id, None)
|
||||
|
||||
run['task'] = asyncio.create_task(execute())
|
||||
return queue
|
||||
|
||||
async def stop(self, context, conversation_id, revision):
|
||||
require_permission(context, Permission.RUNTIME_OPERATE)
|
||||
conversation = await self.get(context, conversation_id)
|
||||
run = self._runs.get(conversation_id)
|
||||
if run and run['revision'] != revision:
|
||||
raise AssistantError('stale_turn')
|
||||
if not run:
|
||||
if conversation['revision'] != revision:
|
||||
raise AssistantError('stale_turn')
|
||||
return self.public_view(conversation)
|
||||
await run['started'].wait()
|
||||
if not run['stopping']:
|
||||
run['stopping'] = True
|
||||
run['task'].cancel()
|
||||
await asyncio.shield(run['task'])
|
||||
return self.public_view(await self.get(context, conversation_id))
|
||||
|
||||
async def recommended_model(self, context):
|
||||
require_permission(context, Permission.RUNTIME_OPERATE)
|
||||
try:
|
||||
recommended = await self.ap.space_service.get_recommended_assistant_model(context)
|
||||
model = await self.ap.model_mgr.get_model_by_uuid(
|
||||
ExecutionContext.from_request(context), recommended['uuid']
|
||||
)
|
||||
if 'func_call' not in (model.model_entity.abilities or []):
|
||||
raise ValueError('Recommended model does not support tools')
|
||||
return {'uuid': recommended['uuid'], 'name': model.model_entity.name}
|
||||
except Exception as exc:
|
||||
raise AssistantError('model_unavailable', 503) from exc
|
||||
|
||||
async def _invoke(self, model, kwargs, on_event):
|
||||
if on_event is None:
|
||||
return await model.provider.invoke_llm(**kwargs)
|
||||
message = {'role': 'assistant', 'content': ''}
|
||||
calls = {}
|
||||
finished = False
|
||||
async for chunk in model.provider.invoke_llm_stream(**kwargs):
|
||||
finished = finished or chunk.is_final
|
||||
content = chunk.content or ''
|
||||
if isinstance(content, list):
|
||||
content = ''.join(item.text or '' for item in content if item.type == 'text')
|
||||
if chunk.all_content is not None:
|
||||
message['content'] = chunk.all_content
|
||||
else:
|
||||
message['content'] += content
|
||||
for field in ('resp_message_id', 'provider_specific_fields'):
|
||||
value = getattr(chunk, field, None)
|
||||
if value is not None:
|
||||
if field == 'provider_specific_fields':
|
||||
message[field] = {**message.get(field, {}), **value}
|
||||
else:
|
||||
message[field] = value
|
||||
for call in chunk.tool_calls or []:
|
||||
value = call.model_dump(mode='json')
|
||||
if call.id in calls:
|
||||
saved = calls[call.id]
|
||||
saved['function']['arguments'] += call.function.arguments or ''
|
||||
if call.function.name:
|
||||
saved['function']['name'] = call.function.name
|
||||
if value.get('provider_specific_fields'):
|
||||
saved['provider_specific_fields'] = {
|
||||
**(saved.get('provider_specific_fields') or {}),
|
||||
**value['provider_specific_fields'],
|
||||
}
|
||||
else:
|
||||
calls[call.id] = value
|
||||
if len(calls) > 8 or len(json.dumps(message, ensure_ascii=False)) + len(json.dumps(calls)) > 64000:
|
||||
raise AssistantError('response_too_large')
|
||||
if content or chunk.all_content is not None:
|
||||
await on_event({'kind': 'text', 'data': {'text': message['content']}})
|
||||
if not finished:
|
||||
raise AssistantError('stream_interrupted')
|
||||
if calls:
|
||||
message['tool_calls'] = list(calls.values())
|
||||
return Message.model_validate(message)
|
||||
|
||||
async def _snapshot(self, conversation, on_event):
|
||||
if on_event is not None:
|
||||
await on_event({'kind': 'snapshot', 'data': self.public_view(conversation)})
|
||||
|
||||
@staticmethod
|
||||
def _scope(context, conversation_id):
|
||||
@@ -150,7 +332,7 @@ class AssistantService:
|
||||
raise AssistantError('stale_turn')
|
||||
conversation.update(status=status, error=error)
|
||||
|
||||
async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None):
|
||||
async def turn(self, context, conversation_id, revision, text=None, approved=None, model_uuid=None, on_event=None):
|
||||
require_permission(context, Permission.RUNTIME_OPERATE)
|
||||
if model_uuid is not None and text is None:
|
||||
raise AssistantError('invalid_input', 400)
|
||||
@@ -201,7 +383,7 @@ class AssistantService:
|
||||
await self._save(context, conversation, 'running')
|
||||
try:
|
||||
if not conversation['model_uuid']:
|
||||
recommended = await self.ap.space_service.get_recommended_chat_model(context)
|
||||
recommended = await self.recommended_model(context)
|
||||
conversation['model_uuid'] = recommended['uuid']
|
||||
execution = ExecutionContext.from_request(context)
|
||||
model = selected_model or await self.ap.model_mgr.get_model_by_uuid(
|
||||
@@ -215,22 +397,30 @@ class AssistantService:
|
||||
return conversation
|
||||
if approved is not None:
|
||||
calls = self._calls(conversation['messages'][-1])
|
||||
await self._execute(context, conversation, calls, approved)
|
||||
for _ in range(8):
|
||||
response = await model.provider.invoke_llm(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=[Message(role='system', content=SYSTEM_PROMPT)]
|
||||
+ [Message.model_validate(message) for message in conversation['messages']],
|
||||
funcs=tool_definitions(context),
|
||||
extra_args=model.model_entity.extra_args or {},
|
||||
remove_think=True,
|
||||
execution_context=execution,
|
||||
await self._execute(context, conversation, calls, approved, on_event)
|
||||
for round_index in range(8):
|
||||
await self._snapshot(conversation, on_event)
|
||||
if on_event is not None:
|
||||
await on_event({'kind': 'phase', 'data': {'phase': 'thinking', 'round': round_index + 1}})
|
||||
response = await self._invoke(
|
||||
model,
|
||||
dict(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=[Message(role='system', content=SYSTEM_PROMPT)]
|
||||
+ [Message.model_validate(message) for message in conversation['messages']],
|
||||
funcs=tool_definitions(context),
|
||||
extra_args=model.model_entity.extra_args or {},
|
||||
remove_think=True,
|
||||
execution_context=execution,
|
||||
),
|
||||
on_event,
|
||||
)
|
||||
message = response.model_dump(mode='json')
|
||||
if len(json.dumps(message, ensure_ascii=False)) > 64000:
|
||||
raise AssistantError('response_too_large')
|
||||
conversation['messages'].append(message)
|
||||
await self._snapshot(conversation, on_event)
|
||||
calls = self._calls(message)
|
||||
if not calls:
|
||||
await self._save(context, conversation, 'ready')
|
||||
@@ -249,9 +439,12 @@ class AssistantService:
|
||||
if any(TOOLS[call['function']['name']][2] for call in calls):
|
||||
await self._save(context, conversation, 'approval')
|
||||
return conversation
|
||||
await self._execute(context, conversation, calls, True)
|
||||
await self._execute(context, conversation, calls, True, on_event)
|
||||
await self._save(context, conversation, 'failed', 'round_limit')
|
||||
except asyncio.CancelledError:
|
||||
partial = self._runs.get(conversation_id, {}).get('progress', {}).get('text')
|
||||
if partial:
|
||||
conversation['messages'].append(Message(role='assistant', content=partial).model_dump(mode='json'))
|
||||
await asyncio.shield(self._save(context, conversation, 'failed', 'result_unknown'))
|
||||
raise
|
||||
except TimeoutError:
|
||||
@@ -280,7 +473,7 @@ class AssistantService:
|
||||
).model_dump(mode='json')
|
||||
)
|
||||
|
||||
async def _execute(self, context, conversation, calls, approved):
|
||||
async def _execute(self, context, conversation, calls, approved, on_event=None):
|
||||
# Validate the complete saved batch before any write, including after approval.
|
||||
if approved:
|
||||
for call in calls:
|
||||
@@ -293,6 +486,19 @@ class AssistantService:
|
||||
else:
|
||||
# Persist before effects. A crash leaves a running/unknown operation, never a replayable approval.
|
||||
await self._save(context, conversation, 'running')
|
||||
if on_event is not None:
|
||||
await on_event(
|
||||
{
|
||||
'kind': 'phase',
|
||||
'data': {
|
||||
'phase': 'tool',
|
||||
'tool': {
|
||||
'name': func['name'],
|
||||
'arguments': redact_secrets(json.loads(func['arguments'] or '{}')),
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
try:
|
||||
result = await execute_tool(self.ap, context, func['name'], json.loads(func['arguments'] or '{}'))
|
||||
except Exception as exc:
|
||||
@@ -308,3 +514,4 @@ class AssistantService:
|
||||
raise AssistantError('tool_failed')
|
||||
self._append_result(conversation, call, result)
|
||||
await self._save(context, conversation, 'running')
|
||||
await self._snapshot(conversation, on_event)
|
||||
|
||||
@@ -312,12 +312,35 @@ async def execute_tool(ap, context, name: str, arguments: dict):
|
||||
if await ap.knowledge_service.get_knowledge_base(context, kb_id) is None:
|
||||
raise ValueError('Knowledge base not found')
|
||||
config = copy.deepcopy(pipeline['config'])
|
||||
config['ai']['runner']['runner'] = 'local-agent'
|
||||
local = config['ai']['local-agent']
|
||||
local['model']['primary'] = args['model_uuid']
|
||||
runner_id = 'plugin:langbot-team/LocalAgent/default'
|
||||
ai = config.get('ai') if isinstance(config.get('ai'), dict) else {}
|
||||
runner = ai.get('runner') if isinstance(ai.get('runner'), dict) else {}
|
||||
runner_configs = ai.get('runner_config') if isinstance(ai.get('runner_config'), dict) else {}
|
||||
local = runner_configs.get(runner_id) if isinstance(runner_configs.get(runner_id), dict) else {}
|
||||
model = local.get('model') if isinstance(local.get('model'), dict) else {}
|
||||
local['model'] = {
|
||||
'primary': args['model_uuid'],
|
||||
'fallbacks': model.get('fallbacks') if isinstance(model.get('fallbacks'), list) else [],
|
||||
'reasoning': model.get('reasoning') if isinstance(model.get('reasoning'), dict) else {},
|
||||
}
|
||||
local['prompt'] = [{'role': 'system', 'content': args['system_prompt']}]
|
||||
local['knowledge-bases'] = args['knowledge_base_uuids']
|
||||
await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], {'config': config})
|
||||
# 4.11 shape: ai.runner.id + ai.runner_config[id]; legacy keys are dropped.
|
||||
ai['runner'] = {'id': runner_id, 'expire-time': runner.get('expire-time', 0)}
|
||||
ai['runner_config'] = {**runner_configs, runner_id: local}
|
||||
ai.pop('local-agent', None)
|
||||
config['ai'] = ai
|
||||
update = {'config': config}
|
||||
prefs = pipeline.get('extensions_preferences')
|
||||
if isinstance(prefs, dict) and prefs.get('enable_all_plugins') is False:
|
||||
plugins = prefs.get('plugins') if isinstance(prefs.get('plugins'), list) else []
|
||||
entry = {'author': 'langbot-team', 'name': 'LocalAgent'}
|
||||
if not any(
|
||||
isinstance(p, dict) and p.get('author') == entry['author'] and p.get('name') == entry['name']
|
||||
for p in plugins
|
||||
):
|
||||
update['extensions_preferences'] = {**prefs, 'plugins': [*plugins, entry]}
|
||||
await ap.pipeline_service.update_pipeline(context, args['pipeline_uuid'], update)
|
||||
return {'uuid': args['pipeline_uuid'], 'url': f'/home/pipelines?id={args["pipeline_uuid"]}'}
|
||||
resource_id = await ap.knowledge_service.create_knowledge_base(context, args)
|
||||
return {'uuid': resource_id, 'url': f'/home/knowledge?id={resource_id}'}
|
||||
|
||||
@@ -16,6 +16,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
||||
from ....utils import httpclient
|
||||
from ....platform.sources import http_bot_signing
|
||||
from ....platform.adapter_names import canonical_adapter_name
|
||||
from ....cloud.plan_policy import require_workspace_platform
|
||||
from ....agent.runner.errors import RunnerNotFoundError
|
||||
|
||||
|
||||
@@ -52,19 +53,19 @@ class BotService:
|
||||
return None
|
||||
|
||||
def _adapter_declares_webhook_url(self, adapter_name: str) -> bool:
|
||||
"""Whether the adapter manifest declares a generated webhook URL config item."""
|
||||
"""Whether the manifest needs a generated public bot ingress URL."""
|
||||
component = self._get_adapter_component(adapter_name)
|
||||
if component is None:
|
||||
return False
|
||||
|
||||
for config_item in component.spec.get('config', []):
|
||||
if config_item.get('type') == 'webhook-url':
|
||||
if config_item.get('type') == 'webhook-url' or config_item.get('name') == '__system.onebot_ws_url':
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_message_event_pattern(event_pattern: str) -> bool:
|
||||
return event_pattern == 'message.*' or event_pattern.startswith('message.')
|
||||
return event_pattern == 'message.received'
|
||||
|
||||
@staticmethod
|
||||
def _event_pattern_covers(supported_pattern: str, binding_pattern: str) -> bool:
|
||||
@@ -507,7 +508,7 @@ class BotService:
|
||||
|
||||
if target_type == 'pipeline':
|
||||
if not self._is_message_event_pattern(event_pattern):
|
||||
raise ValueError('Pipeline can only be bound to message events')
|
||||
raise ValueError('Pipeline can only be bound to message.received')
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline.uuid).where(
|
||||
@@ -685,7 +686,9 @@ class BotService:
|
||||
"""Create bot"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
from .workspace_limits import workspace_limits
|
||||
|
||||
limitation = await workspace_limits(self.ap, workspace_uuid)
|
||||
max_bots = limitation.get('max_bots', -1)
|
||||
if max_bots >= 0:
|
||||
existing_bots = await self.get_bots(context)
|
||||
@@ -694,6 +697,7 @@ class BotService:
|
||||
|
||||
# TODO: 检查配置信息格式
|
||||
bot_data = await self._prepare_bot_data(context, bot_data, include_uuid=True)
|
||||
await require_workspace_platform(self.ap, workspace_uuid, bot_data['adapter'])
|
||||
bot_data['uuid'] = str(uuid.uuid4())
|
||||
bot_data['workspace_uuid'] = workspace_uuid
|
||||
bot_data.setdefault('event_bindings', [])
|
||||
@@ -704,7 +708,8 @@ class BotService:
|
||||
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
|
||||
|
||||
try:
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
if bot.get('enable'):
|
||||
await self.ap.platform_mgr.load_bot(context, bot)
|
||||
except Exception as exc:
|
||||
raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc
|
||||
|
||||
@@ -715,6 +720,9 @@ class BotService:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
update_data = await self._prepare_bot_data(context, bot_data, include_uuid=False)
|
||||
update_data.pop('workspace_uuid', None)
|
||||
if 'adapter' in update_data or update_data.get('enable'):
|
||||
current = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
await require_workspace_platform(self.ap, workspace_uuid, update_data.get('adapter', current['adapter']))
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
scope_statement(
|
||||
@@ -740,8 +748,8 @@ class BotService:
|
||||
bot = await self.get_bot(context, bot_uuid, include_secret=True)
|
||||
try:
|
||||
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
|
||||
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
|
||||
if runtime_bot.enable:
|
||||
if bot.get('enable'):
|
||||
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
|
||||
await runtime_bot.run()
|
||||
except Exception as exc:
|
||||
raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc
|
||||
@@ -775,7 +783,8 @@ class BotService:
|
||||
raise WorkspaceNotFoundError('Bot not found')
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||
if runtime_bot is None:
|
||||
raise Exception('Bot not found')
|
||||
# Saved but disabled/unconfigured bots have no runtime logs yet.
|
||||
return [], 0
|
||||
|
||||
logs, total_count = await runtime_bot.logger.get_logs(from_index, max_count)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import t
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....api.http.authz import WorkspaceRequiredError
|
||||
@@ -56,14 +58,20 @@ class KnowledgeService:
|
||||
require_workspace_uuid(context)
|
||||
# In new architecture, we delegate entirely to RAGManager which uses plugins.
|
||||
# Legacy internal KB creation is removed.
|
||||
limitation = (
|
||||
getattr(getattr(self.ap, 'instance_config', None), 'data', {}).get('system', {}).get('limitation', {})
|
||||
)
|
||||
from .workspace_limits import workspace_limits
|
||||
|
||||
limitation = await workspace_limits(self.ap, require_workspace_uuid(context))
|
||||
max_knowledge_bases = limitation.get('max_knowledge_bases', -1)
|
||||
if max_knowledge_bases >= 0:
|
||||
knowledge_bases = await self.ap.rag_mgr.get_all_knowledge_base_details(context)
|
||||
if len(knowledge_bases) >= max_knowledge_bases:
|
||||
raise ValueError(f'Maximum number of knowledge bases ({max_knowledge_bases}) reached')
|
||||
raise ValueError(
|
||||
t(
|
||||
'Maximum number of {resource} ({limit}) reached',
|
||||
resource=t('knowledge bases'),
|
||||
limit=max_knowledge_bases,
|
||||
)
|
||||
)
|
||||
|
||||
knowledge_engine_plugin_id = kb_data.get('knowledge_engine_plugin_id')
|
||||
if not knowledge_engine_plugin_id:
|
||||
|
||||
@@ -114,6 +114,10 @@ class MaintenanceService:
|
||||
}
|
||||
if media_cache is not None and is_singleton:
|
||||
result['media_files'] = media_cleanup.get('expired_deleted', 0) + media_cleanup.get('size_deleted', 0)
|
||||
if media_cache is not None:
|
||||
from ....storage.inline_media_cleanup import cleanup_inline_media
|
||||
|
||||
result['inline_media_records'] = await cleanup_inline_media(self.ap, context, media_retention_days)
|
||||
return result
|
||||
|
||||
async def get_storage_analysis(self, context: TenantContext) -> dict[str, Any]:
|
||||
@@ -148,6 +152,10 @@ class MaintenanceService:
|
||||
scoped_storage_path = Path('data/storage') / self.ap.storage_mgr.scoped_prefix(context)
|
||||
roots = [('storage', scoped_storage_path)]
|
||||
|
||||
skill_repository = getattr(self.ap, 'skill_repository', None)
|
||||
if skill_repository is not None:
|
||||
roots.append(('skills', Path(skill_repository.storage_root(context))))
|
||||
|
||||
sections = await asyncio.to_thread(self._collect_sections, roots)
|
||||
runtime_processes = await self._runtime_storage_processes(
|
||||
context,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import t
|
||||
|
||||
import copy
|
||||
import re
|
||||
import uuid
|
||||
@@ -212,7 +214,9 @@ class MCPService:
|
||||
# stdio MCP even though Box Runtime itself remains available.
|
||||
require_stdio_mcp_enabled(self.ap, server_data)
|
||||
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
from .workspace_limits import workspace_limits
|
||||
|
||||
limitation = await workspace_limits(self.ap, workspace_uuid)
|
||||
max_extensions = limitation.get('max_extensions', -1)
|
||||
if max_extensions >= 0:
|
||||
mcp_count_result = await self.ap.persistence_mgr.execute_async(
|
||||
@@ -226,7 +230,9 @@ class MCPService:
|
||||
.where(persistence_plugin.PluginSetting.workspace_uuid == workspace_uuid)
|
||||
)
|
||||
if (mcp_count_result.scalar() or 0) + (plugin_count_result.scalar() or 0) >= max_extensions:
|
||||
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
|
||||
raise ValueError(
|
||||
t('Maximum number of {resource} ({limit}) reached', resource=t('extensions'), limit=max_extensions)
|
||||
)
|
||||
|
||||
payload = dict(server_data)
|
||||
payload.pop('workspace_uuid', None)
|
||||
@@ -244,7 +250,7 @@ class MCPService:
|
||||
)
|
||||
)
|
||||
if existing_result.first() is not None:
|
||||
raise ValueError(f'MCP server already exists: {server_name}')
|
||||
raise ValueError(t('MCP server already exists: {name}', name=server_name))
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(payload))
|
||||
created = await self._get_mcp_server_by_uuid_raw(execution_context, payload['uuid'])
|
||||
@@ -330,7 +336,7 @@ class MCPService:
|
||||
raise ValueError('MCP server name is required')
|
||||
duplicate = await self._get_mcp_server_by_name_raw(execution_context, payload['name'])
|
||||
if duplicate is not None and duplicate['uuid'] != server_uuid:
|
||||
raise ValueError(f'MCP server already exists: {payload["name"]}')
|
||||
raise ValueError(t('MCP server already exists: {name}', name=payload['name']))
|
||||
|
||||
effective_server = {**old_server, **payload}
|
||||
# Existing disabled rows remain readable/deletable. Switching away
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import t
|
||||
|
||||
import uuid
|
||||
|
||||
import sqlalchemy
|
||||
@@ -146,7 +148,13 @@ async def _validate_provider_supports(
|
||||
if not support_type:
|
||||
return
|
||||
if model_type not in support_type:
|
||||
raise ValueError(f'Provider requester "{requester_name}" does not support {model_type} models')
|
||||
raise ValueError(
|
||||
t(
|
||||
'Provider {provider} does not support {model_type} models',
|
||||
provider=requester_name,
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def _require_workspace_provider(
|
||||
@@ -196,6 +204,91 @@ class LLMModelsService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def get_starred_model(self, context: TenantContext) -> str | None:
|
||||
from ....entity.persistence.metadata import WorkspaceMetadata
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(WorkspaceMetadata.value).where(
|
||||
WorkspaceMetadata.workspace_uuid == require_workspace_uuid(context),
|
||||
WorkspaceMetadata.key == 'starred_llm_model',
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
model_uuid = row[0] if row else None
|
||||
if model_uuid and await self.get_llm_model(context, model_uuid) is not None:
|
||||
return model_uuid
|
||||
return None
|
||||
|
||||
async def set_starred_model(self, context: TenantContext, model_uuid: str | None) -> None:
|
||||
from ....entity.persistence.metadata import WorkspaceMetadata
|
||||
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
if model_uuid and await self.get_llm_model(context, model_uuid) is None:
|
||||
raise ValueError('Model not found in this Workspace')
|
||||
# A single metadata row makes concurrent selections replace each other.
|
||||
dialect = self.ap.persistence_mgr.get_db_engine().dialect.name
|
||||
if dialect == 'postgresql':
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
else:
|
||||
from sqlalchemy.dialects.sqlite import insert
|
||||
statement = insert(WorkspaceMetadata).values(
|
||||
workspace_uuid=workspace_uuid,
|
||||
key='starred_llm_model',
|
||||
value=model_uuid or '',
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'key'],
|
||||
set_={'value': model_uuid or ''},
|
||||
)
|
||||
)
|
||||
|
||||
async def get_default_model(self, context: TenantContext) -> str | None:
|
||||
starred = await self.get_starred_model(context)
|
||||
if starred:
|
||||
return starred
|
||||
if self.ap.instance_config.data.get('space', {}).get('disable_models_service', False):
|
||||
return None
|
||||
owner = await self.ap.user_service.get_workspace_owner(require_workspace_uuid(context))
|
||||
cloud = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||
if not cloud and not (owner and owner.space_account_uuid):
|
||||
return None
|
||||
try:
|
||||
return (await self.ap.space_service.get_recommended_chat_model(context))['uuid']
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def apply_default_model(self, context: TenantContext, schema: list[dict], parameters: dict) -> dict:
|
||||
import copy
|
||||
|
||||
fields = [
|
||||
field
|
||||
for field in schema
|
||||
if field.get('type')
|
||||
in (
|
||||
'llm-model-selector',
|
||||
'select-llm-model',
|
||||
'model-fallback-selector',
|
||||
)
|
||||
and field.get('name')
|
||||
]
|
||||
if not fields:
|
||||
return parameters
|
||||
model_uuid = await self.get_default_model(context)
|
||||
if not model_uuid:
|
||||
return parameters
|
||||
result = copy.deepcopy(parameters)
|
||||
for field in fields:
|
||||
name = field['name']
|
||||
if field['type'] == 'model-fallback-selector':
|
||||
value = result.get(name)
|
||||
value = value if isinstance(value, dict) else {}
|
||||
if not value.get('primary'):
|
||||
result[name] = {**value, 'primary': model_uuid, 'fallbacks': value.get('fallbacks', [])}
|
||||
elif not result.get(name):
|
||||
result[name] = model_uuid
|
||||
return result
|
||||
|
||||
async def get_llm_models(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
|
||||
"""Get all LLM models with provider info"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,895 @@
|
||||
"""Tenant-scoped execution inputs, outputs, related runs and conversation history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from langbot_plugin.api.entities.builtin.platform.events import event_summary
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ....entity.persistence.agent_run import AgentRun, AgentRunEvent
|
||||
from ....entity.persistence.event_log import EventLog
|
||||
from ....entity.persistence.transcript import Transcript
|
||||
from ....entity.persistence import monitoring as models
|
||||
from .tenant import require_workspace_uuid
|
||||
|
||||
|
||||
def epoch(value):
|
||||
if value is None:
|
||||
return None
|
||||
return int(value.replace(tzinfo=datetime.timezone.utc).timestamp() * 1000)
|
||||
|
||||
|
||||
def parsed(value, fallback=None):
|
||||
try:
|
||||
return json.loads(value) if value else fallback
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
|
||||
def event_content(content, event_data):
|
||||
"""Keep event-specific properties alongside the normalized message input."""
|
||||
if not isinstance(event_data, dict) or not event_data:
|
||||
return content
|
||||
# Keep the original event intact; text is the existing normalized input field.
|
||||
return {'text': event_summary(event_data), 'event': event_data, 'input': content}
|
||||
|
||||
|
||||
def event_preview(payload, fallback='', event_type=''):
|
||||
"""Never use serialized event metadata as a human-readable list preview."""
|
||||
if event_type.startswith('wecomcs.'):
|
||||
return ''
|
||||
if isinstance(payload, dict):
|
||||
if payload.get('type'):
|
||||
if str(payload['type']).startswith('wecomcs.'):
|
||||
return ''
|
||||
return event_summary(payload)[:1000]
|
||||
if isinstance(payload.get('text'), str):
|
||||
return payload['text'][:1000]
|
||||
if fallback and not isinstance(parsed(fallback), (dict, list)):
|
||||
return fallback[:1000]
|
||||
return ''
|
||||
|
||||
|
||||
def event_item(row):
|
||||
metadata = parsed(row.metadata_json, {})
|
||||
return {
|
||||
'id': row.event_id,
|
||||
'event_type': row.event_type,
|
||||
'source': row.source,
|
||||
'timestamp_ms': epoch(row.event_time or row.created_at),
|
||||
'actor_id': row.actor_id,
|
||||
'actor_name': row.actor_name,
|
||||
'conversation_id': row.conversation_id,
|
||||
'thread_id': row.thread_id,
|
||||
'content': event_content(parsed(row.input_json, None) or row.input_summary, metadata.get('input_event')),
|
||||
'metadata': metadata,
|
||||
}
|
||||
|
||||
|
||||
def message_item(row):
|
||||
return {
|
||||
'id': row.id,
|
||||
'role': row.role or 'user',
|
||||
'timestamp_ms': epoch(row.timestamp),
|
||||
'content': parsed(row.message_content, None) or row.message_content,
|
||||
'actor_id': row.user_id,
|
||||
'actor_name': row.user_name,
|
||||
'status': row.status,
|
||||
'event_id': row.event_id,
|
||||
'run_id': row.run_id,
|
||||
'origin': 'delivery' if row.role == 'assistant' else 'input',
|
||||
'delivery': parsed(row.variables, {}) if row.role == 'assistant' else None,
|
||||
}
|
||||
|
||||
|
||||
def transcript_item(row):
|
||||
return {
|
||||
'id': row.transcript_id,
|
||||
'role': row.role,
|
||||
'timestamp_ms': epoch(row.created_at),
|
||||
'content': parsed(row.content_json, None) or row.content,
|
||||
'attachments': parsed(row.attachment_refs_json, []),
|
||||
'event_id': row.event_id,
|
||||
'run_id': row.run_id,
|
||||
'origin': 'generated' if row.role == 'assistant' else 'input',
|
||||
}
|
||||
|
||||
|
||||
class ExecutionDetailsMixin:
|
||||
def _session_message_source(self, workspace, bot_ids, *, summary_only=False):
|
||||
"""Combine delivered messages with ingress missing from the legacy ledger."""
|
||||
message = models.MonitoringMessage
|
||||
conversation = sa.func.coalesce(
|
||||
EventLog.conversation_id,
|
||||
sa.select(AgentRun.conversation_id)
|
||||
.where(
|
||||
AgentRun.workspace_id == workspace,
|
||||
AgentRun.bot_id == EventLog.bot_id,
|
||||
AgentRun.event_id == EventLog.event_id,
|
||||
)
|
||||
.order_by(AgentRun.id)
|
||||
.limit(1)
|
||||
.scalar_subquery(),
|
||||
sa.case(
|
||||
(
|
||||
self._execution_json_text(EventLog.input_json, 'chat_type') == 'group',
|
||||
sa.literal('group_') + self._execution_json_text(EventLog.input_json, 'chat_id'),
|
||||
),
|
||||
(
|
||||
self._execution_json_text(EventLog.input_json, 'chat_type') == 'private',
|
||||
sa.literal('person_') + self._execution_json_text(EventLog.input_json, ('sender', 'id')),
|
||||
),
|
||||
),
|
||||
)
|
||||
values = {
|
||||
'id': EventLog.event_id,
|
||||
'workspace_uuid': EventLog.workspace_id,
|
||||
'timestamp': sa.func.coalesce(EventLog.event_time, EventLog.created_at),
|
||||
'bot_id': EventLog.bot_id,
|
||||
'bot_name': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, 'bot_name'), ''),
|
||||
'pipeline_id': sa.func.coalesce(
|
||||
self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_uuid')), ''
|
||||
),
|
||||
'pipeline_name': sa.func.coalesce(
|
||||
self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_type')), ''
|
||||
),
|
||||
'message_content': sa.func.coalesce(EventLog.input_json, EventLog.input_summary, ''),
|
||||
'session_id': conversation,
|
||||
'status': sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, 'status'), 'success'),
|
||||
'level': sa.literal('info'),
|
||||
'platform': self._execution_json_text(EventLog.metadata_json, 'platform'),
|
||||
'user_id': EventLog.actor_id,
|
||||
'user_name': EventLog.actor_name,
|
||||
'role': sa.literal('user'),
|
||||
'event_id': EventLog.event_id,
|
||||
'run_id': EventLog.run_id,
|
||||
}
|
||||
columns = [
|
||||
column
|
||||
for column in message.__table__.columns
|
||||
if not summary_only or column.name not in {'message_content', 'variables'}
|
||||
]
|
||||
stored = sa.select(*columns).where(message.workspace_uuid == workspace, message.bot_id.in_(bot_ids))
|
||||
incoming = sa.select(
|
||||
*[values.get(column.name, sa.literal(None)).label(column.name) for column in columns]
|
||||
).where(
|
||||
EventLog.workspace_id == workspace,
|
||||
EventLog.bot_id.in_(bot_ids),
|
||||
EventLog.source == 'platform',
|
||||
EventLog.event_type == 'message.received',
|
||||
conversation.is_not(None),
|
||||
conversation != '',
|
||||
~sa.exists(
|
||||
sa.select(message.id).where(
|
||||
message.workspace_uuid == workspace,
|
||||
message.bot_id == EventLog.bot_id,
|
||||
sa.func.coalesce(message.role, 'user') != 'assistant',
|
||||
sa.or_(message.event_id == EventLog.event_id, message.id == EventLog.event_id),
|
||||
)
|
||||
),
|
||||
)
|
||||
return sa.union_all(stored, incoming).subquery()
|
||||
|
||||
async def _session_projection_page(self, workspace, statement, limit, offset):
|
||||
count = (
|
||||
await self._execution_query(workspace, sa.select(sa.func.count()).select_from(statement.subquery()))
|
||||
).scalar_one()
|
||||
rows = (await self._execution_query(workspace, statement.limit(limit).offset(offset))).mappings().all()
|
||||
return [
|
||||
{key: value.isoformat() if isinstance(value, datetime.datetime) else value for key, value in row.items()}
|
||||
for row in rows
|
||||
], count
|
||||
|
||||
async def get_bot_conversation_sessions(
|
||||
self, workspace, bot_ids, start_time, end_time, user_query, is_active, limit, offset
|
||||
):
|
||||
"""Read session summaries across processor types without mutating history."""
|
||||
# Window functions sort and retain their input rows. Carrying historical
|
||||
# inline media through that sort can consume gigabytes for a summary.
|
||||
messages = self._session_message_source(workspace, bot_ids, summary_only=True)
|
||||
key = [messages.c.workspace_uuid, messages.c.bot_id, messages.c.session_id]
|
||||
ranked = (
|
||||
sa.select(
|
||||
*messages.c,
|
||||
sa.func.row_number()
|
||||
.over(
|
||||
partition_by=key,
|
||||
order_by=[
|
||||
sa.case((messages.c.role == 'user', 0), else_=1),
|
||||
messages.c.timestamp.desc(),
|
||||
messages.c.id.desc(),
|
||||
],
|
||||
)
|
||||
.label('position'),
|
||||
sa.func.min(messages.c.timestamp).over(partition_by=key).label('first_seen'),
|
||||
sa.func.max(messages.c.timestamp).over(partition_by=key).label('last_seen'),
|
||||
sa.func.count().over(partition_by=key).label('count'),
|
||||
)
|
||||
.where(messages.c.session_id != '')
|
||||
.subquery()
|
||||
)
|
||||
session = models.MonitoringSession
|
||||
fields = list(session.__table__.columns)
|
||||
projected = {
|
||||
'start_time': ranked.c.first_seen,
|
||||
'last_activity': ranked.c.last_seen,
|
||||
'message_count': ranked.c['count'],
|
||||
'is_active': sa.literal(True),
|
||||
}
|
||||
derived = sa.select(
|
||||
*[
|
||||
(projected[field.name] if field.name in projected else ranked.c[field.name]).label(field.name)
|
||||
for field in fields
|
||||
]
|
||||
).where(ranked.c.position == 1)
|
||||
existing = sa.select(*fields).where(session.workspace_uuid == workspace, session.bot_id.in_(bot_ids))
|
||||
combined = sa.union_all(existing, derived).subquery()
|
||||
group = [combined.c.workspace_uuid, combined.c.bot_id, combined.c.session_id]
|
||||
merged = sa.select(
|
||||
*[
|
||||
combined.c[field.name]
|
||||
for field in fields
|
||||
if field.name not in {'message_count', 'start_time', 'last_activity'}
|
||||
],
|
||||
sa.func.max(combined.c.message_count).over(partition_by=group).label('message_count'),
|
||||
sa.func.min(combined.c.start_time).over(partition_by=group).label('start_time'),
|
||||
sa.func.max(combined.c.last_activity).over(partition_by=group).label('last_activity'),
|
||||
sa.func.row_number().over(partition_by=group, order_by=combined.c.last_activity.desc()).label('position'),
|
||||
).subquery()
|
||||
query = sa.select(*[merged.c[field.name] for field in fields]).where(merged.c.position == 1)
|
||||
if start_time:
|
||||
query = query.where(merged.c.last_activity >= start_time)
|
||||
if end_time:
|
||||
query = query.where(merged.c.last_activity <= end_time)
|
||||
if user_query and user_query.strip():
|
||||
pattern = f'%{user_query.strip()}%'
|
||||
query = query.where(sa.or_(merged.c.user_id.ilike(pattern), merged.c.user_name.ilike(pattern)))
|
||||
if is_active is not None:
|
||||
query = query.where(merged.c.is_active == is_active)
|
||||
return await self._session_projection_page(
|
||||
workspace,
|
||||
query.order_by(merged.c.last_activity.desc(), merged.c.session_id),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
|
||||
def _execution_json_text(self, column, key):
|
||||
# SQLite JSON functions accept TEXT directly; PostgreSQL operators
|
||||
# require an explicit JSON cast for the existing text-backed journals.
|
||||
if self.ap.persistence_mgr.get_db_engine().dialect.name == 'sqlite':
|
||||
expression = sa.type_coerce(column, sa.JSON)
|
||||
else:
|
||||
expression = sa.cast(column, sa.JSON)
|
||||
return expression[key].as_string()
|
||||
|
||||
async def link_execution_message(self, context, message_id, run_id, event_id):
|
||||
workspace = self._require_write_context(context)
|
||||
statement = (
|
||||
sa.update(models.MonitoringMessage)
|
||||
.where(
|
||||
models.MonitoringMessage.workspace_uuid == workspace,
|
||||
models.MonitoringMessage.id == message_id,
|
||||
)
|
||||
.values(run_id=run_id, event_id=sa.func.coalesce(models.MonitoringMessage.event_id, event_id))
|
||||
)
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
if callable(tenant_uow):
|
||||
async with tenant_uow(workspace) as uow:
|
||||
await uow.session.execute(statement)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(statement)
|
||||
|
||||
async def record_ingress_event(
|
||||
self,
|
||||
context,
|
||||
*,
|
||||
event_id,
|
||||
event_type,
|
||||
bot_id,
|
||||
bot_name,
|
||||
platform,
|
||||
payload,
|
||||
actor_id=None,
|
||||
actor_name=None,
|
||||
conversation_id=None,
|
||||
status='running',
|
||||
routes=None,
|
||||
):
|
||||
"""Persist entry facts even when routing never creates a processor run."""
|
||||
from ....agent.runner.event_log_store import EventLogStore
|
||||
|
||||
workspace = self._require_write_context(context)
|
||||
store = EventLogStore(self.ap.persistence_mgr.get_db_engine())
|
||||
# Reuse the message sanitizer (including inline-media handling) instead
|
||||
# of storing an unbounded adapter object in the event journal.
|
||||
content = parsed(self._sanitize_message_content(json.dumps(payload, ensure_ascii=False, default=str)), {})
|
||||
summary = event_preview(content, event_type=event_type)
|
||||
await store.append_event(
|
||||
event_id=event_id,
|
||||
event_type=event_type,
|
||||
source='platform',
|
||||
workspace_id=workspace,
|
||||
bot_id=bot_id,
|
||||
actor_id=actor_id,
|
||||
actor_name=actor_name,
|
||||
conversation_id=conversation_id,
|
||||
actor_type='user' if actor_id else 'system',
|
||||
input_summary=summary,
|
||||
input_json=content,
|
||||
metadata={'status': status, 'bot_name': bot_name, 'platform': platform},
|
||||
)
|
||||
if routes is not None:
|
||||
safe_routes = []
|
||||
for route in routes:
|
||||
if isinstance(route, BaseException):
|
||||
safe_routes.append({'status': 'failed', 'reason': str(route) or type(route).__name__})
|
||||
elif isinstance(route, dict):
|
||||
safe_routes.append(
|
||||
{
|
||||
key: route.get(key)
|
||||
for key in (
|
||||
'status',
|
||||
'reason',
|
||||
'target_type',
|
||||
'target_uuid',
|
||||
'run_id',
|
||||
'failure_code',
|
||||
)
|
||||
}
|
||||
)
|
||||
metadata = {'status': status, 'bot_name': bot_name, 'platform': platform, 'routes': safe_routes}
|
||||
async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session:
|
||||
await session.execute(
|
||||
sa.update(EventLog)
|
||||
.where(
|
||||
EventLog.workspace_id == workspace,
|
||||
EventLog.event_id == event_id,
|
||||
)
|
||||
.values(metadata_json=json.dumps(metadata, ensure_ascii=False))
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def _execution_query(self, workspace, statement):
|
||||
tenant_uow = getattr(self.ap.persistence_mgr, 'tenant_uow', None)
|
||||
if callable(tenant_uow):
|
||||
async with tenant_uow(workspace) as uow:
|
||||
return await uow.session.execute(statement)
|
||||
async with AsyncSession(self.ap.persistence_mgr.get_db_engine()) as session:
|
||||
return await session.execute(statement)
|
||||
|
||||
async def _execution_rows(self, workspace, statement, *, summary_only=False):
|
||||
result = await self._execution_query(workspace, statement)
|
||||
if not summary_only:
|
||||
return list(result.scalars().all())
|
||||
records = []
|
||||
for row in result.mappings():
|
||||
fields = dict(row)
|
||||
metadata = {
|
||||
key.removeprefix('display_metadata_'): fields.pop(key)
|
||||
for key in row
|
||||
if key.startswith('display_metadata_')
|
||||
}
|
||||
if metadata:
|
||||
fields['metadata_json'] = json.dumps(metadata, ensure_ascii=False)
|
||||
records.append(SimpleNamespace(**fields))
|
||||
return records
|
||||
|
||||
def _execution_statement(self, model, *, summary_only=False):
|
||||
"""Keep periodic snapshots independent of message and attachment sizes."""
|
||||
if not summary_only:
|
||||
return sa.select(model)
|
||||
columns = [
|
||||
column
|
||||
for column in model.__table__.columns
|
||||
if column.name not in {'message_content', 'variables', 'input_json', 'authorization_json', 'metadata_json'}
|
||||
]
|
||||
if model is models.MonitoringMessage:
|
||||
return sa.select(*columns)
|
||||
keys = (
|
||||
('processor_id', 'processor_type', 'event_type', 'agent_name', 'target_name', 'bot_name')
|
||||
if model is AgentRun
|
||||
else ('platform', 'bot_name')
|
||||
)
|
||||
# Metadata also contains complete inputs. Project only the small display
|
||||
# fields in SQL, rather than deserializing an attachment-bearing object.
|
||||
for key in keys:
|
||||
columns.append(self._execution_json_text(model.metadata_json, key).label(f'display_metadata_{key}'))
|
||||
return sa.select(*columns)
|
||||
|
||||
async def _execution_page(self, workspace, statement, convert, offset, limit):
|
||||
rows = await self._execution_rows(workspace, statement.offset(offset).limit(limit + 1))
|
||||
return {
|
||||
'items': [convert(row) for row in rows[:limit]],
|
||||
'has_more': len(rows) > limit,
|
||||
'next_offset': offset + min(len(rows), limit),
|
||||
}
|
||||
|
||||
async def _conversation_message_page(self, workspace, statement, offset, limit):
|
||||
"""Select a page from the covering index before loading its payloads."""
|
||||
message = models.MonitoringMessage
|
||||
identities = list(
|
||||
(
|
||||
await self._execution_query(
|
||||
workspace, statement.with_only_columns(message.id).offset(offset).limit(limit + 1)
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
selected = identities[:limit]
|
||||
records = {}
|
||||
if selected:
|
||||
# Equality joins keep SQLite on the primary key even for multiple IDs.
|
||||
page = sa.union_all(*[sa.select(sa.literal(identity).label('id')) for identity in selected]).subquery()
|
||||
rows = await self._execution_rows(
|
||||
workspace,
|
||||
sa.select(message)
|
||||
.select_from(page)
|
||||
.join(message, message.id == page.c.id)
|
||||
.where(message.workspace_uuid == workspace),
|
||||
)
|
||||
records = {row.id: row for row in rows}
|
||||
return {
|
||||
'items': [message_item(records[identity]) for identity in selected if identity in records],
|
||||
'has_more': len(identities) > limit,
|
||||
'next_offset': offset + len(selected),
|
||||
}
|
||||
|
||||
def _unhandled_event_conditions(self, workspace, bot_ids=None, start_time=None, end_time=None):
|
||||
# Absorbed steering events belong to an existing run, too.
|
||||
conditions = [
|
||||
EventLog.workspace_id == workspace,
|
||||
# The primary route is first; plugin subscriptions follow it.
|
||||
# Delivered pipeline ingress is not an unhandled execution, including
|
||||
# older queued/aggregated messages whose trace identity was lost.
|
||||
~sa.and_(
|
||||
sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'target_type')), '')
|
||||
== 'pipeline',
|
||||
sa.func.coalesce(self._execution_json_text(EventLog.metadata_json, ('routes', 0, 'status')), '')
|
||||
== 'delivered',
|
||||
),
|
||||
~sa.exists(
|
||||
sa.select(AgentRun.id).where(
|
||||
AgentRun.workspace_id == workspace,
|
||||
sa.or_(
|
||||
AgentRun.event_id == EventLog.event_id,
|
||||
AgentRun.run_id == EventLog.run_id,
|
||||
self._execution_json_text(AgentRun.metadata_json, 'ingress_event_id') == EventLog.event_id,
|
||||
),
|
||||
)
|
||||
),
|
||||
~sa.exists(
|
||||
sa.select(models.MonitoringMessage.id).where(
|
||||
models.MonitoringMessage.workspace_uuid == workspace,
|
||||
models.MonitoringMessage.event_id == EventLog.event_id,
|
||||
)
|
||||
),
|
||||
]
|
||||
if bot_ids:
|
||||
conditions.append(EventLog.bot_id.in_(bot_ids))
|
||||
if start_time:
|
||||
conditions.append(EventLog.created_at >= start_time)
|
||||
if end_time:
|
||||
conditions.append(EventLog.created_at <= end_time)
|
||||
return conditions
|
||||
|
||||
def _serialize_event_execution(self, row):
|
||||
metadata = parsed(row.metadata_json, {})
|
||||
status = metadata.get('status', 'ignored')
|
||||
return {
|
||||
'source': 'event',
|
||||
'id': row.event_id,
|
||||
'event_id': row.event_id,
|
||||
'status': status,
|
||||
'status_group': status,
|
||||
'title': row.event_type,
|
||||
'input_preview': event_preview(parsed(row.input_json), row.input_summary, row.event_type),
|
||||
'target_kind': 'event',
|
||||
'target_id': None,
|
||||
'target_name': None,
|
||||
'bot_id': row.bot_id,
|
||||
'bot_name': metadata.get('bot_name'),
|
||||
'platform': metadata.get('platform') or row.source,
|
||||
'user_id': row.actor_id,
|
||||
'user_name': row.actor_name,
|
||||
'conversation_id': row.conversation_id,
|
||||
'session_id': row.conversation_id,
|
||||
'created_at_ms': epoch(row.created_at),
|
||||
'started_at_ms': None,
|
||||
'finished_at_ms': None,
|
||||
'duration_ms': None,
|
||||
'usage': None,
|
||||
'cost': None,
|
||||
'queue_name': None,
|
||||
'pipeline_id': None,
|
||||
'pipeline_name': None,
|
||||
'runner_id': None,
|
||||
'debug': row.source == 'debug',
|
||||
'has_error': status == 'failed',
|
||||
'status_reason': metadata.get('reason')
|
||||
or '; '.join(
|
||||
route.get('reason') or route.get('failure_code') or 'failure_reason_not_recorded'
|
||||
for route in metadata.get('routes', [])
|
||||
if isinstance(route, dict) and route.get('status') == 'failed'
|
||||
)
|
||||
or None,
|
||||
}
|
||||
|
||||
async def enrich_execution_rows(self, workspace, items, *, summary_only=False):
|
||||
pipeline_items = {item['id']: item for item in items if item.get('source') == 'pipeline'}
|
||||
if pipeline_items:
|
||||
# Pipeline rows own the UI identity; their linked Runner ledger owns
|
||||
# timing and usage. Read them in one batch for both list and detail.
|
||||
linked = await self._execution_query(
|
||||
workspace,
|
||||
sa.select(
|
||||
models.MonitoringMessage.id,
|
||||
AgentRun.started_at,
|
||||
AgentRun.finished_at,
|
||||
AgentRun.usage_json,
|
||||
AgentRun.cost_json,
|
||||
)
|
||||
.select_from(models.MonitoringMessage)
|
||||
.join(
|
||||
AgentRun,
|
||||
sa.and_(
|
||||
AgentRun.workspace_id == workspace,
|
||||
AgentRun.run_id == models.MonitoringMessage.run_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
models.MonitoringMessage.workspace_uuid == workspace,
|
||||
models.MonitoringMessage.id.in_(pipeline_items),
|
||||
),
|
||||
)
|
||||
for run in linked.all():
|
||||
pipeline_items[run.id].update(
|
||||
started_at_ms=epoch(run.started_at),
|
||||
finished_at_ms=epoch(run.finished_at),
|
||||
duration_ms=int(round((run.finished_at - run.started_at).total_seconds() * 1000))
|
||||
if run.started_at and run.finished_at
|
||||
else None,
|
||||
usage=self._deserialize_json(run.usage_json) or None,
|
||||
cost=self._deserialize_json(run.cost_json) or None,
|
||||
)
|
||||
|
||||
calls = models.MonitoringLLMCall
|
||||
usage_rows = await self._execution_query(
|
||||
workspace,
|
||||
sa.select(
|
||||
calls.message_id,
|
||||
sa.func.sum(calls.input_tokens),
|
||||
sa.func.sum(calls.output_tokens),
|
||||
sa.func.sum(calls.total_tokens),
|
||||
)
|
||||
.where(
|
||||
calls.workspace_uuid == workspace,
|
||||
calls.message_id.in_(pipeline_items),
|
||||
)
|
||||
.group_by(calls.message_id),
|
||||
)
|
||||
for message_id, input_tokens, output_tokens, total_tokens in usage_rows.all():
|
||||
if not pipeline_items[message_id].get('usage'):
|
||||
pipeline_items[message_id]['usage'] = {
|
||||
'input_tokens': int(input_tokens or 0),
|
||||
'output_tokens': int(output_tokens or 0),
|
||||
'total_tokens': int(total_tokens or 0),
|
||||
}
|
||||
ids = {item['event_id'] for item in items if item.get('event_id')}
|
||||
if not ids:
|
||||
return
|
||||
events = await self._execution_rows(
|
||||
workspace,
|
||||
self._execution_statement(EventLog, summary_only=summary_only).where(
|
||||
EventLog.workspace_id == workspace,
|
||||
EventLog.event_id.in_(ids),
|
||||
),
|
||||
summary_only=summary_only,
|
||||
)
|
||||
by_id = {row.event_id: row for row in events}
|
||||
for item in items:
|
||||
event = by_id.get(item.get('event_id'))
|
||||
if event:
|
||||
meta = parsed(event.metadata_json, {})
|
||||
item.update(
|
||||
event_type=event.event_type,
|
||||
input_preview=event_preview(
|
||||
None if summary_only else parsed(event.input_json), event.input_summary, event.event_type
|
||||
),
|
||||
user_id=event.actor_id,
|
||||
user_name=event.actor_name,
|
||||
platform=meta.get('platform') or event.source,
|
||||
bot_name=item.get('bot_name') or meta.get('bot_name'),
|
||||
)
|
||||
|
||||
async def get_execution_detail(self, context, source, execution_id, *, section=None, offset=0, limit=100):
|
||||
workspace = require_workspace_uuid(context)
|
||||
limit, offset = self.normalize_page_window(limit, offset)
|
||||
limit = min(limit, 100)
|
||||
sections = {
|
||||
'inputs',
|
||||
'outputs',
|
||||
'deliveries',
|
||||
'conversation',
|
||||
'related',
|
||||
'events',
|
||||
'llm_calls',
|
||||
'tool_calls',
|
||||
'errors',
|
||||
}
|
||||
if section is not None and section not in sections | {'overview'}:
|
||||
raise ValueError('Unknown execution section')
|
||||
requested = (
|
||||
sections
|
||||
if section is None
|
||||
else ({'inputs', 'outputs', 'deliveries', 'errors'} if section == 'overview' else {section})
|
||||
)
|
||||
|
||||
run = message = event = None
|
||||
if source in {'agent', 'auto'}:
|
||||
rows = await self._execution_rows(
|
||||
workspace,
|
||||
sa.select(AgentRun).where(
|
||||
AgentRun.workspace_id == workspace,
|
||||
AgentRun.run_id == execution_id,
|
||||
),
|
||||
)
|
||||
run = rows[0] if rows else None
|
||||
if run is None and source in {'pipeline', 'auto'}:
|
||||
rows = await self._execution_rows(
|
||||
workspace,
|
||||
sa.select(models.MonitoringMessage).where(
|
||||
models.MonitoringMessage.workspace_uuid == workspace,
|
||||
models.MonitoringMessage.id == execution_id,
|
||||
),
|
||||
)
|
||||
message = rows[0] if rows else None
|
||||
if message and message.parent_message_id:
|
||||
return await self.get_execution_detail(
|
||||
context, 'auto', message.parent_message_id, section=section, offset=offset, limit=limit
|
||||
)
|
||||
if run is None and message is None and source in {'event', 'auto'}:
|
||||
rows = await self._execution_rows(
|
||||
workspace,
|
||||
sa.select(EventLog).where(
|
||||
EventLog.workspace_id == workspace,
|
||||
EventLog.event_id == execution_id,
|
||||
),
|
||||
)
|
||||
event = rows[0] if rows else None
|
||||
if run is None and message is None and event is None:
|
||||
raise ValueError('Execution not found')
|
||||
|
||||
if message and message.run_id:
|
||||
linked = await self._execution_rows(
|
||||
workspace,
|
||||
sa.select(AgentRun).where(AgentRun.workspace_id == workspace, AgentRun.run_id == message.run_id),
|
||||
)
|
||||
if linked:
|
||||
linked_run = linked[0]
|
||||
else:
|
||||
linked_run = None
|
||||
else:
|
||||
linked_run = None
|
||||
|
||||
if run:
|
||||
names = await self._agent_names(workspace, [run])
|
||||
row = self._serialize_agent_execution(run, names)
|
||||
event_id, bot, conversation, thread = run.event_id, run.bot_id, run.conversation_id, run.thread_id
|
||||
run_id = run.run_id
|
||||
elif message:
|
||||
row = self._serialize_pipeline_execution(message)
|
||||
event_id, bot, conversation, thread = message.event_id, message.bot_id, message.session_id, None
|
||||
run_id = message.run_id
|
||||
if linked_run:
|
||||
row['duration_ms'] = (
|
||||
int((linked_run.finished_at - linked_run.started_at).total_seconds() * 1000)
|
||||
if linked_run.started_at and linked_run.finished_at
|
||||
else None
|
||||
)
|
||||
row['usage'] = parsed(linked_run.usage_json)
|
||||
row['status_reason'] = linked_run.status_reason
|
||||
else:
|
||||
row = self._serialize_event_execution(event)
|
||||
event_id, bot, conversation, thread = event.event_id, event.bot_id, event.conversation_id, event.thread_id
|
||||
run_id = event.run_id
|
||||
await self.enrich_execution_rows(workspace, [row])
|
||||
|
||||
ledger = run or linked_run
|
||||
ingress_id = parsed(ledger.metadata_json, {}).get('ingress_event_id') if ledger else None
|
||||
event_conditions = [
|
||||
EventLog.workspace_id == workspace,
|
||||
sa.or_(
|
||||
EventLog.event_id == ingress_id if ingress_id else sa.false(),
|
||||
EventLog.event_id == ledger.event_id if ledger else sa.false(),
|
||||
EventLog.event_id == event_id if event_id else sa.false(),
|
||||
EventLog.run_id == run_id if run_id else sa.false(),
|
||||
),
|
||||
]
|
||||
event_ids = sa.select(EventLog.event_id).where(*event_conditions)
|
||||
# All joins use explicit identities. Session/time matching is reserved
|
||||
# for the visibly separate conversation history section.
|
||||
message_scope = [models.MonitoringMessage.workspace_uuid == workspace]
|
||||
related_message = sa.or_(
|
||||
models.MonitoringMessage.id == message.id if message else sa.false(),
|
||||
models.MonitoringMessage.parent_message_id == (message.id if message else run_id)
|
||||
if message or run_id
|
||||
else sa.false(),
|
||||
models.MonitoringMessage.run_id == run_id if run_id else sa.false(),
|
||||
)
|
||||
transcript_scope = [Transcript.workspace_id == workspace, Transcript.bot_id == bot]
|
||||
transcripts = sa.select(Transcript).where(
|
||||
*transcript_scope,
|
||||
sa.or_(
|
||||
Transcript.run_id == run_id if run_id else sa.false(),
|
||||
sa.and_(Transcript.role == 'user', Transcript.event_id.in_(event_ids)),
|
||||
),
|
||||
)
|
||||
call_ids = sa.select(models.MonitoringMessage.id).where(*message_scope, related_message)
|
||||
statements = {
|
||||
'inputs': (sa.select(EventLog).where(*event_conditions).order_by(EventLog.id.asc()), event_item),
|
||||
'outputs': (transcripts.where(Transcript.role != 'user').order_by(Transcript.seq.asc()), transcript_item),
|
||||
'deliveries': (
|
||||
sa.select(models.MonitoringMessage)
|
||||
.where(*message_scope, related_message, models.MonitoringMessage.role == 'assistant')
|
||||
.order_by(models.MonitoringMessage.timestamp.asc(), models.MonitoringMessage.id.asc()),
|
||||
message_item,
|
||||
),
|
||||
'events': (
|
||||
sa.select(AgentRunEvent)
|
||||
.where(AgentRunEvent.run_id == run_id if run_id else sa.false())
|
||||
.order_by(AgentRunEvent.sequence.asc()),
|
||||
lambda r: {
|
||||
'id': r.id,
|
||||
'sequence': r.sequence,
|
||||
'type': r.type,
|
||||
'data': parsed(r.data_json, {}),
|
||||
'timestamp_ms': epoch(r.created_at),
|
||||
},
|
||||
),
|
||||
}
|
||||
for name, model in [
|
||||
('llm_calls', models.MonitoringLLMCall),
|
||||
('tool_calls', models.MonitoringToolCall),
|
||||
('errors', models.MonitoringError),
|
||||
]:
|
||||
statements[name] = (
|
||||
sa.select(model)
|
||||
.where(
|
||||
model.workspace_uuid == workspace,
|
||||
sa.or_(
|
||||
model.message_id.in_(call_ids),
|
||||
model.message_id == run_id if run_id else sa.false(),
|
||||
),
|
||||
)
|
||||
.order_by(model.timestamp.asc(), model.id.asc()),
|
||||
lambda r, model=model: self.ap.persistence_mgr.serialize_model(model, r),
|
||||
)
|
||||
if message:
|
||||
statements['conversation'] = (
|
||||
sa.select(models.MonitoringMessage)
|
||||
.where(
|
||||
*message_scope,
|
||||
models.MonitoringMessage.bot_id == bot,
|
||||
models.MonitoringMessage.session_id == conversation,
|
||||
)
|
||||
.order_by(models.MonitoringMessage.timestamp.desc(), models.MonitoringMessage.id.desc()),
|
||||
message_item,
|
||||
)
|
||||
else:
|
||||
statements['conversation'] = (
|
||||
sa.select(Transcript)
|
||||
.where(
|
||||
*transcript_scope,
|
||||
Transcript.conversation_id == conversation if conversation else sa.false(),
|
||||
Transcript.thread_id == thread,
|
||||
)
|
||||
.order_by(Transcript.seq.desc()),
|
||||
transcript_item,
|
||||
)
|
||||
|
||||
pages = {}
|
||||
for name, (query, convert) in statements.items():
|
||||
if name in requested:
|
||||
pages[name] = (
|
||||
await self._conversation_message_page(workspace, query, offset, limit)
|
||||
if name == 'conversation' and message
|
||||
else await self._execution_page(workspace, query, convert, offset, limit)
|
||||
)
|
||||
if run:
|
||||
meta = parsed(run.metadata_json, {})
|
||||
for item in pages.get('inputs', {}).get('items', []):
|
||||
# Older event logs omitted data, but the root run may retain it.
|
||||
# Do not apply the root snapshot to later steering events.
|
||||
if item['id'] == run.event_id and not item.get('metadata', {}).get('input_event'):
|
||||
item['content'] = event_content(item['content'], meta.get('input_event'))
|
||||
if message and 'inputs' in requested:
|
||||
# The original rich message is authoritative even when the event
|
||||
# log is absent or only retained a compact event summary.
|
||||
if offset == 0:
|
||||
pages['inputs']['items'].insert(0, message_item(message))
|
||||
if run and not pages.get('inputs', {}).get('items') and offset == 0 and 'inputs' in requested:
|
||||
meta = parsed(run.metadata_json, {})
|
||||
content = event_content(meta.get('input'), meta.get('input_event'))
|
||||
if content:
|
||||
pages['inputs']['items'] = [
|
||||
{'id': run.event_id or run.run_id, 'content': content, 'timestamp_ms': epoch(run.created_at)}
|
||||
]
|
||||
|
||||
if 'related' in requested:
|
||||
# Union of ledger executions and legacy pipeline roots. SQL paging
|
||||
# avoids silently dropping siblings on highly fanned-out events.
|
||||
related_runs = sa.select(
|
||||
sa.literal('agent').label('source'), AgentRun.run_id.label('id'), AgentRun.created_at.label('time')
|
||||
).where(
|
||||
AgentRun.workspace_id == workspace,
|
||||
sa.or_(
|
||||
AgentRun.event_id.in_(event_ids),
|
||||
self._execution_json_text(AgentRun.metadata_json, 'ingress_event_id').in_(event_ids),
|
||||
),
|
||||
AgentRun.run_id != (run_id or ''),
|
||||
~sa.exists().where(
|
||||
models.MonitoringMessage.workspace_uuid == workspace,
|
||||
models.MonitoringMessage.run_id == AgentRun.run_id,
|
||||
models.MonitoringMessage.role != 'assistant',
|
||||
),
|
||||
)
|
||||
related_messages = sa.select(
|
||||
sa.literal('pipeline'), models.MonitoringMessage.id, models.MonitoringMessage.timestamp
|
||||
).where(
|
||||
*message_scope,
|
||||
models.MonitoringMessage.event_id.in_(event_ids),
|
||||
models.MonitoringMessage.role != 'assistant',
|
||||
models.MonitoringMessage.id != (message.id if message else ''),
|
||||
)
|
||||
union = sa.union_all(related_runs, related_messages).subquery()
|
||||
refs = (
|
||||
await self._execution_query(
|
||||
workspace,
|
||||
sa.select(union).order_by(union.c.time, union.c.source, union.c.id).offset(offset).limit(limit + 1),
|
||||
)
|
||||
).all()
|
||||
related = []
|
||||
for ref in refs[:limit]:
|
||||
if ref.source == 'agent':
|
||||
records = await self._execution_rows(
|
||||
workspace,
|
||||
sa.select(AgentRun).where(AgentRun.workspace_id == workspace, AgentRun.run_id == ref.id),
|
||||
)
|
||||
related.append(
|
||||
self._serialize_agent_execution(records[0], await self._agent_names(workspace, records))
|
||||
)
|
||||
else:
|
||||
records = await self._execution_rows(
|
||||
workspace,
|
||||
sa.select(models.MonitoringMessage).where(
|
||||
*message_scope, models.MonitoringMessage.id == ref.id
|
||||
),
|
||||
)
|
||||
related.append(self._serialize_pipeline_execution(records[0]))
|
||||
pages['related'] = {
|
||||
'items': related,
|
||||
'has_more': len(refs) > limit,
|
||||
'next_offset': offset + min(len(refs), limit),
|
||||
}
|
||||
|
||||
result = {
|
||||
'source': row['source'],
|
||||
'row': row,
|
||||
'pages': pages,
|
||||
'legacy_context': bool(message and not message.event_id),
|
||||
}
|
||||
if run:
|
||||
# Do not expose runtime authorization, leases or claim tokens.
|
||||
result['run'] = {'run_id': run.run_id, 'status': run.status, 'status_reason': run.status_reason}
|
||||
if message:
|
||||
result['message'] = self.ap.persistence_mgr.serialize_model(models.MonitoringMessage, message)
|
||||
# Backwards-compatible fields for existing API consumers.
|
||||
for key in ('events', 'llm_calls', 'tool_calls', 'errors'):
|
||||
if key in pages:
|
||||
result[key] = pages[key]['items']
|
||||
result['has_more'] = pages.get('events', {}).get('has_more', False)
|
||||
result['next_cursor'] = pages.get('events', {}).get('next_offset')
|
||||
return result
|
||||
@@ -24,9 +24,14 @@ async def get_traffic_series(
|
||||
pipeline_ids: list[str] | None = None,
|
||||
start_time: datetime.datetime | None = None,
|
||||
end_time: datetime.datetime | None = None,
|
||||
execution_statuses: list[str] | None = None,
|
||||
mode: str = 'all',
|
||||
) -> dict:
|
||||
"""Count all matching records in UTC buckets, returning at most 1000 points."""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
from .monitoring import MonitoringService
|
||||
|
||||
service = MonitoringService(ap)
|
||||
bucket = 'hour' if start_time and end_time and end_time - start_time <= datetime.timedelta(days=7) else 'day'
|
||||
step = datetime.timedelta(hours=1) if bucket == 'hour' else datetime.timedelta(days=1)
|
||||
postgres = ap.persistence_mgr.get_db_engine().dialect.name == 'postgresql'
|
||||
@@ -40,10 +45,11 @@ async def get_traffic_series(
|
||||
pattern = '%Y-%m-%dT%H:00:00' if bucket == 'hour' else '%Y-%m-%dT00:00:00'
|
||||
time_bucket = sqlalchemy.func.strftime(pattern, timestamp)
|
||||
conditions = [model.workspace_uuid == workspace_uuid]
|
||||
conditions.append(service._monitoring_execution_condition(model, workspace_uuid, execution_statuses, mode))
|
||||
if bot_ids:
|
||||
conditions.append(model.bot_id.in_(bot_ids))
|
||||
if pipeline_ids:
|
||||
conditions.append(model.pipeline_id.in_(pipeline_ids))
|
||||
conditions.append(service._monitoring_processor_condition(model, workspace_uuid, pipeline_ids))
|
||||
if start_time is not None:
|
||||
conditions.append(timestamp >= start_time)
|
||||
if end_time is not None:
|
||||
|
||||
@@ -68,6 +68,11 @@ class PipelineService:
|
||||
runner_config['id'] = selected.id
|
||||
runner_config.setdefault('expire-time', 0)
|
||||
ai_config['runner_config'] = {selected.id: self._get_default_values_from_schema(selected.config_schema)}
|
||||
ai_config['runner_config'][selected.id] = await self.ap.llm_model_service.apply_default_model(
|
||||
context,
|
||||
selected.config_schema,
|
||||
ai_config['runner_config'][selected.id],
|
||||
)
|
||||
return config
|
||||
|
||||
async def get_pipeline_metadata(self, context: TenantContext) -> list[dict]:
|
||||
@@ -168,7 +173,9 @@ class PipelineService:
|
||||
if 'config' in pipeline_data:
|
||||
RunnerConfigResolver.validate_pipeline_config(pipeline_data['config'])
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
from .workspace_limits import workspace_limits
|
||||
|
||||
limitation = await workspace_limits(self.ap, workspace_uuid)
|
||||
max_pipelines = limitation.get('max_pipelines', -1)
|
||||
if max_pipelines >= 0:
|
||||
existing_pipelines = await self.get_pipelines(context)
|
||||
@@ -285,7 +292,9 @@ class PipelineService:
|
||||
"""Copy a pipeline with all its configurations"""
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
# Check limitation
|
||||
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
|
||||
from .workspace_limits import workspace_limits
|
||||
|
||||
limitation = await workspace_limits(self.ap, workspace_uuid)
|
||||
max_pipelines = limitation.get('max_pipelines', -1)
|
||||
if max_pipelines >= 0:
|
||||
existing_pipelines = await self.get_pipelines(context)
|
||||
|
||||
@@ -152,6 +152,28 @@ def _json(value):
|
||||
return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
_SKIPPED_STATES = ('already_current', 'not_legacy', 'unsupported_runner')
|
||||
|
||||
|
||||
def _version_at_least(installed, minimum):
|
||||
# Pins are reviewed minimums; never downgrade an installed newer plugin.
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
if installed == minimum:
|
||||
return True
|
||||
try:
|
||||
return Version(str(installed)) >= Version(str(minimum))
|
||||
except (InvalidVersion, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _candidate(row, plan):
|
||||
candidate = {**row, 'config': plan.get('config')}
|
||||
if plan.get('extensions_preferences') is not None:
|
||||
candidate['extensions_preferences'] = plan['extensions_preferences']
|
||||
return candidate
|
||||
|
||||
|
||||
def _fingerprint(row):
|
||||
# SQL timestamps are neither monotonic revisions nor portable CAS values.
|
||||
return hashlib.sha256(
|
||||
@@ -312,7 +334,7 @@ class PipelineMigrationService:
|
||||
pending = dict(snapshots[0]._mapping)
|
||||
source = pending['source_snapshot']
|
||||
plan = plan_legacy_pipeline(source['config'], source['extensions_preferences'])
|
||||
candidate = {**row, 'config': plan.get('config')}
|
||||
candidate = _candidate(row, plan)
|
||||
if plan['state'] != 'ready' or _fingerprint(candidate) != _fingerprint(row):
|
||||
raise MigrationError('planner_changed')
|
||||
facts = await self._plugin(ctx, plan)
|
||||
@@ -439,7 +461,7 @@ class PipelineMigrationService:
|
||||
sources = []
|
||||
for row in await self._rows(ctx):
|
||||
_, _, _, state, _ = await self._plan(ctx, row)
|
||||
if state not in ('already_current', 'not_legacy'):
|
||||
if state not in _SKIPPED_STATES:
|
||||
sources.append(row)
|
||||
if not sources:
|
||||
raise MigrationError('nothing_to_migrate')
|
||||
@@ -482,8 +504,11 @@ class PipelineMigrationService:
|
||||
hard_blockers = [b for b in blockers if b['code'] not in ('plugin_missing', 'plugin_disabled')]
|
||||
if hard_blockers:
|
||||
raise MigrationError(hard_blockers[0]['code'])
|
||||
if state in ('already_current', 'not_legacy'):
|
||||
result.update(state='already_current', code=None)
|
||||
if state in _SKIPPED_STATES:
|
||||
result.update(
|
||||
state='unsupported_runner' if state == 'unsupported_runner' else 'already_current',
|
||||
code=None,
|
||||
)
|
||||
continue
|
||||
target = plan.get('target_plugin')
|
||||
if not target:
|
||||
@@ -505,7 +530,7 @@ class PipelineMigrationService:
|
||||
facts
|
||||
and facts['enabled']
|
||||
and descriptor
|
||||
and descriptor.plugin_version == target['version']
|
||||
and _version_at_least(descriptor.plugin_version, target['version'])
|
||||
)
|
||||
if not ready:
|
||||
task_context.metadata['phase'] = 'installing'
|
||||
@@ -516,15 +541,22 @@ class PipelineMigrationService:
|
||||
try:
|
||||
await self._authorize(ctx)
|
||||
await self.ap.plugin_connector.require_workspace_context(execution)
|
||||
await self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE,
|
||||
{
|
||||
'plugin_author': target['author'],
|
||||
'plugin_name': target['name'],
|
||||
'plugin_version': target['version'],
|
||||
},
|
||||
task_context=install_context,
|
||||
)
|
||||
spec = {'plugin_author': target['author'], 'plugin_name': target['name']}
|
||||
try:
|
||||
await self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE,
|
||||
{**spec, 'plugin_version': target['version']},
|
||||
task_context=install_context,
|
||||
)
|
||||
except (MarketplacePluginVersionNotFoundError, httpx.HTTPStatusError) as exc:
|
||||
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code != 404:
|
||||
raise
|
||||
# The reviewed pin was withdrawn: fall back to the
|
||||
# marketplace latest; _verify_runtime still checks
|
||||
# the schema against the planned config.
|
||||
await self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE, spec, task_context=install_context
|
||||
)
|
||||
install_code = None
|
||||
except PluginRuntimeNotConnectedError:
|
||||
install_code = 'plugin_runtime_unavailable'
|
||||
@@ -595,7 +627,7 @@ class PipelineMigrationService:
|
||||
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
|
||||
if descriptor is None or 'agent' not in descriptor.usages:
|
||||
raise MigrationError('runner_unavailable')
|
||||
if descriptor.plugin_version != plan['target_plugin']['version']:
|
||||
if not _version_at_least(descriptor.plugin_version, plan['target_plugin']['version']):
|
||||
raise MigrationError('plugin_version_incompatible')
|
||||
config = plan['config']
|
||||
RunnerConfigResolver.validate_pipeline_config(config)
|
||||
@@ -685,12 +717,23 @@ class PipelineMigrationService:
|
||||
)
|
||||
|
||||
async def _verify_resources(self, execution, descriptor, runner_config):
|
||||
"""Fail closed on references the scoped Host cannot authorize.
|
||||
"""Fail closed on unauthorized grants; prune dangling references.
|
||||
|
||||
This runs only on explicit execution, outside the mutation transaction.
|
||||
Resource use is still reauthorized by the Host on every eventual run.
|
||||
"""
|
||||
|
||||
async def has_row(model, identifier, *, column=None):
|
||||
column = model.uuid if column is None else column
|
||||
return (
|
||||
await self.pm.execute_async(
|
||||
sa.select(model.uuid).where(
|
||||
model.workspace_uuid == execution.workspace_uuid,
|
||||
column == identifier,
|
||||
)
|
||||
)
|
||||
).first() is not None
|
||||
|
||||
async def require_row(model, identifier, *, column=None):
|
||||
column = model.uuid if column is None else column
|
||||
found = (
|
||||
@@ -715,20 +758,26 @@ class PipelineMigrationService:
|
||||
if value and value not in config_schema.NONE_SENTINELS:
|
||||
await require_row(EmbeddingModel, value)
|
||||
|
||||
# Dangling knowledge base / tool / MCP references (deleted in 4.10)
|
||||
# are pruned rather than blocking the migration.
|
||||
kb_ids = runner_config.get('knowledge-bases', [])
|
||||
if not isinstance(kb_ids, list) or any(not isinstance(v, str) or not v for v in kb_ids):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
if not isinstance(kb_ids, list):
|
||||
kb_ids = []
|
||||
kb_ids = [v for v in kb_ids if isinstance(v, str) and v and await has_row(KnowledgeBase, v)]
|
||||
if 'knowledge-bases' in runner_config:
|
||||
runner_config['knowledge-bases'] = kb_ids
|
||||
if kb_ids:
|
||||
if not config_schema.uses_host_knowledge_bases(descriptor) or 'retrieve' not in getattr(
|
||||
permissions, 'knowledge_bases', []
|
||||
):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
for kb_id in kb_ids:
|
||||
await require_row(KnowledgeBase, kb_id)
|
||||
|
||||
tool_names = runner_config.get('tools', [])
|
||||
if not isinstance(tool_names, list) or any(not isinstance(v, str) or not v for v in tool_names):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
if not isinstance(tool_names, list):
|
||||
tool_names = []
|
||||
tool_names = [v for v in tool_names if isinstance(v, str) and v]
|
||||
if 'tools' in runner_config:
|
||||
runner_config['tools'] = tool_names
|
||||
tool_grant = (
|
||||
runner_config.get('enable-all-tools', False)
|
||||
or tool_names
|
||||
@@ -745,20 +794,22 @@ class PipelineMigrationService:
|
||||
descriptor,
|
||||
runner_config,
|
||||
)
|
||||
if not set(tool_names) <= {tool['tool_name'] for tool in tools}:
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
available = {tool['tool_name'] for tool in tools}
|
||||
if 'tools' in runner_config:
|
||||
runner_config['tools'] = [v for v in tool_names if v in available]
|
||||
|
||||
for attachment in runner_config.get('mcp-resources', []):
|
||||
if not isinstance(attachment, dict):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
identifier = attachment.get('server_uuid') or attachment.get('server_id')
|
||||
name = attachment.get('server_name')
|
||||
if identifier:
|
||||
await require_row(MCPServer, identifier)
|
||||
elif name:
|
||||
await require_row(MCPServer, name, column=MCPServer.name)
|
||||
else:
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
if 'mcp-resources' in runner_config:
|
||||
kept = []
|
||||
for attachment in runner_config.get('mcp-resources') or []:
|
||||
if not isinstance(attachment, dict):
|
||||
continue
|
||||
identifier = attachment.get('server_uuid') or attachment.get('server_id')
|
||||
name = attachment.get('server_name')
|
||||
if (identifier and await has_row(MCPServer, identifier)) or (
|
||||
not identifier and name and await has_row(MCPServer, name, column=MCPServer.name)
|
||||
):
|
||||
kept.append(attachment)
|
||||
runner_config['mcp-resources'] = kept
|
||||
|
||||
async def _lock(self, ctx, pipeline_uuid, session):
|
||||
# Every pending producer shares this row and revalidates its captured
|
||||
@@ -792,11 +843,15 @@ class PipelineMigrationService:
|
||||
if pending:
|
||||
return pending['uuid'], pending['target_fingerprint']
|
||||
target = {**row, 'config': candidate['config']}
|
||||
values = {'config': candidate['config']}
|
||||
if plan.get('extensions_preferences') is not None:
|
||||
target['extensions_preferences'] = plan['extensions_preferences']
|
||||
values['extensions_preferences'] = plan['extensions_preferences']
|
||||
t = LegacyPipeline.__table__
|
||||
cas = [t.c.workspace_uuid == ctx.workspace_uuid, t.c.uuid == row['uuid']]
|
||||
for key in ('config', 'stages', 'extensions_preferences'):
|
||||
cas.append(sa.cast(t.c[key], sa.Text) == row['_raw_' + key])
|
||||
result = await self.pm.execute_async(sa.update(t).where(*cas).values(config=candidate['config']))
|
||||
result = await self.pm.execute_async(sa.update(t).where(*cas).values(**values))
|
||||
if result.rowcount != 1:
|
||||
raise MigrationError('preview_stale')
|
||||
snapshot_uuid = str(uuid.uuid4())
|
||||
@@ -861,6 +916,8 @@ class PipelineMigrationService:
|
||||
RunnerConfigResolver.validate_pipeline_config(plan['config'])
|
||||
candidate_entity = {k: copy.deepcopy(v) for k, v in row.items() if not k.startswith('_')}
|
||||
candidate_entity['config'] = copy.deepcopy(plan['config'])
|
||||
if plan.get('extensions_preferences') is not None:
|
||||
candidate_entity['extensions_preferences'] = copy.deepcopy(plan['extensions_preferences'])
|
||||
runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity))
|
||||
if not data_only and await self._verify_runtime(execution, plan) != runtime_schema:
|
||||
raise MigrationError('runner_schema_changed')
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Optional
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
import httpx
|
||||
from langbot_plugin.entities.io.errors import ActionCallTimeoutError
|
||||
|
||||
from ....core import app
|
||||
from ....skill.utils import parse_frontmatter
|
||||
@@ -26,6 +25,7 @@ _PUBLIC_SKILL_FIELDS = (
|
||||
'description',
|
||||
'instructions',
|
||||
'package_root',
|
||||
'revision',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
)
|
||||
@@ -54,38 +54,11 @@ class SkillService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
def _box_service(self):
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is not None and getattr(box_service, 'available', False):
|
||||
return box_service
|
||||
return None
|
||||
|
||||
def _require_box(self, action: str):
|
||||
"""Return the Box service or raise if it is not available.
|
||||
|
||||
Box is the only source of truth for skills. Every read and write
|
||||
operation goes through it — there is no local-filesystem fallback.
|
||||
"""
|
||||
box_service = self._box_service()
|
||||
if box_service is not None:
|
||||
return box_service
|
||||
ap_box = getattr(self.ap, 'box_service', None)
|
||||
if ap_box is None:
|
||||
reason = 'not initialised'
|
||||
elif not getattr(ap_box, 'enabled', True):
|
||||
reason = 'disabled in config (box.enabled = false)'
|
||||
else:
|
||||
connector_error = getattr(ap_box, '_connector_error', '') or 'currently unavailable'
|
||||
reason = f'unavailable: {connector_error}'
|
||||
raise ValueError(
|
||||
f'{action} requires the Box runtime, which is {reason}. '
|
||||
f'Enable Box in config.yaml (box.enabled = true) and ensure the '
|
||||
f'runtime is reachable before retrying.'
|
||||
)
|
||||
|
||||
def _require_box_for_write(self, action: str) -> None:
|
||||
"""Backwards-compatible alias preserved for clarity at call sites."""
|
||||
self._require_box(action)
|
||||
def _repository(self):
|
||||
repository = getattr(self.ap, 'skill_repository', None)
|
||||
if repository is None:
|
||||
raise ValueError('Skill repository is not initialised')
|
||||
return repository
|
||||
|
||||
async def _execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
workspace_uuid = require_workspace_uuid(context)
|
||||
@@ -114,27 +87,11 @@ class SkillService:
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
# When Box is unavailable, surface an empty list rather than raising —
|
||||
# the skills page should render cleanly, and the UI separately renders
|
||||
# a "Box disabled / unavailable" banner via useBoxStatus.
|
||||
box_service = self._box_service()
|
||||
if box_service is None:
|
||||
return []
|
||||
try:
|
||||
skills = await box_service.list_skills(execution_context)
|
||||
except ActionCallTimeoutError as exc:
|
||||
logger = getattr(self.ap, 'logger', None)
|
||||
if logger is not None:
|
||||
logger.warning(f'Box skill listing timed out: {exc}')
|
||||
return []
|
||||
return [self._serialize_skill(skill) for skill in skills]
|
||||
return [self._serialize_skill(skill) for skill in await self._repository().list_skills(execution_context)]
|
||||
|
||||
async def get_skill(self, context: TenantContext, skill_name: str) -> Optional[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._box_service()
|
||||
if box_service is None:
|
||||
return None
|
||||
skill = await box_service.get_skill(execution_context, skill_name)
|
||||
skill = await self._repository().get_skill(execution_context, skill_name, snapshot=True)
|
||||
return self._serialize_skill(skill) if skill else None
|
||||
|
||||
async def get_skill_by_name(self, context: TenantContext, name: str) -> Optional[dict]:
|
||||
@@ -142,22 +99,44 @@ class SkillService:
|
||||
|
||||
async def create_skill(self, context: TenantContext, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Creating a skill')
|
||||
created = await box_service.create_skill(execution_context, data)
|
||||
created = await self._repository().create_skill(execution_context, data)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(created)
|
||||
|
||||
async def import_skill_directory(
|
||||
self,
|
||||
context: TenantContext,
|
||||
path: str,
|
||||
data: dict,
|
||||
*,
|
||||
base_revision: str | None = None,
|
||||
) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
created = await self._repository().import_skill_directory(
|
||||
execution_context,
|
||||
path,
|
||||
data,
|
||||
base_revision=base_revision,
|
||||
)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(created)
|
||||
|
||||
async def update_skill(self, context: TenantContext, skill_name: str, data: dict) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Editing a skill')
|
||||
updated = await box_service.update_skill(execution_context, skill_name, data)
|
||||
payload = dict(data)
|
||||
base_revision = str(payload.pop('base_revision', '') or '').strip() or None
|
||||
updated = await self._repository().update_skill(
|
||||
execution_context,
|
||||
skill_name,
|
||||
payload,
|
||||
base_revision=base_revision,
|
||||
)
|
||||
await self._reload_skills(execution_context)
|
||||
return self._serialize_skill(updated)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, skill_name: str) -> bool:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Deleting a skill')
|
||||
await box_service.delete_skill(execution_context, skill_name)
|
||||
await self._repository().delete_skill(execution_context, skill_name)
|
||||
await self._reload_skills(execution_context)
|
||||
return True
|
||||
|
||||
@@ -170,24 +149,41 @@ class SkillService:
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Browsing skill files')
|
||||
return await box_service.list_skill_files(execution_context, skill_name, path, include_hidden, max_entries)
|
||||
return await self._repository().list_skill_files(
|
||||
execution_context,
|
||||
skill_name,
|
||||
path,
|
||||
include_hidden,
|
||||
max_entries,
|
||||
)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, skill_name: str, path: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Reading a skill file')
|
||||
return await box_service.read_skill_file(execution_context, skill_name, path)
|
||||
return await self._repository().read_skill_file(execution_context, skill_name, path)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, skill_name: str, path: str, content: str) -> dict:
|
||||
async def write_skill_file(
|
||||
self,
|
||||
context: TenantContext,
|
||||
skill_name: str,
|
||||
path: str,
|
||||
content: str,
|
||||
*,
|
||||
base_revision: str | None,
|
||||
) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Editing skill files')
|
||||
result = await box_service.write_skill_file(execution_context, skill_name, path, content)
|
||||
result = await self._repository().write_skill_file(
|
||||
execution_context,
|
||||
skill_name,
|
||||
path,
|
||||
content,
|
||||
base_revision=base_revision,
|
||||
)
|
||||
await self._reload_skills(execution_context)
|
||||
return result
|
||||
|
||||
async def install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Installing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
@@ -206,7 +202,7 @@ class SkillService:
|
||||
|
||||
zip_bytes = await self._download_github_asset(asset_url)
|
||||
filename = f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip'
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await repository.install_skill_zip(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
@@ -219,7 +215,7 @@ class SkillService:
|
||||
|
||||
async def preview_install_from_github(self, context: TenantContext, data: dict) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Previewing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
owner = str(data['owner']).strip()
|
||||
repo = str(data['repo']).strip()
|
||||
release_tag = str(data.get('release_tag', '')).strip()
|
||||
@@ -236,7 +232,7 @@ class SkillService:
|
||||
source_subdir = str(data.get('source_subdir', '') or '').strip()
|
||||
|
||||
zip_bytes = await self._download_github_asset(asset_url)
|
||||
return await box_service.preview_skill_zip(
|
||||
return await repository.preview_skill_zip(
|
||||
execution_context,
|
||||
zip_bytes,
|
||||
f'{repo}-{release_tag.lstrip("v").replace("/", "-") or "source"}.zip',
|
||||
@@ -253,8 +249,7 @@ class SkillService:
|
||||
source_path: str = '',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Installing a skill from upload')
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await self._repository().install_skill_zip(
|
||||
execution_context,
|
||||
file_bytes,
|
||||
filename,
|
||||
@@ -272,8 +267,7 @@ class SkillService:
|
||||
filename: str,
|
||||
) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Previewing a skill upload')
|
||||
return await box_service.preview_skill_zip(execution_context, file_bytes, filename)
|
||||
return await self._repository().preview_skill_zip(execution_context, file_bytes, filename)
|
||||
|
||||
async def _install_github_skill_md(
|
||||
self,
|
||||
@@ -284,14 +278,14 @@ class SkillService:
|
||||
repo: str,
|
||||
data: dict,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Installing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
zip_bytes, filename, _package_name = await self._download_github_skill_directory_as_zip(
|
||||
asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
installed = await box_service.install_skill_zip(
|
||||
installed = await repository.install_skill_zip(
|
||||
context,
|
||||
zip_bytes,
|
||||
filename,
|
||||
@@ -310,13 +304,13 @@ class SkillService:
|
||||
owner: str,
|
||||
repo: str,
|
||||
) -> list[dict]:
|
||||
box_service = self._require_box('Previewing a skill from GitHub')
|
||||
repository = self._repository()
|
||||
zip_bytes, _filename, package_name = await self._download_github_skill_directory_as_zip(
|
||||
asset_url,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
)
|
||||
return await box_service.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
return await repository.preview_skill_zip(context, zip_bytes, f'{package_name}.zip', target_suffix='')
|
||||
|
||||
async def reload_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._execution_context(context)
|
||||
@@ -325,8 +319,7 @@ class SkillService:
|
||||
|
||||
async def scan_directory_async(self, context: TenantContext, path: str) -> dict:
|
||||
execution_context = await self._execution_context(context)
|
||||
box_service = self._require_box('Scanning a skill directory')
|
||||
return await box_service.scan_skill_directory(execution_context, path)
|
||||
return await self._repository().scan_skill_directory(execution_context, path)
|
||||
|
||||
async def _reload_skills(self, context: TenantContext) -> None:
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Short-lived Slack app provisioning and OAuth handoff sessions."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
import aiohttp
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
|
||||
|
||||
class SlackSetupError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def callback_url(webhook_url: str, bot_uuid: str) -> str:
|
||||
parsed = urlsplit(webhook_url)
|
||||
suffix = f'/bots/{bot_uuid}'
|
||||
if (
|
||||
parsed.scheme != 'https'
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or not parsed.path.endswith(suffix)
|
||||
or parsed.hostname.lower() == 'localhost'
|
||||
):
|
||||
raise SlackSetupError('invalid_webhook_url')
|
||||
try:
|
||||
if not ipaddress.ip_address(parsed.hostname).is_global:
|
||||
raise SlackSetupError('invalid_webhook_url')
|
||||
except ValueError as exc:
|
||||
if isinstance(exc, SlackSetupError):
|
||||
raise
|
||||
return webhook_url[: -len(suffix)] + '/api/v1/platform/adapters/slack/setup/callback'
|
||||
|
||||
|
||||
def manifest(name: str, webhook_url: str, redirect_url: str, *, events: bool, socket_mode=False) -> dict:
|
||||
settings = {'socket_mode_enabled': socket_mode}
|
||||
if events:
|
||||
settings['event_subscriptions'] = {
|
||||
**({} if socket_mode else {'request_url': webhook_url}),
|
||||
'bot_events': [
|
||||
'app_mention',
|
||||
'message.im',
|
||||
'message.channels',
|
||||
'message.groups',
|
||||
'message.mpim',
|
||||
'reaction_added',
|
||||
'reaction_removed',
|
||||
'member_joined_channel',
|
||||
'member_left_channel',
|
||||
'channel_rename',
|
||||
'group_rename',
|
||||
'channel_archive',
|
||||
'channel_unarchive',
|
||||
'group_archive',
|
||||
'group_unarchive',
|
||||
'app_home_opened',
|
||||
'app_uninstalled',
|
||||
'tokens_revoked',
|
||||
],
|
||||
}
|
||||
settings['interactivity'] = {
|
||||
'is_enabled': True,
|
||||
**({} if socket_mode else {'request_url': webhook_url}),
|
||||
}
|
||||
return {
|
||||
'display_information': {'name': name},
|
||||
'features': {
|
||||
'bot_user': {'display_name': name, 'always_online': False},
|
||||
'app_home': {'messages_tab_enabled': True, 'messages_tab_read_only_enabled': False},
|
||||
},
|
||||
'oauth_config': {
|
||||
'redirect_urls': [redirect_url],
|
||||
'scopes': {
|
||||
'bot': [
|
||||
'app_mentions:read',
|
||||
'reactions:read',
|
||||
'chat:write',
|
||||
'channels:history',
|
||||
'channels:read',
|
||||
'groups:history',
|
||||
'groups:read',
|
||||
'im:history',
|
||||
'im:read',
|
||||
'im:write',
|
||||
'mpim:history',
|
||||
'mpim:read',
|
||||
'users:read',
|
||||
'files:read',
|
||||
'files:write',
|
||||
]
|
||||
},
|
||||
},
|
||||
'settings': settings,
|
||||
}
|
||||
|
||||
|
||||
class SlackSetupService:
|
||||
TTL = 900
|
||||
|
||||
def __init__(self, ap):
|
||||
self.ap = ap
|
||||
self.sessions = {}
|
||||
|
||||
@staticmethod
|
||||
def scope(context):
|
||||
return (context.instance_uuid, context.workspace_uuid, context.placement_generation, context.principal)
|
||||
|
||||
def owned(self, context, session_id):
|
||||
session = self.sessions.get(session_id)
|
||||
if not session or session['scope'] != self.scope(context):
|
||||
raise SlackSetupError('session_not_found')
|
||||
return session
|
||||
|
||||
def discard(self, session_id):
|
||||
session = self.sessions.pop(session_id, None)
|
||||
if session:
|
||||
session['timer'].cancel()
|
||||
task = session.get('task')
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
session.clear()
|
||||
|
||||
async def api(self, method, token=None, **params):
|
||||
headers = {'Authorization': f'Bearer {token}'} if token else {}
|
||||
try:
|
||||
async with httpclient.get_session().post(
|
||||
f'https://slack.com/api/{method}',
|
||||
data=params,
|
||||
headers=headers,
|
||||
timeout=aiohttp.ClientTimeout(total=25),
|
||||
allow_redirects=False,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
raise SlackSetupError(f'slack_http_{response.status}')
|
||||
data = await response.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
|
||||
raise SlackSetupError('slack_connection_failed') from exc
|
||||
if not data.get('ok'):
|
||||
# Never surface arbitrary upstream text or credentials in errors.
|
||||
code = str(data.get('error', 'unknown_error'))
|
||||
if not re.fullmatch(r'[a-z_]{1,80}', code):
|
||||
code = 'unknown_error'
|
||||
raise SlackSetupError(code)
|
||||
return data
|
||||
|
||||
async def start(
|
||||
self,
|
||||
context,
|
||||
*,
|
||||
bot_uuid,
|
||||
access_token,
|
||||
refresh_token,
|
||||
webhook_url='',
|
||||
name='LangBot',
|
||||
socket_mode=False,
|
||||
redirect_url='',
|
||||
):
|
||||
if not all(isinstance(value, str) for value in (bot_uuid, access_token, refresh_token, webhook_url, name)):
|
||||
raise SlackSetupError('invalid_input')
|
||||
access_token, refresh_token, webhook_url, name = (
|
||||
access_token.strip(),
|
||||
refresh_token.strip(),
|
||||
webhook_url.strip(),
|
||||
name.strip(),
|
||||
)
|
||||
if not access_token or not refresh_token or not 1 <= len(name) <= 35:
|
||||
raise SlackSetupError('invalid_input')
|
||||
if not isinstance(socket_mode, bool):
|
||||
raise SlackSetupError('invalid_input')
|
||||
if socket_mode:
|
||||
parsed = urlsplit(redirect_url)
|
||||
if (
|
||||
not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or not parsed.path.endswith('/api/v1/platform/adapters/slack/setup/callback')
|
||||
or not (parsed.scheme == 'https' or (parsed.scheme == 'http' and parsed.hostname == 'localhost'))
|
||||
):
|
||||
raise SlackSetupError('invalid_redirect_url')
|
||||
redirect = redirect_url
|
||||
else:
|
||||
redirect = callback_url(webhook_url, bot_uuid)
|
||||
bot = await self.ap.bot_service.get_bot(context, bot_uuid)
|
||||
if not bot or bot['adapter'] not in ('slack', 'slack-omni'):
|
||||
raise SlackSetupError('slack_bot_not_found')
|
||||
if (
|
||||
len(self.sessions) >= 100
|
||||
or sum(s['context'].workspace_uuid == context.workspace_uuid for s in self.sessions.values()) >= 10
|
||||
):
|
||||
raise SlackSetupError('too_many_sessions')
|
||||
if any(s['bot_uuid'] == bot_uuid for s in self.sessions.values()):
|
||||
raise SlackSetupError('setup_already_active')
|
||||
sid = secrets.token_urlsafe(32)
|
||||
session = {
|
||||
'scope': self.scope(context),
|
||||
'context': context,
|
||||
'status': 'creating',
|
||||
'bot_uuid': bot_uuid,
|
||||
'redirect_url': redirect,
|
||||
'webhook_url': webhook_url,
|
||||
'socket_mode': socket_mode,
|
||||
'timer': asyncio.get_running_loop().call_later(self.TTL, self.discard, sid),
|
||||
}
|
||||
self.sessions[sid] = session
|
||||
coro = self._create(session, access_token, refresh_token, name, sid)
|
||||
try:
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
coro,
|
||||
kind='platform-adapter-credential-exchange',
|
||||
name=f'slack-setup-{sid}',
|
||||
label='Slack app setup',
|
||||
instance_uuid=context.instance_uuid,
|
||||
workspace_uuid=context.workspace_uuid,
|
||||
placement_generation=context.placement_generation,
|
||||
)
|
||||
session['task'] = wrapper.task
|
||||
except Exception:
|
||||
coro.close()
|
||||
self.discard(sid)
|
||||
raise SlackSetupError('too_many_sessions') from None
|
||||
return {'session_id': sid}
|
||||
|
||||
async def _create(self, session, access, refresh, name, sid):
|
||||
async def config_api(method, **params):
|
||||
nonlocal access, refresh
|
||||
try:
|
||||
return await self.api(method, access, **params)
|
||||
except SlackSetupError as exc:
|
||||
if str(exc) not in ('token_expired', 'invalid_auth', 'token_revoked'):
|
||||
raise
|
||||
rotated = await self.api('tooling.tokens.rotate', refresh_token=refresh)
|
||||
access, refresh = rotated['token'], rotated['refresh_token']
|
||||
return await self.api(method, access, **params)
|
||||
|
||||
try:
|
||||
base = manifest(
|
||||
name, session['webhook_url'], session['redirect_url'], events=False, socket_mode=session['socket_mode']
|
||||
)
|
||||
result = await config_api('apps.manifest.create', manifest=json.dumps(base))
|
||||
session['app_id'] = result['app_id']
|
||||
session['credentials'] = result['credentials']
|
||||
# Register the signing secret before Slack validates the final event URL.
|
||||
complete = manifest(
|
||||
name, session['webhook_url'], session['redirect_url'], events=True, socket_mode=session['socket_mode']
|
||||
)
|
||||
await config_api('apps.manifest.update', app_id=result['app_id'], manifest=json.dumps(complete))
|
||||
session['authorize_url'] = 'https://slack.com/oauth/v2/authorize?' + urlencode(
|
||||
{
|
||||
'client_id': result['credentials']['client_id'],
|
||||
'scope': ','.join(complete['oauth_config']['scopes']['bot']),
|
||||
'redirect_uri': session['redirect_url'],
|
||||
'state': sid,
|
||||
}
|
||||
)
|
||||
session['status'] = 'waiting'
|
||||
except Exception as exc:
|
||||
if session:
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'setup_failed'
|
||||
session.pop('credentials', None)
|
||||
finally:
|
||||
access = refresh = ''
|
||||
|
||||
def status(self, context, sid):
|
||||
session = self.owned(context, sid)
|
||||
result = {key: session[key] for key in ('status', 'app_id', 'authorize_url', 'error') if key in session}
|
||||
if session['status'] == 'success':
|
||||
result['config'] = session['config']
|
||||
return result
|
||||
|
||||
async def finish(self, sid, code, error=None):
|
||||
session = self.sessions.get(sid)
|
||||
if not session or session['status'] != 'waiting':
|
||||
raise SlackSetupError('session_not_found')
|
||||
session['status'] = 'authorizing'
|
||||
try:
|
||||
if error or not code:
|
||||
raise SlackSetupError('authorization_denied')
|
||||
context = session['context']
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
context.workspace_uuid,
|
||||
expected_generation=context.placement_generation,
|
||||
)
|
||||
creds = session['credentials']
|
||||
result = await self.api(
|
||||
'oauth.v2.access',
|
||||
client_id=creds['client_id'],
|
||||
client_secret=creds['client_secret'],
|
||||
code=code,
|
||||
redirect_uri=session['redirect_url'],
|
||||
)
|
||||
if result.get('app_id') != session['app_id'] or result.get('token_type') != 'bot':
|
||||
raise SlackSetupError('unexpected_oauth_response')
|
||||
session['config'] = {
|
||||
'bot_token': result['access_token'],
|
||||
'signing_secret': creds['signing_secret'],
|
||||
'socket_mode': session['socket_mode'],
|
||||
}
|
||||
session['status'] = 'success'
|
||||
except Exception as exc:
|
||||
if session:
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(exc) if isinstance(exc, SlackSetupError) else 'authorization_failed'
|
||||
raise SlackSetupError('authorization_failed') from None
|
||||
finally:
|
||||
session.pop('credentials', None)
|
||||
session.pop('authorize_url', None)
|
||||
|
||||
async def challenge(self, bot_uuid, request):
|
||||
session = next((s for s in self.sessions.values() if s['bot_uuid'] == bot_uuid and s.get('credentials')), None)
|
||||
if not session:
|
||||
return None
|
||||
body = await request.get_data()
|
||||
timestamp = request.headers.get('X-Slack-Request-Timestamp', '')
|
||||
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
|
||||
return None
|
||||
signature = (
|
||||
'v0='
|
||||
+ hmac.new(
|
||||
session['credentials']['signing_secret'].encode(),
|
||||
b'v0:' + timestamp.encode() + b':' + body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
)
|
||||
if not hmac.compare_digest(signature, request.headers.get('X-Slack-Signature', '')):
|
||||
return None
|
||||
data = await request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or data.get('type') != 'url_verification':
|
||||
return None
|
||||
context = session['context']
|
||||
await self.ap.workspace_service.get_execution_binding(
|
||||
context.workspace_uuid,
|
||||
expected_generation=context.placement_generation,
|
||||
)
|
||||
return {'challenge': data.get('challenge', '')}
|
||||
|
||||
|
||||
def get_slack_setup(ap):
|
||||
if not hasattr(ap, 'slack_setup_service'):
|
||||
ap.slack_setup_service = SlackSetupService(ap)
|
||||
return ap.slack_setup_service
|
||||
@@ -290,7 +290,23 @@ class SpaceService:
|
||||
selection = await self.get_model_selection('chat')
|
||||
if not selection:
|
||||
raise ValueError('No recommended chat model is available')
|
||||
recommended = selection[0]
|
||||
return await self._resolve_recommended_model(context, selection[0])
|
||||
|
||||
async def get_recommended_assistant_model(self, context: typing.Any) -> dict:
|
||||
"""Resolve the dedicated, health-filtered assistant recommendation without fallback."""
|
||||
space_url = self._get_space_config()['url']
|
||||
session = httpclient.get_session()
|
||||
async with session.get(f'{space_url}/api/v1/models/assistant/recommendation') as response:
|
||||
if response.status != 200:
|
||||
raise ValueError('No recommended assistant model is available')
|
||||
payload = await httpclient.read_json_limited(response)
|
||||
if payload.get('code') != 0:
|
||||
raise ValueError('No recommended assistant model is available')
|
||||
recommended = SpaceModel.model_validate(payload['data']['model'])
|
||||
return await self._resolve_recommended_model(context, recommended)
|
||||
|
||||
async def _resolve_recommended_model(self, context: typing.Any, recommended: SpaceModel) -> dict:
|
||||
"""Map a public catalog model to this Workspace, refreshing the catalog once."""
|
||||
|
||||
async def find_local_model():
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import t
|
||||
|
||||
import sqlalchemy
|
||||
import argon2
|
||||
import jwt
|
||||
@@ -423,12 +425,12 @@ class UserService:
|
||||
|
||||
user_obj = await self.get_user_by_email(user_email)
|
||||
if user_obj is None:
|
||||
raise ValueError('用户不存在')
|
||||
raise ValueError(t('Resource not found'))
|
||||
self._require_active_account(user_obj)
|
||||
|
||||
# Check if this user has a local password set
|
||||
if not user_obj.password:
|
||||
raise ValueError('请使用 LangBot 账号登录')
|
||||
raise ValueError(t('Please sign in with your LangBot Account'))
|
||||
|
||||
await self._verify_password(user_obj.password, password)
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""Resolve resource limits at the Workspace service boundary."""
|
||||
|
||||
from ....cloud.entitlements import EntitlementUnavailableError
|
||||
|
||||
LIMIT_KEYS = {
|
||||
'max_bots': 'bots.max',
|
||||
'max_pipelines': 'pipelines.max',
|
||||
'max_extensions': 'plugins.max',
|
||||
'max_knowledge_bases': 'knowledge_bases.max',
|
||||
}
|
||||
|
||||
|
||||
async def workspace_limits(ap, workspace_uuid: str, *, minimum_revision: int = 0) -> dict[str, int]:
|
||||
if getattr(getattr(ap, 'deployment', None), 'mode', None) != 'cloud':
|
||||
return dict(ap.instance_config.data.get('system', {}).get('limitation', {}))
|
||||
resolver = getattr(ap, 'entitlement_resolver', None)
|
||||
if resolver is None:
|
||||
raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
|
||||
snapshot = await resolver.resolve(workspace_uuid, minimum_revision=minimum_revision)
|
||||
return {config_key: snapshot.limit(limit_key) for config_key, limit_key in LIMIT_KEYS.items()}
|
||||
@@ -19,11 +19,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import typing
|
||||
import functools
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from ..http.authz import Permission, require_permission
|
||||
from .context import get_request_context
|
||||
from ...i18n import language_scope
|
||||
from ...i18n.workspace import get_workspace_language, set_workspace_language
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ...core import app as app_module
|
||||
@@ -78,6 +81,20 @@ class LangBotMCPServer:
|
||||
ap = self.ap
|
||||
mcp = self
|
||||
|
||||
@mcp.tool(description='Get the Workspace language used for backend messages and default bot replies.')
|
||||
async def get_workspace_message_language() -> str:
|
||||
context = _authorized(Permission.WORKSPACE_VIEW)
|
||||
return _dump({'language': await get_workspace_language(ap, context.workspace_uuid, refresh=True)})
|
||||
|
||||
@mcp.tool(
|
||||
description='Set the Workspace message language. Requires workspace.update; does not change personal UI language.'
|
||||
)
|
||||
async def set_workspace_message_language(
|
||||
language: typing.Literal['en_US', 'zh_Hans', 'zh_Hant', 'ja_JP', 'es_ES', 'ru_RU', 'th_TH', 'vi_VN'],
|
||||
) -> str:
|
||||
context = _authorized(Permission.WORKSPACE_UPDATE)
|
||||
return _dump({'language': await set_workspace_language(ap, context.workspace_uuid, language)})
|
||||
|
||||
# ----- System (read-only) -------------------------------------- #
|
||||
@mcp.tool(description='Get basic LangBot system/runtime information (version, edition).')
|
||||
async def get_system_info() -> str:
|
||||
@@ -165,7 +182,7 @@ class LangBotMCPServer:
|
||||
return _dump(ap.box_service.get_recent_errors(context))
|
||||
|
||||
@mcp.tool(
|
||||
description='Read bounded Workspace runtime records. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, knowledge_base_id, user_query, is_active as supported by the record kind.'
|
||||
description='Read bounded Workspace runtime records. LLM calls include optional details (timing in ms, complete provider usage, structured error); older records have null details. Filters follow the monitoring service: bot_ids, pipeline_ids, session_ids, start_time, end_time, mode (all/real/debug), execution_statuses (owning execution status groups), knowledge_base_id, user_query, is_active as supported by the record kind.'
|
||||
)
|
||||
async def get_monitoring_records(
|
||||
kind: typing.Literal['messages', 'llm_calls', 'tool_calls', 'embedding_calls', 'sessions', 'errors'],
|
||||
@@ -176,7 +193,7 @@ class LangBotMCPServer:
|
||||
import datetime
|
||||
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
allowed = {'start_time', 'end_time'}
|
||||
allowed = {'start_time', 'end_time', 'mode', 'execution_statuses'}
|
||||
if kind != 'embedding_calls':
|
||||
allowed |= {'bot_ids', 'pipeline_ids'}
|
||||
if kind in {'messages', 'tool_calls'}:
|
||||
@@ -207,6 +224,116 @@ class LangBotMCPServer:
|
||||
return _dump(await ap.monitoring_service.get_message_details(context, identifier))
|
||||
return _dump(await ap.monitoring_service.get_session_analysis(context, identifier))
|
||||
|
||||
@mcp.tool(
|
||||
description='Read a bounded snapshot of active and recently started Workspace executions, including the latest progress event. No percentage is estimated.'
|
||||
)
|
||||
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='Create/configure a Slack app for an existing Workspace bot draft. Returns a setup session; poll it for the installation authorization URL. The user must approve Slack installation. Configuration tokens stay in memory only.'
|
||||
)
|
||||
async def start_slack_setup(
|
||||
bot_uuid: str,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
webhook_url: str = '',
|
||||
name: str = 'LangBot',
|
||||
socket_mode: bool = False,
|
||||
redirect_url: str = '',
|
||||
) -> str:
|
||||
from langbot.pkg.api.http.service.slack_setup import get_slack_setup
|
||||
|
||||
return _dump(
|
||||
await get_slack_setup(ap).start(
|
||||
_authorized(Permission.RESOURCE_MANAGE),
|
||||
bot_uuid=bot_uuid,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
webhook_url=webhook_url,
|
||||
name=name,
|
||||
socket_mode=socket_mode,
|
||||
redirect_url=redirect_url,
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
description='Read an owned Slack setup session. Success includes adapter credentials; save them with the existing bot update tool. Socket Mode additionally needs an App-Level Token with connections:write.'
|
||||
)
|
||||
async def get_slack_setup_status(session_id: str) -> str:
|
||||
from langbot.pkg.api.http.service.slack_setup import get_slack_setup
|
||||
|
||||
return _dump(get_slack_setup(ap).status(_authorized(Permission.RESOURCE_MANAGE), session_id))
|
||||
|
||||
@mcp.tool(
|
||||
description='Discard an owned Slack setup session and its temporary credentials. Does not delete the Slack app.'
|
||||
)
|
||||
async def cancel_slack_setup(session_id: str) -> str:
|
||||
from langbot.pkg.api.http.service.slack_setup import get_slack_setup
|
||||
|
||||
service = get_slack_setup(ap)
|
||||
service.owned(_authorized(Permission.RESOURCE_MANAGE), session_id)
|
||||
service.discard(session_id)
|
||||
return _dump({'cancelled': True})
|
||||
|
||||
@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.'
|
||||
)
|
||||
async def get_monitoring_executions(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
bot_ids: list[str] | None = None,
|
||||
pipeline_ids: list[str] | None = None,
|
||||
statuses: list[str] | None = None,
|
||||
mode: typing.Literal['all', 'real', 'debug'] = 'all',
|
||||
) -> str:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(
|
||||
await ap.monitoring_service.get_executions(
|
||||
context,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
bot_ids=bot_ids,
|
||||
pipeline_ids=pipeline_ids,
|
||||
statuses=statuses,
|
||||
mode=mode,
|
||||
)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
description='Read execution inputs, generated outputs, delivery records, calls, errors, related executions and conversation history. Use section=overview for inputs, outputs, deliveries and errors without loading history; request other sections on demand. Omit section for the full trace. Each section is independently paginated; follow pages[section].next_offset when has_more is true.'
|
||||
)
|
||||
async def get_monitoring_execution_detail(
|
||||
source: typing.Literal['auto', 'agent', 'pipeline', 'event'],
|
||||
identifier: str,
|
||||
section: str | None = None,
|
||||
offset: int = 0,
|
||||
limit: int = 100,
|
||||
) -> str:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(
|
||||
await ap.monitoring_service.get_execution_detail(
|
||||
context,
|
||||
source,
|
||||
identifier,
|
||||
section=section,
|
||||
offset=offset,
|
||||
limit=limit,
|
||||
)
|
||||
)
|
||||
|
||||
# ----- Bots ---------------------------------------------------- #
|
||||
@mcp.tool(description='List all messaging-platform bots. Secrets are redacted.')
|
||||
async def list_bots() -> str:
|
||||
@@ -220,7 +347,7 @@ class LangBotMCPServer:
|
||||
|
||||
@mcp.tool(
|
||||
description=(
|
||||
'Create a bot. `bot_data` is a JSON object matching the LangBot '
|
||||
'Create a bot. Cloud Workspace platform restrictions also apply to this tool. `bot_data` is a JSON object matching the LangBot '
|
||||
'POST /api/v1/platform/bots body (e.g. name, adapter, config). '
|
||||
'Use event_bindings for exclusive Agent/Pipeline routes; plugin_processors is an array '
|
||||
'of {processor_uuid, enabled} subscriptions that automatically match Runner-declared events. '
|
||||
@@ -378,12 +505,38 @@ class LangBotMCPServer:
|
||||
context = _authorized(Permission.RUNTIME_OPERATE)
|
||||
return _dump(await ap.agent_service.debug_agent(context, processor_uuid, payload))
|
||||
|
||||
@mcp.tool(description='Get the single starred LLM model UUID for the current Workspace.')
|
||||
async def get_starred_model() -> str:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump({'uuid': await ap.llm_model_service.get_starred_model(context)})
|
||||
|
||||
@mcp.tool(
|
||||
description='Star one LLM model for this Workspace, replacing the previous choice. Pass null to clear.'
|
||||
)
|
||||
async def set_starred_model(model_uuid: str | None = None) -> str:
|
||||
context = _authorized(Permission.PROVIDER_SECRET_MANAGE)
|
||||
await ap.llm_model_service.set_starred_model(context, model_uuid)
|
||||
return _dump({'uuid': model_uuid})
|
||||
|
||||
@mcp.tool(
|
||||
description='Resolve the default LLM model: starred model, then the available Space wizard recommendation.'
|
||||
)
|
||||
async def get_default_model() -> str:
|
||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||
return _dump({'uuid': await ap.llm_model_service.get_default_model(context)})
|
||||
|
||||
@mcp.tool(description='List all configured LLM models. Secrets are redacted.')
|
||||
async def list_llm_models() -> str:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.llm_model_service.get_llm_models(context, include_secret=False))
|
||||
|
||||
@mcp.tool(description='Get a single LLM model by UUID.')
|
||||
@mcp.tool(
|
||||
description=(
|
||||
'Get a single LLM model by UUID, including reasoning capabilities. '
|
||||
'Optional budget_tokens min/max describes numeric thinking-budget support; '
|
||||
'reasoning_config and extra_args retain the existing model API shape.'
|
||||
)
|
||||
)
|
||||
async def get_llm_model(model_uuid: str) -> str:
|
||||
context = _authorized(Permission.RESOURCE_VIEW)
|
||||
return _dump(await ap.llm_model_service.get_llm_model(context, model_uuid, include_secret=False))
|
||||
@@ -441,7 +594,12 @@ class LangBotMCPServer:
|
||||
"""Register a tool boundary before its authorization and service call."""
|
||||
|
||||
def register(fn):
|
||||
observed = fn
|
||||
@functools.wraps(fn)
|
||||
async def observed(*args, **kwargs):
|
||||
context = get_request_context()
|
||||
with language_scope(await get_workspace_language(self.ap, context.workspace_uuid)):
|
||||
return await fn(*args, **kwargs)
|
||||
|
||||
return self.mcp.tool(**options)(observed)
|
||||
|
||||
return register
|
||||
|
||||
@@ -147,10 +147,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
- An explicit ``runtime.endpoint`` was configured
|
||||
|
||||
When this is True the Box runtime lives in a separate process with its
|
||||
own filesystem view (container, pod sidecar, or remote host), so paths
|
||||
it reports (e.g. skill ``package_root``) are NOT resolvable on the
|
||||
LangBot side. When False, Box runs as a stdio child process that shares
|
||||
LangBot's filesystem.
|
||||
own filesystem view (container, pod sidecar, or remote host), so only
|
||||
explicitly shared paths are usable on both sides. When False, Box runs
|
||||
as a stdio child process that shares LangBot's filesystem.
|
||||
"""
|
||||
return bool(
|
||||
self.configured_runtime_endpoint
|
||||
|
||||
@@ -24,6 +24,7 @@ class RunBoxBinding:
|
||||
exported: dict = field(default_factory=dict)
|
||||
submitted: set = field(default_factory=set)
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
read_only_mounts: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def prepare_input_files(query, agent_input):
|
||||
@@ -152,9 +153,10 @@ class RunnerBoxService:
|
||||
if set(request.options) - {'image'}:
|
||||
raise BoxValidationError('Only the Box image can be specified; resource limits are Host-owned')
|
||||
spec = {'session_id': session_id, **request.options}
|
||||
if query is not None:
|
||||
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
|
||||
result = await self.box.create_session(context, spec)
|
||||
from ..provider.tools.loaders.skill import build_execution_mounts
|
||||
|
||||
mounts = build_execution_mounts(self.box.ap, query) if query is not None else []
|
||||
result = await self.box.create_session(context, spec, trusted_read_only_mounts=mounts)
|
||||
return self.public_session(result)
|
||||
|
||||
async def bind(self, context, query, run_id, box_id):
|
||||
@@ -174,8 +176,11 @@ class RunnerBoxService:
|
||||
return self.binding_info(current)
|
||||
fields = ('image', 'network', 'cpus', 'memory_mb', 'pids_limit', 'read_only_rootfs', 'persistent')
|
||||
spec = {key: item[key] for key in fields if key in item}
|
||||
spec['extra_mounts'] = self.box.build_skill_extra_mounts(query)
|
||||
binding = RunBoxBinding(run_id, box_id, spec, run_id)
|
||||
from ..provider.tools.loaders.skill import build_execution_mounts
|
||||
|
||||
binding = RunBoxBinding(
|
||||
run_id, box_id, spec, run_id, read_only_mounts=build_execution_mounts(self.box.ap, query)
|
||||
)
|
||||
object.__setattr__(query, '_box_binding', binding)
|
||||
return self.binding_info(binding)
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Bounded, link-free outbox operations executed inside the bound sandbox."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
|
||||
|
||||
def outbox_script(
|
||||
outbox_root: str,
|
||||
query_key: str,
|
||||
*,
|
||||
operation: str,
|
||||
max_file_bytes: int,
|
||||
max_files: int,
|
||||
max_total_bytes: int,
|
||||
) -> str:
|
||||
settings = {
|
||||
'workspace': os.path.dirname(outbox_root),
|
||||
'outbox': os.path.basename(outbox_root),
|
||||
'query': query_key,
|
||||
'operation': operation,
|
||||
'max_file_bytes': max_file_bytes,
|
||||
'max_files': max_files,
|
||||
'max_total_bytes': max_total_bytes,
|
||||
}
|
||||
return f'settings = {settings!r}\n' + textwrap.dedent("""\
|
||||
import base64, errno, json, os, stat
|
||||
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
|
||||
file_flags = os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK
|
||||
out = []
|
||||
budget = {'entries': 512, 'directories': 64, 'bytes': 0}
|
||||
|
||||
def component(value):
|
||||
if (not value or value in {'.', '..'} or len(os.fsencode(value)) > 240
|
||||
or any(c in value for c in ('/', '\\\\', '\\x00'))):
|
||||
raise RuntimeError('Unsafe sandbox outbox path component')
|
||||
return value
|
||||
|
||||
def read_directory(fd, prefix='', depth=0):
|
||||
if depth > 8:
|
||||
return
|
||||
with os.scandir(fd) as entries:
|
||||
for entry in entries:
|
||||
if len(out) >= settings['max_files']:
|
||||
return
|
||||
if budget['entries'] <= 0:
|
||||
raise RuntimeError('Sandbox outbox exceeds the directory-entry limit')
|
||||
budget['entries'] -= 1
|
||||
name = component(entry.name)
|
||||
relative = prefix + '/' + name if prefix else name
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if budget['directories'] <= 0:
|
||||
raise RuntimeError('Sandbox outbox exceeds the directory limit')
|
||||
budget['directories'] -= 1
|
||||
flags = directory_flags
|
||||
elif entry.is_file(follow_symlinks=False):
|
||||
flags = file_flags
|
||||
else:
|
||||
continue
|
||||
try:
|
||||
child_fd = os.open(name, flags, dir_fd=fd)
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOENT, errno.ELOOP, errno.ENOTDIR}:
|
||||
continue
|
||||
raise
|
||||
try:
|
||||
metadata = os.fstat(child_fd)
|
||||
if flags == directory_flags and stat.S_ISDIR(metadata.st_mode):
|
||||
read_directory(child_fd, relative, depth + 1)
|
||||
continue
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
continue
|
||||
remaining = settings['max_total_bytes'] - budget['bytes']
|
||||
if metadata.st_size > settings['max_file_bytes'] or metadata.st_size > remaining:
|
||||
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
|
||||
with os.fdopen(child_fd, 'rb', closefd=False) as source:
|
||||
data = source.read(settings['max_file_bytes'] + 1)
|
||||
if len(data) > settings['max_file_bytes'] or len(data) > remaining:
|
||||
raise RuntimeError('Sandbox output attachment exceeds the export size limit')
|
||||
out.append({'name': relative, 'b64': base64.b64encode(data).decode('ascii')})
|
||||
budget['bytes'] += len(data)
|
||||
finally:
|
||||
os.close(child_fd)
|
||||
|
||||
def clear_directory(fd, depth=0):
|
||||
if depth > 16:
|
||||
raise RuntimeError('Sandbox cleanup exceeded its depth limit')
|
||||
with os.scandir(fd) as entries:
|
||||
for entry in entries:
|
||||
if budget['entries'] <= 0:
|
||||
raise RuntimeError('Sandbox cleanup exceeded its inode limit')
|
||||
budget['entries'] -= 1
|
||||
name = component(entry.name)
|
||||
try:
|
||||
child_fd = os.open(name, directory_flags, dir_fd=fd)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
if exc.errno not in {errno.ELOOP, errno.ENOTDIR}:
|
||||
raise
|
||||
try:
|
||||
os.unlink(name, dir_fd=fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
clear_directory(child_fd, depth + 1)
|
||||
finally:
|
||||
os.close(child_fd)
|
||||
try:
|
||||
os.rmdir(name, dir_fd=fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
try:
|
||||
workspace_fd = os.open(settings['workspace'], directory_flags)
|
||||
try:
|
||||
outbox_fd = os.open(component(settings['outbox']), directory_flags, dir_fd=workspace_fd)
|
||||
try:
|
||||
query_fd = os.open(component(settings['query']), directory_flags, dir_fd=outbox_fd)
|
||||
try:
|
||||
if settings['operation'] == 'read':
|
||||
read_directory(query_fd)
|
||||
elif settings['operation'] == 'clear':
|
||||
budget['entries'] = 4096
|
||||
clear_directory(query_fd)
|
||||
else:
|
||||
raise RuntimeError('Unknown sandbox outbox operation')
|
||||
finally:
|
||||
os.close(query_fd)
|
||||
finally:
|
||||
os.close(outbox_fd)
|
||||
finally:
|
||||
os.close(workspace_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
print(json.dumps(out))
|
||||
""")
|
||||
@@ -14,7 +14,9 @@ class UnsafeWorkspacePathError(OSError):
|
||||
_DIRECTORY_FLAGS = (
|
||||
os.O_RDONLY | getattr(os, 'O_DIRECTORY', 0) | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
||||
)
|
||||
_FILE_READ_FLAGS = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
||||
_FILE_READ_FLAGS = (
|
||||
os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NONBLOCK', 0)
|
||||
)
|
||||
_FILE_WRITE_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0) | getattr(os, 'O_CLOEXEC', 0)
|
||||
_MAX_REMOVAL_ENTRIES = 4096
|
||||
_MAX_REMOVAL_DEPTH = 16
|
||||
@@ -45,6 +47,8 @@ def _unsafe(path: str, exc: BaseException | None = None) -> UnsafeWorkspacePathE
|
||||
def _root_fd(root: str):
|
||||
try:
|
||||
fd = os.open(root, _DIRECTORY_FLAGS)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise _unsafe(root, exc)
|
||||
try:
|
||||
@@ -64,6 +68,8 @@ def _open_dir_at(parent_fd: int, name: str, *, create: bool) -> int:
|
||||
pass
|
||||
try:
|
||||
fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise _unsafe(name, exc)
|
||||
if not stat.S_ISDIR(os.fstat(fd).st_mode):
|
||||
@@ -91,6 +97,8 @@ def _remove_entry(
|
||||
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=parent_fd)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError as exc:
|
||||
if exc.errno not in {errno.ELOOP, errno.ENOTDIR, errno.EACCES}:
|
||||
raise
|
||||
@@ -198,6 +206,8 @@ def _read_directory(
|
||||
remaining_directories[0] -= 1
|
||||
try:
|
||||
child_fd = os.open(name, _DIRECTORY_FLAGS, dir_fd=directory_fd)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
@@ -218,6 +228,8 @@ def _read_directory(
|
||||
continue
|
||||
try:
|
||||
file_fd = os.open(name, _FILE_READ_FLAGS, dir_fd=directory_fd)
|
||||
except PermissionError:
|
||||
raise
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
|
||||
+134
-294
@@ -20,7 +20,6 @@ from langbot_plugin.box.security import BOX_SHARED_WORKSPACE_PROBE_PREFIX
|
||||
from .admission import SandboxAdmissionController, require_cloud_admission_policy
|
||||
from .connector import BoxRuntimeConnector, _get_box_config
|
||||
from . import secure_fs
|
||||
from ..telemetry import features as telemetry_features
|
||||
from ..utils import httpclient
|
||||
from ..api.http.context import ExecutionContext
|
||||
from ..api.http.service.tenant import TenantContext, require_workspace_uuid
|
||||
@@ -28,8 +27,10 @@ from langbot_plugin.box.errors import BoxAdmissionError, BoxError, BoxValidation
|
||||
from langbot_plugin.box.models import (
|
||||
BUILTIN_PROFILES,
|
||||
BoxExecutionResult,
|
||||
BoxHostMountMode,
|
||||
BoxManagedProcessInfo,
|
||||
BoxManagedProcessSpec,
|
||||
BoxMountSpec,
|
||||
BoxProfile,
|
||||
BoxSpec,
|
||||
)
|
||||
@@ -169,7 +170,7 @@ class BoxService:
|
||||
self._connector_error = 'Box runtime is disabled in config (box.enabled = false)'
|
||||
self.ap.logger.info(
|
||||
'Box runtime disabled by config; sandbox features (exec/read/write/edit, '
|
||||
'skill add/edit, stdio MCP) will be unavailable.'
|
||||
'stdio MCP, executable package scripts) will be unavailable.'
|
||||
)
|
||||
return
|
||||
try:
|
||||
@@ -275,17 +276,6 @@ class BoxService:
|
||||
await self._purge_attachment_dirs()
|
||||
self._available = True
|
||||
self._connector_error = ''
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
||||
if callable(reload_skills) and not self._cloud_managed:
|
||||
binding = await self.ap.workspace_service.get_execution_binding()
|
||||
await reload_skills(
|
||||
ExecutionContext(
|
||||
instance_uuid=binding.instance_uuid,
|
||||
workspace_uuid=binding.workspace_uuid,
|
||||
placement_generation=binding.placement_generation,
|
||||
)
|
||||
)
|
||||
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -365,19 +355,17 @@ class BoxService:
|
||||
"""Whether LangBot and the Box runtime share a filesystem view.
|
||||
|
||||
This is True only when Box runs as a local stdio child process of
|
||||
LangBot (same container/host). In that case paths the Box runtime
|
||||
reports — notably skill ``package_root`` — resolve identically on the
|
||||
LangBot side, so LangBot may validate them against its own filesystem.
|
||||
LangBot (same container/host). In that case host paths resolve
|
||||
identically on both sides and Core may perform local filesystem work.
|
||||
|
||||
It is False for every separated deployment (Docker Compose, k8s
|
||||
sidecar, ``--standalone-box``, or an explicit ``runtime.endpoint``),
|
||||
where the Box runtime owns its own filesystem and LangBot must trust
|
||||
the paths it reports rather than checking them locally.
|
||||
where only explicitly shared and identically mounted roots can cross
|
||||
the process boundary.
|
||||
|
||||
When Box is wired up with an injected client (tests, custom embeds)
|
||||
there is no connector to introspect; we conservatively report False so
|
||||
LangBot never wrongly drops Box-reported skills. An explicit override
|
||||
can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
there is no connector to introspect; we conservatively report False.
|
||||
An explicit override can be set via ``_shares_filesystem_with_box`` (used by tests and any
|
||||
embedder that knows the real topology).
|
||||
"""
|
||||
if self._shares_filesystem_with_box_override is not None:
|
||||
@@ -490,11 +478,18 @@ class BoxService:
|
||||
self,
|
||||
context: TenantContext,
|
||||
spec_payload: dict,
|
||||
*,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Reject tenant-owned policy fields and apply the Cloud hard policy."""
|
||||
|
||||
payload = dict(spec_payload)
|
||||
trusted_mounts = self._normalize_trusted_read_only_mounts(trusted_read_only_mounts or [])
|
||||
if not self._cloud_managed:
|
||||
if trusted_mounts:
|
||||
if payload.get('extra_mounts'):
|
||||
raise BoxValidationError('extra_mounts and trusted_read_only_mounts cannot both be supplied')
|
||||
payload['extra_mounts'] = trusted_mounts
|
||||
return payload
|
||||
policy = self._admission_policy
|
||||
if policy is None:
|
||||
@@ -544,7 +539,7 @@ class BoxService:
|
||||
'network': 'off',
|
||||
'host_path': canonical_host_path,
|
||||
'mount_path': '/workspace',
|
||||
'extra_mounts': [],
|
||||
'extra_mounts': trusted_mounts,
|
||||
'persistent': True,
|
||||
'timeout_sec': min(timeout, policy.max_timeout_sec),
|
||||
'cpus': policy.cpus,
|
||||
@@ -556,6 +551,22 @@ class BoxService:
|
||||
)
|
||||
return payload
|
||||
|
||||
def _normalize_trusted_read_only_mounts(self, mounts: list[dict]) -> list[dict]:
|
||||
"""Validate Core-composed artifacts before crossing into Box."""
|
||||
|
||||
normalized: list[dict] = []
|
||||
for raw_mount in mounts:
|
||||
mount = BoxMountSpec.model_validate(raw_mount)
|
||||
if mount.mode != BoxHostMountMode.READ_ONLY:
|
||||
raise BoxAdmissionError('Core-composed additional mounts must be read-only')
|
||||
host_path = os.path.realpath(mount.host_path)
|
||||
if not os.path.isdir(host_path):
|
||||
raise BoxAdmissionError('Core-composed read-only mount source is unavailable')
|
||||
if not any(_is_path_under(host_path, root) for root in self.allowed_mount_roots):
|
||||
raise BoxAdmissionError('Core-composed read-only mount source is outside allowed_mount_roots')
|
||||
normalized.append(mount.model_copy(update={'host_path': host_path}).model_dump(mode='json'))
|
||||
return normalized
|
||||
|
||||
def _reject_cloud_managed_process(self) -> None:
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Managed processes are disabled for Cloud sandboxes')
|
||||
@@ -577,7 +588,25 @@ class BoxService:
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skip_host_mount_validation: bool = False,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
result = await self._execute_spec_payload_result(
|
||||
spec_payload,
|
||||
query,
|
||||
skip_host_mount_validation=skip_host_mount_validation,
|
||||
trusted_read_only_mounts=trusted_read_only_mounts,
|
||||
)
|
||||
return self._serialize_result(result)
|
||||
|
||||
async def _execute_spec_payload_result(
|
||||
self,
|
||||
spec_payload: dict,
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skip_host_mount_validation: bool = False,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> BoxExecutionResult:
|
||||
"""Execute within the bound Workspace and preserve the raw result for file transfers."""
|
||||
if not self._available:
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
@@ -585,11 +614,17 @@ class BoxService:
|
||||
from .runner import binding_for
|
||||
|
||||
binding = binding_for(query)
|
||||
if trusted_read_only_mounts is None:
|
||||
trusted_read_only_mounts = binding.read_only_mounts
|
||||
if spec_payload.get('session_id') not in (None, '', binding.session_id):
|
||||
raise BoxValidationError('session_id must match the bound Box')
|
||||
spec_payload = {**binding.spec, **spec_payload, 'session_id': binding.session_id}
|
||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
spec_payload = self._managed_policy_payload(
|
||||
execution_context,
|
||||
spec_payload,
|
||||
trusted_read_only_mounts=trusted_read_only_mounts,
|
||||
)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
@@ -636,8 +671,7 @@ class BoxService:
|
||||
f'query_id={query.query_id} '
|
||||
f'summary={json.dumps(self._summarize_result(result), ensure_ascii=False)}'
|
||||
)
|
||||
telemetry_features.increment(query, 'sandbox', 'execs')
|
||||
return self._serialize_result(result)
|
||||
return result
|
||||
|
||||
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
|
||||
"""Use the Box explicitly bound by the current Runner invocation."""
|
||||
@@ -645,70 +679,12 @@ class BoxService:
|
||||
|
||||
return binding_for(query).session_id
|
||||
|
||||
def build_skill_extra_mounts(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Build extra_mounts entries for all pipeline-bound skills.
|
||||
|
||||
This ensures that when a container is first created it already has
|
||||
all skill packages mounted, regardless of which skill is currently
|
||||
activated.
|
||||
|
||||
Path validation is filesystem-topology dependent. When LangBot and the
|
||||
Box runtime share a filesystem (local stdio mode), a skill whose
|
||||
``package_root`` is missing or no longer a directory is skipped with a
|
||||
warning instead of being passed through to the backend. Without that
|
||||
guard the three backends behave inconsistently on a stale mount: nsjail
|
||||
refuses to start the sandbox (failing every exec in the session),
|
||||
Docker silently auto-creates a root-owned empty directory on the host,
|
||||
and E2B silently skips the upload — none of which surfaces an
|
||||
actionable error.
|
||||
|
||||
When Box runs as a separate process (Docker Compose, k8s sidecar,
|
||||
``--standalone-box``, or a remote ``runtime.endpoint``), the
|
||||
``package_root`` reported by ``list_skills`` is the Box runtime's own
|
||||
filesystem path and is NOT resolvable on the LangBot side. Validating
|
||||
it locally would wrongly drop every skill, so LangBot trusts the path
|
||||
and lets the Box runtime resolve it. The Box runtime only ever reports
|
||||
skills it discovered on its own filesystem, so the path is valid there
|
||||
by construction.
|
||||
"""
|
||||
if self._cloud_managed:
|
||||
return []
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return []
|
||||
|
||||
from ..provider.tools.loaders import skill as skill_loader
|
||||
|
||||
validate_locally = self.shares_filesystem_with_box
|
||||
|
||||
visible_skills = skill_loader.get_visible_skills(self.ap, query)
|
||||
mounts: list[dict] = []
|
||||
for skill_name, skill_data in visible_skills.items():
|
||||
package_root = str(skill_data.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
continue
|
||||
if validate_locally and not os.path.isdir(package_root):
|
||||
self.ap.logger.warning(
|
||||
f'Skill "{skill_name}" package_root missing on filesystem '
|
||||
f'({package_root}); skipping mount to prevent sandbox failures. '
|
||||
f'The skill cache may be stale — consider reloading skills.'
|
||||
)
|
||||
continue
|
||||
mounts.append(
|
||||
{
|
||||
'host_path': package_root,
|
||||
'mount_path': f'/workspace/.skills/{skill_name}',
|
||||
'mode': 'rw',
|
||||
}
|
||||
)
|
||||
return mounts
|
||||
|
||||
async def execute_tool(
|
||||
self,
|
||||
parameters: dict,
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
skill_name: str | None = None,
|
||||
read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
"""Execute an agent-facing ``exec`` tool call.
|
||||
|
||||
@@ -716,8 +692,6 @@ class BoxService:
|
||||
``BoxSpec.cmd`` field and injects the session id from the query.
|
||||
"""
|
||||
spec_payload: dict = {'cmd': parameters['command']}
|
||||
if skill_name is not None:
|
||||
spec_payload['skill_name'] = skill_name
|
||||
|
||||
# Pass through allowed agent-facing fields
|
||||
for key in ('workdir', 'timeout_sec', 'env'):
|
||||
@@ -727,11 +701,11 @@ class BoxService:
|
||||
# Inject context the agent must not control
|
||||
spec_payload.setdefault('session_id', self.resolve_box_session_id(query))
|
||||
|
||||
# Mount all pipeline-bound skills so they are available in the container
|
||||
if 'extra_mounts' not in spec_payload:
|
||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||
|
||||
return await self.execute_spec_payload(spec_payload, query)
|
||||
return await self.execute_spec_payload(
|
||||
spec_payload,
|
||||
query,
|
||||
trusted_read_only_mounts=read_only_mounts,
|
||||
)
|
||||
|
||||
async def execute_in_context(
|
||||
self,
|
||||
@@ -813,6 +787,10 @@ class BoxService:
|
||||
to the sandbox (and vice-versa). It is ``None`` / not a local dir for
|
||||
E2B and remote runtimes, where we must fall back to the exec channel.
|
||||
"""
|
||||
# secure_fs relies on POSIX openat/O_NOFOLLOW. On Windows use the
|
||||
# sandbox execution channel rather than misclassifying EACCES as a link.
|
||||
if os.open not in os.supports_dir_fd or not hasattr(os, 'O_NOFOLLOW'):
|
||||
return None
|
||||
root = self._tenant_workspace(self._query_execution_context(query))
|
||||
if not root or not os.path.isdir(root) or os.path.islink(root):
|
||||
return None
|
||||
@@ -1151,12 +1129,10 @@ class BoxService:
|
||||
async def collect_outbound_attachments(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Collect files the agent produced in the sandbox outbox.
|
||||
|
||||
Reads ``/workspace/outbox/<query_id>/`` (recursively) — directly from
|
||||
the bind-mounted host directory when available (no size limit), else
|
||||
via the exec channel — returns a list of ``{type, name, base64}``
|
||||
ready to become platform message components, then clears the outbox so
|
||||
a later turn in the same session does not re-send stale files. Returns
|
||||
``[]`` when nothing was produced.
|
||||
Read regular files within the configured attachment limits from the
|
||||
current run's outbox. Private container output uses the bound sandbox
|
||||
channel; invalid or truncated responses retain the files. Clear a
|
||||
successful collection and return ``{type, name, base64}`` descriptors.
|
||||
"""
|
||||
if not self._available:
|
||||
return []
|
||||
@@ -1165,7 +1141,11 @@ class BoxService:
|
||||
|
||||
host_dir = self._host_query_dir(self.OUTBOX_SUBDIR, query)
|
||||
if host_dir is not None:
|
||||
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
|
||||
try:
|
||||
entries = await asyncio.to_thread(self._read_outbox_host, host_dir)
|
||||
except PermissionError:
|
||||
# Root-owned container output can be private to its sandbox UID.
|
||||
entries = await self._read_outbox_via_exec(query)
|
||||
else:
|
||||
entries = await self._read_outbox_via_exec(query)
|
||||
|
||||
@@ -1204,80 +1184,48 @@ class BoxService:
|
||||
async def _read_outbox_via_exec(self, query: pipeline_query.Query) -> list[dict]:
|
||||
"""Fallback: read the outbox over the exec channel (E2B / remote).
|
||||
|
||||
Uses ``client.execute`` directly (bypassing ``_serialize_result``)
|
||||
so stdout is NOT truncated by ``output_limit_chars`` - the raw
|
||||
base64 payload can be far larger than the 4000-char display limit.
|
||||
Use the same Workspace-scoped execution path as tools, retaining raw
|
||||
stdout because base64 payloads can exceed the display truncation limit.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
target_dir = f'{self.OUTBOX_MOUNT_DIR}/{self._attachment_query_key(query)}'
|
||||
max_file_bytes = self._EXEC_FALLBACK_MAX_BYTES
|
||||
max_files = self._ATTACHMENT_MAX_FILES
|
||||
max_total_bytes = max_file_bytes * max_files
|
||||
max_scan_entries = 1000
|
||||
script = (
|
||||
'import base64, json, os\n'
|
||||
f'target = {target_dir!r}\n'
|
||||
f'max_file_bytes = {max_file_bytes}\n'
|
||||
f'max_files = {max_files}\n'
|
||||
f'max_total_bytes = {max_total_bytes}\n'
|
||||
f'max_scan_entries = {max_scan_entries}\n'
|
||||
'out = []\n'
|
||||
'total_bytes = 0\n'
|
||||
'scanned_entries = 0\n'
|
||||
'stack = [target]\n'
|
||||
'if os.path.isdir(target):\n'
|
||||
' while stack and len(out) < max_files and scanned_entries < max_scan_entries:\n'
|
||||
' current = stack.pop()\n'
|
||||
' try:\n'
|
||||
' with os.scandir(current) as iterator:\n'
|
||||
' entries = sorted(iterator, key=lambda item: item.name, reverse=True)\n'
|
||||
' except OSError:\n'
|
||||
' continue\n'
|
||||
' for entry in entries:\n'
|
||||
' scanned_entries += 1\n'
|
||||
' if scanned_entries > max_scan_entries:\n'
|
||||
' break\n'
|
||||
' try:\n'
|
||||
' if entry.is_dir(follow_symlinks=False):\n'
|
||||
' stack.append(entry.path)\n'
|
||||
' continue\n'
|
||||
' if not entry.is_file(follow_symlinks=False):\n'
|
||||
' continue\n'
|
||||
' size = entry.stat(follow_symlinks=False).st_size\n'
|
||||
' if size > max_file_bytes or total_bytes + size > max_total_bytes:\n'
|
||||
' continue\n'
|
||||
" with open(entry.path, 'rb') as f:\n"
|
||||
' data = f.read(max_file_bytes + 1)\n'
|
||||
' if len(data) > max_file_bytes or total_bytes + len(data) > max_total_bytes:\n'
|
||||
' continue\n'
|
||||
' except OSError:\n'
|
||||
' continue\n'
|
||||
' rel = os.path.relpath(entry.path, target)\n'
|
||||
" out.append({'name': rel, 'b64': base64.b64encode(data).decode('ascii')})\n"
|
||||
' total_bytes += len(data)\n'
|
||||
' if len(out) >= max_files:\n'
|
||||
' break\n'
|
||||
'print(json.dumps(out))\n'
|
||||
from .sandbox_files import outbox_script
|
||||
|
||||
max_file_bytes = self._ATTACHMENT_MAX_BYTES
|
||||
max_total_bytes = self._ATTACHMENT_MAX_TOTAL_BYTES
|
||||
script = outbox_script(
|
||||
self.OUTBOX_MOUNT_DIR,
|
||||
self._attachment_query_key(query),
|
||||
operation='read',
|
||||
max_file_bytes=max_file_bytes,
|
||||
max_files=self._ATTACHMENT_MAX_FILES,
|
||||
max_total_bytes=max_total_bytes,
|
||||
)
|
||||
spec_payload: dict = {
|
||||
'cmd': f"python3 - <<'LBPY'\n{script}\nLBPY",
|
||||
'timeout_sec': 120,
|
||||
'session_id': self.resolve_box_session_id(query),
|
||||
}
|
||||
if 'extra_mounts' not in spec_payload:
|
||||
spec_payload['extra_mounts'] = self.build_skill_extra_mounts(query)
|
||||
try:
|
||||
spec = self.build_spec(spec_payload)
|
||||
result = await self.client.execute(spec)
|
||||
except Exception:
|
||||
return []
|
||||
result = await self._execute_spec_payload_result(spec_payload, query)
|
||||
if not result.ok:
|
||||
return []
|
||||
if 'Sandbox output attachment exceeds the export size limit' in (result.stderr or ''):
|
||||
raise BoxValidationError(
|
||||
f'Sandbox output attachments exceed the export limit '
|
||||
f'({max_file_bytes} bytes per file, {max_total_bytes} bytes total); files were retained'
|
||||
)
|
||||
raise BoxValidationError(f'Failed to read sandbox outbox (exit code {result.exit_code})')
|
||||
try:
|
||||
return _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||
except Exception:
|
||||
return []
|
||||
entries = _json.loads(str(result.stdout or '').strip().splitlines()[-1])
|
||||
if not isinstance(entries, list) or any(
|
||||
not isinstance(entry, dict)
|
||||
or not isinstance(entry.get('name'), str)
|
||||
or not isinstance(entry.get('b64'), str)
|
||||
for entry in entries
|
||||
):
|
||||
raise ValueError('Invalid attachment list')
|
||||
return entries
|
||||
except (ValueError, IndexError) as exc:
|
||||
raise BoxValidationError('Sandbox outbox returned invalid attachment data') from exc
|
||||
|
||||
async def _clear_outbox(self, query: pipeline_query.Query, host_dir: str | None) -> None:
|
||||
"""Empty the per-query outbox after collection.
|
||||
@@ -1302,19 +1250,37 @@ class BoxService:
|
||||
try:
|
||||
secure_fs.reset_directory(tenant_root, subdir, query_key)
|
||||
return False
|
||||
except secure_fs.UnsafeWorkspacePathError:
|
||||
raise
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
survived = await asyncio.to_thread(_clear)
|
||||
try:
|
||||
survived = await asyncio.to_thread(_clear)
|
||||
except secure_fs.UnsafeWorkspacePathError as exc:
|
||||
self.ap.logger.warning(f'Failed to clear unsafe sandbox outbox {target_dir}: {exc}')
|
||||
return
|
||||
if not survived:
|
||||
return
|
||||
# Root-owned container files survived the host delete — fall through.
|
||||
|
||||
try:
|
||||
await self.execute_tool(
|
||||
{'command': f'rm -rf {target_dir} && mkdir -p {target_dir}', 'timeout_sec': 30},
|
||||
from .sandbox_files import outbox_script
|
||||
|
||||
script = outbox_script(
|
||||
self.OUTBOX_MOUNT_DIR,
|
||||
self._attachment_query_key(query),
|
||||
operation='clear',
|
||||
max_file_bytes=self._ATTACHMENT_MAX_BYTES,
|
||||
max_files=self._ATTACHMENT_MAX_FILES,
|
||||
max_total_bytes=self._ATTACHMENT_MAX_TOTAL_BYTES,
|
||||
)
|
||||
result = await self.execute_tool(
|
||||
{'command': f"python3 - <<'LBPY'\n{script}\nLBPY", 'timeout_sec': 30},
|
||||
query,
|
||||
)
|
||||
if not result.get('ok'):
|
||||
raise BoxValidationError('Sandbox outbox cleanup failed')
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Failed to clear sandbox outbox {target_dir}: {exc}')
|
||||
|
||||
@@ -1422,9 +1388,12 @@ class BoxService:
|
||||
spec_payload: dict,
|
||||
*,
|
||||
skip_host_mount_validation: bool = False,
|
||||
trusted_read_only_mounts: list[dict] | None = None,
|
||||
) -> dict:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||
spec_payload = self._managed_policy_payload(
|
||||
execution_context, spec_payload, trusted_read_only_mounts=trusted_read_only_mounts
|
||||
)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
if spec_payload.get('host_path') in (None, ''):
|
||||
tenant_workspace = self._tenant_workspace(execution_context)
|
||||
@@ -1528,126 +1497,6 @@ class BoxService:
|
||||
self._runtime_connector.get_relay_headers(action_context),
|
||||
)
|
||||
|
||||
async def list_skills(self, context: TenantContext) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skills(action_context=self._action_context(execution_context))
|
||||
|
||||
async def get_skill(self, context: TenantContext, name: str) -> dict | None:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.get_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def create_skill(self, context: TenantContext, skill: dict) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed and str(payload.get('package_root', '') or '').strip():
|
||||
raise BoxAdmissionError('Cloud skill package_root is runtime-owned')
|
||||
if self._cloud_managed:
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.create_skill(payload, action_context=self._action_context(execution_context))
|
||||
|
||||
async def update_skill(self, context: TenantContext, name: str, skill: dict) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
payload = dict(skill)
|
||||
payload.pop('workspace_uuid', None)
|
||||
if self._cloud_managed:
|
||||
# The runtime already owns the package path for an existing skill.
|
||||
# A serialized read response may contain it, but it is never an
|
||||
# authority-bearing update field in shared Cloud mode.
|
||||
payload.pop('package_root', None)
|
||||
return await self.client.update_skill(
|
||||
name,
|
||||
payload,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def delete_skill(self, context: TenantContext, name: str) -> None:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
await self.client.delete_skill(name, action_context=self._action_context(execution_context))
|
||||
|
||||
async def scan_skill_directory(self, context: TenantContext, path: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
if self._cloud_managed:
|
||||
raise BoxAdmissionError('Scanning arbitrary host skill directories is disabled in Cloud')
|
||||
return await self.client.scan_skill_directory(path, action_context=self._action_context(execution_context))
|
||||
|
||||
async def _validated_skill_execution_context(self, context: TenantContext) -> ExecutionContext:
|
||||
execution_context = await self._validated_execution_context(context)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
return execution_context
|
||||
|
||||
async def list_skill_files(
|
||||
self,
|
||||
context: TenantContext,
|
||||
name: str,
|
||||
path: str = '.',
|
||||
include_hidden: bool = False,
|
||||
max_entries: int = 200,
|
||||
) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.list_skill_files(
|
||||
name,
|
||||
path,
|
||||
include_hidden,
|
||||
max_entries,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def read_skill_file(self, context: TenantContext, name: str, path: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.read_skill_file(
|
||||
name,
|
||||
path,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def write_skill_file(self, context: TenantContext, name: str, path: str, content: str) -> dict:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.write_skill_file(
|
||||
name,
|
||||
path,
|
||||
content,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def preview_skill_zip(
|
||||
self,
|
||||
context: TenantContext,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.preview_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
source_subdir,
|
||||
target_suffix,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
async def install_skill_zip(
|
||||
self,
|
||||
context: TenantContext,
|
||||
file_bytes: bytes,
|
||||
filename: str,
|
||||
source_paths: list[str] | None = None,
|
||||
source_path: str = '',
|
||||
source_subdir: str = '',
|
||||
target_suffix: str = 'upload',
|
||||
) -> list[dict]:
|
||||
execution_context = await self._validated_skill_execution_context(context)
|
||||
return await self.client.install_skill_zip(
|
||||
file_bytes,
|
||||
filename,
|
||||
source_paths,
|
||||
source_path,
|
||||
source_subdir,
|
||||
target_suffix,
|
||||
action_context=self._action_context(execution_context),
|
||||
)
|
||||
|
||||
def _serialize_result(self, result: BoxExecutionResult) -> dict:
|
||||
stdout, stdout_truncated = self._truncate(result.stdout)
|
||||
stderr, stderr_truncated = self._truncate(result.stderr)
|
||||
@@ -1784,14 +1633,6 @@ class BoxService:
|
||||
default_workspace = os.path.join(self.host_root, default_workspace)
|
||||
return os.path.realpath(os.path.abspath(default_workspace))
|
||||
|
||||
def get_skills_root(self) -> str | None:
|
||||
skills_root = str(self._local_config().get('skills_root', '') or 'skills').strip()
|
||||
if not skills_root:
|
||||
skills_root = 'skills'
|
||||
if not os.path.isabs(skills_root) and self.host_root is not None:
|
||||
skills_root = os.path.join(self.host_root, skills_root)
|
||||
return os.path.realpath(os.path.abspath(skills_root))
|
||||
|
||||
def _load_enabled(self) -> bool:
|
||||
"""Read ``box.enabled`` (top-level, not ``box.local.*``). Default True
|
||||
— disabling is opt-in. Accepts bool, ``'true'``/``'false'`` strings,
|
||||
@@ -2011,7 +1852,6 @@ class BoxService:
|
||||
# ── Observability ─────────────────────────────────────────────────
|
||||
|
||||
def _record_error(self, exc: Exception, query: pipeline_query.Query):
|
||||
telemetry_features.increment(query, 'sandbox', 'errors')
|
||||
self._recent_errors.append(
|
||||
{
|
||||
'timestamp': _dt.datetime.now(_UTC).isoformat(),
|
||||
|
||||
@@ -1,43 +1,28 @@
|
||||
"""Reusable workspace/session helpers built on top of Box.
|
||||
|
||||
This module is the middle layer between the raw Box runtime primitives and
|
||||
application-specific flows such as skills or MCP stdio.
|
||||
This module is the middle layer between raw Box runtime primitives and
|
||||
application-specific consumers.
|
||||
|
||||
It intentionally stays generic:
|
||||
- path and virtualenv rewriting are workspace concerns
|
||||
- Python project detection/bootstrap are workspace concerns
|
||||
- session exec / managed-process helpers are workspace concerns
|
||||
|
||||
Higher layers add their own semantics on top, for example:
|
||||
- skills choose a stable per-skill session id and use repeated exec
|
||||
- MCP stdio chooses how to prepare dependencies and attaches to a managed process
|
||||
Higher layers add their own semantics on top; BoxWorkspaceSession retains only
|
||||
workspace, execution, and managed-process concepts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from typing import Any
|
||||
|
||||
PYTHON_MANIFEST_FILES = (
|
||||
'requirements.txt',
|
||||
'pyproject.toml',
|
||||
'setup.py',
|
||||
'setup.cfg',
|
||||
)
|
||||
from ..utils.python_workspace import list_python_manifest_files
|
||||
|
||||
_VENV_DIRS = frozenset({'.venv', 'venv', 'env', '.env'})
|
||||
_VENV_BIN_DIRS = frozenset({'bin', 'Scripts'})
|
||||
|
||||
|
||||
def normalize_host_path(path: str | None) -> str:
|
||||
if path is None:
|
||||
return ''
|
||||
stripped = str(path).strip()
|
||||
if not stripped:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(stripped))
|
||||
|
||||
|
||||
def rewrite_mounted_path(path: str, host_path: str | None, *, mount_path: str = '/workspace') -> str:
|
||||
"""Translate a host path into the path visible inside the sandbox mount."""
|
||||
if not host_path or not path:
|
||||
@@ -98,13 +83,6 @@ def rewrite_venv_command(command: str, host_path: str | None, *, mount_path: str
|
||||
return rewrite_mounted_path(normalized_command, host_path, mount_path=mount_path)
|
||||
|
||||
|
||||
def list_python_manifest_files(host_path: str | None) -> list[str]:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return []
|
||||
return [filename for filename in PYTHON_MANIFEST_FILES if os.path.isfile(os.path.join(normalized_root, filename))]
|
||||
|
||||
|
||||
def classify_python_workspace(host_path: str | None) -> str | None:
|
||||
"""Return the generic Python workspace shape, without app-specific policy."""
|
||||
manifest_files = set(list_python_manifest_files(host_path))
|
||||
@@ -117,163 +95,6 @@ def classify_python_workspace(host_path: str | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def should_prepare_python_env(host_path: str | None) -> bool:
|
||||
normalized_root = normalize_host_path(host_path)
|
||||
if not normalized_root:
|
||||
return False
|
||||
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
||||
return True
|
||||
return bool(list_python_manifest_files(normalized_root))
|
||||
|
||||
|
||||
def wrap_python_command_with_env(
|
||||
command: str,
|
||||
*,
|
||||
mount_path: str = '/workspace',
|
||||
state_path: str | None = None,
|
||||
) -> str:
|
||||
"""Wrap a command with a reusable sandbox-local Python env bootstrap.
|
||||
|
||||
``mount_path`` is always the source tree used for manifest hashing and
|
||||
installation. ``state_path`` may point at a separate writable directory
|
||||
for read-only source mounts; when omitted, legacy mutable-workspace behavior
|
||||
stores the environment beside the source.
|
||||
"""
|
||||
writable_state_path = state_path or mount_path
|
||||
bootstrap = textwrap.dedent(
|
||||
f"""
|
||||
set -e
|
||||
|
||||
_LB_VENV_DIR="{writable_state_path}/.venv"
|
||||
_LB_META_DIR="{writable_state_path}/.langbot"
|
||||
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
||||
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
||||
_LB_TMP_DIR="{writable_state_path}/.tmp"
|
||||
_LB_PIP_CACHE_DIR="{writable_state_path}/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
_LB_SYSTEM_PYTHON="$(command -v python3 || command -v python || true)"
|
||||
if [ -z "$_LB_SYSTEM_PYTHON" ]; then
|
||||
echo "python3 or python is required to prepare the workspace Python environment" >&2
|
||||
exit 127
|
||||
fi
|
||||
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {{
|
||||
"$_LB_SYSTEM_PYTHON" - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = "{mount_path}"
|
||||
max_manifest_bytes = 10 * 1024 * 1024
|
||||
digest = hashlib.sha256()
|
||||
manifest_files = []
|
||||
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
||||
path = os.path.join(root, rel)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
if os.path.getsize(path) > max_manifest_bytes:
|
||||
raise RuntimeError(
|
||||
f"Python project manifest exceeds {{max_manifest_bytes}} bytes: {{rel}}"
|
||||
)
|
||||
manifest_files.append(rel)
|
||||
with open(path, "rb") as handle:
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(b"\\0")
|
||||
while chunk := handle.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
digest.update(b"\\0")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{{
|
||||
"python_executable": sys.executable,
|
||||
"python_version": list(sys.version_info[:3]),
|
||||
"manifest_files": manifest_files,
|
||||
"manifest_sha256": digest.hexdigest(),
|
||||
}},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
PY
|
||||
}}
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
_LB_LOCK_OWNER="$(cat "$_LB_LOCK_DIR/pid" 2>/dev/null || true)"
|
||||
if [ -n "$_LB_LOCK_OWNER" ] && kill -0 "$_LB_LOCK_OWNER" 2>/dev/null; then
|
||||
echo "Timed out waiting for active Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock, clearing stale lock: $_LB_LOCK_DIR" >&2
|
||||
rm -rf "$_LB_LOCK_DIR" 2>/dev/null || true
|
||||
if mkdir "$_LB_LOCK_DIR" 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
printf '%s\\n' "$$" > "$_LB_LOCK_DIR/pid" 2>/dev/null || true
|
||||
|
||||
_lb_cleanup_lock() {{
|
||||
rm -rf "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
"$_LB_SYSTEM_PYTHON" -m venv "$_LB_VENV_DIR"
|
||||
. "$_LB_VENV_DIR/bin/activate"
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
if [ -f "{mount_path}/requirements.txt" ]; then
|
||||
python -m pip install -r "{mount_path}/requirements.txt"
|
||||
elif [ -f "{mount_path}/pyproject.toml" ] || [ -f "{mount_path}/setup.py" ] || [ -f "{mount_path}/setup.cfg" ]; then
|
||||
python -m pip install "{mount_path}"
|
||||
fi
|
||||
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
||||
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
||||
{command}
|
||||
"""
|
||||
).strip()
|
||||
return bootstrap + '\n'
|
||||
|
||||
|
||||
class BoxWorkspaceSession:
|
||||
"""High-level handle for one reusable workspace-backed Box session.
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ class EntitlementSnapshot(pydantic.BaseModel):
|
||||
# must continue to use generic ``features`` and ``limits`` exclusively.
|
||||
plan_name: str | None = pydantic.Field(default=None, min_length=1, max_length=128)
|
||||
|
||||
subscription_started_at: int | None = pydantic.Field(default=None, ge=0)
|
||||
plan_revision: int | None = pydantic.Field(default=None, ge=1)
|
||||
monthly_bonus_credits: int | None = pydantic.Field(default=None, ge=0)
|
||||
|
||||
@pydantic.field_validator('features')
|
||||
@classmethod
|
||||
def _validate_feature_names(cls, value: dict[str, bool]) -> dict[str, bool]:
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Workspace plan policies evaluated from trusted entitlement projections."""
|
||||
|
||||
from .entitlements import EntitlementFeatureUnavailableError, EntitlementUnavailableError
|
||||
from ..platform.adapter_names import canonical_adapter_name
|
||||
|
||||
|
||||
def platform_feature_names(adapter: str) -> set[str]:
|
||||
canonical = canonical_adapter_name(adapter)
|
||||
names = {adapter, canonical}
|
||||
if adapter.lower() == 'line':
|
||||
names.update({'line', 'LINE'})
|
||||
if canonical.endswith('-omni'):
|
||||
names.add(canonical[:-5])
|
||||
names.add(canonical[:-5] + '-eba')
|
||||
return {'platform.' + name for name in names}
|
||||
|
||||
|
||||
async def workspace_entitlement(ap, workspace_uuid: str):
|
||||
if getattr(getattr(ap, 'deployment', None), 'mode', None) != 'cloud':
|
||||
return None
|
||||
resolver = getattr(ap, 'entitlement_resolver', None)
|
||||
if resolver is None:
|
||||
raise EntitlementUnavailableError('Workspace entitlement resolver is unavailable')
|
||||
return await resolver.resolve(workspace_uuid)
|
||||
|
||||
|
||||
async def require_workspace_platform(ap, workspace_uuid: str, adapter: str) -> None:
|
||||
snapshot = await workspace_entitlement(ap, workspace_uuid)
|
||||
if snapshot is None:
|
||||
return
|
||||
for feature in platform_feature_names(adapter):
|
||||
if snapshot.features.get(feature) is False:
|
||||
raise EntitlementFeatureUnavailableError(feature, entitlement_revision=snapshot.entitlement_revision)
|
||||
|
||||
|
||||
async def workspace_history_retention_days(ap, workspace_uuid: str, fallback: int = 30) -> int:
|
||||
snapshot = await workspace_entitlement(ap, workspace_uuid)
|
||||
if snapshot is None:
|
||||
return fallback
|
||||
# Old signed snapshots inherit the existing 30-day policy. Never interpret
|
||||
# missing entitlement data as an unlimited retention policy.
|
||||
return max(1, snapshot.limits.get('chat_history.retention_days', 30))
|
||||
@@ -47,6 +47,7 @@ from . import entities as core_entities
|
||||
from ..rag.knowledge import kbmgr as rag_mgr
|
||||
from ..rag.service import RAGRuntimeService
|
||||
from ..vector import mgr as vectordb_mgr
|
||||
from ..skill import repository as skill_repository
|
||||
from ..telemetry import telemetry as telemetry_module
|
||||
from ..survey import manager as survey_module
|
||||
from ..skill import manager as skill_mgr
|
||||
@@ -91,6 +92,7 @@ class Application:
|
||||
# TODO move to pipeline
|
||||
tool_mgr: llm_tool_mgr.ToolManager = None
|
||||
box_service: box_service_module.BoxService = None
|
||||
skill_repository: skill_repository.SkillRepository = None
|
||||
|
||||
# ======= Config manager =======
|
||||
|
||||
@@ -309,6 +311,14 @@ class Application:
|
||||
if runtime_ops_reporter is not None:
|
||||
runtime_ops_stats = runtime_ops_reporter.get_stats()
|
||||
|
||||
telemetry_stats = {}
|
||||
telemetry_health = getattr(self.telemetry, 'health', None)
|
||||
if callable(telemetry_health):
|
||||
try:
|
||||
telemetry_stats = telemetry_health()
|
||||
except Exception:
|
||||
telemetry_stats = {}
|
||||
|
||||
return {
|
||||
'asyncio_tasks': asyncio_tasks,
|
||||
'event_loop': self.event_loop_monitor.snapshot(),
|
||||
@@ -320,7 +330,7 @@ class Application:
|
||||
'models': model_stats,
|
||||
'runtimes': runtime_stats,
|
||||
'runtime_ops': runtime_ops_stats,
|
||||
'telemetry_tasks': len(getattr(self.telemetry, 'send_tasks', ())),
|
||||
'telemetry': telemetry_stats,
|
||||
}
|
||||
|
||||
async def initialize(self):
|
||||
@@ -350,6 +360,13 @@ class Application:
|
||||
|
||||
async def run(self):
|
||||
self.event_loop_monitor.start()
|
||||
from ..utils.execution_lease import maintain_execution_leases
|
||||
|
||||
self.task_mgr.create_task(
|
||||
maintain_execution_leases(self),
|
||||
name='execution-lease-maintenance',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
try:
|
||||
if (
|
||||
self.directory_projection_service is not None
|
||||
@@ -412,7 +429,10 @@ class Application:
|
||||
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
monitoring_enabled = auto_cleanup_cfg.get('enabled', True)
|
||||
monitoring_enabled = (
|
||||
auto_cleanup_cfg.get('enabled', True)
|
||||
or getattr(getattr(self, 'deployment', None), 'mode', None) == 'cloud'
|
||||
)
|
||||
retention_days = self._get_positive_int_config(
|
||||
auto_cleanup_cfg.get('retention_days', 30),
|
||||
default=30,
|
||||
@@ -525,7 +545,7 @@ class Application:
|
||||
if total_deleted > 0:
|
||||
self.logger.info(
|
||||
f'Storage maintenance for Workspace {context.workspace_uuid}: '
|
||||
f'deleted expired files: {deleted}'
|
||||
f'cleaned expired storage data: {deleted}'
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
|
||||
@@ -62,7 +62,6 @@ async def main(loop: asyncio.AbstractEventLoop):
|
||||
import signal
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
print('[Signal] Program exit.')
|
||||
runtime_loop.call_soon_threadsafe(shutdown_requested.set)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
@@ -18,13 +18,11 @@ required_paths = [
|
||||
|
||||
|
||||
async def generate_files() -> list[str]:
|
||||
global required_files, required_paths
|
||||
|
||||
from ...utils import paths as path_utils
|
||||
|
||||
for required_paths in required_paths:
|
||||
if not os.path.exists(required_paths):
|
||||
os.mkdir(required_paths)
|
||||
for path in required_paths:
|
||||
if not os.path.exists(path):
|
||||
os.mkdir(path)
|
||||
|
||||
generated_files = []
|
||||
for file in required_files:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import colorlog
|
||||
@@ -23,6 +25,122 @@ LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
|
||||
|
||||
LOG_DIR = 'data/logs'
|
||||
|
||||
CONSOLE_QUEUE_CAPACITY = 256
|
||||
CONSOLE_RECORD_MAX_BYTES = 16 * 1024
|
||||
CONSOLE_CLOSE_TIMEOUT = 0.2
|
||||
|
||||
|
||||
class NonBlockingConsoleHandler(logging.Handler):
|
||||
"""Isolate a stalled console from callers with at most 4 MiB of queued bytes.
|
||||
|
||||
Only formatted UTF-8 bytes cross the thread boundary, so queued records do
|
||||
not retain arguments or exception frames. Overflow drops console copies;
|
||||
other handlers still receive the original records.
|
||||
"""
|
||||
|
||||
def __init__(self, stream, *, capacity: int = CONSOLE_QUEUE_CAPACITY):
|
||||
if capacity <= 0:
|
||||
raise ValueError('Console queue capacity must be positive')
|
||||
# Drain startup prints before the worker can fill the pipe. Otherwise
|
||||
# interpreter finalization could block flushing the original stream.
|
||||
stream.flush()
|
||||
super().__init__()
|
||||
self._queue: queue.Queue[bytes | None] = queue.Queue(maxsize=capacity)
|
||||
# logging.shutdown holds Handler.lock while joining the worker.
|
||||
self._state_lock = threading.Lock()
|
||||
self._failed = threading.Event()
|
||||
self.dropped_records = 0
|
||||
self.write_error: str | None = None
|
||||
# The worker owns this descriptor. Do not share a buffered Python stream
|
||||
# lock with the main thread or interpreter finalization.
|
||||
self._fd = os.dup(stream.fileno())
|
||||
self._worker = threading.Thread(target=self._consume, name='langbot-console-log', daemon=True)
|
||||
try:
|
||||
self._worker.start()
|
||||
except Exception:
|
||||
os.close(self._fd)
|
||||
super().close()
|
||||
raise
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
with self._state_lock:
|
||||
if self._closed or self._failed.is_set() or self._queue.full():
|
||||
self.dropped_records += 1
|
||||
return
|
||||
try:
|
||||
text = self.format(record)
|
||||
payload = (text[:CONSOLE_RECORD_MAX_BYTES] + '\n').encode('utf-8', errors='backslashreplace')
|
||||
if len(text) > CONSOLE_RECORD_MAX_BYTES or len(payload) > CONSOLE_RECORD_MAX_BYTES:
|
||||
marker = b'\n[console log truncated]\n'
|
||||
payload = (
|
||||
payload[: CONSOLE_RECORD_MAX_BYTES - len(marker)]
|
||||
.decode('utf-8', errors='ignore')
|
||||
.encode('utf-8')
|
||||
+ marker
|
||||
)
|
||||
self._queue.put_nowait(payload)
|
||||
except queue.Full:
|
||||
self.dropped_records += 1
|
||||
except Exception:
|
||||
# Logging's default handleError writes to stderr, which may
|
||||
# share the same stalled container logging driver.
|
||||
self.dropped_records += 1
|
||||
|
||||
def _write(self, payload: bytes) -> None:
|
||||
remaining = memoryview(payload)
|
||||
while remaining:
|
||||
written = os.write(self._fd, remaining)
|
||||
if written == 0:
|
||||
raise OSError('Console write made no progress')
|
||||
remaining = remaining[written:]
|
||||
|
||||
def _consume(self) -> None:
|
||||
reported_drops = 0
|
||||
try:
|
||||
while (payload := self._queue.get()) is not None:
|
||||
self._write(payload)
|
||||
with self._state_lock:
|
||||
dropped = self.dropped_records
|
||||
if dropped > reported_drops:
|
||||
self._write(
|
||||
f'[WARNING] Console logging dropped {dropped - reported_drops} records.\n'.encode('utf-8')
|
||||
)
|
||||
reported_drops = dropped
|
||||
except OSError as exc:
|
||||
with self._state_lock:
|
||||
self.write_error = str(exc)
|
||||
self._failed.set()
|
||||
self.dropped_records += 1
|
||||
finally:
|
||||
os.close(self._fd)
|
||||
with self._state_lock:
|
||||
while True:
|
||||
try:
|
||||
if self._queue.get_nowait() is not None:
|
||||
self.dropped_records += 1
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def close(self) -> None:
|
||||
with self._state_lock:
|
||||
if self._closed:
|
||||
return
|
||||
super().close()
|
||||
# A full queue must not make shutdown wait for the writer.
|
||||
while True:
|
||||
try:
|
||||
self._queue.put_nowait(None)
|
||||
break
|
||||
except queue.Full:
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self.dropped_records += 1
|
||||
except queue.Empty:
|
||||
# The writer consumed the last record after Full;
|
||||
# retry inserting the shutdown sentinel.
|
||||
continue
|
||||
self._worker.join(timeout=CONSOLE_CLOSE_TIMEOUT)
|
||||
|
||||
|
||||
class DailyGroupedRotatingFileHandler(logging.handlers.RotatingFileHandler):
|
||||
"""File handler that writes to ``data/logs/langbot-YYYY-MM-DD.log``.
|
||||
@@ -94,6 +212,10 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
|
||||
qcg_logger = logging.getLogger('langbot')
|
||||
|
||||
for handler in qcg_logger.handlers[:]:
|
||||
qcg_logger.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
qcg_logger.setLevel(level)
|
||||
|
||||
color_formatter = colorlog.ColoredFormatter(
|
||||
@@ -102,10 +224,7 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
log_colors=log_colors_config,
|
||||
)
|
||||
|
||||
stream_handler = logging.StreamHandler(sys.stdout)
|
||||
# stream_handler.setLevel(level)
|
||||
# stream_handler.setFormatter(color_formatter)
|
||||
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
|
||||
stream_handler = NonBlockingConsoleHandler(sys.stdout)
|
||||
|
||||
# Rotate by size within a day and switch files when the date changes,
|
||||
# so long-running processes still produce a log file for the current day.
|
||||
|
||||
@@ -28,6 +28,7 @@ from ...api.http.service import knowledge as knowledge_service
|
||||
from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...skill import repository as skill_repository
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...operation_trace import service as operation_trace_service
|
||||
from ...api.http.service import skill as skill_service
|
||||
@@ -137,6 +138,7 @@ class BuildAppStage(stage.BootingStage):
|
||||
webhook_service_inst = webhook_service.WebhookService(ap)
|
||||
ap.webhook_service = webhook_service_inst
|
||||
|
||||
ap.skill_repository = skill_repository.SkillRepository(ap)
|
||||
skill_service_inst = skill_service.SkillService(ap)
|
||||
ap.skill_service = skill_service_inst
|
||||
|
||||
|
||||
@@ -59,6 +59,9 @@ _RUNTIME_POLICY_DEFAULTS = {
|
||||
'restart_failure_window_seconds': 30.0,
|
||||
'restart_circuit_open_seconds': 60.0,
|
||||
'require_hard_limits': False,
|
||||
# Cloud-only optional overrides; null inherits the unified policy.
|
||||
'dedicated': dict.fromkeys(('max_cpus', 'max_memory_mb', 'max_pids', 'max_open_files', 'max_file_size_mb')),
|
||||
'shared': dict.fromkeys(('max_cpus', 'max_memory_mb', 'max_pids', 'max_open_files', 'max_file_size_mb')),
|
||||
},
|
||||
},
|
||||
'mcp': {'stdio': {'enabled': True}},
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..i18n import t
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import typing
|
||||
@@ -202,7 +204,7 @@ class TaskWrapper:
|
||||
"""Return the stable task projection exposed to API-key callers."""
|
||||
if self.task.cancelled():
|
||||
status = 'cancelled'
|
||||
error = {'type': 'task_cancelled', 'message': 'Task was cancelled'}
|
||||
error = {'type': 'task_cancelled', 'message': t('Task was cancelled')}
|
||||
result = None
|
||||
elif not self.task.done():
|
||||
status = 'running'
|
||||
@@ -212,7 +214,7 @@ class TaskWrapper:
|
||||
exception = self.assume_exception()
|
||||
if exception is not None:
|
||||
status = 'failed'
|
||||
error = {'type': 'task_failed', 'message': 'Task execution failed'}
|
||||
error = {'type': 'task_failed', 'message': t('Task execution failed')}
|
||||
result = None
|
||||
else:
|
||||
status = 'succeeded'
|
||||
|
||||
@@ -6,6 +6,7 @@ import datetime
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...persistence.media import MediaReferenceText
|
||||
from .base import Base
|
||||
|
||||
|
||||
@@ -44,6 +45,10 @@ class AgentRun(Base):
|
||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
"""Bot UUID this run belongs to."""
|
||||
|
||||
execution_owner_id = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True)
|
||||
execution_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
|
||||
"""Host process lease, independent of execution duration and remote queue claims."""
|
||||
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, index=True)
|
||||
"""Run lifecycle status."""
|
||||
|
||||
@@ -103,15 +108,17 @@ class AgentRun(Base):
|
||||
authorization_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Run-scoped authorization snapshot JSON."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata JSON."""
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_agent_run_media_ttl', 'workspace_id', 'created_at', 'id'),
|
||||
sqlalchemy.Index(
|
||||
'ix_agent_run_scope_status', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'status'
|
||||
),
|
||||
sqlalchemy.Index('ix_agent_run_runner_status', 'runner_id', 'status'),
|
||||
sqlalchemy.Index('ix_agent_run_queue_claim', 'queue_name', 'status', 'priority', 'id'),
|
||||
sqlalchemy.Index('ix_agent_run_workspace_status_lease', 'workspace_id', 'status', 'execution_lease_expires_at'),
|
||||
)
|
||||
|
||||
|
||||
@@ -179,7 +186,7 @@ class AgentRunEvent(Base):
|
||||
type = sqlalchemy.Column(sqlalchemy.String(100), nullable=False, index=True)
|
||||
"""Result event type."""
|
||||
|
||||
data_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
data_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Result event payload JSON."""
|
||||
|
||||
usage_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
@@ -191,10 +198,11 @@ class AgentRunEvent(Base):
|
||||
source = sqlalchemy.Column(sqlalchemy.String(50), nullable=True)
|
||||
"""Source that appended the event."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata JSON."""
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.UniqueConstraint('run_id', 'sequence', name='uq_agent_run_event_run_sequence'),
|
||||
sqlalchemy.Index('ix_agent_run_event_run_sequence', 'run_id', 'sequence'),
|
||||
sqlalchemy.Index('ix_agent_run_event_media_ttl', 'created_at', 'id', 'run_id'),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
from ...persistence.media import MediaReferenceText
|
||||
|
||||
|
||||
class EventLog(Base):
|
||||
@@ -66,7 +67,7 @@ class EventLog(Base):
|
||||
input_summary = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Brief summary of input (truncated text, max 1000 chars)."""
|
||||
|
||||
input_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
input_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Full input JSON if reasonably sized (AgentInput as JSON string)."""
|
||||
|
||||
# Raw event reference
|
||||
@@ -82,5 +83,7 @@ class EventLog(Base):
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
"""When this record was created."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata as JSON string."""
|
||||
|
||||
__table_args__ = (sqlalchemy.Index('ix_event_log_media_ttl', 'workspace_id', 'created_at', 'id'),)
|
||||
|
||||
@@ -9,7 +9,7 @@ class Metadata(Base):
|
||||
__tablename__ = 'metadata'
|
||||
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
value = sqlalchemy.Column(sqlalchemy.String(255))
|
||||
value = sqlalchemy.Column(sqlalchemy.Text())
|
||||
|
||||
|
||||
class WorkspaceMetadata(Base):
|
||||
@@ -23,4 +23,4 @@ class WorkspaceMetadata(Base):
|
||||
primary_key=True,
|
||||
)
|
||||
key = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
value = sqlalchemy.Column(sqlalchemy.String(255))
|
||||
value = sqlalchemy.Column(sqlalchemy.Text())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sqlalchemy
|
||||
|
||||
from ...persistence.media import MediaReferenceText
|
||||
from .base import Base
|
||||
|
||||
|
||||
@@ -19,20 +20,37 @@ class MonitoringMessage(Base):
|
||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||
message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
|
||||
message_content = sqlalchemy.Column(MediaReferenceText, nullable=False)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
execution_owner_id = sqlalchemy.Column(sqlalchemy.String(36), nullable=True, index=True)
|
||||
execution_lease_expires_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True, index=True)
|
||||
"""Host process lease, independent of execution duration and remote queue claims."""
|
||||
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error, pending
|
||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||
variables = sqlalchemy.Column(MediaReferenceText, nullable=True) # Query variables as JSON string
|
||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
||||
event_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
run_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
parent_message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_messages_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_messages_media_ttl', 'workspace_uuid', 'timestamp', 'id'),
|
||||
sqlalchemy.Index('ix_monitoring_messages_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
sqlalchemy.Index(
|
||||
'ix_monitoring_messages_conversation_page', 'workspace_uuid', 'bot_id', 'session_id', 'timestamp', 'id'
|
||||
),
|
||||
sqlalchemy.Index(
|
||||
'ix_monitoring_messages_workspace_status_lease',
|
||||
'workspace_uuid',
|
||||
'status',
|
||||
'execution_lease_expires_at',
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -63,6 +81,8 @@ class MonitoringLLMCall(Base):
|
||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
|
||||
|
||||
details = sqlalchemy.Column(sqlalchemy.JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_timestamp', 'workspace_uuid', 'timestamp'),
|
||||
sqlalchemy.Index('ix_monitoring_llm_calls_workspace_session', 'workspace_uuid', 'session_id'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import sqlalchemy
|
||||
import datetime
|
||||
|
||||
from .base import Base
|
||||
from ...persistence.media import MediaReferenceText
|
||||
|
||||
|
||||
class Transcript(Base):
|
||||
@@ -48,11 +49,11 @@ class Transcript(Base):
|
||||
content = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
"""Text content summary (may be truncated for large messages, max 4000 chars)."""
|
||||
|
||||
content_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
content_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Full structured content as JSON string (Message model dump)."""
|
||||
|
||||
# Attachment references
|
||||
attachment_refs_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
attachment_refs_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Attachment references as JSON string."""
|
||||
|
||||
# Sequence for cursor-based pagination
|
||||
@@ -69,11 +70,12 @@ class Transcript(Base):
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, default=datetime.datetime.utcnow)
|
||||
"""When this item was created."""
|
||||
|
||||
metadata_json = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
||||
metadata_json = sqlalchemy.Column(MediaReferenceText, nullable=True)
|
||||
"""Additional metadata as JSON string (sender_id, platform, etc.)."""
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
sqlalchemy.Index('ix_transcript_media_ttl', 'workspace_id', 'created_at', 'id'),
|
||||
sqlalchemy.Index('ix_transcript_conversation_seq', 'conversation_id', 'seq'),
|
||||
sqlalchemy.Index('ix_transcript_conversation_created', 'conversation_id', 'created_at'),
|
||||
sqlalchemy.Index('ix_transcript_scope_seq', 'bot_id', 'workspace_id', 'conversation_id', 'thread_id', 'seq'),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Small, dependency-free catalog for LangBot-owned runtime messages.
|
||||
|
||||
English message templates are the stable catalog IDs. Diagnostic text from
|
||||
providers, plugins and validators is deliberately not translated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import contextvars
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_LANGUAGE = 'en_US'
|
||||
SUPPORTED_LANGUAGES = ('en_US', 'zh_Hans', 'zh_Hant', 'ja_JP', 'es_ES', 'ru_RU', 'th_TH', 'vi_VN')
|
||||
language = contextvars.ContextVar('langbot_message_language', default=DEFAULT_LANGUAGE)
|
||||
_catalogs = {
|
||||
locale: json.loads((Path(__file__).parent / 'locales' / f'{locale}.json').read_text(encoding='utf-8'))
|
||||
for locale in SUPPORTED_LANGUAGES
|
||||
}
|
||||
|
||||
# Normalize existing API wording at the response boundary. Only exact owned
|
||||
# messages are matched; validators and third-party diagnostics pass through.
|
||||
_ALIASES = {
|
||||
**{
|
||||
name: 'Resource not found'
|
||||
for name in (
|
||||
'bot not found',
|
||||
'model not found',
|
||||
'provider not found',
|
||||
'plugin not found',
|
||||
'Server not found',
|
||||
'Skill not found',
|
||||
'Pipeline not found',
|
||||
'pipeline not found',
|
||||
'knowledge base not found',
|
||||
'Knowledge base not found',
|
||||
'User not found',
|
||||
'Account not found',
|
||||
'agent not found',
|
||||
'API key not found',
|
||||
'Message not found',
|
||||
'Task not found',
|
||||
'Passkey not found',
|
||||
'Webhook not found',
|
||||
'Bot not found or not available',
|
||||
'Bot is unavailable',
|
||||
'WebSocket adapter not found',
|
||||
'adapter not found',
|
||||
'requester not found',
|
||||
'icon not found',
|
||||
'Session not found',
|
||||
)
|
||||
},
|
||||
'Unauthorized': 'Invalid authentication credentials',
|
||||
'No valid user token provided': 'Invalid authentication credentials',
|
||||
'No valid API key provided': 'Invalid authentication credentials',
|
||||
'Invalid username or password': 'Invalid authentication credentials',
|
||||
'Invalid TOTP code': 'Invalid TOTP or recovery code',
|
||||
'Too many attempts, try again later': 'Too many requests. Please try again later.',
|
||||
'The configured Agent runner is unavailable': 'Agent runner temporarily unavailable. Please try again.',
|
||||
'The configured Agent runner is not authorized': 'Agent runner not authorized for this pipeline.',
|
||||
'The Agent runner could not complete this test': 'Agent runner execution failed.',
|
||||
'Forbidden': 'Permission denied',
|
||||
'Member removal permission is required': 'Permission denied',
|
||||
'The Agent runner returned an invalid response': 'The Agent runner returned an invalid response.',
|
||||
'Invalid or expired state': 'Authorization failed. Please try signing in again.',
|
||||
'Space OAuth Workspace binding mismatch': 'Authorization failed. Please try signing in again.',
|
||||
'Space OAuth response is missing the Cloud Workspace binding': 'Authorization failed. Please try signing in again.',
|
||||
'Space launch requires Cloud mode': 'This feature is unavailable in this Workspace.',
|
||||
'An Account is required': 'Please sign in with your LangBot Account',
|
||||
'ChatGPT authorization requires an authorized workspace user': 'Permission denied',
|
||||
'ChatGPT authorization is busy. Please retry shortly.': 'Server is busy. Please try again later.',
|
||||
'ChatGPT authorization network error. Please retry.': 'Connection failed',
|
||||
'ChatGPT credential storage failed. Please retry.': 'Failed to save ChatGPT credentials. Please retry.',
|
||||
'ChatGPT Codex response was not completed': 'ChatGPT Codex response failed or was incomplete. Please retry.',
|
||||
'ChatGPT Codex stream ended before completion. Please retry.': 'ChatGPT Codex response failed or was incomplete. Please retry.',
|
||||
'Not permitted': 'Permission denied',
|
||||
'Unauthorized or session expired': 'Invalid authentication credentials',
|
||||
'TOTP is not enabled for that Account': 'TOTP is not enabled for this Account',
|
||||
'No pending TOTP enrolment for this Account': 'TOTP is not enabled for this Account',
|
||||
'Too many active credential exchanges': 'Server is busy. Please try again later.',
|
||||
'Timeout waiting for QR code': 'Request timed out',
|
||||
'Model availability is unavailable': 'This feature is unavailable in this Workspace.',
|
||||
'Survey not available': 'This feature is unavailable in this Workspace.',
|
||||
**{
|
||||
message: 'Submission failed. Please try again.'
|
||||
for message in (
|
||||
'Failed to submit response',
|
||||
'Failed to submit feedback',
|
||||
'Failed to dismiss',
|
||||
)
|
||||
},
|
||||
**{
|
||||
message: 'File size exceeds the limit.'
|
||||
for message in (
|
||||
'Image size exceeds 10MB limit.',
|
||||
'file size exceeds 10MB limit',
|
||||
'attachment too large',
|
||||
)
|
||||
},
|
||||
**{
|
||||
message: 'Authorization failed. Please try signing in again.'
|
||||
for message in (
|
||||
'Space launch failed',
|
||||
'Space OAuth failed',
|
||||
'LangBot Account binding failed',
|
||||
'Failed to get access token from Space',
|
||||
'ChatGPT returned an incomplete authorization. Please sign in again.',
|
||||
'ChatGPT authorization was cancelled or replaced. Please retry.',
|
||||
'ChatGPT returned an invalid authorization response',
|
||||
'ChatGPT returned an invalid device code',
|
||||
'ChatGPT returned an invalid device code expiry',
|
||||
'ChatGPT device authorization failed. Please start again.',
|
||||
'ChatGPT returned an incomplete device authorization',
|
||||
'ChatGPT token exchange failed. Please start sign-in again.',
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def normalize_language(value: str | None) -> str:
|
||||
value = (value or '').replace('-', '_')
|
||||
aliases = {
|
||||
'zh_CN': 'zh_Hans',
|
||||
'zh_TW': 'zh_Hant',
|
||||
'en': 'en_US',
|
||||
'ja': 'ja_JP',
|
||||
'es': 'es_ES',
|
||||
'ru': 'ru_RU',
|
||||
'th': 'th_TH',
|
||||
'vi': 'vi_VN',
|
||||
}
|
||||
value = aliases.get(value, value)
|
||||
return value if value in SUPPORTED_LANGUAGES else DEFAULT_LANGUAGE
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def language_scope(locale: str):
|
||||
token = language.set(normalize_language(locale))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
language.reset(token)
|
||||
|
||||
|
||||
def t(message: str, *, locale: str | None = None, **params) -> str:
|
||||
selected = normalize_language(locale or language.get())
|
||||
# Preserve established English diagnostic wording and resource names.
|
||||
# Aliases only share translations; they do not rewrite the source language.
|
||||
key = _ALIASES.get(message, message)
|
||||
text = message if selected == DEFAULT_LANGUAGE else _catalogs[selected].get(key, message)
|
||||
return text.format(**params) if params else text
|
||||
|
||||
|
||||
def error_text(code: int | str, message: str) -> str:
|
||||
"""Stable business codes take precedence over variable diagnostic wording."""
|
||||
if language.get() == DEFAULT_LANGUAGE:
|
||||
return message
|
||||
messages = {
|
||||
'permission_denied': 'Permission denied',
|
||||
'resource_not_found': 'Resource not found',
|
||||
'membership_not_found': 'Resource not found',
|
||||
'invalid_authentication': 'Invalid authentication credentials',
|
||||
'task_capacity_exceeded': 'Server is busy. Please try again later.',
|
||||
'blocking_work_capacity_exceeded': 'Server is busy. Please try again later.',
|
||||
'edition_limit': 'This feature is unavailable in this Workspace.',
|
||||
'entitlement_unavailable': 'This feature is unavailable in this Workspace.',
|
||||
'last_owner_required': 'The Workspace must retain an owner.',
|
||||
'already_a_member': 'This user is already a member.',
|
||||
'invitation_email_mismatch': 'The invitation email does not match your Account',
|
||||
'totp_invalid_code': 'Invalid TOTP or recovery code',
|
||||
'totp_challenge_invalid': 'Invalid TOTP or recovery code',
|
||||
'totp_already_enrolled': 'TOTP is already enabled for this Account',
|
||||
'totp_not_enrolled': 'TOTP is not enabled for this Account',
|
||||
}
|
||||
if code in ('invitation_invalid', 'invitation_expired', 'invitation_revoked', 'invitation_used'):
|
||||
return t('Invalid or expired invitation')
|
||||
return t(messages.get(code, message))
|
||||
|
||||
|
||||
def pipeline_notice(query, name: str, default: str, **params) -> str:
|
||||
"""Empty overrides inherit the Workspace language; user text stays literal.
|
||||
|
||||
Only documented placeholders are substituted, so arbitrary braces in custom
|
||||
messages cannot cause a formatting exception.
|
||||
"""
|
||||
config = query.pipeline_config.get('output', {}).get('misc', {})
|
||||
custom = config.get(name)
|
||||
# This was the shipped default before language inheritance existed.
|
||||
if name == 'failure-hint' and custom == 'Request failed.':
|
||||
custom = None
|
||||
if not isinstance(custom, str) or not custom.strip():
|
||||
return t(default, **params)
|
||||
for key, value in params.items():
|
||||
custom = custom.replace('{' + key + '}', str(value))
|
||||
return custom
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Language boundaries for adapter-owned replies outside the Pipeline."""
|
||||
|
||||
import functools
|
||||
|
||||
from . import DEFAULT_LANGUAGE, language_scope, t
|
||||
from .workspace import _cache, get_workspace_language
|
||||
|
||||
|
||||
def _owner(adapter):
|
||||
logger = getattr(adapter, 'logger', None)
|
||||
context = getattr(logger, 'execution_context', None)
|
||||
workspace_uuid = getattr(context, 'workspace_uuid', None)
|
||||
return getattr(logger, 'ap', None), workspace_uuid if isinstance(workspace_uuid, str) else None
|
||||
|
||||
|
||||
def adapter_t(adapter, message: str, **params) -> str:
|
||||
"""Synchronous SDK callbacks use the bot's own cached language, never a peer's."""
|
||||
ap, workspace_uuid = _owner(adapter)
|
||||
cached = _cache(ap).get(workspace_uuid) if ap is not None and workspace_uuid else None
|
||||
return t(message, locale=cached[0] if cached else DEFAULT_LANGUAGE, **params)
|
||||
|
||||
|
||||
def localized_adapter(method):
|
||||
@functools.wraps(method)
|
||||
async def wrapped(self, *args, **kwargs):
|
||||
ap, workspace_uuid = _owner(self)
|
||||
locale = await get_workspace_language(ap, workspace_uuid) if ap is not None else DEFAULT_LANGUAGE
|
||||
with language_scope(locale):
|
||||
return await method(self, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "Request failed.",
|
||||
"Too many requests. Please try again later.": "Too many requests. Please try again later.",
|
||||
"Your message contains inappropriate content. Please revise it.": "Your message contains inappropriate content. Please revise it.",
|
||||
"Content checking failed. Please contact the administrator.": "Content checking failed. Please contact the administrator.",
|
||||
"Calling {tools}...": "Calling {tools}...",
|
||||
"Agent runner not found: {runner}": "Agent runner not found: {runner}",
|
||||
"Agent runner not authorized for this pipeline.": "Agent runner not authorized for this pipeline.",
|
||||
"Agent runner temporarily unavailable. Please try again.": "Agent runner temporarily unavailable. Please try again.",
|
||||
"Agent runner execution failed.": "Agent runner execution failed.",
|
||||
"The Agent runner returned an invalid response.": "The Agent runner returned an invalid response.",
|
||||
"Model request failed": "Model request failed",
|
||||
"Context length exceeded": "Context length exceeded",
|
||||
"Invalid request parameters": "Invalid request parameters",
|
||||
"Invalid API key": "Invalid API key",
|
||||
"Invalid model or endpoint": "Invalid model or endpoint",
|
||||
"Rate limit exceeded or insufficient balance": "Rate limit exceeded or insufficient balance",
|
||||
"Request timed out": "Request timed out",
|
||||
"Connection failed": "Connection failed",
|
||||
"API error": "API error",
|
||||
"Unknown error": "Unknown error",
|
||||
"Internal server error": "Internal server error",
|
||||
"Resource not found": "Resource not found",
|
||||
"Permission denied": "Permission denied",
|
||||
"Invalid authentication credentials": "Invalid authentication credentials",
|
||||
"MCP runtime failed": "MCP runtime failed",
|
||||
"Task execution failed": "Task execution failed",
|
||||
"Task was cancelled": "Task was cancelled",
|
||||
"Maximum number of {resource} ({limit}) reached": "Maximum number of {resource} ({limit}) reached",
|
||||
"extensions": "extensions",
|
||||
"knowledge bases": "knowledge bases",
|
||||
"MCP server already exists: {name}": "MCP server already exists: {name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models is managed by Cloud and cannot be modified",
|
||||
"Please sign in with your LangBot Account": "Please sign in with your LangBot Account",
|
||||
"Current password is incorrect": "Current password is incorrect",
|
||||
"Invalid TOTP or recovery code": "Invalid TOTP or recovery code",
|
||||
"No local password set, please set a password first": "No local password set, please set a password first",
|
||||
"This LangBot Account is already bound to another user": "This LangBot Account is already bound to another user",
|
||||
"Invalid recovery key": "Invalid recovery key",
|
||||
"Done": "Done",
|
||||
"Thank you for your feedback": "Thank you for your feedback",
|
||||
"Failed to process feedback": "Failed to process feedback",
|
||||
"Helpful": "Helpful",
|
||||
"Not helpful": "Not helpful",
|
||||
"Select an option": "Select an option",
|
||||
"Enter a value": "Enter a value",
|
||||
"Submit": "Submit",
|
||||
"Submitted": "Submitted",
|
||||
"Thinking": "Thinking",
|
||||
"AI-generated": "AI-generated",
|
||||
"AI-generated content. Refer to the cited sources for details.": "AI-generated content. Refer to the cited sources for details.",
|
||||
"No bridges configured.": "No bridges configured.",
|
||||
"Signing in to all bridges again...": "Signing in to all bridges again...",
|
||||
"Bridge status:": "Bridge status:",
|
||||
"Signed in ✓": "Signed in ✓",
|
||||
"Not signed in ✗": "Not signed in ✗",
|
||||
"None": "None",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "Bridge {bridge}: skipped (login command or DM room missing).",
|
||||
"Bridge {bridge}: sending {command}...": "Bridge {bridge}: sending {command}...",
|
||||
"Bridge {bridge}: sign-out failed.": "Bridge {bridge}: sign-out failed.",
|
||||
"Bridge {bridge}: sign-in started.": "Bridge {bridge}: sign-in started.",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "Slack authorization completed. Return to LangBot to finish setup.",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "Slack authorization failed or expired. Return to LangBot for details.",
|
||||
"TOTP is already enabled for this Account": "TOTP is already enabled for this Account",
|
||||
"TOTP is not enabled for this Account": "TOTP is not enabled for this Account",
|
||||
"Invalid or expired passkey challenge": "Invalid or expired passkey challenge",
|
||||
"Passkey credential not recognized": "Passkey credential not recognized",
|
||||
"Account is disabled": "Account is disabled",
|
||||
"Modifying login info is disabled": "Modifying login info is disabled",
|
||||
"Invalid GitHub repository URL": "Invalid GitHub repository URL",
|
||||
"Server is busy. Please try again later.": "Server is busy. Please try again later.",
|
||||
"This feature is unavailable in this Workspace.": "This feature is unavailable in this Workspace.",
|
||||
"The Workspace must retain an owner.": "The Workspace must retain an owner.",
|
||||
"Invalid or expired invitation": "Invalid or expired invitation",
|
||||
"This user is already a member.": "This user is already a member.",
|
||||
"Voice connection failed": "Voice connection failed",
|
||||
"User is not in the voice channel": "User is not in the voice channel",
|
||||
"Voice service is unavailable": "Voice service is unavailable",
|
||||
"Submission failed. Please try again.": "Submission failed. Please try again.",
|
||||
"File size exceeds the limit.": "File size exceeds the limit.",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "File size exceeds 10MB limit. Please split large files into smaller parts.",
|
||||
"Authorization failed. Please try signing in again.": "Authorization failed. Please try signing in again.",
|
||||
"Bind the LangBot Account with the same email as this local Account": "Bind the LangBot Account with the same email as this local Account",
|
||||
"Only local accounts can bind to Space": "Only local accounts can bind to Space",
|
||||
"System already initialized": "System already initialized",
|
||||
"System not initialized": "System not initialized",
|
||||
"Password login is disabled on LangBot Cloud": "Password login is disabled on LangBot Cloud",
|
||||
"This edition supports one Workspace per instance": "This edition supports one Workspace per instance",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "Cloud Workspaces are created by the SaaS control plane",
|
||||
"Login with your LangBot Account to accept this invitation": "Login with your LangBot Account to accept this invitation",
|
||||
"RAG migration is not needed": "RAG migration is not needed",
|
||||
"New Agent": "New Agent",
|
||||
"New Pipeline": "New Pipeline",
|
||||
"Provider {provider} does not support {model_type} models": "Provider {provider} does not support {model_type} models",
|
||||
"Invalid JSON format": "Invalid JSON format",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "ChatGPT sign-in required. Open this provider and sign in again.",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT denied this request. Check subscription and workspace permissions.",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT rejected the model or request. Check the selected model and request settings.",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex upstream request failed. Please retry later.",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT subscription usage limit reached. Please retry later or check your plan.",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex response failed or was incomplete. Please retry.",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT token refresh temporarily failed. Please retry.",
|
||||
"A second factor is required": "A second factor is required",
|
||||
"Sign in or provide registration details to accept this invitation": "Sign in or provide registration details to accept this invitation",
|
||||
"Sign out before creating the invited local Account": "Sign out before creating the invited local Account",
|
||||
"Turnstile verification failed": "Turnstile verification failed",
|
||||
"The invitation email does not match your Account": "The invitation email does not match your Account",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "Failed to save ChatGPT credentials. Please retry.",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.",
|
||||
"Option {number}": "Option {number}",
|
||||
"Confirmation needed": "Confirmation needed",
|
||||
"Session reset successfully": "Session reset successfully"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "La solicitud ha fallado.",
|
||||
"Too many requests. Please try again later.": "Demasiadas solicitudes. Inténtalo más tarde.",
|
||||
"Your message contains inappropriate content. Please revise it.": "Tu mensaje contiene contenido inapropiado. Modifícalo.",
|
||||
"Content checking failed. Please contact the administrator.": "No se pudo verificar el contenido. Contacta con el administrador.",
|
||||
"Calling {tools}...": "Llamando a {tools}…",
|
||||
"Agent runner not found: {runner}": "No se encontró el ejecutor del agente: {runner}",
|
||||
"Agent runner not authorized for this pipeline.": "Este flujo no tiene autorización para usar el ejecutor del agente.",
|
||||
"Agent runner temporarily unavailable. Please try again.": "El ejecutor del agente no está disponible temporalmente. Reinténtalo.",
|
||||
"Agent runner execution failed.": "La ejecución del agente ha fallado.",
|
||||
"The Agent runner returned an invalid response.": "El ejecutor del agente devolvió una respuesta no válida.",
|
||||
"Model request failed": "Error en la solicitud al modelo",
|
||||
"Context length exceeded": "Se superó el límite de contexto",
|
||||
"Invalid request parameters": "Parámetros de solicitud no válidos",
|
||||
"Invalid API key": "Clave API no válida",
|
||||
"Invalid model or endpoint": "Modelo o endpoint no válido",
|
||||
"Rate limit exceeded or insufficient balance": "Límite de solicitudes excedido o saldo insuficiente",
|
||||
"Request timed out": "La solicitud agotó el tiempo de espera",
|
||||
"Connection failed": "Error de conexión",
|
||||
"API error": "Error de API",
|
||||
"Unknown error": "Error desconocido",
|
||||
"Internal server error": "Error interno del servidor",
|
||||
"Resource not found": "Recurso no encontrado",
|
||||
"Permission denied": "No tienes permiso para realizar esta acción",
|
||||
"Invalid authentication credentials": "Error de autenticación. Vuelve a iniciar sesión",
|
||||
"MCP runtime failed": "Error de ejecución de MCP",
|
||||
"Task execution failed": "La tarea ha fallado",
|
||||
"Task was cancelled": "La tarea se ha cancelado",
|
||||
"Maximum number of {resource} ({limit}) reached": "Se alcanzó el máximo de {resource} ({limit})",
|
||||
"extensions": "extensiones",
|
||||
"knowledge bases": "bases de conocimiento",
|
||||
"MCP server already exists: {name}": "El servidor MCP ya existe: {name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models está gestionado por Cloud y no se puede modificar",
|
||||
"Please sign in with your LangBot Account": "Inicia sesión con tu cuenta de LangBot",
|
||||
"Current password is incorrect": "La contraseña actual es incorrecta",
|
||||
"Invalid TOTP or recovery code": "Código TOTP o de recuperación no válido",
|
||||
"No local password set, please set a password first": "No hay contraseña local. Configura una primero",
|
||||
"This LangBot Account is already bound to another user": "Esta cuenta de LangBot ya está vinculada a otro usuario",
|
||||
"Invalid recovery key": "Clave de recuperación no válida",
|
||||
"Done": "Hecho",
|
||||
"Thank you for your feedback": "Gracias por tus comentarios",
|
||||
"Failed to process feedback": "No se pudieron procesar los comentarios",
|
||||
"Helpful": "Útil",
|
||||
"Not helpful": "No es útil",
|
||||
"Select an option": "Selecciona una opción",
|
||||
"Enter a value": "Introduce un valor",
|
||||
"Submit": "Enviar",
|
||||
"Submitted": "Enviado",
|
||||
"Thinking": "Razonamiento",
|
||||
"AI-generated": "Generado por IA",
|
||||
"AI-generated content. Refer to the cited sources for details.": "Contenido generado por IA. Consulta las fuentes citadas para más detalles.",
|
||||
"No bridges configured.": "No hay puentes configurados.",
|
||||
"Signing in to all bridges again...": "Iniciando sesión de nuevo en todos los puentes…",
|
||||
"Bridge status:": "Estado de los puentes:",
|
||||
"Signed in ✓": "Sesión iniciada ✓",
|
||||
"Not signed in ✗": "Sin sesión ✗",
|
||||
"None": "Ninguno",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "Puente {bridge}: omitido (falta comando de inicio de sesión o sala DM).",
|
||||
"Bridge {bridge}: sending {command}...": "Puente {bridge}: enviando {command}…",
|
||||
"Bridge {bridge}: sign-out failed.": "Puente {bridge}: no se pudo cerrar sesión.",
|
||||
"Bridge {bridge}: sign-in started.": "Puente {bridge}: inicio de sesión en curso.",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "Autorización de Slack completada. Vuelve a LangBot para terminar la configuración.",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "La autorización de Slack falló o caducó. Consulta los detalles en LangBot.",
|
||||
"TOTP is already enabled for this Account": "Esta cuenta ya tiene TOTP activado",
|
||||
"TOTP is not enabled for this Account": "Esta cuenta no tiene TOTP activado",
|
||||
"Invalid or expired passkey challenge": "El desafío de clave de acceso no es válido o ha caducado",
|
||||
"Passkey credential not recognized": "No se reconoce la clave de acceso",
|
||||
"Account is disabled": "La cuenta está desactivada",
|
||||
"Modifying login info is disabled": "No se permite modificar los datos de inicio de sesión",
|
||||
"Invalid GitHub repository URL": "URL del repositorio GitHub no válida",
|
||||
"Server is busy. Please try again later.": "El servidor está ocupado. Inténtalo más tarde.",
|
||||
"This feature is unavailable in this Workspace.": "Esta función no está disponible en este espacio de trabajo.",
|
||||
"The Workspace must retain an owner.": "El espacio de trabajo debe conservar un propietario.",
|
||||
"Invalid or expired invitation": "Invitación no válida o caducada",
|
||||
"This user is already a member.": "Este usuario ya es miembro.",
|
||||
"Voice connection failed": "Error de conexión de voz",
|
||||
"User is not in the voice channel": "El usuario no está en el canal de voz",
|
||||
"Voice service is unavailable": "El servicio de voz no está disponible",
|
||||
"Submission failed. Please try again.": "El envío ha fallado. Reinténtalo.",
|
||||
"File size exceeds the limit.": "El archivo supera el tamaño permitido.",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "El archivo supera los 10 MB. Divídelo en partes más pequeñas.",
|
||||
"Authorization failed. Please try signing in again.": "La autorización ha fallado. Vuelve a iniciar sesión.",
|
||||
"Bind the LangBot Account with the same email as this local Account": "Vincula una cuenta de LangBot con el mismo correo que esta cuenta local",
|
||||
"Only local accounts can bind to Space": "Solo las cuentas locales pueden vincularse a Space",
|
||||
"System already initialized": "El sistema ya está inicializado",
|
||||
"System not initialized": "El sistema no está inicializado",
|
||||
"Password login is disabled on LangBot Cloud": "El acceso con contraseña está desactivado en LangBot Cloud",
|
||||
"This edition supports one Workspace per instance": "Esta edición admite un espacio de trabajo por instancia",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "Los espacios de trabajo Cloud se crean desde el panel SaaS",
|
||||
"Login with your LangBot Account to accept this invitation": "Inicia sesión con tu cuenta de LangBot para aceptar la invitación",
|
||||
"RAG migration is not needed": "No es necesaria la migración de RAG",
|
||||
"New Agent": "Nuevo agente",
|
||||
"New Pipeline": "Nuevo flujo",
|
||||
"Provider {provider} does not support {model_type} models": "El proveedor {provider} no admite modelos {model_type}",
|
||||
"Invalid JSON format": "Formato JSON no válido",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "Debes iniciar sesión en ChatGPT. Abre este proveedor y vuelve a iniciar sesión.",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT limitó la solicitud por frecuencia o uso. Inténtalo más tarde o revisa tu plan.",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT rechazó la solicitud. Revisa la suscripción y los permisos del espacio de trabajo.",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT rechazó el modelo o la solicitud. Revisa el modelo seleccionado y la configuración.",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "Falló la solicitud a ChatGPT Codex. Inténtalo más tarde.",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Se alcanzó el límite de uso de tu suscripción de ChatGPT. Inténtalo más tarde o revisa tu plan.",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "La respuesta de ChatGPT Codex falló o está incompleta. Inténtalo de nuevo.",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "No se pudo renovar el token de ChatGPT temporalmente. Inténtalo de nuevo.",
|
||||
"A second factor is required": "Se requiere un segundo factor de autenticación",
|
||||
"Sign in or provide registration details to accept this invitation": "Inicia sesión o proporciona los datos de registro para aceptar esta invitación",
|
||||
"Sign out before creating the invited local Account": "Cierra sesión antes de crear la cuenta local invitada",
|
||||
"Turnstile verification failed": "La verificación de Turnstile falló. Inténtalo de nuevo",
|
||||
"The invitation email does not match your Account": "El correo de la invitación no coincide con tu cuenta",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "No se pudieron guardar las credenciales de ChatGPT. Inténtalo de nuevo.",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "No se pudo iniciar el acceso por dispositivo. Activa el inicio de sesión con código de dispositivo en la configuración de ChatGPT.",
|
||||
"Option {number}": "Opción {number}",
|
||||
"Confirmation needed": "Se requiere confirmación",
|
||||
"Session reset successfully": "Sesión restablecida"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "リクエストに失敗しました。",
|
||||
"Too many requests. Please try again later.": "リクエストが多すぎます。しばらくしてから再試行してください。",
|
||||
"Your message contains inappropriate content. Please revise it.": "不適切な内容が含まれています。メッセージを修正してください。",
|
||||
"Content checking failed. Please contact the administrator.": "コンテンツの確認に失敗しました。管理者にお問い合わせください。",
|
||||
"Calling {tools}...": "{tools} を呼び出しています…",
|
||||
"Agent runner not found: {runner}": "Agent ランナーが見つかりません:{runner}",
|
||||
"Agent runner not authorized for this pipeline.": "このパイプラインには Agent ランナーの使用権限がありません。",
|
||||
"Agent runner temporarily unavailable. Please try again.": "Agent ランナーは一時的に利用できません。再試行してください。",
|
||||
"Agent runner execution failed.": "Agent の実行に失敗しました。",
|
||||
"The Agent runner returned an invalid response.": "Agent ランナーが無効な応答を返しました。",
|
||||
"Model request failed": "モデルへのリクエストに失敗しました",
|
||||
"Context length exceeded": "コンテキスト長の上限を超えました",
|
||||
"Invalid request parameters": "リクエストのパラメータが無効です",
|
||||
"Invalid API key": "API キーが無効です",
|
||||
"Invalid model or endpoint": "モデルまたはエンドポイントが無効です",
|
||||
"Rate limit exceeded or insufficient balance": "リクエスト制限を超えたか、残高が不足しています",
|
||||
"Request timed out": "リクエストがタイムアウトしました",
|
||||
"Connection failed": "接続に失敗しました",
|
||||
"API error": "API エラー",
|
||||
"Unknown error": "不明なエラー",
|
||||
"Internal server error": "サーバー内部エラー",
|
||||
"Resource not found": "リソースが見つかりません",
|
||||
"Permission denied": "この操作の権限がありません",
|
||||
"Invalid authentication credentials": "認証に失敗しました。再度ログインしてください",
|
||||
"MCP runtime failed": "MCP の実行に失敗しました",
|
||||
"Task execution failed": "タスクの実行に失敗しました",
|
||||
"Task was cancelled": "タスクがキャンセルされました",
|
||||
"Maximum number of {resource} ({limit}) reached": "{resource}の上限数({limit})に達しました",
|
||||
"extensions": "拡張機能",
|
||||
"knowledge bases": "ナレッジベース",
|
||||
"MCP server already exists: {name}": "MCP サーバーは既に存在します:{name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models はクラウドで管理されているため変更できません",
|
||||
"Please sign in with your LangBot Account": "LangBot アカウントでログインしてください",
|
||||
"Current password is incorrect": "現在のパスワードが正しくありません",
|
||||
"Invalid TOTP or recovery code": "認証コードまたはリカバリーコードが無効です",
|
||||
"No local password set, please set a password first": "ローカルパスワードが未設定です。先に設定してください",
|
||||
"This LangBot Account is already bound to another user": "この LangBot アカウントは別のユーザーに連携されています",
|
||||
"Invalid recovery key": "リカバリーキーが無効です",
|
||||
"Done": "完了しました",
|
||||
"Thank you for your feedback": "フィードバックありがとうございます",
|
||||
"Failed to process feedback": "フィードバックの処理に失敗しました",
|
||||
"Helpful": "役に立った",
|
||||
"Not helpful": "役に立たなかった",
|
||||
"Select an option": "選択してください",
|
||||
"Enter a value": "入力してください",
|
||||
"Submit": "送信",
|
||||
"Submitted": "送信済み",
|
||||
"Thinking": "思考過程",
|
||||
"AI-generated": "AI による生成コンテンツ",
|
||||
"AI-generated content. Refer to the cited sources for details.": "AI が生成した内容です。詳細は引用元を参照してください。",
|
||||
"No bridges configured.": "ブリッジが設定されていません。",
|
||||
"Signing in to all bridges again...": "すべてのブリッジに再ログインしています…",
|
||||
"Bridge status:": "ブリッジの状態:",
|
||||
"Signed in ✓": "ログイン済み ✓",
|
||||
"Not signed in ✗": "未ログイン ✗",
|
||||
"None": "なし",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "ブリッジ {bridge}:スキップしました(ログインコマンドまたは DM ルームがありません)。",
|
||||
"Bridge {bridge}: sending {command}...": "ブリッジ {bridge}:{command} を送信中…",
|
||||
"Bridge {bridge}: sign-out failed.": "ブリッジ {bridge}:ログアウトに失敗しました。",
|
||||
"Bridge {bridge}: sign-in started.": "ブリッジ {bridge}:ログインを開始しました。",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "Slack の認証が完了しました。LangBot に戻って設定を完了してください。",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "Slack の認証に失敗したか期限切れです。詳細は LangBot で確認してください。",
|
||||
"TOTP is already enabled for this Account": "このアカウントでは二段階認証が有効です",
|
||||
"TOTP is not enabled for this Account": "このアカウントでは二段階認証が無効です",
|
||||
"Invalid or expired passkey challenge": "パスキーの認証要求が無効または期限切れです",
|
||||
"Passkey credential not recognized": "このパスキーは認識できません",
|
||||
"Account is disabled": "アカウントは無効です",
|
||||
"Modifying login info is disabled": "ログイン情報の変更は無効です",
|
||||
"Invalid GitHub repository URL": "GitHub リポジトリの URL が無効です",
|
||||
"Server is busy. Please try again later.": "サーバーが混雑しています。後で再試行してください。",
|
||||
"This feature is unavailable in this Workspace.": "このワークスペースではこの機能を利用できません。",
|
||||
"The Workspace must retain an owner.": "ワークスペースにはオーナーが必要です。",
|
||||
"Invalid or expired invitation": "招待が無効または期限切れです",
|
||||
"This user is already a member.": "このユーザーは既にメンバーです。",
|
||||
"Voice connection failed": "音声接続に失敗しました",
|
||||
"User is not in the voice channel": "ユーザーは音声チャンネルに参加していません",
|
||||
"Voice service is unavailable": "音声サービスを利用できません",
|
||||
"Submission failed. Please try again.": "送信に失敗しました。再試行してください。",
|
||||
"File size exceeds the limit.": "ファイルサイズが上限を超えています。",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "ファイルが 10MB を超えています。分割してアップロードしてください。",
|
||||
"Authorization failed. Please try signing in again.": "認証に失敗しました。再度ログインしてください。",
|
||||
"Bind the LangBot Account with the same email as this local Account": "このローカルアカウントと同じメールアドレスの LangBot アカウントを連携してください",
|
||||
"Only local accounts can bind to Space": "Space と連携できるのはローカルアカウントのみです",
|
||||
"System already initialized": "システムは初期化済みです",
|
||||
"System not initialized": "システムが初期化されていません",
|
||||
"Password login is disabled on LangBot Cloud": "LangBot Cloud ではパスワードログインは無効です",
|
||||
"This edition supports one Workspace per instance": "このエディションではインスタンスごとにワークスペースは1つです",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "クラウドのワークスペースは SaaS 管理画面で作成します",
|
||||
"Login with your LangBot Account to accept this invitation": "招待を承諾するには LangBot アカウントでログインしてください",
|
||||
"RAG migration is not needed": "RAG の移行は不要です",
|
||||
"New Agent": "新しい Agent",
|
||||
"New Pipeline": "新しいパイプライン",
|
||||
"Provider {provider} does not support {model_type} models": "プロバイダー {provider} は {model_type} モデルに対応していません",
|
||||
"Invalid JSON format": "JSON 形式が無効です",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "ChatGPT へのログインが必要です。このプロバイダーを開いて再ログインしてください。",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT のリクエストが制限されました(頻度または使用量)。後で再試行するか、プランを確認してください。",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT がリクエストを拒否しました。サブスクリプションとワークスペースの権限を確認してください。",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT がモデルまたはリクエストを拒否しました。選択したモデルとリクエスト設定を確認してください。",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex の上流リクエストに失敗しました。後で再試行してください。",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT のサブスクリプション使用量が上限に達しました。後で再試行するか、プランを確認してください。",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex の応答に失敗したか、応答が不完全です。再試行してください。",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT トークンの更新に一時的に失敗しました。再試行してください。",
|
||||
"A second factor is required": "二要素認証が必要です",
|
||||
"Sign in or provide registration details to accept this invitation": "招待を承諾するにはログインするか、登録情報を入力してください",
|
||||
"Sign out before creating the invited local Account": "招待されたローカルアカウントを作成する前にログアウトしてください",
|
||||
"Turnstile verification failed": "人間確認に失敗しました。再試行してください",
|
||||
"The invitation email does not match your Account": "招待のメールアドレスがアカウントと一致しません",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "ChatGPT 認証情報の保存に失敗しました。再試行してください。",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "ChatGPT のデバイスログインを開始できません。ChatGPT の設定でデバイスコードログインを有効にしてください。",
|
||||
"Option {number}": "選択肢 {number}",
|
||||
"Confirmation needed": "確認が必要です",
|
||||
"Session reset successfully": "セッションをリセットしました"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "Запрос не выполнен.",
|
||||
"Too many requests. Please try again later.": "Слишком много запросов. Повторите позже.",
|
||||
"Your message contains inappropriate content. Please revise it.": "Сообщение содержит недопустимый контент. Измените его.",
|
||||
"Content checking failed. Please contact the administrator.": "Ошибка проверки содержимого. Обратитесь к администратору.",
|
||||
"Calling {tools}...": "Вызов {tools}…",
|
||||
"Agent runner not found: {runner}": "Исполнитель агента не найден: {runner}",
|
||||
"Agent runner not authorized for this pipeline.": "У конвейера нет разрешения на использование исполнителя агента.",
|
||||
"Agent runner temporarily unavailable. Please try again.": "Исполнитель агента временно недоступен. Повторите попытку.",
|
||||
"Agent runner execution failed.": "Ошибка выполнения агента.",
|
||||
"The Agent runner returned an invalid response.": "Исполнитель агента вернул некорректный ответ.",
|
||||
"Model request failed": "Ошибка запроса к модели",
|
||||
"Context length exceeded": "Превышена длина контекста",
|
||||
"Invalid request parameters": "Некорректные параметры запроса",
|
||||
"Invalid API key": "Недействительный API-ключ",
|
||||
"Invalid model or endpoint": "Некорректная модель или адрес API",
|
||||
"Rate limit exceeded or insufficient balance": "Превышен лимит запросов или недостаточно средств",
|
||||
"Request timed out": "Время ожидания запроса истекло",
|
||||
"Connection failed": "Ошибка подключения",
|
||||
"API error": "Ошибка API",
|
||||
"Unknown error": "Неизвестная ошибка",
|
||||
"Internal server error": "Внутренняя ошибка сервера",
|
||||
"Resource not found": "Ресурс не найден",
|
||||
"Permission denied": "Нет разрешения на это действие",
|
||||
"Invalid authentication credentials": "Ошибка аутентификации. Войдите снова",
|
||||
"MCP runtime failed": "Ошибка выполнения MCP",
|
||||
"Task execution failed": "Ошибка выполнения задачи",
|
||||
"Task was cancelled": "Задача отменена",
|
||||
"Maximum number of {resource} ({limit}) reached": "Достигнуто максимальное число {resource} ({limit})",
|
||||
"extensions": "расширений",
|
||||
"knowledge bases": "баз знаний",
|
||||
"MCP server already exists: {name}": "Сервер MCP уже существует: {name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models управляется облаком и не может быть изменён",
|
||||
"Please sign in with your LangBot Account": "Войдите с учётной записью LangBot",
|
||||
"Current password is incorrect": "Текущий пароль неверен",
|
||||
"Invalid TOTP or recovery code": "Неверный код TOTP или восстановления",
|
||||
"No local password set, please set a password first": "Локальный пароль не задан. Сначала задайте пароль",
|
||||
"This LangBot Account is already bound to another user": "Эта учётная запись LangBot уже связана с другим пользователем",
|
||||
"Invalid recovery key": "Недействительный ключ восстановления",
|
||||
"Done": "Готово",
|
||||
"Thank you for your feedback": "Спасибо за отзыв",
|
||||
"Failed to process feedback": "Не удалось обработать отзыв",
|
||||
"Helpful": "Полезно",
|
||||
"Not helpful": "Не полезно",
|
||||
"Select an option": "Выберите вариант",
|
||||
"Enter a value": "Введите значение",
|
||||
"Submit": "Отправить",
|
||||
"Submitted": "Отправлено",
|
||||
"Thinking": "Ход рассуждений",
|
||||
"AI-generated": "Создано ИИ",
|
||||
"AI-generated content. Refer to the cited sources for details.": "Контент создан ИИ. Подробности смотрите в указанных источниках.",
|
||||
"No bridges configured.": "Мосты не настроены.",
|
||||
"Signing in to all bridges again...": "Повторный вход во все мосты…",
|
||||
"Bridge status:": "Состояние мостов:",
|
||||
"Signed in ✓": "Вход выполнен ✓",
|
||||
"Not signed in ✗": "Вход не выполнен ✗",
|
||||
"None": "Нет",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "Мост {bridge}: пропущен (нет команды входа или комнаты DM).",
|
||||
"Bridge {bridge}: sending {command}...": "Мост {bridge}: отправка {command}…",
|
||||
"Bridge {bridge}: sign-out failed.": "Мост {bridge}: ошибка выхода.",
|
||||
"Bridge {bridge}: sign-in started.": "Мост {bridge}: вход начат.",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "Авторизация Slack завершена. Вернитесь в LangBot для завершения настройки.",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "Авторизация Slack не удалась или истекла. Подробности в LangBot.",
|
||||
"TOTP is already enabled for this Account": "Для этой учётной записи TOTP уже включён",
|
||||
"TOTP is not enabled for this Account": "Для этой учётной записи TOTP не включён",
|
||||
"Invalid or expired passkey challenge": "Запрос проверки ключа доступа недействителен или истёк",
|
||||
"Passkey credential not recognized": "Ключ доступа не распознан",
|
||||
"Account is disabled": "Учётная запись отключена",
|
||||
"Modifying login info is disabled": "Изменение данных входа отключено",
|
||||
"Invalid GitHub repository URL": "Некорректный URL репозитория GitHub",
|
||||
"Server is busy. Please try again later.": "Сервер занят. Повторите позже.",
|
||||
"This feature is unavailable in this Workspace.": "Эта функция недоступна в данном рабочем пространстве.",
|
||||
"The Workspace must retain an owner.": "У рабочего пространства должен остаться владелец.",
|
||||
"Invalid or expired invitation": "Приглашение недействительно или истекло",
|
||||
"This user is already a member.": "Этот пользователь уже является участником.",
|
||||
"Voice connection failed": "Ошибка голосового соединения",
|
||||
"User is not in the voice channel": "Пользователь не в голосовом канале",
|
||||
"Voice service is unavailable": "Голосовая служба недоступна",
|
||||
"Submission failed. Please try again.": "Не удалось отправить. Повторите попытку.",
|
||||
"File size exceeds the limit.": "Размер файла превышает ограничение.",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "Размер файла превышает 10 МБ. Разделите его на части.",
|
||||
"Authorization failed. Please try signing in again.": "Авторизация не удалась. Войдите снова.",
|
||||
"Bind the LangBot Account with the same email as this local Account": "Свяжите учётную запись LangBot с тем же адресом почты, что и локальная",
|
||||
"Only local accounts can bind to Space": "К Space можно привязать только локальные учётные записи",
|
||||
"System already initialized": "Система уже инициализирована",
|
||||
"System not initialized": "Система не инициализирована",
|
||||
"Password login is disabled on LangBot Cloud": "В LangBot Cloud вход по паролю отключён",
|
||||
"This edition supports one Workspace per instance": "Эта редакция поддерживает одно рабочее пространство на экземпляр",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "Облачные рабочие пространства создаются в панели SaaS",
|
||||
"Login with your LangBot Account to accept this invitation": "Войдите в LangBot, чтобы принять приглашение",
|
||||
"RAG migration is not needed": "Миграция RAG не требуется",
|
||||
"New Agent": "Новый агент",
|
||||
"New Pipeline": "Новый конвейер",
|
||||
"Provider {provider} does not support {model_type} models": "Провайдер {provider} не поддерживает модели {model_type}",
|
||||
"Invalid JSON format": "Некорректный формат JSON",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "Требуется вход в ChatGPT. Откройте этого провайдера и войдите снова.",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "Запрос ChatGPT ограничен по частоте или использованию. Повторите позже или проверьте тариф.",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT отклонил запрос. Проверьте подписку и права рабочего пространства.",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT отклонил модель или запрос. Проверьте выбранную модель и настройки запроса.",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "Ошибка запроса к ChatGPT Codex. Повторите позже.",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Достигнут лимит использования подписки ChatGPT. Повторите позже или проверьте тариф.",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "Ошибка или неполный ответ ChatGPT Codex. Повторите запрос.",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "Временная ошибка обновления токена ChatGPT. Повторите попытку.",
|
||||
"A second factor is required": "Требуется второй фактор аутентификации",
|
||||
"Sign in or provide registration details to accept this invitation": "Войдите или укажите данные регистрации, чтобы принять приглашение",
|
||||
"Sign out before creating the invited local Account": "Выйдите перед созданием приглашённой локальной учётной записи",
|
||||
"Turnstile verification failed": "Проверка Turnstile не пройдена. Повторите попытку",
|
||||
"The invitation email does not match your Account": "Адрес приглашения не совпадает с вашей учётной записью",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "Не удалось сохранить учётные данные ChatGPT. Повторите попытку.",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Не удалось начать вход с устройства. Включите вход по коду устройства в настройках ChatGPT.",
|
||||
"Option {number}": "Вариант {number}",
|
||||
"Confirmation needed": "Требуется подтверждение",
|
||||
"Session reset successfully": "Сессия сброшена"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "คำขอล้มเหลว",
|
||||
"Too many requests. Please try again later.": "มีคำขอมากเกินไป โปรดลองอีกครั้งภายหลัง",
|
||||
"Your message contains inappropriate content. Please revise it.": "ข้อความมีเนื้อหาไม่เหมาะสม โปรดแก้ไข",
|
||||
"Content checking failed. Please contact the administrator.": "ตรวจสอบเนื้อหาไม่สำเร็จ โปรดติดต่อผู้ดูแลระบบ",
|
||||
"Calling {tools}...": "กำลังเรียกใช้ {tools}…",
|
||||
"Agent runner not found: {runner}": "ไม่พบตัวรัน Agent: {runner}",
|
||||
"Agent runner not authorized for this pipeline.": "ไปป์ไลน์นี้ไม่ได้รับอนุญาตให้ใช้ตัวรัน Agent",
|
||||
"Agent runner temporarily unavailable. Please try again.": "ตัวรัน Agent ไม่พร้อมใช้งานชั่วคราว โปรดลองอีกครั้ง",
|
||||
"Agent runner execution failed.": "การทำงานของ Agent ล้มเหลว",
|
||||
"The Agent runner returned an invalid response.": "ตัวรัน Agent ส่งการตอบกลับที่ไม่ถูกต้อง",
|
||||
"Model request failed": "คำขอไปยังโมเดลล้มเหลว",
|
||||
"Context length exceeded": "ความยาวบริบทเกินขีดจำกัด",
|
||||
"Invalid request parameters": "พารามิเตอร์คำขอไม่ถูกต้อง",
|
||||
"Invalid API key": "คีย์ API ไม่ถูกต้อง",
|
||||
"Invalid model or endpoint": "โมเดลหรือจุดเชื่อมต่อไม่ถูกต้อง",
|
||||
"Rate limit exceeded or insufficient balance": "เกินขีดจำกัดคำขอหรือยอดเงินไม่เพียงพอ",
|
||||
"Request timed out": "คำขอหมดเวลา",
|
||||
"Connection failed": "การเชื่อมต่อล้มเหลว",
|
||||
"API error": "ข้อผิดพลาด API",
|
||||
"Unknown error": "ข้อผิดพลาดที่ไม่ทราบสาเหตุ",
|
||||
"Internal server error": "ข้อผิดพลาดภายในเซิร์ฟเวอร์",
|
||||
"Resource not found": "ไม่พบทรัพยากร",
|
||||
"Permission denied": "ไม่มีสิทธิ์ดำเนินการนี้",
|
||||
"Invalid authentication credentials": "ยืนยันตัวตนไม่สำเร็จ โปรดเข้าสู่ระบบอีกครั้ง",
|
||||
"MCP runtime failed": "การทำงานของ MCP ล้มเหลว",
|
||||
"Task execution failed": "การทำงานของงานล้มเหลว",
|
||||
"Task was cancelled": "งานถูกยกเลิกแล้ว",
|
||||
"Maximum number of {resource} ({limit}) reached": "ถึงจำนวนสูงสุดของ{resource} ({limit}) แล้ว",
|
||||
"extensions": "ส่วนขยาย",
|
||||
"knowledge bases": "ฐานความรู้",
|
||||
"MCP server already exists: {name}": "มีเซิร์ฟเวอร์ MCP อยู่แล้ว: {name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models จัดการโดย Cloud และไม่สามารถแก้ไขได้",
|
||||
"Please sign in with your LangBot Account": "โปรดเข้าสู่ระบบด้วยบัญชี LangBot",
|
||||
"Current password is incorrect": "รหัสผ่านปัจจุบันไม่ถูกต้อง",
|
||||
"Invalid TOTP or recovery code": "รหัส TOTP หรือรหัสกู้คืนไม่ถูกต้อง",
|
||||
"No local password set, please set a password first": "ยังไม่ได้ตั้งรหัสผ่านในระบบ โปรดตั้งรหัสผ่านก่อน",
|
||||
"This LangBot Account is already bound to another user": "บัญชี LangBot นี้เชื่อมโยงกับผู้ใช้อื่นแล้ว",
|
||||
"Invalid recovery key": "คีย์กู้คืนไม่ถูกต้อง",
|
||||
"Done": "เสร็จแล้ว",
|
||||
"Thank you for your feedback": "ขอบคุณสำหรับความคิดเห็น",
|
||||
"Failed to process feedback": "ประมวลผลความคิดเห็นไม่สำเร็จ",
|
||||
"Helpful": "มีประโยชน์",
|
||||
"Not helpful": "ไม่มีประโยชน์",
|
||||
"Select an option": "โปรดเลือก",
|
||||
"Enter a value": "โปรดป้อนค่า",
|
||||
"Submit": "ส่ง",
|
||||
"Submitted": "ส่งแล้ว",
|
||||
"Thinking": "กระบวนการคิด",
|
||||
"AI-generated": "สร้างโดย AI",
|
||||
"AI-generated content. Refer to the cited sources for details.": "เนื้อหาสร้างโดย AI ดูรายละเอียดจากแหล่งอ้างอิง",
|
||||
"No bridges configured.": "ยังไม่ได้ตั้งค่าบริดจ์",
|
||||
"Signing in to all bridges again...": "กำลังเข้าสู่ระบบบริดจ์ทั้งหมดอีกครั้ง…",
|
||||
"Bridge status:": "สถานะบริดจ์:",
|
||||
"Signed in ✓": "เข้าสู่ระบบแล้ว ✓",
|
||||
"Not signed in ✗": "ยังไม่ได้เข้าสู่ระบบ ✗",
|
||||
"None": "ไม่มี",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "บริดจ์ {bridge}: ข้ามแล้ว (ไม่มีคำสั่งเข้าสู่ระบบหรือห้อง DM)",
|
||||
"Bridge {bridge}: sending {command}...": "บริดจ์ {bridge}: กำลังส่ง {command}…",
|
||||
"Bridge {bridge}: sign-out failed.": "บริดจ์ {bridge}: ออกจากระบบไม่สำเร็จ",
|
||||
"Bridge {bridge}: sign-in started.": "บริดจ์ {bridge}: เริ่มเข้าสู่ระบบแล้ว",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "อนุญาต Slack สำเร็จแล้ว กลับไปที่ LangBot เพื่อดำเนินการตั้งค่าให้เสร็จ",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "อนุญาต Slack ไม่สำเร็จหรือหมดอายุ ดูรายละเอียดใน LangBot",
|
||||
"TOTP is already enabled for this Account": "บัญชีนี้เปิดใช้ TOTP แล้ว",
|
||||
"TOTP is not enabled for this Account": "บัญชีนี้ยังไม่ได้เปิดใช้ TOTP",
|
||||
"Invalid or expired passkey challenge": "คำขอยืนยัน Passkey ไม่ถูกต้องหรือหมดอายุ",
|
||||
"Passkey credential not recognized": "ไม่รู้จัก Passkey นี้",
|
||||
"Account is disabled": "บัญชีถูกปิดใช้งาน",
|
||||
"Modifying login info is disabled": "ไม่อนุญาตให้แก้ไขข้อมูลเข้าสู่ระบบ",
|
||||
"Invalid GitHub repository URL": "URL ที่เก็บ GitHub ไม่ถูกต้อง",
|
||||
"Server is busy. Please try again later.": "เซิร์ฟเวอร์ไม่ว่าง โปรดลองอีกครั้งภายหลัง",
|
||||
"This feature is unavailable in this Workspace.": "พื้นที่ทำงานนี้ไม่สามารถใช้ฟีเจอร์นี้ได้",
|
||||
"The Workspace must retain an owner.": "พื้นที่ทำงานต้องมีเจ้าของอย่างน้อยหนึ่งคน",
|
||||
"Invalid or expired invitation": "คำเชิญไม่ถูกต้องหรือหมดอายุ",
|
||||
"This user is already a member.": "ผู้ใช้นี้เป็นสมาชิกอยู่แล้ว",
|
||||
"Voice connection failed": "การเชื่อมต่อเสียงล้มเหลว",
|
||||
"User is not in the voice channel": "ผู้ใช้ไม่ได้อยู่ในช่องเสียง",
|
||||
"Voice service is unavailable": "บริการเสียงไม่พร้อมใช้งาน",
|
||||
"Submission failed. Please try again.": "ส่งไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||
"File size exceeds the limit.": "ขนาดไฟล์เกินขีดจำกัด",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "ไฟล์ใหญ่กว่า 10MB โปรดแบ่งเป็นไฟล์ย่อย",
|
||||
"Authorization failed. Please try signing in again.": "การอนุญาตล้มเหลว โปรดเข้าสู่ระบบอีกครั้ง",
|
||||
"Bind the LangBot Account with the same email as this local Account": "เชื่อมโยงบัญชี LangBot ที่ใช้อีเมลเดียวกับบัญชีในระบบนี้",
|
||||
"Only local accounts can bind to Space": "เฉพาะบัญชีในระบบเท่านั้นที่เชื่อมโยงกับ Space ได้",
|
||||
"System already initialized": "ระบบเริ่มต้นแล้ว",
|
||||
"System not initialized": "ระบบยังไม่ได้เริ่มต้น",
|
||||
"Password login is disabled on LangBot Cloud": "LangBot Cloud ปิดการเข้าสู่ระบบด้วยรหัสผ่าน",
|
||||
"This edition supports one Workspace per instance": "รุ่นนี้รองรับพื้นที่ทำงานหนึ่งแห่งต่ออินสแตนซ์",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "พื้นที่ทำงาน Cloud สร้างจากแผงควบคุม SaaS",
|
||||
"Login with your LangBot Account to accept this invitation": "เข้าสู่ระบบด้วยบัญชี LangBot เพื่อรับคำเชิญ",
|
||||
"RAG migration is not needed": "ไม่จำเป็นต้องย้าย RAG",
|
||||
"New Agent": "Agent ใหม่",
|
||||
"New Pipeline": "ไปป์ไลน์ใหม่",
|
||||
"Provider {provider} does not support {model_type} models": "ผู้ให้บริการ {provider} ไม่รองรับโมเดล {model_type}",
|
||||
"Invalid JSON format": "รูปแบบ JSON ไม่ถูกต้อง",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "ต้องเข้าสู่ระบบ ChatGPT โปรดเปิดผู้ให้บริการนี้แล้วเข้าสู่ระบบอีกครั้ง",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "คำขอ ChatGPT ถูกจำกัดตามความถี่หรือปริมาณการใช้งาน โปรดลองภายหลังหรือตรวจสอบแผนของคุณ",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT ปฏิเสธคำขอ โปรดตรวจสอบการสมัครสมาชิกและสิทธิ์ของพื้นที่ทำงาน",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT ปฏิเสธโมเดลหรือคำขอ โปรดตรวจสอบโมเดลและการตั้งค่าคำขอ",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "คำขอไปยัง ChatGPT Codex ล้มเหลว โปรดลองอีกครั้งภายหลัง",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "การใช้งานสมาชิก ChatGPT ถึงขีดจำกัดแล้ว โปรดลองภายหลังหรือตรวจสอบแผนของคุณ",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "การตอบกลับของ ChatGPT Codex ล้มเหลวหรือไม่ครบถ้วน โปรดลองอีกครั้ง",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "ไม่สามารถรีเฟรชโทเค็น ChatGPT ได้ชั่วคราว โปรดลองอีกครั้ง",
|
||||
"A second factor is required": "ต้องยืนยันตัวตนสองขั้นตอน",
|
||||
"Sign in or provide registration details to accept this invitation": "เข้าสู่ระบบหรือกรอกข้อมูลลงทะเบียนเพื่อยอมรับคำเชิญนี้",
|
||||
"Sign out before creating the invited local Account": "ออกจากระบบก่อนสร้างบัญชีภายในที่ได้รับเชิญ",
|
||||
"Turnstile verification failed": "การยืนยัน Turnstile ล้มเหลว โปรดลองอีกครั้ง",
|
||||
"The invitation email does not match your Account": "อีเมลคำเชิญไม่ตรงกับบัญชีของคุณ",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "บันทึกข้อมูลรับรอง ChatGPT ไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "ไม่สามารถเริ่มเข้าสู่ระบบด้วยอุปกรณ์ได้ โปรดเปิดการเข้าสู่ระบบด้วยรหัสอุปกรณ์ในการตั้งค่า ChatGPT",
|
||||
"Option {number}": "ตัวเลือก {number}",
|
||||
"Confirmation needed": "ต้องยืนยัน",
|
||||
"Session reset successfully": "รีเซ็ตเซสชันแล้ว"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "Yêu cầu thất bại.",
|
||||
"Too many requests. Please try again later.": "Quá nhiều yêu cầu. Vui lòng thử lại sau.",
|
||||
"Your message contains inappropriate content. Please revise it.": "Tin nhắn chứa nội dung không phù hợp. Vui lòng chỉnh sửa.",
|
||||
"Content checking failed. Please contact the administrator.": "Kiểm tra nội dung thất bại. Vui lòng liên hệ quản trị viên.",
|
||||
"Calling {tools}...": "Đang gọi {tools}…",
|
||||
"Agent runner not found: {runner}": "Không tìm thấy trình chạy Agent: {runner}",
|
||||
"Agent runner not authorized for this pipeline.": "Pipeline này không được phép sử dụng trình chạy Agent.",
|
||||
"Agent runner temporarily unavailable. Please try again.": "Trình chạy Agent tạm thời không khả dụng. Vui lòng thử lại.",
|
||||
"Agent runner execution failed.": "Thực thi Agent thất bại.",
|
||||
"The Agent runner returned an invalid response.": "Trình chạy Agent trả về phản hồi không hợp lệ.",
|
||||
"Model request failed": "Yêu cầu đến mô hình thất bại",
|
||||
"Context length exceeded": "Đã vượt quá độ dài ngữ cảnh",
|
||||
"Invalid request parameters": "Tham số yêu cầu không hợp lệ",
|
||||
"Invalid API key": "Khóa API không hợp lệ",
|
||||
"Invalid model or endpoint": "Mô hình hoặc địa chỉ API không hợp lệ",
|
||||
"Rate limit exceeded or insufficient balance": "Vượt giới hạn yêu cầu hoặc số dư không đủ",
|
||||
"Request timed out": "Yêu cầu đã hết thời gian chờ",
|
||||
"Connection failed": "Kết nối thất bại",
|
||||
"API error": "Lỗi API",
|
||||
"Unknown error": "Lỗi không xác định",
|
||||
"Internal server error": "Lỗi máy chủ nội bộ",
|
||||
"Resource not found": "Không tìm thấy tài nguyên",
|
||||
"Permission denied": "Bạn không có quyền thực hiện thao tác này",
|
||||
"Invalid authentication credentials": "Xác thực thất bại. Vui lòng đăng nhập lại",
|
||||
"MCP runtime failed": "Thực thi MCP thất bại",
|
||||
"Task execution failed": "Thực thi tác vụ thất bại",
|
||||
"Task was cancelled": "Tác vụ đã bị hủy",
|
||||
"Maximum number of {resource} ({limit}) reached": "Đã đạt số lượng tối đa của {resource} ({limit})",
|
||||
"extensions": "tiện ích mở rộng",
|
||||
"knowledge bases": "cơ sở tri thức",
|
||||
"MCP server already exists: {name}": "Máy chủ MCP đã tồn tại: {name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models được quản lý bởi Cloud và không thể chỉnh sửa",
|
||||
"Please sign in with your LangBot Account": "Vui lòng đăng nhập bằng tài khoản LangBot",
|
||||
"Current password is incorrect": "Mật khẩu hiện tại không đúng",
|
||||
"Invalid TOTP or recovery code": "Mã TOTP hoặc mã khôi phục không hợp lệ",
|
||||
"No local password set, please set a password first": "Chưa có mật khẩu cục bộ. Vui lòng đặt mật khẩu trước",
|
||||
"This LangBot Account is already bound to another user": "Tài khoản LangBot này đã được liên kết với người dùng khác",
|
||||
"Invalid recovery key": "Khóa khôi phục không hợp lệ",
|
||||
"Done": "Hoàn tất",
|
||||
"Thank you for your feedback": "Cảm ơn bạn đã phản hồi",
|
||||
"Failed to process feedback": "Không thể xử lý phản hồi",
|
||||
"Helpful": "Hữu ích",
|
||||
"Not helpful": "Không hữu ích",
|
||||
"Select an option": "Chọn một mục",
|
||||
"Enter a value": "Nhập giá trị",
|
||||
"Submit": "Gửi",
|
||||
"Submitted": "Đã gửi",
|
||||
"Thinking": "Quá trình suy nghĩ",
|
||||
"AI-generated": "Được tạo bởi AI",
|
||||
"AI-generated content. Refer to the cited sources for details.": "Nội dung do AI tạo. Xem nguồn trích dẫn để biết chi tiết.",
|
||||
"No bridges configured.": "Chưa cấu hình cầu nối nào.",
|
||||
"Signing in to all bridges again...": "Đang đăng nhập lại vào tất cả cầu nối…",
|
||||
"Bridge status:": "Trạng thái cầu nối:",
|
||||
"Signed in ✓": "Đã đăng nhập ✓",
|
||||
"Not signed in ✗": "Chưa đăng nhập ✗",
|
||||
"None": "Không có",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "Cầu nối {bridge}: đã bỏ qua (thiếu lệnh đăng nhập hoặc phòng DM).",
|
||||
"Bridge {bridge}: sending {command}...": "Cầu nối {bridge}: đang gửi {command}…",
|
||||
"Bridge {bridge}: sign-out failed.": "Cầu nối {bridge}: đăng xuất thất bại.",
|
||||
"Bridge {bridge}: sign-in started.": "Cầu nối {bridge}: đã bắt đầu đăng nhập.",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "Đã cấp quyền Slack. Quay lại LangBot để hoàn tất thiết lập.",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "Cấp quyền Slack thất bại hoặc đã hết hạn. Xem chi tiết trong LangBot.",
|
||||
"TOTP is already enabled for this Account": "Tài khoản này đã bật TOTP",
|
||||
"TOTP is not enabled for this Account": "Tài khoản này chưa bật TOTP",
|
||||
"Invalid or expired passkey challenge": "Yêu cầu xác minh passkey không hợp lệ hoặc đã hết hạn",
|
||||
"Passkey credential not recognized": "Không nhận diện được passkey",
|
||||
"Account is disabled": "Tài khoản đã bị vô hiệu hóa",
|
||||
"Modifying login info is disabled": "Không được phép sửa thông tin đăng nhập",
|
||||
"Invalid GitHub repository URL": "URL kho GitHub không hợp lệ",
|
||||
"Server is busy. Please try again later.": "Máy chủ đang bận. Vui lòng thử lại sau.",
|
||||
"This feature is unavailable in this Workspace.": "Tính năng này không khả dụng trong không gian làm việc này.",
|
||||
"The Workspace must retain an owner.": "Không gian làm việc phải có ít nhất một chủ sở hữu.",
|
||||
"Invalid or expired invitation": "Lời mời không hợp lệ hoặc đã hết hạn",
|
||||
"This user is already a member.": "Người dùng này đã là thành viên.",
|
||||
"Voice connection failed": "Kết nối thoại thất bại",
|
||||
"User is not in the voice channel": "Người dùng không ở trong kênh thoại",
|
||||
"Voice service is unavailable": "Dịch vụ thoại không khả dụng",
|
||||
"Submission failed. Please try again.": "Gửi thất bại. Vui lòng thử lại.",
|
||||
"File size exceeds the limit.": "Kích thước tệp vượt quá giới hạn.",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "Tệp vượt quá 10MB. Vui lòng chia thành các phần nhỏ hơn.",
|
||||
"Authorization failed. Please try signing in again.": "Cấp quyền thất bại. Vui lòng đăng nhập lại.",
|
||||
"Bind the LangBot Account with the same email as this local Account": "Liên kết tài khoản LangBot có cùng email với tài khoản cục bộ này",
|
||||
"Only local accounts can bind to Space": "Chỉ tài khoản cục bộ mới có thể liên kết với Space",
|
||||
"System already initialized": "Hệ thống đã được khởi tạo",
|
||||
"System not initialized": "Hệ thống chưa được khởi tạo",
|
||||
"Password login is disabled on LangBot Cloud": "LangBot Cloud không cho phép đăng nhập bằng mật khẩu",
|
||||
"This edition supports one Workspace per instance": "Phiên bản này hỗ trợ một không gian làm việc cho mỗi phiên bản triển khai",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "Không gian làm việc Cloud được tạo từ bảng điều khiển SaaS",
|
||||
"Login with your LangBot Account to accept this invitation": "Đăng nhập bằng tài khoản LangBot để chấp nhận lời mời",
|
||||
"RAG migration is not needed": "Không cần di chuyển RAG",
|
||||
"New Agent": "Agent mới",
|
||||
"New Pipeline": "Pipeline mới",
|
||||
"Provider {provider} does not support {model_type} models": "Nhà cung cấp {provider} không hỗ trợ mô hình {model_type}",
|
||||
"Invalid JSON format": "Định dạng JSON không hợp lệ",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "Cần đăng nhập ChatGPT. Mở nhà cung cấp này và đăng nhập lại.",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "Yêu cầu ChatGPT bị giới hạn tần suất hoặc mức sử dụng. Hãy thử lại sau hoặc kiểm tra gói của bạn.",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT đã từ chối yêu cầu. Kiểm tra gói đăng ký và quyền của không gian làm việc.",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT đã từ chối mô hình hoặc yêu cầu. Kiểm tra mô hình đã chọn và cài đặt yêu cầu.",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "Yêu cầu tới ChatGPT Codex thất bại. Vui lòng thử lại sau.",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "Đã đạt giới hạn sử dụng gói ChatGPT. Hãy thử lại sau hoặc kiểm tra gói của bạn.",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "Phản hồi ChatGPT Codex thất bại hoặc chưa hoàn tất. Vui lòng thử lại.",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "Tạm thời không thể làm mới token ChatGPT. Vui lòng thử lại.",
|
||||
"A second factor is required": "Cần xác thực hai yếu tố",
|
||||
"Sign in or provide registration details to accept this invitation": "Đăng nhập hoặc cung cấp thông tin đăng ký để chấp nhận lời mời",
|
||||
"Sign out before creating the invited local Account": "Đăng xuất trước khi tạo tài khoản cục bộ được mời",
|
||||
"Turnstile verification failed": "Xác minh Turnstile thất bại. Vui lòng thử lại.",
|
||||
"The invitation email does not match your Account": "Email trong lời mời không khớp với tài khoản của bạn",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "Không thể lưu thông tin xác thực ChatGPT. Vui lòng thử lại.",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "Không thể bắt đầu đăng nhập thiết bị ChatGPT. Hãy bật đăng nhập bằng mã thiết bị trong cài đặt ChatGPT.",
|
||||
"Option {number}": "Lựa chọn {number}",
|
||||
"Confirmation needed": "Cần xác nhận",
|
||||
"Session reset successfully": "Đã đặt lại phiên"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "请求失败。",
|
||||
"Too many requests. Please try again later.": "请求过于频繁,请稍后重试。",
|
||||
"Your message contains inappropriate content. Please revise it.": "消息中存在不合适的内容,请修改。",
|
||||
"Content checking failed. Please contact the administrator.": "内容检查失败,请联系管理员。",
|
||||
"Calling {tools}...": "正在调用 {tools}…",
|
||||
"Agent runner not found: {runner}": "找不到 Agent 运行器:{runner}",
|
||||
"Agent runner not authorized for this pipeline.": "此流水线未获授权使用该 Agent 运行器。",
|
||||
"Agent runner temporarily unavailable. Please try again.": "Agent 运行器暂时不可用,请重试。",
|
||||
"Agent runner execution failed.": "Agent 执行失败。",
|
||||
"The Agent runner returned an invalid response.": "Agent 运行器返回了无效响应。",
|
||||
"Model request failed": "模型请求失败",
|
||||
"Context length exceeded": "上下文长度超限",
|
||||
"Invalid request parameters": "请求参数错误",
|
||||
"Invalid API key": "API 密钥无效",
|
||||
"Invalid model or endpoint": "模型或接口地址无效",
|
||||
"Rate limit exceeded or insufficient balance": "请求过于频繁或余额不足",
|
||||
"Request timed out": "请求超时",
|
||||
"Connection failed": "连接失败",
|
||||
"API error": "API 错误",
|
||||
"Unknown error": "未知错误",
|
||||
"Internal server error": "服务器内部错误",
|
||||
"Resource not found": "资源不存在",
|
||||
"Permission denied": "没有执行此操作的权限",
|
||||
"Invalid authentication credentials": "身份验证失败,请重新登录",
|
||||
"MCP runtime failed": "MCP 运行失败",
|
||||
"Task execution failed": "任务执行失败",
|
||||
"Task was cancelled": "任务已取消",
|
||||
"Maximum number of {resource} ({limit}) reached": "{resource}数量已达到上限({limit})",
|
||||
"extensions": "扩展",
|
||||
"knowledge bases": "知识库",
|
||||
"MCP server already exists: {name}": "MCP 服务器已存在:{name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models 由云端管理,无法修改",
|
||||
"Please sign in with your LangBot Account": "请使用 LangBot 账号登录",
|
||||
"Current password is incorrect": "当前密码不正确",
|
||||
"Invalid TOTP or recovery code": "验证码或恢复码无效",
|
||||
"No local password set, please set a password first": "尚未设置本地密码,请先设置密码",
|
||||
"This LangBot Account is already bound to another user": "此 LangBot 账号已绑定其他用户",
|
||||
"Invalid recovery key": "恢复密钥无效",
|
||||
"Done": "操作成功",
|
||||
"Thank you for your feedback": "感谢您的反馈",
|
||||
"Failed to process feedback": "反馈处理失败",
|
||||
"Helpful": "有帮助",
|
||||
"Not helpful": "无帮助",
|
||||
"Select an option": "请选择",
|
||||
"Enter a value": "请输入",
|
||||
"Submit": "提交",
|
||||
"Submitted": "已提交",
|
||||
"Thinking": "思考过程",
|
||||
"AI-generated": "内容由 AI 生成",
|
||||
"AI-generated content. Refer to the cited sources for details.": "内容由 AI 生成,详情请参考引用来源。",
|
||||
"No bridges configured.": "没有配置任何桥。",
|
||||
"Signing in to all bridges again...": "正在重新登录所有桥…",
|
||||
"Bridge status:": "桥状态:",
|
||||
"Signed in ✓": "已登录 ✓",
|
||||
"Not signed in ✗": "未登录 ✗",
|
||||
"None": "无",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "桥 {bridge}:已跳过(缺少登录命令或私聊房间)。",
|
||||
"Bridge {bridge}: sending {command}...": "桥 {bridge}:正在发送 {command}…",
|
||||
"Bridge {bridge}: sign-out failed.": "桥 {bridge}:退出登录失败。",
|
||||
"Bridge {bridge}: sign-in started.": "桥 {bridge}:已开始登录。",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "Slack 授权完成,请返回 LangBot 完成配置。",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "Slack 授权失败或已过期,请返回 LangBot 查看详情。",
|
||||
"TOTP is already enabled for this Account": "此账号已启用两步验证",
|
||||
"TOTP is not enabled for this Account": "此账号尚未启用两步验证",
|
||||
"Invalid or expired passkey challenge": "通行密钥验证请求无效或已过期",
|
||||
"Passkey credential not recognized": "无法识别此通行密钥",
|
||||
"Account is disabled": "账号已停用",
|
||||
"Modifying login info is disabled": "不允许修改登录信息",
|
||||
"Invalid GitHub repository URL": "GitHub 仓库地址无效",
|
||||
"Server is busy. Please try again later.": "服务器繁忙,请稍后重试。",
|
||||
"This feature is unavailable in this Workspace.": "此工作区无法使用该功能。",
|
||||
"The Workspace must retain an owner.": "工作区必须保留一位所有者。",
|
||||
"Invalid or expired invitation": "邀请无效或已过期",
|
||||
"This user is already a member.": "此用户已是工作区成员。",
|
||||
"Voice connection failed": "语音连接失败",
|
||||
"User is not in the voice channel": "用户不在语音频道中",
|
||||
"Voice service is unavailable": "语音服务不可用",
|
||||
"Submission failed. Please try again.": "提交失败,请重试。",
|
||||
"File size exceeds the limit.": "文件大小超过限制。",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "文件大小超过 10MB,请拆分后上传。",
|
||||
"Authorization failed. Please try signing in again.": "授权失败,请重新登录。",
|
||||
"Bind the LangBot Account with the same email as this local Account": "请绑定与此本地账号邮箱相同的 LangBot 账号",
|
||||
"Only local accounts can bind to Space": "只有本地账号可以绑定 Space",
|
||||
"System already initialized": "系统已初始化",
|
||||
"System not initialized": "系统尚未初始化",
|
||||
"Password login is disabled on LangBot Cloud": "LangBot Cloud 不支持密码登录",
|
||||
"This edition supports one Workspace per instance": "此版本每个实例仅支持一个工作区",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "云工作区由 SaaS 控制台创建",
|
||||
"Login with your LangBot Account to accept this invitation": "请登录 LangBot 账号以接受邀请",
|
||||
"RAG migration is not needed": "无需迁移知识库",
|
||||
"New Agent": "新 Agent",
|
||||
"New Pipeline": "新流水线",
|
||||
"Provider {provider} does not support {model_type} models": "提供商 {provider} 不支持 {model_type} 模型",
|
||||
"Invalid JSON format": "JSON 格式无效",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "需要登录 ChatGPT。请打开此提供商并重新登录。",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT 请求受限(请求频率或用量限制)。请稍后重试或检查套餐。",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT 拒绝了此请求。请检查订阅和工作区权限。",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT 拒绝了模型或请求。请检查所选模型和请求设置。",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex 上游请求失败,请稍后重试。",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT 订阅用量已达上限。请稍后重试或检查套餐。",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex 响应失败或不完整,请重试。",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT 令牌暂时刷新失败,请重试。",
|
||||
"A second factor is required": "需要二次验证",
|
||||
"Sign in or provide registration details to accept this invitation": "请登录或填写注册信息以接受此邀请",
|
||||
"Sign out before creating the invited local Account": "请先退出登录,再创建受邀本地账号",
|
||||
"Turnstile verification failed": "人机验证失败,请重试",
|
||||
"The invitation email does not match your Account": "邀请邮箱与您的账号不匹配",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "保存 ChatGPT 凭据失败,请重试。",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "无法启动 ChatGPT 设备登录。请在 ChatGPT 设置中启用设备代码登录。",
|
||||
"Option {number}": "选项 {number}",
|
||||
"Confirmation needed": "需要确认",
|
||||
"Session reset successfully": "会话已重置"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Request failed.": "請求失敗。",
|
||||
"Too many requests. Please try again later.": "請求過於頻繁,請稍後重試。",
|
||||
"Your message contains inappropriate content. Please revise it.": "訊息中存在不適當的內容,請修改。",
|
||||
"Content checking failed. Please contact the administrator.": "內容檢查失敗,請聯絡管理員。",
|
||||
"Calling {tools}...": "正在呼叫 {tools}…",
|
||||
"Agent runner not found: {runner}": "找不到 Agent 執行器:{runner}",
|
||||
"Agent runner not authorized for this pipeline.": "此流水線未獲授權使用該 Agent 執行器。",
|
||||
"Agent runner temporarily unavailable. Please try again.": "Agent 執行器暫時無法使用,請重試。",
|
||||
"Agent runner execution failed.": "Agent 執行失敗。",
|
||||
"The Agent runner returned an invalid response.": "Agent 執行器傳回了無效回應。",
|
||||
"Model request failed": "模型請求失敗",
|
||||
"Context length exceeded": "上下文長度超出限制",
|
||||
"Invalid request parameters": "請求參數錯誤",
|
||||
"Invalid API key": "API 金鑰無效",
|
||||
"Invalid model or endpoint": "模型或介面位址無效",
|
||||
"Rate limit exceeded or insufficient balance": "請求過於頻繁或餘額不足",
|
||||
"Request timed out": "請求逾時",
|
||||
"Connection failed": "連線失敗",
|
||||
"API error": "API 錯誤",
|
||||
"Unknown error": "未知錯誤",
|
||||
"Internal server error": "伺服器內部錯誤",
|
||||
"Resource not found": "資源不存在",
|
||||
"Permission denied": "沒有執行此操作的權限",
|
||||
"Invalid authentication credentials": "身分驗證失敗,請重新登入",
|
||||
"MCP runtime failed": "MCP 執行失敗",
|
||||
"Task execution failed": "任務執行失敗",
|
||||
"Task was cancelled": "任務已取消",
|
||||
"Maximum number of {resource} ({limit}) reached": "{resource}數量已達上限({limit})",
|
||||
"extensions": "擴充功能",
|
||||
"knowledge bases": "知識庫",
|
||||
"MCP server already exists: {name}": "MCP 伺服器已存在:{name}",
|
||||
"LangBot Models is managed by Cloud and cannot be modified": "LangBot Models 由雲端管理,無法修改",
|
||||
"Please sign in with your LangBot Account": "請使用 LangBot 帳號登入",
|
||||
"Current password is incorrect": "目前密碼不正確",
|
||||
"Invalid TOTP or recovery code": "驗證碼或復原碼無效",
|
||||
"No local password set, please set a password first": "尚未設定本機密碼,請先設定密碼",
|
||||
"This LangBot Account is already bound to another user": "此 LangBot 帳號已綁定其他使用者",
|
||||
"Invalid recovery key": "復原金鑰無效",
|
||||
"Done": "操作成功",
|
||||
"Thank you for your feedback": "感謝您的回饋",
|
||||
"Failed to process feedback": "回饋處理失敗",
|
||||
"Helpful": "有幫助",
|
||||
"Not helpful": "沒有幫助",
|
||||
"Select an option": "請選擇",
|
||||
"Enter a value": "請輸入",
|
||||
"Submit": "提交",
|
||||
"Submitted": "已提交",
|
||||
"Thinking": "思考過程",
|
||||
"AI-generated": "內容由 AI 產生",
|
||||
"AI-generated content. Refer to the cited sources for details.": "內容由 AI 產生,詳情請參考引用來源。",
|
||||
"No bridges configured.": "尚未設定任何橋接。",
|
||||
"Signing in to all bridges again...": "正在重新登入所有橋接…",
|
||||
"Bridge status:": "橋接狀態:",
|
||||
"Signed in ✓": "已登入 ✓",
|
||||
"Not signed in ✗": "未登入 ✗",
|
||||
"None": "無",
|
||||
"Bridge {bridge}: skipped (login command or DM room missing).": "橋接 {bridge}:已略過(缺少登入命令或私訊房間)。",
|
||||
"Bridge {bridge}: sending {command}...": "橋接 {bridge}:正在傳送 {command}…",
|
||||
"Bridge {bridge}: sign-out failed.": "橋接 {bridge}:登出失敗。",
|
||||
"Bridge {bridge}: sign-in started.": "橋接 {bridge}:已開始登入。",
|
||||
"Slack authorization completed. Return to LangBot to finish setup.": "Slack 授權完成,請返回 LangBot 完成設定。",
|
||||
"Slack authorization failed or expired. Return to LangBot for details.": "Slack 授權失敗或已過期,請返回 LangBot 查看詳情。",
|
||||
"TOTP is already enabled for this Account": "此帳號已啟用兩步驗證",
|
||||
"TOTP is not enabled for this Account": "此帳號尚未啟用兩步驗證",
|
||||
"Invalid or expired passkey challenge": "通行金鑰驗證請求無效或已過期",
|
||||
"Passkey credential not recognized": "無法識別此通行金鑰",
|
||||
"Account is disabled": "帳號已停用",
|
||||
"Modifying login info is disabled": "不允許修改登入資訊",
|
||||
"Invalid GitHub repository URL": "GitHub 儲存庫網址無效",
|
||||
"Server is busy. Please try again later.": "伺服器忙碌中,請稍後重試。",
|
||||
"This feature is unavailable in this Workspace.": "此工作區無法使用該功能。",
|
||||
"The Workspace must retain an owner.": "工作區必須保留一位擁有者。",
|
||||
"Invalid or expired invitation": "邀請無效或已過期",
|
||||
"This user is already a member.": "此使用者已是工作區成員。",
|
||||
"Voice connection failed": "語音連線失敗",
|
||||
"User is not in the voice channel": "使用者不在語音頻道中",
|
||||
"Voice service is unavailable": "語音服務無法使用",
|
||||
"Submission failed. Please try again.": "提交失敗,請重試。",
|
||||
"File size exceeds the limit.": "檔案大小超過限制。",
|
||||
"File size exceeds 10MB limit. Please split large files into smaller parts.": "檔案大小超過 10MB,請分割後上傳。",
|
||||
"Authorization failed. Please try signing in again.": "授權失敗,請重新登入。",
|
||||
"Bind the LangBot Account with the same email as this local Account": "請綁定與此本機帳號電子郵件相同的 LangBot 帳號",
|
||||
"Only local accounts can bind to Space": "只有本機帳號可以綁定 Space",
|
||||
"System already initialized": "系統已初始化",
|
||||
"System not initialized": "系統尚未初始化",
|
||||
"Password login is disabled on LangBot Cloud": "LangBot Cloud 不支援密碼登入",
|
||||
"This edition supports one Workspace per instance": "此版本每個執行個體僅支援一個工作區",
|
||||
"Cloud Workspaces are created by the SaaS control plane": "雲端工作區由 SaaS 控制台建立",
|
||||
"Login with your LangBot Account to accept this invitation": "請登入 LangBot 帳號以接受邀請",
|
||||
"RAG migration is not needed": "無需遷移知識庫",
|
||||
"New Agent": "新 Agent",
|
||||
"New Pipeline": "新流水線",
|
||||
"Provider {provider} does not support {model_type} models": "提供商 {provider} 不支援 {model_type} 模型",
|
||||
"Invalid JSON format": "JSON 格式無效",
|
||||
"ChatGPT sign-in required. Open this provider and sign in again.": "需要登入 ChatGPT。請開啟此供應商並重新登入。",
|
||||
"ChatGPT request was limited (rate limit or usage restriction). Please retry later or check your plan.": "ChatGPT 請求受限(請求頻率或用量限制)。請稍後重試或檢查方案。",
|
||||
"ChatGPT denied this request. Check subscription and workspace permissions.": "ChatGPT 拒絕了此請求。請檢查訂閱和工作區權限。",
|
||||
"ChatGPT rejected the model or request. Check the selected model and request settings.": "ChatGPT 拒絕了模型或請求。請檢查所選模型和請求設定。",
|
||||
"ChatGPT Codex upstream request failed. Please retry later.": "ChatGPT Codex 上游請求失敗,請稍後重試。",
|
||||
"ChatGPT subscription usage limit reached. Please retry later or check your plan.": "ChatGPT 訂閱用量已達上限。請稍後重試或檢查方案。",
|
||||
"ChatGPT Codex response failed or was incomplete. Please retry.": "ChatGPT Codex 回應失敗或不完整,請重試。",
|
||||
"ChatGPT token refresh temporarily failed. Please retry.": "ChatGPT 權杖暫時更新失敗,請重試。",
|
||||
"A second factor is required": "需要雙重驗證",
|
||||
"Sign in or provide registration details to accept this invitation": "請登入或填寫註冊資料以接受此邀請",
|
||||
"Sign out before creating the invited local Account": "請先登出,再建立受邀本機帳號",
|
||||
"Turnstile verification failed": "人機驗證失敗,請重試",
|
||||
"The invitation email does not match your Account": "邀請信箱與您的帳號不符",
|
||||
"Failed to save ChatGPT credentials. Please retry.": "儲存 ChatGPT 憑證失敗,請重試。",
|
||||
"Unable to start ChatGPT device login. Enable device code login in ChatGPT settings.": "無法啟動 ChatGPT 裝置登入。請在 ChatGPT 設定中啟用裝置代碼登入。",
|
||||
"Option {number}": "選項 {number}",
|
||||
"Confirmation needed": "需要確認",
|
||||
"Session reset successfully": "對話已重設"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Workspace-owned message language, stored in the existing tenant metadata."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
|
||||
from ..entity.persistence.metadata import WorkspaceMetadata
|
||||
from . import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, normalize_language
|
||||
|
||||
LANGUAGE_KEY = 'message_language'
|
||||
_CACHE_TTL = 30
|
||||
_CACHE_SIZE = 256
|
||||
|
||||
|
||||
def _cache(ap) -> OrderedDict:
|
||||
cache = getattr(ap, '_message_language_cache', None)
|
||||
if cache is None:
|
||||
cache = OrderedDict()
|
||||
ap._message_language_cache = cache
|
||||
return cache
|
||||
|
||||
|
||||
async def get_workspace_language(ap, workspace_uuid: str, *, refresh: bool = False) -> str:
|
||||
if not workspace_uuid:
|
||||
return DEFAULT_LANGUAGE
|
||||
cache = _cache(ap)
|
||||
cached = cache.get(workspace_uuid)
|
||||
if not refresh and cached and cached[1] > time.monotonic():
|
||||
return cached[0]
|
||||
manager = getattr(ap, 'persistence_mgr', None)
|
||||
if manager is None or not callable(getattr(manager, 'tenant_uow', None)):
|
||||
return DEFAULT_LANGUAGE
|
||||
async with manager.tenant_uow(workspace_uuid) as uow:
|
||||
result = await uow.execute(
|
||||
sa.select(WorkspaceMetadata.value).where(
|
||||
WorkspaceMetadata.workspace_uuid == workspace_uuid,
|
||||
WorkspaceMetadata.key == LANGUAGE_KEY,
|
||||
)
|
||||
)
|
||||
locale = normalize_language(result.scalar_one_or_none())
|
||||
cache[workspace_uuid] = (locale, time.monotonic() + _CACHE_TTL)
|
||||
cache.move_to_end(workspace_uuid)
|
||||
while len(cache) > _CACHE_SIZE:
|
||||
cache.popitem(last=False)
|
||||
return locale
|
||||
|
||||
|
||||
async def set_workspace_language(ap, workspace_uuid: str, locale: str) -> str:
|
||||
if locale not in SUPPORTED_LANGUAGES:
|
||||
raise ValueError('Unsupported Workspace language')
|
||||
async with ap.persistence_mgr.tenant_uow(workspace_uuid) as uow:
|
||||
dialect = ap.persistence_mgr.get_db_engine().dialect.name
|
||||
insert = pg_insert if dialect == 'postgresql' else sqlite_insert
|
||||
statement = insert(WorkspaceMetadata).values(workspace_uuid=workspace_uuid, key=LANGUAGE_KEY, value=locale)
|
||||
await uow.execute(
|
||||
statement.on_conflict_do_update(
|
||||
index_elements=['workspace_uuid', 'key'],
|
||||
set_={'value': locale},
|
||||
)
|
||||
)
|
||||
# Synchronous platform callbacks may run in SDK threads. Publish the new
|
||||
# value only after commit, so they see changes without restarting a bot.
|
||||
_cache(ap)[workspace_uuid] = (locale, time.monotonic() + _CACHE_TTL)
|
||||
while len(_cache(ap)) > _CACHE_SIZE:
|
||||
_cache(ap).popitem(last=False)
|
||||
return locale
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Link monitoring inputs and deliveries to events and runs."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0034_monitoring_execution_links'
|
||||
down_revision = '0033_operation_traceability'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table('monitoring_messages'):
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('monitoring_messages')}
|
||||
indexes = {i['name'] for i in inspector.get_indexes('monitoring_messages')}
|
||||
for name in ('event_id', 'run_id', 'parent_message_id'):
|
||||
if name not in columns:
|
||||
op.add_column('monitoring_messages', sa.Column(name, sa.String(255), nullable=True))
|
||||
index = f'ix_monitoring_messages_{name}'
|
||||
if index not in indexes:
|
||||
op.create_index(index, 'monitoring_messages', [name])
|
||||
|
||||
|
||||
def downgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if not inspector.has_table('monitoring_messages'):
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('monitoring_messages')}
|
||||
indexes = {i['name'] for i in inspector.get_indexes('monitoring_messages')}
|
||||
for name in ('parent_message_id', 'run_id', 'event_id'):
|
||||
if f'ix_monitoring_messages_{name}' in indexes:
|
||||
op.drop_index(f'ix_monitoring_messages_{name}', table_name='monitoring_messages')
|
||||
if name in columns:
|
||||
op.drop_column('monitoring_messages', name)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Narrow legacy Pipeline wildcard routes to incoming messages."""
|
||||
|
||||
import json
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0035_pipeline_received_only'
|
||||
down_revision = '0034_monitoring_execution_links'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
connection = op.get_bind()
|
||||
if not sa.inspect(connection).has_table('bots'):
|
||||
return
|
||||
bots = sa.table('bots', sa.column('uuid', sa.String()), sa.column('event_bindings', sa.JSON()))
|
||||
for bot_uuid, bindings in connection.execute(sa.select(bots.c.uuid, bots.c.event_bindings)).all():
|
||||
if isinstance(bindings, str):
|
||||
bindings = json.loads(bindings)
|
||||
if not isinstance(bindings, list):
|
||||
continue
|
||||
changed = False
|
||||
for binding in bindings:
|
||||
if (
|
||||
isinstance(binding, dict)
|
||||
and binding.get('target_type') == 'pipeline'
|
||||
and binding.get('event_pattern') == 'message.*'
|
||||
):
|
||||
binding['event_pattern'] = 'message.received'
|
||||
changed = True
|
||||
if changed:
|
||||
connection.execute(bots.update().where(bots.c.uuid == bot_uuid).values(event_bindings=bindings))
|
||||
|
||||
|
||||
def downgrade():
|
||||
# An exact incoming-message route remains valid with older versions.
|
||||
pass
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Track the liveness of hosts owning direct executions."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0036_execution_host_leases'
|
||||
down_revision = '0035_pipeline_received_only'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
for table in ('agent_run', 'monitoring_messages'):
|
||||
if not inspector.has_table(table):
|
||||
continue
|
||||
columns = {c['name'] for c in inspector.get_columns(table)}
|
||||
indexes = {i['name'] for i in inspector.get_indexes(table)}
|
||||
for column, column_type in (
|
||||
('execution_owner_id', sa.String(36)),
|
||||
('execution_lease_expires_at', sa.DateTime()),
|
||||
):
|
||||
if column not in columns:
|
||||
op.add_column(table, sa.Column(column, column_type, nullable=True))
|
||||
index = f'ix_{table}_{column}'
|
||||
if index not in indexes:
|
||||
op.create_index(index, table, [column])
|
||||
|
||||
|
||||
def downgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
for table in ('agent_run', 'monitoring_messages'):
|
||||
if not inspector.has_table(table):
|
||||
continue
|
||||
columns = {c['name'] for c in inspector.get_columns(table)}
|
||||
indexes = {i['name'] for i in inspector.get_indexes(table)}
|
||||
for column in ('execution_owner_id', 'execution_lease_expires_at'):
|
||||
if f'ix_{table}_{column}' in indexes:
|
||||
op.drop_index(f'ix_{table}_{column}', table_name=table)
|
||||
if column in columns:
|
||||
op.drop_column(table, column)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Allow structured wizard progress in metadata values."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0037_metadata_text'
|
||||
down_revision = '0036_execution_host_leases'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
for table in ('metadata', 'workspace_metadata'):
|
||||
if inspector.has_table(table):
|
||||
with op.batch_alter_table(table) as batch:
|
||||
batch.alter_column('value', existing_type=sa.String(255), type_=sa.Text(), existing_nullable=True)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Keep the widened type: narrowing would destroy valid saved progress.
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Persist additive LLM timing, usage and structured failure diagnostics."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0038_llm_call_details'
|
||||
down_revision = '0037_metadata_text'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table('monitoring_llm_calls'):
|
||||
columns = {column['name'] for column in inspector.get_columns('monitoring_llm_calls')}
|
||||
if 'details' not in columns:
|
||||
op.add_column('monitoring_llm_calls', sa.Column('details', sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if inspector.has_table('monitoring_llm_calls'):
|
||||
columns = {column['name'] for column in inspector.get_columns('monitoring_llm_calls')}
|
||||
if 'details' in columns:
|
||||
op.drop_column('monitoring_llm_calls', 'details')
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Index execution leases, conversation paging, and historical inline image expiry."""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '0039_execution_lease_indexes'
|
||||
down_revision = '0038_llm_call_details'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
INDEXES = (
|
||||
('agent_run', 'ix_agent_run_workspace_status_lease', ['workspace_id', 'status', 'execution_lease_expires_at']),
|
||||
(
|
||||
'monitoring_messages',
|
||||
'ix_monitoring_messages_workspace_status_lease',
|
||||
['workspace_uuid', 'status', 'execution_lease_expires_at'],
|
||||
),
|
||||
(
|
||||
'monitoring_messages',
|
||||
'ix_monitoring_messages_conversation_page',
|
||||
['workspace_uuid', 'bot_id', 'session_id', 'timestamp', 'id'],
|
||||
),
|
||||
('monitoring_messages', 'ix_monitoring_messages_media_ttl', ['workspace_uuid', 'timestamp', 'id']),
|
||||
('event_log', 'ix_event_log_media_ttl', ['workspace_id', 'created_at', 'id']),
|
||||
('agent_run', 'ix_agent_run_media_ttl', ['workspace_id', 'created_at', 'id']),
|
||||
('agent_run_event', 'ix_agent_run_event_media_ttl', ['created_at', 'id', 'run_id']),
|
||||
('transcript', 'ix_transcript_media_ttl', ['workspace_id', 'created_at', 'id']),
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
for table, index, columns in INDEXES:
|
||||
if not inspector.has_table(table):
|
||||
continue
|
||||
# Fresh installations create these indexes from ORM metadata before migrations.
|
||||
if index not in {item['name'] for item in inspector.get_indexes(table)}:
|
||||
op.create_index(index, table, columns)
|
||||
|
||||
|
||||
def downgrade():
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
for table, index, _ in reversed(INDEXES):
|
||||
if not inspector.has_table(table):
|
||||
continue
|
||||
if index in {item['name'] for item in inspector.get_indexes(table)}:
|
||||
op.drop_index(index, table_name=table)
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy
|
||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
|
||||
from .. import database
|
||||
@@ -13,3 +14,8 @@ class SQLiteDatabaseManager(database.BaseDatabaseManager):
|
||||
db_file_path = self.ap.instance_config.data.get('database', {}).get('sqlite', {}).get('path', 'data/langbot.db')
|
||||
engine_url = f'sqlite+aiosqlite:///{db_file_path}'
|
||||
self.engine = sqlalchemy_asyncio.create_async_engine(engine_url)
|
||||
# Reader transactions must not block independent plugin callback commits.
|
||||
# Configure the persistent journal before serving requests, not per write.
|
||||
async with self.engine.connect() as connection:
|
||||
await connection.execute(sqlalchemy.text('PRAGMA journal_mode=WAL'))
|
||||
await connection.commit()
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""The storage contract for conversation payloads containing image references."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import Text
|
||||
from sqlalchemy.engine import Dialect
|
||||
from sqlalchemy.sql.operators import OperatorType
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
|
||||
|
||||
class MediaReferenceText(TypeDecorator[str]):
|
||||
"""Keep the existing TEXT format while rejecting structured inline images.
|
||||
|
||||
Writers must externalize retained images through MediaCache or redact
|
||||
metadata-only images before binding a value. Both ORM and Core writes use
|
||||
this contract; reads preserve historical values for incremental cleanup.
|
||||
Plain text remains valid for fields such as monitoring message content.
|
||||
"""
|
||||
|
||||
impl = Text
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: str | None, dialect: Dialect) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
# Model imports must not initialize the media cache's runtime dependencies.
|
||||
from ..storage.media import MediaCache
|
||||
|
||||
if MediaCache.has_inline_images(payload):
|
||||
raise ValueError(
|
||||
'Inline images cannot be persisted; use MediaCache.externalize_chain_dump() '
|
||||
'for retained content or MediaCache.redact_inline_images() for metadata'
|
||||
)
|
||||
return value
|
||||
|
||||
def coerce_compared_value(self, op: OperatorType | None, value: Any) -> Text:
|
||||
# Legacy payloads must remain usable in searches and compare-and-swap updates.
|
||||
return self.impl
|
||||
@@ -19,6 +19,8 @@ from sqlalchemy.dialects.postgresql.dml import OnConflictDoUpdate as PostgreSQLO
|
||||
from sqlalchemy.dialects.sqlite.dml import OnConflictDoNothing as SQLiteOnConflictDoNothing
|
||||
from sqlalchemy.dialects.sqlite.dml import OnConflictDoUpdate as SQLiteOnConflictDoUpdate
|
||||
|
||||
from .media import MediaReferenceText
|
||||
|
||||
|
||||
TENANT_SETTING = 'langbot.workspace_uuid'
|
||||
ACCOUNT_SETTING = 'langbot.account_uuid'
|
||||
@@ -218,7 +220,7 @@ _ALLOWED_SCOPED_BUILTIN_FUNCTION_TYPES = {
|
||||
'now': sqlalchemy.sql.functions.now,
|
||||
'sum': sqlalchemy.sql.functions.sum,
|
||||
}
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'strftime'})
|
||||
_ALLOWED_SCOPED_GENERIC_FUNCTIONS = frozenset({'date_trunc', 'length', 'nullif', 'row_number', 'strftime'})
|
||||
_ALLOWED_SCOPED_CUSTOM_OPERATORS = frozenset({'<=>'})
|
||||
_ALLOWED_SCOPED_STATEMENT_TYPES = (
|
||||
sqlalchemy.sql.dml.UpdateBase,
|
||||
@@ -292,7 +294,7 @@ def _validate_scoped_sql_type(
|
||||
|
||||
if type(sql_type) in {Vector, HALFVEC}:
|
||||
return
|
||||
if not type(sql_type).__module__.startswith('sqlalchemy.'):
|
||||
if type(sql_type) is not MediaReferenceText and not type(sql_type).__module__.startswith('sqlalchemy.'):
|
||||
raise ScopedSessionTransactionError('TenantUnitOfWork does not allow custom SQL types in public statements')
|
||||
|
||||
for identifier_attribute in ('name', 'schema', 'collation'):
|
||||
@@ -483,9 +485,18 @@ def _validate_scoped_statement_call(args: tuple[typing.Any, ...], kwargs: dict[s
|
||||
and getattr(getattr(source, 'table', None), 'name', None) == 'legacy_pipelines'
|
||||
and source.name in {'config', 'stages', 'extensions_preferences'}
|
||||
)
|
||||
if not pipeline_json_cas:
|
||||
monitoring_metadata = (
|
||||
type(element.type) is sqlalchemy.JSON
|
||||
and isinstance(source, sqlalchemy.Column)
|
||||
and type(source.type) in {sqlalchemy.Text, MediaReferenceText}
|
||||
and getattr(getattr(source, 'table', None), 'name', None) in {'event_log', 'agent_run'}
|
||||
and (
|
||||
source.name == 'metadata_json' or (source.table.name == 'event_log' and source.name == 'input_json')
|
||||
)
|
||||
)
|
||||
if not (pipeline_json_cas or monitoring_metadata):
|
||||
raise ScopedSessionTransactionError(
|
||||
'TenantUnitOfWork only allows trusted pgvector and Pipeline JSON CAS casts'
|
||||
'TenantUnitOfWork only allows trusted pgvector, Pipeline JSON CAS and monitoring metadata casts'
|
||||
)
|
||||
|
||||
if isinstance(element, sqlalchemy.sql.functions.FunctionElement):
|
||||
|
||||
@@ -162,6 +162,13 @@ class MessageAggregator:
|
||||
if variables:
|
||||
enabled = False
|
||||
|
||||
# Preserve ingress identity across the queue and detached debounce task.
|
||||
from ..telemetry import trace
|
||||
|
||||
ingress_trace = trace.current()
|
||||
if ingress_trace is not None:
|
||||
variables = {**(variables or {}), '_ingress_event_id': ingress_trace.event_id}
|
||||
|
||||
if not enabled:
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=bot_uuid,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import pipeline_notice
|
||||
|
||||
from .. import entities
|
||||
from .. import filter as filter_model
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -58,6 +60,10 @@ class BaiduCloudExamine(filter_model.ContentFilter):
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.BLOCK,
|
||||
replacement=message,
|
||||
user_notice='消息中存在不合适的内容, 请修改',
|
||||
user_notice=pipeline_notice(
|
||||
query,
|
||||
'content-filter-hint',
|
||||
'Your message contains inappropriate content. Please revise it.',
|
||||
),
|
||||
console_notice=f'百度云判定结果:{conclusion}',
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import pipeline_notice
|
||||
|
||||
from .. import filter as filter_model
|
||||
from .. import entities
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -35,13 +37,19 @@ class BanWordFilter(filter_model.ContentFilter):
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.BLOCK,
|
||||
replacement='',
|
||||
user_notice='内容检查规则执行失败,请联系管理员',
|
||||
user_notice=pipeline_notice(
|
||||
query, 'content-filter-error-hint', 'Content checking failed. Please contact the administrator.'
|
||||
),
|
||||
console_notice=f'Sensitive-word regex rejected: {exc}',
|
||||
)
|
||||
|
||||
return entities.FilterResult(
|
||||
level=entities.ResultLevel.MASKED if found else entities.ResultLevel.PASS,
|
||||
replacement=current,
|
||||
user_notice='消息中存在不合适的内容, 请修改' if found else '',
|
||||
user_notice=pipeline_notice(
|
||||
query, 'content-filter-hint', 'Your message contains inappropriate content. Please revise it.'
|
||||
)
|
||||
if found
|
||||
else '',
|
||||
console_notice='',
|
||||
)
|
||||
|
||||
@@ -19,18 +19,21 @@ import re
|
||||
from urllib.parse import urlsplit
|
||||
from string import Formatter
|
||||
|
||||
PLANNER_VERSION = '4'
|
||||
PLANNER_VERSION = '5'
|
||||
|
||||
# Minimum reviewed plugin versions. The marketplace delists old releases, so the
|
||||
# installer falls back to the latest release when a pin is gone; the Host then
|
||||
# validates the emitted config against the installed runner schema.
|
||||
_TARGETS = {
|
||||
'local-agent': ('LocalAgent', '0.1.10'),
|
||||
'dify-service-api': ('DifyAgent', '0.1.10'),
|
||||
'coze-api': ('CozeAgent', '0.1.10'),
|
||||
'dashscope-app-api': ('DashScopeAgent', '0.1.10'),
|
||||
'n8n-service-api': ('N8nAgent', '0.1.10'),
|
||||
'langflow-api': ('LangflowAgent', '0.1.10'),
|
||||
'deerflow-api': ('DeerFlowAgent', '0.1.10'),
|
||||
'tbox-app-api': ('TboxAgent', '0.1.8'),
|
||||
'weknora-api': ('WeKnoraAgent', '0.1.10'),
|
||||
'local-agent': ('LocalAgent', '0.2.1'),
|
||||
'dify-service-api': ('DifyAgent', '0.2.3'),
|
||||
'coze-api': ('CozeAgent', '0.2.0'),
|
||||
'dashscope-app-api': ('DashScopeAgent', '0.2.2'),
|
||||
'n8n-service-api': ('N8nAgent', '0.2.0'),
|
||||
'langflow-api': ('LangflowAgent', '0.2.2'),
|
||||
'deerflow-api': ('DeerFlowAgent', '0.2.1'),
|
||||
'tbox-app-api': ('TboxAgent', '0.2.1'),
|
||||
'weknora-api': ('WeKnoraAgent', '0.2.1'),
|
||||
}
|
||||
_DEERFLOW_FIELDS = {
|
||||
'api-base',
|
||||
@@ -192,6 +195,51 @@ _DEFAULTS = {
|
||||
'advanced-settings': False,
|
||||
},
|
||||
}
|
||||
# Missing values for these fields cannot be invented: they are credentials or
|
||||
# endpoints. Everything else is repaired from reviewed 4.10 defaults.
|
||||
_CRITICAL = {
|
||||
'local-agent': ('model',),
|
||||
'dify-service-api': ('base-url', 'api-key'),
|
||||
'coze-api': ('api-key', 'bot-id'),
|
||||
'dashscope-app-api': ('api-key', 'app-id'),
|
||||
'n8n-service-api': ('webhook-url',),
|
||||
'langflow-api': ('base-url', 'api-key', 'flow-id'),
|
||||
'deerflow-api': ('api-base',),
|
||||
'tbox-app-api': ('api-key', 'app-id'),
|
||||
'weknora-api': ('base-url', 'api-key'),
|
||||
}
|
||||
_LOCAL_PROMPT = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
# 4.10 UI defaults for required, non-critical fields.
|
||||
_FILL = {
|
||||
'local-agent': {'prompt': _LOCAL_PROMPT, 'box-session-id-template': '{launcher_type}_{launcher_id}'},
|
||||
'dify-service-api': {
|
||||
'app-type': 'chat',
|
||||
'base-prompt': (
|
||||
'When the file content is readable, please read the content of this file. '
|
||||
'When the file is an image, describe the content of this image.'
|
||||
),
|
||||
},
|
||||
'coze-api': {'api-base': 'https://api.coze.cn', 'timeout': 120},
|
||||
'dashscope-app-api': {'app-type': 'agent'},
|
||||
'weknora-api': {'app-type': 'chat'},
|
||||
}
|
||||
_INT_TIMEOUT = {'deerflow-api', 'n8n-service-api', 'dify-service-api'}
|
||||
_TIMEOUT_DEFAULT = {
|
||||
'dify-service-api': 30,
|
||||
'coze-api': 120,
|
||||
'dashscope-app-api': 120,
|
||||
'n8n-service-api': 120,
|
||||
'deerflow-api': 300,
|
||||
'tbox-app-api': 120,
|
||||
'weknora-api': 120,
|
||||
'local-agent': 300,
|
||||
}
|
||||
_ALIASES = {
|
||||
# legacy runner: (UI/hyphen key, alternate key, default when both missing)
|
||||
'coze-api': [('auto-save-history', 'auto_save_history')],
|
||||
'dashscope-app-api': [('references-quote', 'references_quote')],
|
||||
'langflow-api': [('input-type', 'input_type'), ('output-type', 'output_type')],
|
||||
}
|
||||
_ASSET_RUNNERS = {'dify-service-api', 'coze-api', 'dashscope-app-api', 'n8n-service-api', 'langflow-api'}
|
||||
_REMOVE_THINK_RUNNERS = {'local-agent', 'dify-service-api', 'coze-api', 'dashscope-app-api', 'tbox-app-api'}
|
||||
_REASONING_LEVELS = {'provider_default', 'disabled', 'enabled', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'}
|
||||
@@ -240,38 +288,6 @@ def _attachments(value):
|
||||
)
|
||||
|
||||
|
||||
def _validate_preferences(result, preferences, name):
|
||||
if preferences is None:
|
||||
return
|
||||
if type(preferences) is not dict or not _strict_json(preferences):
|
||||
_block(result, 'invalid_extension_preferences', 'extensions_preferences')
|
||||
return
|
||||
for field in (
|
||||
'enable_all_plugins',
|
||||
'enable_all_mcp_servers',
|
||||
'enable_all_skills',
|
||||
'mcp_resource_agent_read_enabled',
|
||||
):
|
||||
if field in preferences and type(preferences[field]) is not bool:
|
||||
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
|
||||
for field in ('mcp_servers', 'skills'):
|
||||
if field in preferences and not _names(preferences[field]):
|
||||
_block(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
|
||||
if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']):
|
||||
_block(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources')
|
||||
plugins = preferences.get('plugins', [])
|
||||
if type(plugins) is not list or not all(
|
||||
type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name')) for item in plugins
|
||||
):
|
||||
_block(result, 'invalid_extension_preferences', 'extensions_preferences.plugins')
|
||||
elif preferences.get('enable_all_plugins', True) is False and not any(
|
||||
item['author'] == 'langbot-team' and item['name'] == name for item in plugins
|
||||
):
|
||||
# The immutable result contract contains no extension-preference patch.
|
||||
# Require explicit binding instead of silently flipping allow-all.
|
||||
_block(result, 'extensions.runner_excluded', 'extensions_preferences.plugins')
|
||||
|
||||
|
||||
def _warn(result, code, field):
|
||||
diagnostic = {'code': code, 'field': field}
|
||||
if diagnostic not in result['warnings']:
|
||||
@@ -321,42 +337,138 @@ def _valid_prompt(prompt):
|
||||
return type(prompt) is list and all(_message_shape(item, fields, ('role',)) for item in prompt)
|
||||
|
||||
|
||||
def _validate_local(result, section):
|
||||
_TRUE = {'true', '1', 'yes', 'on'}
|
||||
_FALSE = {'false', '0', 'no', 'off'}
|
||||
_CUSTOM_RUNNER_NAME = re.compile(r'[A-Za-z0-9_.\-]{1,64}')
|
||||
_DEFAULT_BOX_TEMPLATE = '{launcher_type}_{launcher_id}'
|
||||
_HEADER_NAME = re.compile(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+")
|
||||
|
||||
|
||||
def _coerce(kind, value):
|
||||
"""Best-effort, lossless coercion. Return (ok, value)."""
|
||||
if kind == 'bool':
|
||||
if type(value) is bool:
|
||||
return True, value
|
||||
if type(value) is int and value in (0, 1):
|
||||
return True, bool(value)
|
||||
if type(value) is str and value.strip().lower() in _TRUE:
|
||||
return True, True
|
||||
if type(value) is str and value.strip().lower() in _FALSE:
|
||||
return True, False
|
||||
return False, None
|
||||
if kind in ('int', 'number'):
|
||||
if type(value) is int:
|
||||
return True, value
|
||||
number = value
|
||||
if type(value) is str and value.strip():
|
||||
text = value.strip()
|
||||
try:
|
||||
return True, int(text)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
number = float(text)
|
||||
except ValueError:
|
||||
return False, None
|
||||
if type(number) is not float or not math.isfinite(number):
|
||||
return False, None
|
||||
if number.is_integer():
|
||||
return True, int(number)
|
||||
return (kind == 'number'), (number if kind == 'number' else None)
|
||||
if kind == 'names':
|
||||
if type(value) is str:
|
||||
return True, ([value.strip()] if value.strip() else [])
|
||||
if type(value) is list:
|
||||
return True, [v.strip() for v in value if type(v) is str and v.strip()]
|
||||
return False, None
|
||||
if kind == 'attachments':
|
||||
if type(value) is list:
|
||||
return True, [v for v in value if type(v) is dict and ('enabled' not in v or type(v['enabled']) is bool)]
|
||||
return False, None
|
||||
if type(value) is str:
|
||||
return True, value
|
||||
if type(value) in (int, float):
|
||||
return True, str(value)
|
||||
return False, None
|
||||
|
||||
|
||||
def _kind(legacy, field):
|
||||
if field in _BOOLEANS:
|
||||
return 'bool'
|
||||
if field in _INTEGERS:
|
||||
return 'int'
|
||||
if field == 'timeout':
|
||||
return 'int' if legacy in _INT_TIMEOUT else 'number'
|
||||
if field in _NAME_LISTS:
|
||||
return 'names'
|
||||
if field == 'mcp-resources':
|
||||
return 'attachments'
|
||||
return 'str'
|
||||
|
||||
|
||||
def _normalize_local(result, section):
|
||||
"""Repair the LocalAgent model/prompt/template; never block on the model."""
|
||||
prefix = 'ai.local-agent'
|
||||
model = section.get('model')
|
||||
if type(model) is dict:
|
||||
if set(model) - {'primary', 'fallbacks', 'reasoning'}:
|
||||
_block(result, 'unknown_field', f'{prefix}.model')
|
||||
if type(model.get('primary')) is not str or not model['primary'] or not _names(model.get('fallbacks', [])):
|
||||
_block(result, 'invalid_type', f'{prefix}.model')
|
||||
_warn(result, 'migration.unknown_field_dropped', f'{prefix}.model')
|
||||
primary = model.get('primary')
|
||||
if type(primary) is not str:
|
||||
_warn(result, 'migration.missing_default_filled', f'{prefix}.model')
|
||||
primary = ''
|
||||
raw_fallbacks = model.get('fallbacks', [])
|
||||
ok, fallbacks = _coerce('names', raw_fallbacks)
|
||||
if not ok or fallbacks != raw_fallbacks:
|
||||
_warn(result, 'migration.value_coerced', f'{prefix}.model.fallbacks')
|
||||
reasoning = model.get('reasoning', {})
|
||||
if type(reasoning) is not dict or not all(type(v) is str for v in reasoning.values()):
|
||||
_block(result, 'invalid_type', f'{prefix}.model.reasoning')
|
||||
elif any(value not in _REASONING_LEVELS for value in reasoning.values()):
|
||||
_block(result, 'local.reasoning_value', f'{prefix}.model')
|
||||
elif reasoning:
|
||||
if type(reasoning) is not dict:
|
||||
_warn(result, 'local.reasoning_dropped', f'{prefix}.model.reasoning')
|
||||
reasoning = {}
|
||||
valid = {k: v for k, v in reasoning.items() if type(v) is str and v in _REASONING_LEVELS}
|
||||
if len(valid) != len(reasoning):
|
||||
_warn(result, 'local.reasoning_dropped', f'{prefix}.model.reasoning')
|
||||
if valid:
|
||||
_warn(result, 'local.model_reasoning', f'{prefix}.model.reasoning')
|
||||
elif type(model) is not str or not model:
|
||||
_block(result, 'invalid_type', f'{prefix}.model')
|
||||
if not _valid_prompt(section.get('prompt')):
|
||||
_block(result, 'local.prompt_shape', f'{prefix}.prompt')
|
||||
section['model'] = {'primary': primary, 'fallbacks': fallbacks or [], 'reasoning': valid}
|
||||
elif type(model) is str and model.strip():
|
||||
section['model'] = {'primary': model, 'fallbacks': [], 'reasoning': {}}
|
||||
else:
|
||||
# No usable model: migrate anyway; the user picks one after migration.
|
||||
_warn(result, 'migration.missing_default_filled', f'{prefix}.model')
|
||||
section['model'] = {'primary': '', 'fallbacks': [], 'reasoning': {}}
|
||||
prompt = section.get('prompt')
|
||||
if prompt is None:
|
||||
section['prompt'] = copy.deepcopy(_LOCAL_PROMPT)
|
||||
_warn(result, 'migration.missing_default_filled', f'{prefix}.prompt')
|
||||
elif type(prompt) is str:
|
||||
section['prompt'] = [{'role': 'system', 'content': prompt}]
|
||||
_warn(result, 'local.prompt_normalized', f'{prefix}.prompt')
|
||||
elif not _valid_prompt(prompt):
|
||||
kept = []
|
||||
if type(prompt) is list:
|
||||
for item in prompt:
|
||||
if type(item) is dict and type(item.get('role')) is str and type(item.get('content')) is str:
|
||||
kept.append({'role': item['role'], 'content': item['content']})
|
||||
section['prompt'] = kept if kept and _valid_prompt(kept) else copy.deepcopy(_LOCAL_PROMPT)
|
||||
_warn(result, 'local.prompt_normalized', f'{prefix}.prompt')
|
||||
_warn(result, 'local.context_defaults', f'{prefix}.max-round')
|
||||
_warn(result, 'local.serial_tools_preserved', f'{prefix}.tools')
|
||||
_warn(result, 'local.retrieval_defaults', f'{prefix}.knowledge-bases')
|
||||
template = section.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
if type(template) is not str or not template.strip():
|
||||
_block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
|
||||
else:
|
||||
template = section.get('box-session-id-template', _DEFAULT_BOX_TEMPLATE)
|
||||
valid_template = type(template) is str and bool(template.strip())
|
||||
if valid_template:
|
||||
try:
|
||||
fields = list(Formatter().parse(template))
|
||||
if any(
|
||||
valid_template = not any(
|
||||
name is not None
|
||||
and (not name or name.isdecimal() or re.search(r'[.\[\]{}]', name) or spec or conversion)
|
||||
for _, name, spec, conversion in fields
|
||||
):
|
||||
raise ValueError('Only named interpolation variables are supported')
|
||||
for _, name, spec, conversion in Formatter().parse(template)
|
||||
)
|
||||
except ValueError:
|
||||
_block(result, 'local.box_template_invalid', f'{prefix}.box-session-id-template')
|
||||
valid_template = False
|
||||
if not valid_template:
|
||||
section['box-session-id-template'] = _DEFAULT_BOX_TEMPLATE
|
||||
_warn(result, 'local.box_template_defaulted', f'{prefix}.box-session-id-template')
|
||||
_warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template')
|
||||
|
||||
|
||||
@@ -380,36 +492,28 @@ def _parse_tweaks(raw):
|
||||
return decoded
|
||||
|
||||
|
||||
def _validate_external(result, legacy, section):
|
||||
def _default_for(legacy, field):
|
||||
for source in (_FILL.get(legacy, {}), _DEFAULTS[legacy]):
|
||||
if field in source:
|
||||
return True, copy.deepcopy(source[field])
|
||||
return False, None
|
||||
|
||||
|
||||
def _normalize_external(result, legacy, section):
|
||||
prefix = f'ai.{legacy}'
|
||||
app_types = {
|
||||
'dify-service-api': ('chat', 'agent', 'workflow', 'chatflow'),
|
||||
'dashscope-app-api': ('agent', 'workflow'),
|
||||
'weknora-api': ('chat', 'agent'),
|
||||
}
|
||||
required_strings = {
|
||||
'dify-service-api': ('api-key', 'base-url'),
|
||||
'coze-api': ('api-key', 'bot-id'),
|
||||
'dashscope-app-api': ('api-key', 'app-id'),
|
||||
'n8n-service-api': ('webhook-url',),
|
||||
'langflow-api': ('base-url', 'api-key', 'flow-id'),
|
||||
'tbox-app-api': ('api-key', 'app-id'),
|
||||
'weknora-api': ('api-key', 'base-url'),
|
||||
}
|
||||
for field in required_strings.get(legacy, ()):
|
||||
for field in _CRITICAL[legacy]:
|
||||
value = section.get(field)
|
||||
if type(value) is str and (not value or (legacy == 'weknora-api' and not value.strip())):
|
||||
if type(value) is str and not value.strip():
|
||||
_block(result, 'invalid_value', f'{prefix}.{field}')
|
||||
if legacy in app_types and 'app-type' in section and section['app-type'] not in app_types[legacy]:
|
||||
_block(result, 'invalid_value', f'{prefix}.app-type')
|
||||
if legacy in app_types and section.get('app-type') not in app_types[legacy]:
|
||||
section['app-type'] = _FILL[legacy]['app-type']
|
||||
_warn(result, 'migration.invalid_value_defaulted', f'{prefix}.app-type')
|
||||
if legacy == 'coze-api':
|
||||
actual, alias = section.get('auto_save_history'), section.get('auto-save-history')
|
||||
if type(actual) is bool and type(alias) is bool and actual != alias:
|
||||
_block(result, 'coze.history_alias', f'{prefix}.auto_save_history')
|
||||
if 'auto_save_history' in section or 'auto-save-history' in section:
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.auto_save_history')
|
||||
if actual is None and alias is None:
|
||||
_warn(result, 'migration.null_default', f'{prefix}.auto_save_history')
|
||||
base = section.get('api-base')
|
||||
try:
|
||||
parsed = urlsplit(base) if type(base) is str else None
|
||||
@@ -428,48 +532,35 @@ def _validate_external(result, legacy, section):
|
||||
except ValueError:
|
||||
_block(result, 'coze.custom_endpoint', f'{prefix}.api-base')
|
||||
_warn(result, 'coze.persistent_history', prefix)
|
||||
elif legacy == 'dashscope-app-api':
|
||||
if 'references-quote' in section:
|
||||
if 'references_quote' in section and section['references_quote'] != section['references-quote']:
|
||||
_block(result, 'dashscope.references_alias', f'{prefix}.references_quote')
|
||||
else:
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.references-quote')
|
||||
elif legacy == 'langflow-api':
|
||||
for axis in ('input', 'output'):
|
||||
actual, ui = f'{axis}_type', f'{axis}-type'
|
||||
if ui in section and section[ui] != section.get(actual, 'chat'):
|
||||
_block(result, 'langflow.io_alias', f'{prefix}.{actual}')
|
||||
if actual in section:
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.{actual}')
|
||||
try:
|
||||
_parse_tweaks(section.get('tweaks'))
|
||||
except (ValueError, RecursionError):
|
||||
_block(result, 'langflow.invalid_tweaks', f'{prefix}.tweaks')
|
||||
if section.get('tweaks') is None or (
|
||||
type(section.get('tweaks')) is str
|
||||
and (not section['tweaks'].strip() or section['tweaks'].strip() == 'null')
|
||||
):
|
||||
raw = section.get('tweaks')
|
||||
if raw is None or (type(raw) is str and (not raw.strip() or raw.strip() == 'null')):
|
||||
_warn(result, 'langflow.tweaks_default', f'{prefix}.tweaks')
|
||||
try:
|
||||
section['tweaks'] = copy.deepcopy(_parse_tweaks(raw))
|
||||
except (ValueError, RecursionError):
|
||||
section['tweaks'] = {}
|
||||
_warn(result, 'langflow.tweaks_invalid_defaulted', f'{prefix}.tweaks')
|
||||
_warn(result, 'langflow.persistent_history', prefix)
|
||||
elif legacy == 'dify-service-api':
|
||||
_warn(result, 'dify.timeout_default', f'{prefix}.timeout')
|
||||
elif legacy == 'n8n-service-api':
|
||||
mode = section.get('auth-type', 'none')
|
||||
auth_fields = {
|
||||
'none': (),
|
||||
'basic': ('basic-username', 'basic-password'),
|
||||
'jwt': ('jwt-secret',),
|
||||
'header': ('header-name', 'header-value'),
|
||||
}
|
||||
if type(mode) is not str or mode not in auth_fields:
|
||||
_block(result, 'invalid_value', f'{prefix}.auth-type')
|
||||
else:
|
||||
for field in auth_fields[mode]:
|
||||
if field not in section:
|
||||
_block(result, 'missing_field', f'{prefix}.{field}')
|
||||
response = section.get('response-handling', 'reply')
|
||||
if response not in ('reply', 'ignore'):
|
||||
_block(result, 'invalid_value', f'{prefix}.response-handling')
|
||||
mode = section.get('auth-type', 'none')
|
||||
if mode not in auth_fields:
|
||||
mode = section['auth-type'] = 'none'
|
||||
_warn(result, 'migration.invalid_value_defaulted', f'{prefix}.auth-type')
|
||||
for field in auth_fields[mode]:
|
||||
if field not in section:
|
||||
_block(result, 'missing_field', f'{prefix}.{field}')
|
||||
if section.get('response-handling', 'reply') not in ('reply', 'ignore'):
|
||||
section['response-handling'] = 'reply'
|
||||
_warn(result, 'migration.invalid_value_defaulted', f'{prefix}.response-handling')
|
||||
if mode == 'basic':
|
||||
for field in ('basic-username', 'basic-password'):
|
||||
value = section.get(field)
|
||||
@@ -481,75 +572,133 @@ def _validate_external(result, legacy, section):
|
||||
except (UnicodeEncodeError, ValueError):
|
||||
_block(result, 'n8n.basic_encoding', f'{prefix}.{field}')
|
||||
if mode == 'header' and type(section.get('header-name')) is str:
|
||||
if not re.fullmatch(r"[!#$%&'*+.^_`|~0-9A-Za-z-]+", section['header-name']):
|
||||
if not _HEADER_NAME.fullmatch(section['header-name']):
|
||||
_block(result, 'n8n.header_name', f'{prefix}.header-name')
|
||||
_warn(result, 'n8n.session_ids_reset', prefix)
|
||||
elif legacy == 'weknora-api':
|
||||
knowledge = section.get('knowledge-base-ids')
|
||||
if type(knowledge) is list and any(type(v) is str and not v.strip() for v in knowledge):
|
||||
_block(result, 'invalid_value', f'{prefix}.knowledge-base-ids')
|
||||
if legacy in ('coze-api', 'n8n-service-api', 'weknora-api'):
|
||||
timeout = section.get('timeout', 120)
|
||||
if type(timeout) in (int, float) and timeout <= 0:
|
||||
_block(result, 'invalid_value', f'{prefix}.timeout')
|
||||
|
||||
|
||||
def _validate_section(result, legacy, section):
|
||||
prefix = f'ai.{legacy}'
|
||||
if set(section) - _FIELDS[legacy]:
|
||||
_block(result, 'unknown_field', prefix)
|
||||
for field in _REQUIRED[legacy]:
|
||||
if field not in section:
|
||||
_block(result, 'missing_field', f'{prefix}.{field}')
|
||||
# Iterate only known keys, never put an arbitrary saved key in diagnostics.
|
||||
for field in sorted(_FIELDS[legacy] & section.keys()):
|
||||
value = section[field]
|
||||
if field in ('model', 'prompt', 'tweaks') or (legacy == 'dify-service-api' and field == 'timeout'):
|
||||
continue
|
||||
if value is None and (
|
||||
(legacy == 'coze-api' and field in ('auto_save_history', 'auto-save-history'))
|
||||
or (legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids'))
|
||||
):
|
||||
continue
|
||||
if field in _BOOLEANS:
|
||||
valid = type(value) is bool
|
||||
elif field in _INTEGERS or (field == 'timeout' and legacy == 'deerflow-api'):
|
||||
valid = type(value) is int
|
||||
elif field == 'timeout':
|
||||
valid = type(value) in (int, float)
|
||||
elif field in _NAME_LISTS:
|
||||
valid = _names(value)
|
||||
elif field == 'mcp-resources':
|
||||
valid = _attachments(value)
|
||||
else:
|
||||
valid = type(value) is str
|
||||
if not valid:
|
||||
_block(result, 'invalid_type', f'{prefix}.{field}')
|
||||
if legacy == 'local-agent':
|
||||
_validate_local(result, section)
|
||||
else:
|
||||
_validate_external(result, legacy, section)
|
||||
if legacy == 'deerflow-api':
|
||||
elif legacy == 'deerflow-api':
|
||||
base = section.get('api-base')
|
||||
if type(base) is str and not base.strip().startswith(('http://', 'https://')):
|
||||
_block(result, 'invalid_value', f'{prefix}.api-base')
|
||||
|
||||
|
||||
def _validate_output(result, config, legacy):
|
||||
def _normalize_section(result, legacy, section):
|
||||
"""Return a repaired copy of a legacy runner section (value-free diagnostics)."""
|
||||
prefix = f'ai.{legacy}'
|
||||
critical = _CRITICAL[legacy]
|
||||
if set(section) - _FIELDS[legacy]:
|
||||
_warn(result, 'migration.unknown_field_dropped', prefix)
|
||||
# Iterate only known keys, never put an arbitrary saved key in diagnostics.
|
||||
known = {k: copy.deepcopy(section[k]) for k in sorted(_FIELDS[legacy] & section.keys())}
|
||||
for preferred, other in _ALIASES.get(legacy, []):
|
||||
# The UI (hyphen) key wins; dashscope's plugin schema keeps the underscore.
|
||||
output = 'references_quote' if legacy == 'dashscope-app-api' else preferred
|
||||
drop = other if output == preferred else preferred
|
||||
if drop not in known:
|
||||
continue
|
||||
a, b = known.get(preferred), known.get(other)
|
||||
if a is not None and b is not None and a != b:
|
||||
_warn(result, 'migration.alias_conflict_resolved', f'{prefix}.{drop}')
|
||||
else:
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.{drop}')
|
||||
known.pop(drop)
|
||||
known[output] = a if a is not None else b
|
||||
if legacy == 'coze-api' and known.get('auto-save-history') is None:
|
||||
known['auto-save-history'] = True
|
||||
_warn(result, 'migration.null_default', f'{prefix}.auto_save_history')
|
||||
for field in _REQUIRED[legacy]:
|
||||
if field not in known and field in critical:
|
||||
_block(result, 'missing_field', f'{prefix}.{field}')
|
||||
for field in sorted(known):
|
||||
value = known[field]
|
||||
if field in ('model', 'prompt', 'tweaks'):
|
||||
continue
|
||||
if value is None and legacy == 'weknora-api' and field in ('agent-id', 'knowledge-base-ids'):
|
||||
continue
|
||||
ok, coerced = (False, None) if value is None else _coerce(_kind(legacy, field), value)
|
||||
if ok and field == 'timeout' and coerced <= 0:
|
||||
ok = False
|
||||
if ok:
|
||||
if coerced != value or type(coerced) is not type(value):
|
||||
_warn(result, 'migration.value_coerced', f'{prefix}.{field}')
|
||||
known[field] = coerced
|
||||
elif field in critical:
|
||||
_block(result, 'invalid_type', f'{prefix}.{field}')
|
||||
else:
|
||||
known.pop(field)
|
||||
_warn(result, 'migration.invalid_value_defaulted', f'{prefix}.{field}')
|
||||
for field in _REQUIRED[legacy]:
|
||||
if field not in known and field not in critical:
|
||||
has_default, default = _default_for(legacy, field)
|
||||
if has_default:
|
||||
known[field] = default
|
||||
if field != 'prompt':
|
||||
_warn(result, 'migration.missing_default_filled', f'{prefix}.{field}')
|
||||
if legacy == 'local-agent':
|
||||
_normalize_local(result, known)
|
||||
else:
|
||||
_normalize_external(result, legacy, known)
|
||||
return known
|
||||
|
||||
|
||||
def _normalize_preferences(result, preferences, name):
|
||||
"""Ignore malformed preference fields; return a patch only to bind the runner."""
|
||||
if preferences is None:
|
||||
return None
|
||||
if type(preferences) is not dict or not _strict_json(preferences):
|
||||
_warn(result, 'invalid_extension_preferences', 'extensions_preferences')
|
||||
return None
|
||||
for field in (
|
||||
'enable_all_plugins',
|
||||
'enable_all_mcp_servers',
|
||||
'enable_all_skills',
|
||||
'mcp_resource_agent_read_enabled',
|
||||
):
|
||||
if field in preferences and type(preferences[field]) is not bool:
|
||||
_warn(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
|
||||
for field in ('mcp_servers', 'skills'):
|
||||
if field in preferences and not _names(preferences[field]):
|
||||
_warn(result, 'invalid_extension_preferences', f'extensions_preferences.{field}')
|
||||
if 'mcp_resources' in preferences and not _attachments(preferences['mcp_resources']):
|
||||
_warn(result, 'invalid_extension_preferences', 'extensions_preferences.mcp_resources')
|
||||
plugins = preferences.get('plugins', [])
|
||||
|
||||
def valid(item):
|
||||
return type(item) is dict and all(type(item.get(k)) is str and item[k] for k in ('author', 'name'))
|
||||
|
||||
kept = [item for item in plugins if valid(item)] if type(plugins) is list else []
|
||||
if type(plugins) is not list or len(kept) != len(plugins):
|
||||
_warn(result, 'invalid_extension_preferences', 'extensions_preferences.plugins')
|
||||
if preferences.get('enable_all_plugins', True) is False and not any(
|
||||
item['author'] == 'langbot-team' and item['name'] == name for item in kept
|
||||
):
|
||||
# Bind exactly the migrated runner plugin; never flip allow-all.
|
||||
patched = copy.deepcopy(preferences)
|
||||
patched['plugins'] = [*copy.deepcopy(kept), {'author': 'langbot-team', 'name': name}]
|
||||
_warn(result, 'extensions.runner_added', 'extensions_preferences.plugins')
|
||||
return patched
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_output(result, config):
|
||||
output = config.get('output', {})
|
||||
if type(output) is not dict:
|
||||
_block(result, 'invalid_type', 'output')
|
||||
return
|
||||
_warn(result, 'migration.invalid_value_defaulted', 'output')
|
||||
return False
|
||||
misc = output.get('misc', {})
|
||||
if type(misc) is not dict:
|
||||
_block(result, 'invalid_type', 'output.misc')
|
||||
return
|
||||
_warn(result, 'migration.invalid_value_defaulted', 'output.misc')
|
||||
return False
|
||||
value = misc.get('remove-think', False)
|
||||
ok, coerced = _coerce('bool', value)
|
||||
if not ok:
|
||||
_warn(result, 'migration.invalid_value_defaulted', 'output.misc.remove-think')
|
||||
return False
|
||||
if type(value) is not bool:
|
||||
_block(result, 'invalid_type', 'output.misc.remove-think')
|
||||
_warn(result, 'migration.value_coerced', 'output.misc.remove-think')
|
||||
return coerced
|
||||
|
||||
|
||||
def _assemble(result, legacy, section, config, preferences):
|
||||
def _assemble(result, legacy, section, remove_think, preferences):
|
||||
selected = {**copy.deepcopy(_DEFAULTS[legacy]), **copy.deepcopy(section)}
|
||||
prefix = f'ai.{legacy}'
|
||||
if legacy in _IDENTITY_SOURCES:
|
||||
@@ -557,18 +706,18 @@ def _assemble(result, legacy, section, config, preferences):
|
||||
if legacy == 'local-agent':
|
||||
selected.pop('max-round', None)
|
||||
singular = selected.pop('knowledge-base', '')
|
||||
if not selected['knowledge-bases'] and singular and singular != '__none__':
|
||||
if not selected['knowledge-bases'] and type(singular) is str and singular and singular != '__none__':
|
||||
selected['knowledge-bases'] = [singular]
|
||||
_warn(result, 'migration.alias_repaired', f'{prefix}.knowledge-base')
|
||||
model = selected['model']
|
||||
selected['model'] = (
|
||||
{'primary': model, 'fallbacks': [], 'reasoning': {}}
|
||||
if type(model) is str
|
||||
else {'fallbacks': [], 'reasoning': {}, **model}
|
||||
preferences = preferences if type(preferences) is dict else {}
|
||||
resources = preferences.get('mcp_resources', [])
|
||||
read = preferences.get('mcp_resource_agent_read_enabled', True)
|
||||
selected['mcp-resources'] = copy.deepcopy(
|
||||
section.get('mcp-resources', resources if _attachments(resources) else [])
|
||||
)
|
||||
selected['mcp-resource-agent-read-enabled'] = section.get(
|
||||
'mcp-resource-agent-read-enabled', read if type(read) is bool else True
|
||||
)
|
||||
preferences = preferences or {}
|
||||
for field, default in (('mcp-resources', []), ('mcp-resource-agent-read-enabled', True)):
|
||||
selected[field] = copy.deepcopy(section.get(field, preferences.get(field.replace('-', '_'), default)))
|
||||
else:
|
||||
selected.update(
|
||||
{
|
||||
@@ -582,26 +731,15 @@ def _assemble(result, legacy, section, config, preferences):
|
||||
if legacy in _ASSET_RUNNERS:
|
||||
selected['langbot-assets-enabled'] = False
|
||||
if legacy in _REMOVE_THINK_RUNNERS:
|
||||
selected['remove-think'] = config.get('output', {}).get('misc', {}).get('remove-think', False)
|
||||
selected['remove-think'] = remove_think
|
||||
_warn(result, 'migration.output_policy_copied', 'output.misc.remove-think')
|
||||
if legacy == 'dify-service-api':
|
||||
selected['timeout'] = 30
|
||||
elif legacy == 'coze-api':
|
||||
actual = selected.pop('auto_save_history', None)
|
||||
selected['auto-save-history'] = actual if type(actual) is bool else section.get('auto-save-history')
|
||||
if selected['auto-save-history'] is None:
|
||||
selected['auto-save-history'] = True
|
||||
elif legacy == 'dashscope-app-api':
|
||||
if 'references-quote' in selected:
|
||||
selected['references_quote'] = selected.pop('references-quote')
|
||||
elif legacy == 'langflow-api':
|
||||
for axis in ('input', 'output'):
|
||||
selected[f'{axis}-type'] = selected.pop(f'{axis}_type', selected[f'{axis}-type'])
|
||||
selected['tweaks'] = copy.deepcopy(_parse_tweaks(section.get('tweaks')))
|
||||
elif legacy == 'weknora-api':
|
||||
selected.setdefault(
|
||||
'agent-id', 'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning'
|
||||
)
|
||||
if selected.get('agent-id') is None:
|
||||
selected['agent-id'] = (
|
||||
'builtin-quick-answer' if selected['app-type'] == 'chat' else 'builtin-smart-reasoning'
|
||||
)
|
||||
if selected['knowledge-base-ids'] is None:
|
||||
selected['knowledge-base-ids'] = []
|
||||
_warn(result, 'weknora.session_title_changed', prefix)
|
||||
@@ -616,6 +754,7 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict:
|
||||
'target_runner_id': None,
|
||||
'target_plugin': None,
|
||||
'config': None,
|
||||
'extensions_preferences': None,
|
||||
'changed_paths': [],
|
||||
'blockers': [],
|
||||
'warnings': [],
|
||||
@@ -630,26 +769,43 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict:
|
||||
selection = ai.get('runner', {})
|
||||
if type(selection) is not dict:
|
||||
return _block(result, 'invalid_type', 'ai.runner')
|
||||
if 'id' in selection and 'runner' in selection:
|
||||
return _block(result, 'mixed_runner_selection', 'ai.runner')
|
||||
legacy = selection.get('runner')
|
||||
known_legacy = type(legacy) is str and legacy in _TARGETS
|
||||
if 'id' in selection:
|
||||
current = selection['id']
|
||||
# A blank runner id paired with no legacy runner section means the saved
|
||||
# pipeline simply has no runner selected. There is nothing to migrate and
|
||||
# nothing to convert, so report not_legacy instead of failing the whole
|
||||
# batch with a malformed-id blocker.
|
||||
if type(current) is str and not current.strip() and not any(legacy in ai for legacy in _TARGETS):
|
||||
valid_id = type(current) is str and bool(re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current))
|
||||
if 'runner' in selection:
|
||||
# Prefer a valid current id; otherwise migrate the legacy selection.
|
||||
if valid_id:
|
||||
_warn(result, 'migration.mixed_runner_selection', 'ai.runner')
|
||||
result['state'] = 'already_current'
|
||||
return result
|
||||
if not known_legacy:
|
||||
return _block(result, 'mixed_runner_selection', 'ai.runner')
|
||||
_warn(result, 'migration.mixed_runner_selection', 'ai.runner')
|
||||
else:
|
||||
# A blank runner id paired with no legacy runner section means the
|
||||
# saved pipeline simply has no runner selected: nothing to migrate.
|
||||
if type(current) is str and not current.strip() and not any(name in ai for name in _TARGETS):
|
||||
return result
|
||||
if not valid_id:
|
||||
return _block(result, 'invalid_runner_id', 'ai.runner.id')
|
||||
result['state'] = 'already_current'
|
||||
return result
|
||||
if type(current) is not str or not re.fullmatch(r'plugin:[^/\s]+/[^/\s]+/[^/\s]+', current):
|
||||
return _block(result, 'invalid_runner_id', 'ai.runner.id')
|
||||
result['state'] = 'already_current'
|
||||
return result
|
||||
if 'runner' not in selection:
|
||||
return result
|
||||
legacy = selection['runner']
|
||||
if type(legacy) is not str:
|
||||
return _block(result, 'invalid_type', 'ai.runner.runner')
|
||||
if legacy not in _TARGETS:
|
||||
if not known_legacy:
|
||||
if not legacy.strip():
|
||||
return result
|
||||
# 4.10 allowed in-tree custom runners (@runner_class). They have no
|
||||
# plugin equivalent; report them without failing other pipelines.
|
||||
result.update(
|
||||
state='unsupported_runner',
|
||||
legacy_runner=legacy if _CUSTOM_RUNNER_NAME.fullmatch(legacy) else None,
|
||||
)
|
||||
_warn(result, 'legacy.custom_runner_unsupported', 'ai.runner.runner')
|
||||
return result
|
||||
name, version = _TARGETS[legacy]
|
||||
target = f'plugin:langbot-team/{name}/default'
|
||||
@@ -658,27 +814,27 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict:
|
||||
target_runner_id=target,
|
||||
target_plugin={'author': 'langbot-team', 'name': name, 'version': version},
|
||||
)
|
||||
if 'runner_config' in ai:
|
||||
if type(ai['runner_config']) is not dict:
|
||||
return _block(result, 'invalid_type', 'ai.runner_config')
|
||||
if ai['runner_config']:
|
||||
return _block(result, 'mixed_runner_config', 'ai.runner_config')
|
||||
if 'expire-time' in selection:
|
||||
expiry = selection['expire-time']
|
||||
if type(expiry) is not int or expiry < 0:
|
||||
_block(result, 'invalid_expiry', 'ai.runner.expire-time')
|
||||
if 'runner_config' in ai and (type(ai['runner_config']) is not dict or ai['runner_config']):
|
||||
_warn(result, 'migration.runner_config_replaced', 'ai.runner_config')
|
||||
expiry = selection.get('expire-time', 0)
|
||||
ok, expiry_value = _coerce('int', expiry)
|
||||
if not ok or expiry_value < 0:
|
||||
expiry_value = 0
|
||||
_warn(result, 'migration.invalid_value_defaulted', 'ai.runner.expire-time')
|
||||
elif type(expiry) is not int:
|
||||
_warn(result, 'migration.value_coerced', 'ai.runner.expire-time')
|
||||
if set(ai) - set(_TARGETS) - {'runner', 'runner_config'}:
|
||||
_block(result, 'unknown_field', 'ai')
|
||||
if set(selection) - {'runner', 'expire-time'}:
|
||||
_block(result, 'unknown_field', 'ai.runner')
|
||||
_warn(result, 'migration.unknown_section_archived', 'ai')
|
||||
if set(selection) - {'runner', 'expire-time', 'id'}:
|
||||
_warn(result, 'migration.unknown_field_dropped', 'ai.runner')
|
||||
if legacy not in ai:
|
||||
return _block(result, 'missing_field', f'ai.{legacy}')
|
||||
section = ai[legacy]
|
||||
if type(section) is not dict:
|
||||
return _block(result, 'invalid_type', f'ai.{legacy}')
|
||||
_validate_section(result, legacy, section)
|
||||
_validate_preferences(result, extensions_preferences, name)
|
||||
_validate_output(result, config, legacy)
|
||||
section = _normalize_section(result, legacy, section)
|
||||
patched_preferences = _normalize_preferences(result, extensions_preferences, name)
|
||||
remove_think = _normalize_output(result, config)
|
||||
_warn(result, 'external.state_validation_required', 'ai.runner')
|
||||
_warn(result, 'migration.history_reset', 'ai.runner')
|
||||
_warn(result, 'migration.new_defaults', f'ai.{legacy}')
|
||||
@@ -689,22 +845,22 @@ def plan_legacy_pipeline(config, extensions_preferences=None) -> dict:
|
||||
_warn(result, 'migration.identity_preserved', f'ai.{legacy}')
|
||||
if result['blockers']:
|
||||
return result
|
||||
selected = _assemble(result, legacy, section, remove_think, extensions_preferences)
|
||||
candidate = copy.deepcopy(config)
|
||||
candidate['ai']['runner'].pop('runner')
|
||||
candidate['ai']['runner']['id'] = target
|
||||
selected = _assemble(result, legacy, section, config, extensions_preferences)
|
||||
candidate['ai']['runner_config'] = {target: selected}
|
||||
# The durable transaction stores the full source, including inactive legacy
|
||||
# credentials. The active configuration must be canonical so the guarded
|
||||
# editor never needs to interpret mixed legacy/current containers.
|
||||
archived_paths = []
|
||||
for old_runner in _TARGETS:
|
||||
if old_runner in candidate['ai']:
|
||||
candidate['ai'].pop(old_runner)
|
||||
archived_paths.append(f'ai.{old_runner}')
|
||||
# The durable snapshot stores the full source, including inactive legacy
|
||||
# credentials and unknown leftovers. The active configuration is canonical.
|
||||
candidate['ai'] = {
|
||||
'runner': {'id': target, 'expire-time': expiry_value},
|
||||
'runner_config': {target: selected},
|
||||
}
|
||||
archived_paths = [f'ai.{old}' for old in _TARGETS if old in ai]
|
||||
changed = ['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths]
|
||||
if patched_preferences is not None:
|
||||
changed.append('extensions_preferences.plugins')
|
||||
result.update(
|
||||
state='ready',
|
||||
config=candidate,
|
||||
changed_paths=['ai.runner.runner', 'ai.runner.id', 'ai.runner_config', *archived_paths],
|
||||
extensions_preferences=patched_preferences,
|
||||
changed_paths=changed,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -18,6 +18,103 @@ if typing.TYPE_CHECKING:
|
||||
from .pool import get_query_execution_context
|
||||
|
||||
|
||||
async def resolve_processor_name(
|
||||
ap: app.Application,
|
||||
workspace_uuid: str,
|
||||
processor_type: str,
|
||||
processor_id: str | None,
|
||||
runner_id: str = '',
|
||||
) -> str:
|
||||
"""Display name of the processor kind that handled a run.
|
||||
|
||||
Monitoring attributed every call to a Pipeline, so an Agent or a plugin event
|
||||
processor recorded "Unknown" for the entity that actually ran. Each kind is
|
||||
resolved from the table that owns it, falling back to the identity the
|
||||
binding carried.
|
||||
"""
|
||||
import sqlalchemy
|
||||
|
||||
from ..entity.persistence import agent as persistence_agent
|
||||
from ..entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
identifier = str(processor_id or '').strip()
|
||||
if identifier:
|
||||
try:
|
||||
model = persistence_pipeline.LegacyPipeline if processor_type == 'pipeline' else persistence_agent.Agent
|
||||
result = await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(model.name).where(
|
||||
sqlalchemy.and_(
|
||||
model.uuid == identifier,
|
||||
model.workspace_uuid == workspace_uuid,
|
||||
)
|
||||
)
|
||||
)
|
||||
name = str(result.scalar() or '').strip()
|
||||
if name:
|
||||
return name
|
||||
except Exception as e:
|
||||
ap.logger.warning(f'Failed to resolve monitoring processor name: {e}')
|
||||
# A deleted Agent or an unregistered plugin processor still names the
|
||||
# component that ran, never a bare "Unknown".
|
||||
return identifier or runner_id or processor_type
|
||||
|
||||
|
||||
async def prepare_monitoring_identity(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
*,
|
||||
processor_type: str,
|
||||
processor_id: str | None,
|
||||
runner_id: str = '',
|
||||
execution_record_id: str = '',
|
||||
) -> None:
|
||||
"""Pin the monitoring identity of a runner-owned execution.
|
||||
|
||||
A Pipeline pins these variables when it starts a query; a Runner started by
|
||||
an Agent or a plugin event processor has to pin them itself, otherwise every
|
||||
LLM/tool call it makes is attributed to "Unknown". An enclosing Pipeline
|
||||
already named the run, so its label is never overwritten.
|
||||
|
||||
``_monitoring_message_id`` is the execution record the calls belong to: the
|
||||
Pipeline's message, or the Agent run's id. Both are the id the executions
|
||||
list shows, which is what lets a call jump back to its record.
|
||||
"""
|
||||
try:
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not isinstance(variables, dict):
|
||||
return
|
||||
if variables.get('_monitoring_pipeline_name'):
|
||||
return
|
||||
|
||||
bot_name = 'WebChat'
|
||||
bot_uuid = str(getattr(query, 'bot_uuid', '') or '')
|
||||
workspace_uuid = str(getattr(query, 'workspace_uuid', '') or '')
|
||||
if bot_uuid and workspace_uuid:
|
||||
try:
|
||||
bot = await ap.bot_service.get_bot(
|
||||
workspace_uuid,
|
||||
bot_uuid,
|
||||
include_secret=False,
|
||||
)
|
||||
if bot:
|
||||
bot_name = bot.get('name', bot_name)
|
||||
except Exception as e:
|
||||
ap.logger.warning(f'Failed to resolve monitoring bot name: {e}')
|
||||
|
||||
variables['_monitoring_bot_name'] = bot_name
|
||||
variables['_monitoring_pipeline_name'] = await resolve_processor_name(
|
||||
ap,
|
||||
workspace_uuid,
|
||||
processor_type,
|
||||
processor_id,
|
||||
runner_id,
|
||||
)
|
||||
if execution_record_id:
|
||||
variables['_monitoring_message_id'] = execution_record_id
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to prepare monitoring identity: {e}')
|
||||
|
||||
|
||||
class MonitoringHelper:
|
||||
"""Helper class for monitoring operations"""
|
||||
|
||||
@@ -46,12 +143,9 @@ class MonitoringHelper:
|
||||
sender_name = query.message_event.sender.member_name
|
||||
|
||||
# Try to record message
|
||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||
# Let persistence externalize images before serializing the message chain.
|
||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||
chain_dump = query.message_chain.model_dump()
|
||||
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
|
||||
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
|
||||
message_content = json.dumps(chain_dump, ensure_ascii=False)
|
||||
message_content = query.message_chain.model_dump()
|
||||
else:
|
||||
message_content = str(query)
|
||||
|
||||
@@ -160,10 +254,10 @@ class MonitoringHelper:
|
||||
|
||||
# Extract response content from resp_message_chain
|
||||
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
||||
# Serialize the last response message chain
|
||||
# Preserve the structure until persistence has externalized images.
|
||||
last_resp = query.resp_message_chain[-1]
|
||||
if hasattr(last_resp, 'model_dump'):
|
||||
message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False)
|
||||
message_content = last_resp.model_dump()
|
||||
else:
|
||||
message_content = str(last_resp)
|
||||
elif hasattr(query, 'resp_messages') and query.resp_messages:
|
||||
@@ -171,10 +265,7 @@ class MonitoringHelper:
|
||||
if hasattr(last_resp, 'get_content_platform_message_chain'):
|
||||
chain = last_resp.get_content_platform_message_chain()
|
||||
if hasattr(chain, 'model_dump'):
|
||||
chain_dump = chain.model_dump()
|
||||
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
|
||||
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
|
||||
message_content = json.dumps(chain_dump, ensure_ascii=False)
|
||||
message_content = chain.model_dump()
|
||||
else:
|
||||
message_content = str(chain)
|
||||
else:
|
||||
@@ -199,6 +290,7 @@ class MonitoringHelper:
|
||||
user_name=sender_name,
|
||||
runner_name=runner_name,
|
||||
role='assistant',
|
||||
parent_message_id=(query.variables or {}).get('_monitoring_message_id'),
|
||||
)
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record query response: {e}')
|
||||
@@ -235,6 +327,7 @@ class MonitoringHelper:
|
||||
pipeline_id=pipeline_id,
|
||||
pipeline_name=pipeline_name,
|
||||
message_content=f'Error: {str(error)}',
|
||||
role='assistant',
|
||||
session_id=session_id,
|
||||
status='error',
|
||||
level='error',
|
||||
@@ -281,6 +374,7 @@ class MonitoringHelper:
|
||||
cost: float | None = None,
|
||||
error_message: str | None = None,
|
||||
message_id: str | None = None,
|
||||
details: dict | None = None,
|
||||
):
|
||||
"""Record LLM call"""
|
||||
try:
|
||||
@@ -301,6 +395,7 @@ class MonitoringHelper:
|
||||
cost=cost,
|
||||
error_message=error_message,
|
||||
message_id=message_id,
|
||||
details=details,
|
||||
)
|
||||
except Exception as e:
|
||||
ap.logger.error(f'Failed to record LLM call: {e}')
|
||||
|
||||
@@ -20,6 +20,8 @@ from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType
|
||||
from ..workspace.errors import WorkspaceError, WorkspaceInvariantError
|
||||
from .config_coercion import coerce_pipeline_config
|
||||
from .pool import get_query_execution_context
|
||||
from ..i18n import language_scope
|
||||
from ..i18n.workspace import get_workspace_language
|
||||
from ..agent.runner.config_resolver import RunnerConfigResolver
|
||||
from .extension_preferences import normalize_extension_preferences
|
||||
|
||||
@@ -194,6 +196,19 @@ class RuntimePipeline:
|
||||
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
|
||||
query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments
|
||||
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled
|
||||
# Telemetry: the bindings this lane runs with. None means "all enabled",
|
||||
# which the *_all flags make explicit in the chain record.
|
||||
try:
|
||||
from ..telemetry import resources as telemetry_resources
|
||||
|
||||
telemetry_resources.note_pipeline(
|
||||
plugins=self.bound_plugins,
|
||||
plugins_all=self.enable_all_plugins,
|
||||
mcp_servers=self.bound_mcp_servers,
|
||||
mcp_all=self.enable_all_mcp_servers,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Record query start for monitoring
|
||||
try:
|
||||
@@ -370,7 +385,43 @@ class RuntimePipeline:
|
||||
|
||||
i += 1
|
||||
|
||||
@staticmethod
|
||||
def _synthetic_origin(query: pipeline_query.Query) -> str:
|
||||
"""Origin of a lane that starts without an inbound platform event."""
|
||||
try:
|
||||
variables = getattr(query, 'variables', None)
|
||||
declared = str(variables.get('_telemetry_origin') or '').strip() if isinstance(variables, dict) else ''
|
||||
except Exception:
|
||||
declared = ''
|
||||
if declared in ('webui', 'api'):
|
||||
return declared
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
if adapter is not None and adapter.__class__.__name__ == 'WebSocketAdapter':
|
||||
# WebChat and the CLI diagnostic surface both speak through the
|
||||
# WebSocket proxy adapter; the CLI marks itself in its variables.
|
||||
try:
|
||||
variables = getattr(query, 'variables', None)
|
||||
if isinstance(variables, dict) and '_cli_run_status' in variables:
|
||||
return 'api'
|
||||
except Exception:
|
||||
pass
|
||||
return 'webui'
|
||||
return 'api'
|
||||
|
||||
@staticmethod
|
||||
def _synthetic_event_type(query: pipeline_query.Query) -> str:
|
||||
"""Event type of the virtual inbound event a synthetic lane stands for."""
|
||||
event = getattr(query, 'message_event', None)
|
||||
event_type = str(getattr(event, 'type', None) or '').strip()
|
||||
return event_type[:160] or 'message.received'
|
||||
|
||||
async def process_query(self, query: pipeline_query.Query):
|
||||
workspace_uuid = getattr(query, 'workspace_uuid', None) or getattr(self, 'workspace_uuid', '')
|
||||
with language_scope(await get_workspace_language(self.ap, workspace_uuid)):
|
||||
return await self._process_query_with_language(query)
|
||||
|
||||
async def _process_query_with_language(self, query: pipeline_query.Query):
|
||||
from ..telemetry import trace as trace_mod
|
||||
from ..telemetry.execution import ingress
|
||||
from ..telemetry.execution import record as record_execution
|
||||
from ..telemetry.platform import processing_mode
|
||||
@@ -379,11 +430,23 @@ class RuntimePipeline:
|
||||
token = processing_mode.set('pipeline')
|
||||
# The Workspace-scoped opaque query uuid is the execution identity Space
|
||||
# shows for this lane; it is a stable UUID for every pooled query.
|
||||
execution_id = str(getattr(query, 'query_uuid', '') or '').strip() or str(query.query_id)
|
||||
ingress_event_id = (query.variables or {}).get('_ingress_event_id')
|
||||
execution_id = ingress_event_id or str(getattr(query, 'query_uuid', '') or '').strip() or str(query.query_id)
|
||||
# A lane with no inbound platform event in flight (WebChat, WebUI debug,
|
||||
# HTTP pipeline run) synthesizes its virtual inbound event here, so every
|
||||
# execution starts at an event boundary and the Runner is never the trace
|
||||
# origin. A lane running under a platform route nests inside that ingress.
|
||||
synthesizing = trace_mod.current() is None and not ingress_event_id
|
||||
try:
|
||||
# Callers without a platform event (Webchat, HTTP API) still get one
|
||||
# trace for the whole Pipeline lane; nested calls reuse the trace.
|
||||
with ingress(self.ap, 'pipeline_done', getattr(self, 'execution_context', None), execution_id=execution_id):
|
||||
with ingress(
|
||||
self.ap,
|
||||
'pipeline_done',
|
||||
getattr(self, 'execution_context', None),
|
||||
execution_id=execution_id,
|
||||
debug=synthesizing,
|
||||
origin=self._synthetic_origin(query) if synthesizing else 'platform',
|
||||
synthetic_event=self._synthetic_event_type(query) if synthesizing else '',
|
||||
):
|
||||
# The lane is its own workflow step: acknowledgements and replies it
|
||||
# sends directly, and the runner it drives, hang under this node.
|
||||
# When the lane runs under a platform route the node nests there.
|
||||
@@ -391,7 +454,12 @@ class RuntimePipeline:
|
||||
lane_outcome = 'success'
|
||||
lane_error = ''
|
||||
try:
|
||||
return await self._process_query(query)
|
||||
from ..platform.processing_indicator import processing_indicator
|
||||
|
||||
async with processing_indicator(
|
||||
query.adapter, query.launcher_type.value.lower(), query.launcher_id
|
||||
):
|
||||
return await self._process_query(query)
|
||||
except asyncio.CancelledError:
|
||||
lane_outcome = 'cancelled'
|
||||
lane_error = 'cancelled'
|
||||
@@ -576,14 +644,8 @@ class RuntimePipeline:
|
||||
state = current_trace()
|
||||
# The runner orchestrator already lands its own failed ``runner/execute``
|
||||
# node when the break happens inside a runner; in that case only carry the
|
||||
# reason over instead of repeating the node on the trace.
|
||||
already_recorded = bool(
|
||||
state is not None
|
||||
and any(
|
||||
stage.get('family') == 'runner' and stage.get('outcome') not in ('success', 'skipped')
|
||||
for stage in state.stages
|
||||
)
|
||||
)
|
||||
# reason over instead of repeating the node on the chain.
|
||||
already_recorded = bool(state is not None and state.runner_failed)
|
||||
if not already_recorded:
|
||||
# The lane failure is its own workflow step, hanging under whatever
|
||||
# step routed into it (or a root when nothing did).
|
||||
|
||||
@@ -108,6 +108,12 @@ class PreProcessor(stage.PipelineStage):
|
||||
)
|
||||
catalog = ResourcePolicyProjector.filter_tools(catalog, policy)
|
||||
ToolManager.bind_query_tool_sources(query, catalog)
|
||||
try:
|
||||
from ...telemetry import resources as telemetry_resources
|
||||
|
||||
telemetry_resources.note_pipeline(tools=catalog, tools_all=policy.allow_all_tools)
|
||||
except Exception:
|
||||
pass
|
||||
return ToolManager.tools_from_catalog(catalog)
|
||||
|
||||
def _runner_accepts_multimodal_input(self, descriptor: RunnerDescriptor | None) -> bool:
|
||||
@@ -343,8 +349,10 @@ class PreProcessor(stage.PipelineStage):
|
||||
plain_text += me.text
|
||||
elif isinstance(me, platform_message.Image):
|
||||
if keep_image_inputs:
|
||||
if me.base64 is not None:
|
||||
if me.base64:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
||||
elif me.url:
|
||||
content_list.append(provider_message.ContentElement.from_image_url(me.url))
|
||||
else:
|
||||
content_list.append(provider_message.ContentElement.from_text('[Image]'))
|
||||
plain_text += '[Image]'
|
||||
@@ -365,8 +373,10 @@ class PreProcessor(stage.PipelineStage):
|
||||
content_list.append(provider_message.ContentElement.from_text(msg.text))
|
||||
elif isinstance(msg, platform_message.Image):
|
||||
if keep_image_inputs:
|
||||
if msg.base64 is not None:
|
||||
if msg.base64:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||
elif msg.url:
|
||||
content_list.append(provider_message.ContentElement.from_image_url(msg.url))
|
||||
else:
|
||||
content_list.append(provider_message.ContentElement.from_text('[Image]'))
|
||||
plain_text += '[Image]'
|
||||
@@ -393,6 +403,18 @@ class PreProcessor(stage.PipelineStage):
|
||||
descriptor,
|
||||
runner_config,
|
||||
)
|
||||
# Telemetry: the knowledge bases this lane may retrieve from, plus the
|
||||
# schema-declared selector fields that selected them.
|
||||
try:
|
||||
from ...telemetry import resources as telemetry_resources
|
||||
|
||||
telemetry_resources.note_pipeline(
|
||||
knowledge_bases=query.variables['_knowledge_base_uuids'],
|
||||
runner_id=descriptor.id if descriptor is not None else '',
|
||||
selectors=config_schema.extract_selected_resources(descriptor, runner_config),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Emit PromptPreProcessing before the runner receives the query.
|
||||
|
||||
@@ -427,4 +449,10 @@ class PreProcessor(stage.PipelineStage):
|
||||
bound_skills = extensions_prefs['skills']
|
||||
|
||||
query.variables['_pipeline_bound_skills'] = bound_skills
|
||||
try:
|
||||
from ...telemetry import resources as telemetry_resources
|
||||
|
||||
telemetry_resources.note_pipeline(skills=bound_skills)
|
||||
except Exception:
|
||||
pass
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ....i18n import pipeline_notice
|
||||
|
||||
import uuid
|
||||
import typing
|
||||
import traceback
|
||||
|
||||
|
||||
from .. import handler
|
||||
from ..stream_results import coalesce_stream_results
|
||||
from ... import entities
|
||||
from ... import plugin_diagnostics
|
||||
|
||||
@@ -132,7 +135,10 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
# Use AgentRunOrchestrator to run the agent
|
||||
# This replaces direct runner lookup and PluginRunnerWrapper
|
||||
async for result in self.ap.agent_run_orchestrator.run_from_query(query):
|
||||
results = self.ap.agent_run_orchestrator.run_from_query(query)
|
||||
if is_stream:
|
||||
results = coalesce_stream_results(results)
|
||||
async for result in results:
|
||||
has_result = True
|
||||
self._check_response_size(result)
|
||||
|
||||
@@ -149,6 +155,11 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
)
|
||||
result.attachments = attachments
|
||||
|
||||
if is_stream and result.tool_calls:
|
||||
# A model round ending in tool calls does not end the
|
||||
# reply. Keep the native stream open for the answer.
|
||||
result = result.model_copy(update={'is_final': False})
|
||||
|
||||
result.resp_message_id = str(resp_message_id)
|
||||
|
||||
# For streaming mode, pop previous response before adding new chunk
|
||||
@@ -192,6 +203,11 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
if is_stream:
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
if result.is_final and not result.tool_calls:
|
||||
# Native adapters release their card on completion.
|
||||
# A distinct later message needs a fresh card identity.
|
||||
is_create_card = False
|
||||
resp_message_id = uuid.uuid4()
|
||||
|
||||
# Log final summary after streaming completes
|
||||
if is_stream:
|
||||
@@ -224,14 +240,20 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
# Handle specific runner errors with appropriate messages
|
||||
if isinstance(e, RunnerNotFoundError):
|
||||
user_notice = f'Agent runner not found: {e.runner_id}'
|
||||
user_notice = pipeline_notice(
|
||||
query, 'runner-not-found-hint', 'Agent runner not found: {runner}', runner=e.runner_id
|
||||
)
|
||||
elif isinstance(e, RunnerNotAuthorizedError):
|
||||
user_notice = 'Agent runner not authorized for this pipeline'
|
||||
user_notice = pipeline_notice(
|
||||
query, 'runner-not-authorized-hint', 'Agent runner not authorized for this pipeline.'
|
||||
)
|
||||
elif isinstance(e, RunnerExecutionError):
|
||||
if e.retryable:
|
||||
user_notice = 'Agent runner temporarily unavailable. Please try again.'
|
||||
user_notice = pipeline_notice(
|
||||
query, 'runner-unavailable-hint', 'Agent runner temporarily unavailable. Please try again.'
|
||||
)
|
||||
else:
|
||||
user_notice = 'Agent runner execution failed.'
|
||||
user_notice = pipeline_notice(query, 'failure-hint', 'Agent runner execution failed.')
|
||||
else:
|
||||
# Use existing exception handling
|
||||
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
||||
@@ -239,7 +261,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
if exception_handling == 'show-error':
|
||||
user_notice = f'{e}'
|
||||
elif exception_handling == 'show-hint':
|
||||
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
|
||||
user_notice = pipeline_notice(query, 'failure-hint', 'Request failed.')
|
||||
else: # hide
|
||||
user_notice = None
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Coalesce a runner's terminal delta and completed snapshot before delivery."""
|
||||
|
||||
from collections.abc import AsyncIterable, AsyncIterator
|
||||
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message, MessageChunk
|
||||
|
||||
|
||||
async def coalesce_stream_results(
|
||||
results: AsyncIterable[Message | MessageChunk],
|
||||
) -> AsyncIterator[Message | MessageChunk]:
|
||||
pending: MessageChunk | None = None
|
||||
snapshot = ''
|
||||
async for result in results:
|
||||
if pending is not None:
|
||||
# Completion snapshots can add attachments and usage to the final
|
||||
# delta. Deliver that richer result once, before closing the card.
|
||||
content = pending.all_content if pending.all_content is not None else pending.content
|
||||
# Multi-round runners stream a cumulative transcript, but complete
|
||||
# with only the last model round. Keep the transcript on the same
|
||||
# card while taking attachments and metadata from the completion.
|
||||
final_round = (
|
||||
isinstance(content, str)
|
||||
and isinstance(result.content, str)
|
||||
and bool(result.content)
|
||||
and content.endswith(result.content)
|
||||
)
|
||||
same_message = (
|
||||
isinstance(result, Message)
|
||||
and result.role == pending.role
|
||||
and (result.tool_calls or []) == (pending.tool_calls or [])
|
||||
and (not content or result.content == content or final_round)
|
||||
)
|
||||
if same_message and final_round and content != result.content:
|
||||
result = result.model_copy(update={'content': content})
|
||||
elif not same_message:
|
||||
yield pending
|
||||
pending = None
|
||||
snapshot = ''
|
||||
|
||||
if isinstance(result, MessageChunk) and isinstance(result.content, (str, type(None))):
|
||||
# Runner output is already cumulative (unlike raw provider deltas).
|
||||
# Explicit all_content also supports runners forwarding raw deltas.
|
||||
# Never guess delta semantics from a missing all_content field.
|
||||
snapshot = result.all_content if result.all_content is not None else (result.content or snapshot)
|
||||
result = result.model_copy(update={'all_content': snapshot})
|
||||
elif isinstance(result, Message):
|
||||
snapshot = ''
|
||||
|
||||
if isinstance(result, MessageChunk) and result.is_final:
|
||||
pending = result
|
||||
else:
|
||||
yield result
|
||||
|
||||
if pending is not None:
|
||||
yield pending
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...i18n import pipeline_notice
|
||||
|
||||
import typing
|
||||
|
||||
from .. import entities, stage
|
||||
@@ -62,7 +64,7 @@ class RateLimit(stage.PipelineStage):
|
||||
result_type=entities.ResultType.INTERRUPT,
|
||||
new_query=query,
|
||||
console_notice=f'根据限速规则忽略 {query.launcher_type.value}:{query.launcher_id} 消息',
|
||||
user_notice='请求数超过限速器设定值,已丢弃本消息。',
|
||||
user_notice=pipeline_notice(query, 'rate-limit-hint', 'Too many requests. Please try again later.'),
|
||||
)
|
||||
elif stage_inst_name == 'ReleaseRateLimitOccupancy':
|
||||
await self.algo.release_access(
|
||||
|
||||
@@ -37,8 +37,13 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
|
||||
if query.launcher_type.value != 'group': # 只处理群消息
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
# 通过路由规则明确指定的流水线,跳过群响应规则检查
|
||||
if query.variables and query.variables.get('_routed_by_rule', False):
|
||||
# Interaction submissions and routed platform form callbacks resume the
|
||||
# original run without another mention or prefix. Ordinary routing only
|
||||
# selects a pipeline and must still obey its group response rules.
|
||||
if query.variables and (
|
||||
query.variables.get('_interaction_submission')
|
||||
or (query.variables.get('_routed_by_rule') and query.variables.get('_dify_form_action'))
|
||||
):
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
rules = query.pipeline_config['trigger']['group-respond-rules']
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...i18n import pipeline_notice
|
||||
|
||||
import typing
|
||||
|
||||
from .. import entities
|
||||
@@ -175,7 +177,9 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
if result.tool_calls is not None and len(result.tool_calls) > 0: # 有函数调用
|
||||
function_names = [tc.function.name for tc in result.tool_calls]
|
||||
|
||||
reply_text = f'Call {".".join(function_names)}...'
|
||||
reply_text = pipeline_notice(
|
||||
query, 'tool-call-hint', 'Calling {tools}...', tools=', '.join(function_names)
|
||||
)
|
||||
|
||||
# Only surface the tool-call notice when the pipeline option
|
||||
# is enabled. Emitting it unconditionally would become the
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
OMNI_ADAPTER_NAMES = frozenset(
|
||||
{
|
||||
'aiocqhttp',
|
||||
'telegram',
|
||||
'discord',
|
||||
'qqofficial',
|
||||
@@ -20,6 +19,8 @@ OMNI_ADAPTER_NAMES = frozenset(
|
||||
|
||||
def canonical_adapter_name(name: str) -> str:
|
||||
"""Accept previous built-in IDs without renaming legacy or custom adapters."""
|
||||
if name in ('aiocqhttp-omni', 'aiocqhttp-eba'):
|
||||
return 'onebot-v11'
|
||||
if name.endswith('-eba') and name[:-4] in OMNI_ADAPTER_NAMES:
|
||||
return f'{name[:-4]}-omni'
|
||||
return name
|
||||
|
||||
@@ -2,6 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
import hmac
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from websockets.asyncio.client import connect
|
||||
from langbot.pkg.core.task_boundary import create_detached_task
|
||||
from langbot.pkg.platform.adapters.aiocqhttp.transport import OneBotClient, OneBotConnection
|
||||
import typing
|
||||
|
||||
import aiocqhttp
|
||||
@@ -20,7 +26,10 @@ from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedErro
|
||||
|
||||
|
||||
class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter):
|
||||
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True)
|
||||
bot: typing.Any = pydantic.Field(exclude=True)
|
||||
_runtime: typing.Any = pydantic.PrivateAttr(default=None)
|
||||
_stopped: asyncio.Event = pydantic.PrivateAttr(default_factory=asyncio.Event)
|
||||
_connection_task: typing.Any = pydantic.PrivateAttr(default=None)
|
||||
|
||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
||||
@@ -36,24 +45,64 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
arbitrary_types_allowed = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
run_config = dict(config)
|
||||
config = dict(config)
|
||||
config.setdefault('connection_mode', 'reverse_ws')
|
||||
if not isinstance(config.get('access-token'), str) or not config['access-token'].strip():
|
||||
raise ValueError('OneBot requires a non-empty access token')
|
||||
if config['connection_mode'] not in ('reverse_ws', 'forward_ws'):
|
||||
raise ValueError('Unsupported OneBot connection mode')
|
||||
if config['connection_mode'] == 'forward_ws':
|
||||
url = urlsplit(config.get('ws_url', ''))
|
||||
if url.scheme not in ('ws', 'wss') or not url.hostname or url.username or url.password or url.fragment:
|
||||
raise ValueError('OneBot ws_url must be a ws:// or wss:// endpoint without embedded credentials')
|
||||
super().__init__(config=config, logger=logger, bot=OneBotClient(), bot_account_id='', listeners={})
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
def bind_runtime(self, runtime):
|
||||
self._runtime = runtime
|
||||
|
||||
run_config['shutdown_trigger'] = shutdown_trigger_placeholder
|
||||
access_token = run_config.pop('access-token', '') or None
|
||||
bot = aiocqhttp.CQHttp(access_token=access_token)
|
||||
async def _validate_connection(self):
|
||||
if self._stopped.is_set() or self._runtime is None:
|
||||
raise RuntimeError('OneBot runtime is inactive')
|
||||
current = await self._runtime.ap.platform_mgr.resolve_public_bot(self._runtime.bot_entity.uuid)
|
||||
if current is not self._runtime or not current.enable:
|
||||
raise RuntimeError('OneBot runtime placement changed')
|
||||
|
||||
super().__init__(
|
||||
config=run_config,
|
||||
logger=logger,
|
||||
bot=bot,
|
||||
bot_account_id='',
|
||||
listeners={},
|
||||
def authenticate(self, headers):
|
||||
token = str(self.config.get('access-token', ''))
|
||||
supplied = headers.get('Authorization', '')
|
||||
return bool(token) and hmac.compare_digest(supplied.encode(), ('Bearer ' + token).encode())
|
||||
|
||||
async def serve_connection(self, send, receive, close, self_id=''):
|
||||
await self._validate_connection()
|
||||
if self.bot.connection is not None:
|
||||
raise RuntimeError('OneBot already has an active connection')
|
||||
expected = str(self.config.get('self_id', '') or '')
|
||||
if expected and self_id and expected != self_id:
|
||||
raise ValueError('OneBot account mismatch')
|
||||
connection = OneBotConnection(
|
||||
send,
|
||||
receive,
|
||||
close,
|
||||
self._receive_event,
|
||||
self._validate_connection,
|
||||
self_id=expected or self_id,
|
||||
)
|
||||
self._register_native_handlers()
|
||||
self.bot.connection = connection
|
||||
self.bot_account_id = expected or self_id
|
||||
self._lookup.clear()
|
||||
task = create_detached_task(connection.run(), workspace_uuid=self._runtime.workspace_uuid)
|
||||
self._connection_task = task
|
||||
try:
|
||||
await task
|
||||
finally:
|
||||
await connection.close()
|
||||
if self.bot.connection is connection:
|
||||
self.bot.connection = None
|
||||
self._connection_task = None
|
||||
self._lookup.clear()
|
||||
|
||||
async def _receive_event(self, payload):
|
||||
await self._handle_native_event(aiocqhttp.Event.from_payload(payload))
|
||||
|
||||
def get_supported_events(self) -> list[str]:
|
||||
return [
|
||||
@@ -121,33 +170,51 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
await self.bot._server_app.run_task(**self.config)
|
||||
if not self.config.get('access-token'):
|
||||
raise ValueError('OneBot requires a non-empty access token')
|
||||
if self.config['connection_mode'] == 'reverse_ws':
|
||||
await self._stopped.wait()
|
||||
return
|
||||
delay = 1
|
||||
while not self._stopped.is_set():
|
||||
await self._validate_connection()
|
||||
try:
|
||||
async with connect(
|
||||
self.config['ws_url'],
|
||||
additional_headers={'Authorization': 'Bearer ' + self.config['access-token']},
|
||||
open_timeout=10,
|
||||
close_timeout=5,
|
||||
ping_interval=20,
|
||||
ping_timeout=20,
|
||||
max_size=8 * 1024 * 1024,
|
||||
proxy=None,
|
||||
) as socket:
|
||||
delay = 1
|
||||
await self.serve_connection(socket.send, socket.recv, socket.close)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
await self.logger.warning(f'OneBot connection lost ({type(exc).__name__}); reconnecting')
|
||||
try:
|
||||
await asyncio.wait_for(self._stopped.wait(), timeout=delay)
|
||||
except TimeoutError:
|
||||
pass
|
||||
delay = min(delay * 2, 30)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._stopped.set()
|
||||
task = self._connection_task
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
self._lookup.clear()
|
||||
return False
|
||||
|
||||
def _register_native_handlers(self):
|
||||
@self.bot.on_message()
|
||||
async def on_message(event: aiocqhttp.Event):
|
||||
await self._handle_native_event(event)
|
||||
|
||||
@self.bot.on_notice()
|
||||
async def on_notice(event: aiocqhttp.Event):
|
||||
await self._handle_native_event(event)
|
||||
|
||||
@self.bot.on_request()
|
||||
async def on_request(event: aiocqhttp.Event):
|
||||
await self._handle_native_event(event)
|
||||
|
||||
@self.bot.on_websocket_connection
|
||||
async def on_websocket_connection(event: aiocqhttp.Event):
|
||||
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
||||
await self.logger.info(f'WebSocket connection established, bot id: {self.bot_account_id}')
|
||||
await self._dispatch_native_event(event)
|
||||
return True
|
||||
|
||||
async def _handle_native_event(self, event: aiocqhttp.Event):
|
||||
self.bot_account_id = str(getattr(event, 'self_id', '') or self.bot_account_id)
|
||||
# Connection metadata belongs to the adapter, never business event routing.
|
||||
if getattr(event, 'type', None) == 'meta_event':
|
||||
return
|
||||
if getattr(event, 'type', None) == 'message' and str(getattr(event, 'user_id', '')) == self.bot_account_id:
|
||||
return
|
||||
try:
|
||||
|
||||
@@ -34,8 +34,6 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return AiocqhttpEventConverter.notice_to_eba(event, bot_user_id)
|
||||
if event_type == 'request':
|
||||
return AiocqhttpEventConverter.request_to_eba(event)
|
||||
if event_type == 'meta_event':
|
||||
return AiocqhttpEventConverter.platform_specific(event, f'meta.{getattr(event, "detail_type", "")}')
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@@ -90,7 +88,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
return platform_events.MessageReceivedEvent(
|
||||
type='message.received',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
message_chain=message_chain,
|
||||
sender=sender,
|
||||
@@ -111,13 +109,19 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if notice_type in ('group_recall', 'friend_recall'):
|
||||
return platform_events.MessageDeletedEvent(
|
||||
type='message.deleted',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
message_id=getattr(event, 'message_id', ''),
|
||||
operator=AiocqhttpEventConverter.user(getattr(event, 'operator_id', None)),
|
||||
operator=AiocqhttpEventConverter.user(
|
||||
getattr(event, 'user_id', None)
|
||||
if notice_type == 'friend_recall'
|
||||
else getattr(event, 'operator_id', None)
|
||||
),
|
||||
chat_type=platform_entities.ChatType.GROUP
|
||||
if notice_type == 'group_recall'
|
||||
else platform_entities.ChatType.PRIVATE,
|
||||
chat_id=getattr(event, 'group_id', getattr(event, 'user_id', '')),
|
||||
chat_id=getattr(event, 'group_id', '')
|
||||
if notice_type == 'group_recall'
|
||||
else getattr(event, 'user_id', ''),
|
||||
group=AiocqhttpEventConverter.group_from_event(event) if notice_type == 'group_recall' else None,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
@@ -129,7 +133,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
group=group,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
@@ -137,7 +141,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
return platform_events.MemberJoinedEvent(
|
||||
type='group.member_joined',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
group=group,
|
||||
member=user,
|
||||
inviter=AiocqhttpEventConverter.user(inviter_id) if inviter_id else None,
|
||||
@@ -151,7 +155,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if AiocqhttpEventConverter._is_bot_user(getattr(event, 'user_id', None), bot_user_id, event):
|
||||
return platform_events.BotRemovedFromGroupEvent(
|
||||
type='bot.removed_from_group',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
group=group,
|
||||
operator=operator,
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
@@ -159,7 +163,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
)
|
||||
return platform_events.MemberLeftEvent(
|
||||
type='group.member_left',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
is_kicked=getattr(event, 'sub_type', '') in ('kick', 'kick_me'),
|
||||
@@ -175,7 +179,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
event_cls = platform_events.BotMutedEvent if duration > 0 else platform_events.BotUnmutedEvent
|
||||
kwargs: dict[str, typing.Any] = {
|
||||
'type': 'bot.muted' if duration > 0 else 'bot.unmuted',
|
||||
'adapter_name': 'aiocqhttp-omni',
|
||||
'adapter_name': 'onebot-v11',
|
||||
'group': group,
|
||||
'operator': operator,
|
||||
'timestamp': float(getattr(event, 'time', 0) or 0),
|
||||
@@ -187,7 +191,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if duration > 0:
|
||||
return platform_events.MemberBannedEvent(
|
||||
type='group.member_banned',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
group=group,
|
||||
member=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
operator=operator,
|
||||
@@ -198,7 +202,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if notice_type == 'friend_add':
|
||||
return platform_events.FriendAddedEvent(
|
||||
type='friend.added',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
source_platform_object=event,
|
||||
@@ -211,7 +215,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if request_type == 'friend':
|
||||
return platform_events.FriendRequestReceivedEvent(
|
||||
type='friend.request_received',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
user=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
message=getattr(event, 'comment', None),
|
||||
@@ -221,7 +225,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
if request_type == 'group' and getattr(event, 'sub_type', '') == 'invite':
|
||||
return platform_events.BotInvitedToGroupEvent(
|
||||
type='bot.invited_to_group',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
group=AiocqhttpEventConverter.group_from_event(event),
|
||||
inviter=AiocqhttpEventConverter.user(getattr(event, 'user_id', '')),
|
||||
request_id=getattr(event, 'flag', ''),
|
||||
@@ -258,7 +262,7 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
def platform_specific(event: aiocqhttp.Event, action: str) -> platform_events.PlatformSpecificEvent:
|
||||
return platform_events.PlatformSpecificEvent(
|
||||
type='platform.specific',
|
||||
adapter_name='aiocqhttp-omni',
|
||||
adapter_name='onebot-v11',
|
||||
action=action,
|
||||
data={key: value for key, value in dict(event).items() if key not in {'message'}},
|
||||
timestamp=float(getattr(event, 'time', 0) or 0),
|
||||
|
||||
@@ -2,7 +2,7 @@ apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
|
||||
metadata:
|
||||
name: aiocqhttp-omni
|
||||
name: onebot-v11
|
||||
label:
|
||||
en_US: OneBot v11
|
||||
zh_Hans: OneBot v11
|
||||
@@ -15,48 +15,66 @@ metadata:
|
||||
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
- protocol
|
||||
help_links:
|
||||
zh: https://langbot.app/docs/zh/usage/platforms/qq/aiocqhttp/napcat
|
||||
en: https://langbot.app/docs/en/usage/platforms/qq/aiocqhttp/napcat
|
||||
ja: https://langbot.app/docs/ja/usage/platforms/qq/aiocqhttp/napcat
|
||||
config:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机
|
||||
zh_Hant: 主機
|
||||
description:
|
||||
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
||||
zh_Hans: OneBot v11 反向 WebSocket 监听主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
zh_Hant: OneBot v11 反向 WebSocket 監聽主機,除非你知道自己在做什麼,否則請填 0.0.0.0
|
||||
type: string
|
||||
- name: connection_mode
|
||||
label: {en_US: Connection mode, zh_Hans: 连接模式, zh_Hant: 連線模式, ja_JP: 接続方式}
|
||||
type: select
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
- name: port
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 端口
|
||||
zh_Hant: 連接埠
|
||||
default: reverse_ws
|
||||
options:
|
||||
- name: reverse_ws
|
||||
label: {en_US: 'Reverse WS — protocol endpoint connects to LangBot', zh_Hans: '反向 WS — 协议端连接 LangBot', zh_Hant: '反向 WS — 協定端連接 LangBot', ja_JP: '逆方向 WS — プロトコル側から LangBot に接続'}
|
||||
- name: forward_ws
|
||||
label: {en_US: 'Forward WS — LangBot connects to protocol endpoint', zh_Hans: '正向 WS — LangBot 连接协议端', zh_Hant: '正向 WS — LangBot 連接協定端', ja_JP: '順方向 WS — LangBot からプロトコル側に接続'}
|
||||
description:
|
||||
en_US: Reverse WebSocket listen port
|
||||
zh_Hans: 反向 WebSocket 监听端口
|
||||
zh_Hant: 反向 WebSocket 監聽連接埠
|
||||
type: integer
|
||||
required: true
|
||||
default: 2280
|
||||
- name: access-token
|
||||
label:
|
||||
en_US: Access Token
|
||||
zh_Hans: 访问令牌
|
||||
zh_Hant: 存取令牌
|
||||
en_US: Reverse WS uses LangBot's main service port. Forward WS requires a protocol endpoint reachable from LangBot. HTTP is not supported.
|
||||
zh_Hans: 反向 WS 由协议端连接 LangBot 主服务端口,无需额外监听端口;正向 WS 由 LangBot 连接可访问的协议端。不支持 HTTP。
|
||||
zh_Hant: 反向 WS 由協定端連接 LangBot 主服務連接埠;正向 WS 由 LangBot 連接可存取的協定端。不支援 HTTP。
|
||||
ja_JP: 逆方向 WS は LangBot のメインポートを使用。順方向 WS は LangBot から到達可能なプロトコル側に接続します。HTTP は非対応です。
|
||||
- name: __system.onebot_ws_url
|
||||
label: {en_US: Protocol endpoint connection URL, zh_Hans: 协议端连接地址, zh_Hant: 協定端連線位址, ja_JP: プロトコル側の接続先 URL}
|
||||
description:
|
||||
en_US: Custom connection token for the protocol endpoint. Leave empty if the endpoint has no token configured
|
||||
zh_Hans: 自定义的协议端连接令牌;若协议端未设置,则不填
|
||||
zh_Hant: 自訂的協定端連線令牌;若協定端未設定,則不填
|
||||
en_US: Save the bot, then copy this URL into the protocol endpoint's WebSocket client (Universal). Old host/port settings are ignored; update the protocol endpoint URL.
|
||||
zh_Hans: 保存机器人后,将此地址填入协议端的 WebSocket 客户端(Universal)配置。旧 host/port 配置不再生效,请同步修改协议端地址。
|
||||
zh_Hant: 儲存後將此位址填入協定端 WebSocket 用戶端(Universal)。舊 host/port 不再生效,請同步更新協定端位址。
|
||||
ja_JP: 保存後、この URL をプロトコル側の WebSocket クライアント(Universal)に設定。旧 host/port は無効です。接続先を更新してください。
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
default: ''
|
||||
show_if: {field: connection_mode, operator: eq, value: reverse_ws}
|
||||
- name: ws_url
|
||||
label: {en_US: Protocol endpoint WebSocket URL, zh_Hans: 协议端 WebSocket 地址, zh_Hant: 協定端 WebSocket 位址, ja_JP: プロトコル側 WebSocket URL}
|
||||
description:
|
||||
en_US: LangBot connects to this ws:// or wss:// Universal endpoint. It must be reachable from LangBot.
|
||||
zh_Hans: LangBot 将主动连接此 ws:// 或 wss:// 地址,使用协议端同时提供事件和 API 的 Universal 端点。请确保 LangBot 能访问它。
|
||||
zh_Hant: LangBot 主動連接此 ws:// 或 wss:// Universal 位址,請確保可存取。
|
||||
ja_JP: LangBot がこの ws:// または wss:// Universal URL に接続します。到達可能な URL を指定してください。
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
show_if: {field: connection_mode, operator: eq, value: forward_ws}
|
||||
- name: access-token
|
||||
label: {en_US: Access token, zh_Hans: 访问令牌, zh_Hant: 存取權杖, ja_JP: アクセストークン}
|
||||
description:
|
||||
en_US: Required in both modes. Configure the same secret token on LangBot and the protocol endpoint. Sent using Authorization Bearer.
|
||||
zh_Hans: 两种模式均必填。在 LangBot 与协议端设置相同的保密令牌,连接时使用 Authorization Bearer 验证。
|
||||
zh_Hant: 兩種模式皆必填。在 LangBot 與協定端設定相同的保密權杖。
|
||||
ja_JP: 両方式で必須。LangBot とプロトコル側に同じ秘密トークンを設定してください。
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: self_id
|
||||
label: {en_US: Expected bot account (optional), zh_Hans: 预期机器人账号(可选), zh_Hant: 預期機器人帳號(選填), ja_JP: ボットアカウント(任意)}
|
||||
description: {en_US: Reject connections or events from a different account., zh_Hans: 拒绝其他账号的连接或事件。, zh_Hant: 拒絕其他帳號的連線或事件。, ja_JP: 異なるアカウントの接続やイベントを拒否します。}
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
supported_events:
|
||||
- message.received
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user