merge: integrate master into lark webhook signature fix

This commit is contained in:
Hyu
2026-10-19 01:53:46 +08:00
676 changed files with 56961 additions and 17266 deletions
@@ -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
+69 -14
View File
@@ -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
+69 -3
View File
@@ -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)
+3 -1
View File
@@ -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,
+42 -7
View File
@@ -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:
+28 -10
View File
@@ -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,
}
)
+61 -23
View File
@@ -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."""
+222 -15
View File
@@ -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}'}
+18 -9
View File
@@ -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)
+12 -4
View File
@@ -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,
+10 -4
View File
@@ -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
+94 -1
View File
@@ -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:
+11 -2
View File
@@ -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')
+71 -78
View File
@@ -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
+17 -1
View File
@@ -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(
+4 -2
View File
@@ -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()}
+163 -5
View File
@@ -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
+3 -4
View File
@@ -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
+10 -5
View File
@@ -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)
+139
View File
@@ -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))
""")
+13 -1
View File
@@ -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
View File
@@ -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(),
+6 -185
View File
@@ -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.
+4
View File
@@ -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]:
+42
View File
@@ -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))
+23 -3
View File
@@ -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
-1
View File
@@ -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)
+3 -5
View File
@@ -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:
+123 -4
View File
@@ -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.
+2
View File
@@ -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}},
+4 -2
View File
@@ -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'),
+197
View File
@@ -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
+31
View File
@@ -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
+114
View File
@@ -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"
}
+114
View File
@@ -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"
}
+114
View File
@@ -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": "セッションをリセットしました"
}
+114
View File
@@ -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": "Сессия сброшена"
}
+114
View File
@@ -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": "รีเซ็ตเซสชันแล้ว"
}
+114
View File
@@ -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"
}
+114
View File
@@ -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": "会话已重置"
}
+114
View File
@@ -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": "對話已重設"
}
+71
View File
@@ -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()
+46
View File
@@ -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
+15 -4
View File
@@ -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):
+7
View File
@@ -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
+106 -11
View File
@@ -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}')
+75 -13
View File
@@ -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).
+30 -2
View File
@@ -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']
+5 -1
View File
@@ -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 -1
View File
@@ -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