mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-20 15:37:27 +08:00
feat: delegate sandbox policy to runners and simplify pipeline migration
This commit is contained in:
@@ -371,6 +371,7 @@ class RunnerContextBuilder:
|
||||
|
||||
# Build delivery context
|
||||
delivery_context = {
|
||||
'automatic_reply': binding.processor_type == 'pipeline',
|
||||
'surface': event.delivery.surface,
|
||||
'reply_target': event.delivery.reply_target,
|
||||
'supports_streaming': event.delivery.supports_streaming,
|
||||
@@ -520,6 +521,7 @@ class RunnerContextBuilder:
|
||||
'reason': 'current_event_only',
|
||||
},
|
||||
'available_apis': {
|
||||
'box': True,
|
||||
'prompt_get': False,
|
||||
'history_page': history_page_enabled,
|
||||
'history_search': history_search_enabled,
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import typing
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
|
||||
@@ -13,11 +12,9 @@ from langbot_plugin.api.entities.builtin.platform import message as platform_mes
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
from langbot_plugin.api.entities.builtin.provider import session as provider_session
|
||||
|
||||
from ...utils import constants
|
||||
from .host_models import AgentEventEnvelope
|
||||
|
||||
|
||||
HOST_BOX_SCOPE_VARIABLE = '_host_box_scope'
|
||||
AUTHORIZED_SKILLS_VARIABLE = '_pipeline_bound_skills'
|
||||
MCP_RESOURCE_ATTACHMENTS_VARIABLE = '_pipeline_mcp_resource_attachments'
|
||||
MCP_RESOURCE_AGENT_READ_ENABLED_VARIABLE = '_pipeline_mcp_resource_agent_read_enabled'
|
||||
@@ -102,29 +99,12 @@ def prepare_execution_query(
|
||||
authorized_skill_names: list[str],
|
||||
) -> pipeline_query.Query:
|
||||
"""Attach Host-owned execution metadata without changing Query identity."""
|
||||
variables = prepare_box_scope(query, event, preserve_existing=True)
|
||||
variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def prepare_box_scope(
|
||||
query: pipeline_query.Query,
|
||||
event: AgentEventEnvelope,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
) -> dict[str, typing.Any]:
|
||||
"""Attach the Host Box scope before any Query-side file staging."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
variables = query.variables
|
||||
if not isinstance(variables, dict):
|
||||
variables = {}
|
||||
query.variables = variables
|
||||
|
||||
existing_scope = variables.get(HOST_BOX_SCOPE_VARIABLE)
|
||||
if preserve_existing and isinstance(existing_scope, str) and existing_scope.strip():
|
||||
return variables
|
||||
|
||||
variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event, query=query)
|
||||
return variables
|
||||
variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def build_execution_query(
|
||||
@@ -168,50 +148,10 @@ def build_execution_query(
|
||||
variables={},
|
||||
resp_messages=[],
|
||||
)
|
||||
query.variables[HOST_BOX_SCOPE_VARIABLE] = build_host_box_scope(event)
|
||||
query.variables[AUTHORIZED_SKILLS_VARIABLE] = list(dict.fromkeys(authorized_skill_names))
|
||||
return query
|
||||
|
||||
|
||||
def build_host_box_scope(
|
||||
event: AgentEventEnvelope,
|
||||
*,
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> str | None:
|
||||
"""Return a stable Host scope for a Query session or event."""
|
||||
target_type, target_id = _resolve_box_target(event, query)
|
||||
if target_type is None or target_id is None:
|
||||
return None
|
||||
|
||||
capabilities = event.delivery.platform_capabilities or {}
|
||||
adapter_identity = None
|
||||
if query is not None:
|
||||
adapter = getattr(query, 'adapter', None)
|
||||
if adapter is not None:
|
||||
adapter_identity = adapter.__class__.__name__
|
||||
adapter_identity = _first_present(
|
||||
adapter_identity,
|
||||
capabilities.get('adapter'),
|
||||
capabilities.get('source'),
|
||||
event.source if query is None else None,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
'instance_id': _nonempty(constants.instance_id),
|
||||
'workspace_id': _nonempty(event.workspace_id),
|
||||
'bot_id': _nonempty(event.bot_id),
|
||||
'platform_adapter': adapter_identity,
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'thread_id': _nonempty(event.thread_id),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_session_identity(
|
||||
event: AgentEventEnvelope,
|
||||
) -> tuple[provider_session.LauncherTypes, str, str]:
|
||||
@@ -249,42 +189,6 @@ def _resolve_session_identity(
|
||||
return provider_session.LauncherTypes(launcher_type_value), launcher_id, sender_id
|
||||
|
||||
|
||||
def _resolve_box_target(
|
||||
event: AgentEventEnvelope,
|
||||
query: pipeline_query.Query | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
if query is not None:
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
launcher_type = launcher_type.value
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
normalized_type = _nonempty(launcher_type)
|
||||
normalized_id = _nonempty(launcher_id)
|
||||
if normalized_type is not None and normalized_id is not None:
|
||||
return normalized_type, normalized_id
|
||||
|
||||
reply_target = event.delivery.reply_target or {}
|
||||
target_type = _first_present(
|
||||
reply_target.get('target_type'),
|
||||
reply_target.get('launcher_type'),
|
||||
)
|
||||
target_id = _first_present(
|
||||
reply_target.get('target_id'),
|
||||
reply_target.get('launcher_id'),
|
||||
)
|
||||
if target_type is not None and target_id is not None:
|
||||
return target_type, target_id
|
||||
|
||||
conversation_id = _nonempty(event.conversation_id)
|
||||
if conversation_id is not None:
|
||||
return 'conversation', conversation_id
|
||||
|
||||
event_id = _nonempty(event.event_id)
|
||||
if event_id is not None:
|
||||
return 'event', event_id
|
||||
return None, None
|
||||
|
||||
|
||||
def _build_message_chain(event: AgentEventEnvelope) -> platform_message.MessageChain:
|
||||
text = event.input.to_text()
|
||||
if not text:
|
||||
|
||||
@@ -23,7 +23,6 @@ from .execution_context import (
|
||||
append_mcp_resource_context_to_event,
|
||||
build_mcp_resource_context_addition,
|
||||
build_execution_query,
|
||||
prepare_box_scope,
|
||||
prepare_execution_query,
|
||||
project_mcp_resource_config,
|
||||
)
|
||||
@@ -128,6 +127,10 @@ class AgentRunOrchestrator:
|
||||
):
|
||||
await self.interaction_manager.restore_legacy_identity(event, binding)
|
||||
|
||||
from ...box.runner import prepare_input_files
|
||||
|
||||
event = event.model_copy(deep=True)
|
||||
prepare_input_files(execution_query, event.input)
|
||||
execution_event = event
|
||||
resource_addition = await build_mcp_resource_context_addition(self.ap, execution_query)
|
||||
if resource_addition:
|
||||
@@ -167,6 +170,12 @@ class AgentRunOrchestrator:
|
||||
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()
|
||||
if isinstance(key, str) and not key.startswith('_') and isinstance(value, (str, int, float, bool))
|
||||
}
|
||||
context['variables']['query_id'] = execution_query.query_id
|
||||
|
||||
state_context = build_state_context(event, binding, descriptor)
|
||||
run_id = context['run_id']
|
||||
@@ -366,6 +375,17 @@ class AgentRunOrchestrator:
|
||||
assistant_transcript_written = True
|
||||
|
||||
result = await self.result_normalizer.normalize(result_dict, descriptor)
|
||||
file_ids = result_dict.get('data', {}).get('file_ids', [])
|
||||
if file_ids:
|
||||
if result_type != 'message.completed' or result is None:
|
||||
raise ValueError('Files must be attached to a completed message')
|
||||
if binding.processor_type != 'pipeline':
|
||||
raise ValueError('Agent files must be sent explicitly through the reply API')
|
||||
from ...box.runner import exported_message, binding_for
|
||||
|
||||
async with binding_for(execution_query).lock:
|
||||
result.attachments = exported_message(execution_query, file_ids, consume=True)
|
||||
|
||||
if result is not None:
|
||||
yield result
|
||||
|
||||
@@ -397,6 +417,9 @@ class AgentRunOrchestrator:
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
binding_box = getattr(execution_query, '_box_binding', None)
|
||||
if binding_box is not None and binding_box.run_id == run_id:
|
||||
object.__delattr__(execution_query, '_box_binding')
|
||||
session = await self._session_registry.unregister(run_id)
|
||||
await reply_streams.close()
|
||||
pending_steering = session.get('steering_queue', []) if session else []
|
||||
@@ -428,12 +451,6 @@ class AgentRunOrchestrator:
|
||||
adapter_context['_pipeline_conversation'] = getattr(getattr(query, 'session', None), 'using_conversation', None)
|
||||
adapter_context['_execution_context'] = get_query_execution_context(query)
|
||||
|
||||
# Inbound files and subsequent runner tools must share one Host scope.
|
||||
prepare_box_scope(query, plan.event)
|
||||
|
||||
# Materialize inbound attachments into sandbox before running
|
||||
await self._materialize_inbound_attachments(query, plan.event)
|
||||
|
||||
async with contextlib.aclosing(
|
||||
self.run(
|
||||
plan.event,
|
||||
@@ -445,48 +462,6 @@ class AgentRunOrchestrator:
|
||||
async for result in results:
|
||||
yield result
|
||||
|
||||
async def _materialize_inbound_attachments(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
event: AgentEventEnvelope,
|
||||
) -> None:
|
||||
"""Persist inbound attachments into the sandbox and update event.input.attachments.
|
||||
|
||||
No-op when the box service is unavailable or there are no attachments.
|
||||
On success, updates each attachment in event.input.attachments with the
|
||||
sandbox path so runners can tell the model where to find the files.
|
||||
"""
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
return
|
||||
|
||||
try:
|
||||
materialized = await box_service.materialize_inbound_attachments(query)
|
||||
except Exception as e:
|
||||
# Never break the chat turn over attachment IO
|
||||
self.ap.logger.warning(f'Inbound attachment materialization failed: {e}')
|
||||
return
|
||||
|
||||
if not materialized:
|
||||
return
|
||||
|
||||
# Build a lookup by name for matching
|
||||
materialized_by_name = {att.get('name'): att for att in materialized if att.get('name')}
|
||||
|
||||
# Update event.input.attachments with sandbox paths
|
||||
if event.input and event.input.attachments:
|
||||
for attachment in event.input.attachments:
|
||||
name = attachment.name
|
||||
if name and name in materialized_by_name:
|
||||
mat = materialized_by_name[name]
|
||||
# Update the attachment with sandbox path
|
||||
attachment.path = mat.get('path')
|
||||
attachment.size = mat.get('size') or attachment.size
|
||||
attachment.mime_type = attachment.mime_type or mat.get('mime_type')
|
||||
|
||||
# Store materialized descriptors in query variables for downstream use
|
||||
query.variables['_sandbox_inbound_attachments'] = materialized
|
||||
|
||||
def resolve_runner_id_for_telemetry(self, query: pipeline_query.Query) -> str | None:
|
||||
"""Resolve runner ID for telemetry/logging without full execution."""
|
||||
return self.query_bridge.resolve_runner_id_for_telemetry(query)
|
||||
|
||||
@@ -15,6 +15,8 @@ import secrets
|
||||
import sys
|
||||
import uuid
|
||||
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from ..authz import Permission, permissions_for_role, require_permission
|
||||
@@ -86,6 +88,7 @@ class PipelineMigrationService:
|
||||
self.pm = ap.persistence_mgr
|
||||
# Loss of this process key only invalidates previews, never snapshots.
|
||||
self._token_key = secrets.token_bytes(32)
|
||||
self._all_tasks: set[str] = set()
|
||||
|
||||
async def _binding(self, ctx, session=None):
|
||||
binding = await self.ap.workspace_service.get_execution_binding(
|
||||
@@ -296,21 +299,33 @@ class PipelineMigrationService:
|
||||
'total': len(items),
|
||||
}
|
||||
|
||||
async def _selected(self, ctx, selection):
|
||||
async def _selected(self, ctx, selection, *, data_only=False):
|
||||
rows = await self._rows(ctx, selection['pipeline_uuid'])
|
||||
if not rows:
|
||||
raise MigrationError('pipeline_not_found', 404)
|
||||
row = rows[0]
|
||||
plan, facts, pending, state, _ = await self._plan(ctx, row)
|
||||
plan, facts, pending, state, blockers = await self._plan(ctx, row)
|
||||
token = self._token(ctx, row, plan, facts, pending)
|
||||
if not hmac.compare_digest(token, selection['preview_token']):
|
||||
raise MigrationError('preview_stale')
|
||||
if state not in ('ready', 'activation_pending'):
|
||||
if state not in ('ready', 'activation_pending') and not (
|
||||
data_only
|
||||
and plan['state'] == 'ready'
|
||||
and all(b['code'] in ('plugin_missing', 'plugin_disabled') for b in blockers)
|
||||
):
|
||||
raise MigrationError('migration_blocked')
|
||||
return row, plan, facts, pending
|
||||
|
||||
async def execute(self, ctx: RequestContext, body):
|
||||
require_permission(ctx, Permission.RESOURCE_MANAGE)
|
||||
if isinstance(body, dict) and body.get('all') is True:
|
||||
if (
|
||||
set(body) != {'confirmed', 'all', 'install_plugins'}
|
||||
or body['confirmed'] is not True
|
||||
or not isinstance(body['install_plugins'], bool)
|
||||
):
|
||||
raise MigrationError('confirmation_required', 400)
|
||||
return await self._execute_all(ctx, install_plugins=body['install_plugins'])
|
||||
items = validate_execute_request(body)
|
||||
async with self.pm.tenant_scope(ctx.workspace_uuid):
|
||||
await self._authorize(ctx)
|
||||
@@ -333,6 +348,140 @@ class PipelineMigrationService:
|
||||
)
|
||||
return {'task_id': task.id}
|
||||
|
||||
async def _execute_all(self, ctx, *, install_plugins):
|
||||
# Capture all sources on admission. Plugin installation must not silently
|
||||
# include later edits or pipelines created while the task is running.
|
||||
if ctx.workspace_uuid in self._all_tasks:
|
||||
raise MigrationError('migration_running')
|
||||
self._all_tasks.add(ctx.workspace_uuid)
|
||||
try:
|
||||
async with self.pm.tenant_scope(ctx.workspace_uuid):
|
||||
await self._authorize(ctx)
|
||||
sources = []
|
||||
for row in await self._rows(ctx):
|
||||
_, _, _, state, _ = await self._plan(ctx, row)
|
||||
if state not in ('already_current', 'not_legacy'):
|
||||
sources.append(row)
|
||||
if not sources:
|
||||
raise MigrationError('nothing_to_migrate')
|
||||
task_context = TaskContext.new()
|
||||
task_context.metadata = {
|
||||
'kind': 'pipeline_migration',
|
||||
'phase': 'installing' if install_plugins else 'migrating',
|
||||
'results': [{'pipeline_uuid': row['uuid'], 'state': 'pending', 'code': None} for row in sources],
|
||||
}
|
||||
task = self.ap.task_mgr.create_user_task(
|
||||
self._run_all(
|
||||
ctx, ExecutionContext.from_request(ctx), sources, task_context, install_plugins=install_plugins
|
||||
),
|
||||
kind='pipeline_migration',
|
||||
name='pipeline_migration',
|
||||
context=task_context,
|
||||
instance_uuid=ctx.instance_uuid,
|
||||
workspace_uuid=ctx.workspace_uuid,
|
||||
placement_generation=ctx.placement_generation,
|
||||
)
|
||||
return {'task_id': task.id, 'pipeline_uuids': [row['uuid'] for row in sources]}
|
||||
except BaseException:
|
||||
self._all_tasks.discard(ctx.workspace_uuid)
|
||||
raise
|
||||
|
||||
async def _run_all(self, ctx, execution, sources, task_context, *, install_plugins):
|
||||
installed = {}
|
||||
try:
|
||||
async with self.pm.tenant_scope(execution.workspace_uuid):
|
||||
for source, result in zip(sources, task_context.metadata['results']):
|
||||
if result['state'] != 'pending':
|
||||
continue
|
||||
try:
|
||||
await self._authorize(ctx)
|
||||
current = await self._rows(ctx, source['uuid'])
|
||||
if not current or _fingerprint(current[0]) != _fingerprint(source):
|
||||
raise MigrationError('preview_stale')
|
||||
plan, facts, pending, state, blockers = await self._plan(ctx, current[0])
|
||||
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)
|
||||
continue
|
||||
target = plan.get('target_plugin')
|
||||
if not target:
|
||||
raise MigrationError('migration_blocked')
|
||||
if not install_plugins:
|
||||
selection = {
|
||||
'pipeline_uuid': source['uuid'],
|
||||
'preview_token': self._token(ctx, current[0], plan, facts, pending),
|
||||
}
|
||||
await self._run(ctx, execution, [selection], task_context, results=[result], data_only=True)
|
||||
continue
|
||||
key = (target['author'], target['name'], target['version'])
|
||||
if key not in installed:
|
||||
runners = await self.ap.runner_registry.list_runners(
|
||||
execution, use_cache=False, usage='agent'
|
||||
)
|
||||
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
|
||||
ready = (
|
||||
facts
|
||||
and facts['enabled']
|
||||
and descriptor
|
||||
and descriptor.plugin_version == target['version']
|
||||
)
|
||||
if not ready:
|
||||
task_context.metadata['phase'] = 'installing'
|
||||
try:
|
||||
await self._authorize(ctx)
|
||||
await self.ap.plugin_connector.require_workspace_context(execution)
|
||||
# Keep installer diagnostics out of the user task: upstream
|
||||
# errors may contain credentials. Reuse normal quota and
|
||||
# runtime-readiness checks in the connector.
|
||||
await self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE,
|
||||
{
|
||||
'plugin_author': target['author'],
|
||||
'plugin_name': target['name'],
|
||||
'plugin_version': target['version'],
|
||||
},
|
||||
task_context=TaskContext.new(),
|
||||
)
|
||||
installed[key] = None
|
||||
except Exception:
|
||||
installed[key] = 'plugin_install_failed'
|
||||
else:
|
||||
installed[key] = None
|
||||
if installed[key]:
|
||||
raise MigrationError(installed[key])
|
||||
await self._authorize(ctx)
|
||||
current = await self._rows(ctx, source['uuid'])
|
||||
if not current or _fingerprint(current[0]) != _fingerprint(source):
|
||||
raise MigrationError('preview_stale')
|
||||
plan, facts, pending, state, blockers = await self._plan(ctx, current[0])
|
||||
if state not in ('ready', 'activation_pending'):
|
||||
raise MigrationError(blockers[0]['code'] if blockers else 'migration_blocked')
|
||||
selection = {
|
||||
'pipeline_uuid': source['uuid'],
|
||||
'preview_token': self._token(ctx, current[0], plan, facts, pending),
|
||||
}
|
||||
task_context.metadata['phase'] = 'migrating'
|
||||
await self._run(ctx, execution, [selection], task_context, results=[result])
|
||||
except Exception as exc:
|
||||
result.update(
|
||||
state='blocked' if isinstance(exc, MigrationError) else 'failed',
|
||||
code=exc.code if isinstance(exc, MigrationError) else 'migration_failed',
|
||||
)
|
||||
if any(
|
||||
r['code'] in ('operation_cancelled', 'commit_outcome_unknown')
|
||||
for r in task_context.metadata['results']
|
||||
):
|
||||
break
|
||||
finally:
|
||||
for result in task_context.metadata['results']:
|
||||
if result['state'] == 'pending':
|
||||
result.update(state='failed', code='operation_cancelled')
|
||||
task_context.metadata['phase'] = 'finished'
|
||||
self._all_tasks.discard(ctx.workspace_uuid)
|
||||
return task_context.metadata
|
||||
|
||||
async def _verify_runtime(self, execution, plan):
|
||||
runners = await self.ap.runner_registry.list_runners(execution, use_cache=False, usage='agent')
|
||||
descriptor = next((r for r in runners if r.id == plan['target_runner_id']), None)
|
||||
@@ -528,11 +677,11 @@ class PipelineMigrationService:
|
||||
]:
|
||||
await session.execute(sa.select(model.__table__).where(condition).with_for_update())
|
||||
|
||||
async def _commit(self, ctx, selection, expected_facts, candidate):
|
||||
async def _commit(self, ctx, selection, expected_facts, candidate, *, data_only=False):
|
||||
async with self.pm.tenant_uow(ctx.workspace_uuid) as uow:
|
||||
await self._lock(ctx, selection['pipeline_uuid'], uow.session)
|
||||
await self._authorize(ctx, uow.session)
|
||||
row, plan, facts, pending = await self._selected(ctx, selection)
|
||||
row, plan, facts, pending = await self._selected(ctx, selection, data_only=data_only)
|
||||
if facts != expected_facts:
|
||||
raise MigrationError('plugin_changed')
|
||||
if pending:
|
||||
@@ -595,27 +744,30 @@ class PipelineMigrationService:
|
||||
.values(state='active')
|
||||
)
|
||||
|
||||
async def _run(self, ctx, execution, items, task_context):
|
||||
async def _run(self, ctx, execution, items, task_context, results=None, *, data_only=False):
|
||||
async with self.pm.tenant_scope(execution.workspace_uuid):
|
||||
for selection, result in zip(items, task_context.metadata['results']):
|
||||
for selection, result in zip(items, results if results is not None else task_context.metadata['results']):
|
||||
committed = False
|
||||
commit_attempted = False
|
||||
try:
|
||||
await self._authorize(ctx)
|
||||
row, plan, facts, pending = await self._selected(ctx, selection)
|
||||
runtime_schema = await self._verify_runtime(execution, plan)
|
||||
row, plan, facts, pending = await self._selected(ctx, selection, data_only=data_only)
|
||||
runtime_schema = None if data_only else await self._verify_runtime(execution, plan)
|
||||
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'])
|
||||
runtime = await self.ap.pipeline_mgr.prepare_pipeline(execution, copy.deepcopy(candidate_entity))
|
||||
if await self._verify_runtime(execution, plan) != runtime_schema:
|
||||
if not data_only and await self._verify_runtime(execution, plan) != runtime_schema:
|
||||
raise MigrationError('runner_schema_changed')
|
||||
# Runtime awaits are over. Recheck authorization/source/plugin
|
||||
# facts under database locks, then atomically journal and CAS.
|
||||
commit_attempted = True
|
||||
snapshot_uuid, target_fingerprint = await self._commit(ctx, selection, facts, candidate_entity)
|
||||
snapshot_uuid, target_fingerprint = await self._commit(
|
||||
ctx, selection, facts, candidate_entity, **({'data_only': True} if data_only else {})
|
||||
)
|
||||
committed = True
|
||||
await self._activate(ctx, selection, snapshot_uuid, target_fingerprint, runtime, plan, facts)
|
||||
result.update(state='migrated', code=None)
|
||||
result.update(state='migrated', code='data_only' if data_only else None)
|
||||
except (Exception, asyncio.CancelledError) as exc:
|
||||
cancelled = isinstance(exc, asyncio.CancelledError)
|
||||
reconciliation_cancel = None
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Runner-owned Box selection over Host-owned execution and file services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import base64
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
|
||||
from langbot_plugin.api.entities.builtin.platform import message as pm
|
||||
from langbot_plugin.api.entities.builtin.runner.box import BoxAcquireRequest
|
||||
from langbot_plugin.box.errors import BoxValidationError
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunBoxBinding:
|
||||
run_id: str
|
||||
session_id: str
|
||||
spec: dict
|
||||
io_scope: str
|
||||
imported: dict = field(default_factory=dict)
|
||||
exported: dict = field(default_factory=dict)
|
||||
submitted: set = field(default_factory=set)
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
def prepare_input_files(query, agent_input):
|
||||
"""Retain provider inputs and issue references without downloading files.
|
||||
|
||||
Only platform-origin components may carry a Host-local path. A path in an
|
||||
event envelope is metadata, never permission to read the Host filesystem.
|
||||
"""
|
||||
from langbot_plugin.api.entities.builtin.runner.input import InputAttachment
|
||||
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
|
||||
originals = input_files(query)
|
||||
if not agent_input.attachments:
|
||||
agent_input.attachments = [
|
||||
InputAttachment.model_validate(item)
|
||||
for item in QueryEntryAdapter._build_attachments(query, [c.model_dump() for c in agent_input.contents])
|
||||
]
|
||||
components = []
|
||||
for i, attachment in enumerate(agent_input.attachments):
|
||||
kind = (attachment.type or 'file').lower()
|
||||
fields = {'url': attachment.url or '', 'base64': attachment.content or ''}
|
||||
cls = {'image': pm.Image, 'voice': pm.Voice}.get(kind, pm.File)
|
||||
if cls is pm.File:
|
||||
fields.update(name=attachment.name or '', id=attachment.id or '')
|
||||
component = cls(**fields)
|
||||
# Match by payload, never by list position (contents and message chains
|
||||
# can describe attachments in different orders).
|
||||
for original in originals:
|
||||
if type(original) is not cls:
|
||||
continue
|
||||
if (
|
||||
(attachment.url and attachment.url == original.url)
|
||||
or (attachment.content and attachment.content == original.base64)
|
||||
or (
|
||||
attachment.id
|
||||
and attachment.id
|
||||
== getattr(original, 'id', getattr(original, 'image_id', getattr(original, 'voice_id', None)))
|
||||
)
|
||||
or (
|
||||
not attachment.url
|
||||
and not attachment.content
|
||||
and not attachment.id
|
||||
and attachment.name
|
||||
and attachment.name == getattr(original, 'name', None)
|
||||
)
|
||||
):
|
||||
component = original
|
||||
break
|
||||
components.append(component)
|
||||
attachment.ref, attachment.path = f'attachment-{i}', None
|
||||
object.__setattr__(query, '_box_inputs', components)
|
||||
|
||||
|
||||
def input_files(query):
|
||||
prepared = getattr(query, '_box_inputs', None)
|
||||
if prepared is not None:
|
||||
return prepared
|
||||
return [c for c in query.message_chain or [] if isinstance(c, (pm.Image, pm.Voice, pm.File))]
|
||||
|
||||
|
||||
def binding_for(query) -> RunBoxBinding:
|
||||
binding = getattr(query, '_box_binding', None)
|
||||
if not isinstance(binding, RunBoxBinding):
|
||||
raise BoxValidationError('Runner must bind a Box before using sandbox tools or files')
|
||||
return binding
|
||||
|
||||
|
||||
def exported_message(query, file_ids: list[str], *, consume: bool = False) -> pm.MessageChain:
|
||||
binding = binding_for(query)
|
||||
if not isinstance(file_ids, list) or len(file_ids) > 100 or any(type(x) is not str for x in file_ids):
|
||||
raise BoxValidationError('file_ids must contain at most 100 exported file IDs')
|
||||
if len(set(file_ids)) != len(file_ids):
|
||||
raise BoxValidationError('Duplicate exported file ID')
|
||||
if any(key not in binding.exported or key in binding.submitted for key in file_ids):
|
||||
raise BoxValidationError('Output file does not belong to this run or has already been submitted')
|
||||
components = []
|
||||
for key in file_ids:
|
||||
item = binding.exported[key]
|
||||
if item['type'] == 'Image':
|
||||
components.append(pm.Image(base64=item['base64']))
|
||||
elif item['type'] == 'Voice':
|
||||
components.append(pm.Voice(base64=item['base64']))
|
||||
else:
|
||||
components.append(pm.File(name=item['name'], base64=item['base64']))
|
||||
if consume:
|
||||
binding.submitted.update(file_ids)
|
||||
return pm.MessageChain(components)
|
||||
|
||||
|
||||
class RunnerBoxService:
|
||||
def __init__(self, box):
|
||||
self.box = box
|
||||
|
||||
async def status(self, context):
|
||||
status = await self.box.get_status(context)
|
||||
capacity = status.get('capacity', {})
|
||||
return {
|
||||
'enabled': self.box.enabled,
|
||||
'available': status.get('available', False),
|
||||
'limit': capacity.get('limit'),
|
||||
'used': capacity.get('used'),
|
||||
'remaining': capacity.get('remaining'),
|
||||
'required_reuse_key': 'global' if self.box.managed_admission_required else None,
|
||||
'reason': status.get('connector_error'),
|
||||
}
|
||||
|
||||
async def sessions(self, context):
|
||||
# Do not hide connection failures behind an empty list.
|
||||
context = await self.box.require_workspace_sandbox(context)
|
||||
return await self.box.client.get_sessions(action_context=self.box._action_context(context))
|
||||
|
||||
@staticmethod
|
||||
def public_session(item):
|
||||
return {'id': item['session_id'], 'status': item.get('status', 'idle')}
|
||||
|
||||
async def acquire(self, context, data, query=None):
|
||||
request = BoxAcquireRequest.model_validate(data)
|
||||
key = request.reuse_key
|
||||
if self.box.managed_admission_required:
|
||||
if key != 'global':
|
||||
raise BoxValidationError('This deployment requires the global Box reuse key')
|
||||
session_id = 'global'
|
||||
else:
|
||||
session_id = 'runner-' + hashlib.sha256(key.encode()).hexdigest()
|
||||
# Plugin options cannot override policy, host mounts, or execution identity.
|
||||
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)
|
||||
return self.public_session(result)
|
||||
|
||||
async def bind(self, context, query, run_id, box_id):
|
||||
current = getattr(query, '_box_binding', None)
|
||||
if current is not None:
|
||||
if current.run_id != run_id or current.session_id != box_id:
|
||||
raise BoxValidationError('A run cannot change its Box after binding')
|
||||
return self.binding_info(current)
|
||||
sessions = await self.sessions(context)
|
||||
item = next((item for item in sessions if item['session_id'] == box_id), None)
|
||||
if item is None:
|
||||
raise BoxValidationError('Box not found in the current Workspace')
|
||||
current = getattr(query, '_box_binding', None)
|
||||
if current is not None:
|
||||
if current.run_id != run_id or current.session_id != box_id:
|
||||
raise BoxValidationError('A run cannot change its Box after binding')
|
||||
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)
|
||||
object.__setattr__(query, '_box_binding', binding)
|
||||
return self.binding_info(binding)
|
||||
|
||||
@staticmethod
|
||||
def binding_info(binding):
|
||||
return {
|
||||
'box_id': binding.session_id,
|
||||
'outbox': f'/workspace/outbox/{binding.io_scope}',
|
||||
}
|
||||
|
||||
async def import_attachments(self, query, attachment_ids):
|
||||
binding = binding_for(query)
|
||||
available = {f'attachment-{i}': c for i, c in enumerate(input_files(query))}
|
||||
ids = list(available) if attachment_ids is None else attachment_ids
|
||||
if not isinstance(ids, list) or any(type(key) is not str or key not in available for key in ids):
|
||||
raise BoxValidationError('Unknown input attachment reference')
|
||||
if len(ids) != len(set(ids)):
|
||||
raise BoxValidationError('Duplicate input attachment reference')
|
||||
async with binding.lock:
|
||||
for key in ids:
|
||||
if key in binding.imported:
|
||||
continue
|
||||
transfer = copy.copy(query)
|
||||
# Each input has its own directory: repeated imports cannot erase other files.
|
||||
object.__setattr__(transfer, '_box_binding', replace(binding, io_scope=f'{binding.io_scope}-{key}'))
|
||||
transfer.message_chain = pm.MessageChain([available[key]])
|
||||
items = await self.box.materialize_inbound_attachments(transfer)
|
||||
if not items:
|
||||
raise BoxValidationError(f'Failed to import input attachment {key}')
|
||||
binding.imported[key] = {'id': key, **items[0]}
|
||||
return {'items': [binding.imported[key] for key in ids]}
|
||||
|
||||
async def export_files(self, query):
|
||||
binding = binding_for(query)
|
||||
async with binding.lock:
|
||||
items = await self.box.collect_outbound_attachments(query)
|
||||
for item in items:
|
||||
if len(binding.exported) >= 100:
|
||||
raise BoxValidationError('At most 100 output files may be exported per run')
|
||||
size = len(base64.b64decode(item['base64'].split(';base64,')[-1]))
|
||||
if sum(x['size'] for x in binding.exported.values()) + size > self.box._ATTACHMENT_MAX_TOTAL_BYTES:
|
||||
raise BoxValidationError('Output files exceed the per-run byte limit')
|
||||
key = str(uuid.uuid4())
|
||||
binding.exported[key] = {
|
||||
**item,
|
||||
'id': key,
|
||||
'size': size,
|
||||
}
|
||||
return {
|
||||
'items': [
|
||||
{k: v for k, v in item.items() if k != 'base64'}
|
||||
for key, item in binding.exported.items()
|
||||
if key not in binding.submitted
|
||||
]
|
||||
}
|
||||
@@ -38,8 +38,6 @@ _INT_ADAPTER = pydantic.TypeAdapter(int)
|
||||
_UTC = _dt.timezone.utc
|
||||
_MAX_RECENT_ERRORS = 50
|
||||
_MIB = 1024 * 1024
|
||||
_HOST_BOX_SCOPE_VARIABLE = '_host_box_scope'
|
||||
_BOX_SESSION_ID_PREFIX = 'lb-box-'
|
||||
_DEFAULT_MAX_WORKSPACE_ENTRIES = 100_000
|
||||
_HARD_MAX_WORKSPACE_ENTRIES = 1_000_000
|
||||
|
||||
@@ -584,6 +582,10 @@ class BoxService:
|
||||
raise BoxError(
|
||||
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||
)
|
||||
from .runner import binding_for
|
||||
|
||||
binding = binding_for(query)
|
||||
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)
|
||||
await self._require_validated_workspace_sandbox(execution_context)
|
||||
@@ -636,49 +638,10 @@ class BoxService:
|
||||
return self._serialize_result(result)
|
||||
|
||||
def resolve_box_session_id(self, query: pipeline_query.Query) -> str:
|
||||
"""Resolve a Host-owned Box session ID for the current conversation."""
|
||||
if query is None:
|
||||
raise BoxValidationError('Box execution requires a Host session context.')
|
||||
"""Use the Box explicitly bound by the current Runner invocation."""
|
||||
from .runner import binding_for
|
||||
|
||||
if self._cloud_managed:
|
||||
return 'global'
|
||||
|
||||
variables = getattr(query, 'variables', None)
|
||||
if isinstance(variables, dict) and _HOST_BOX_SCOPE_VARIABLE in variables:
|
||||
private_scope = variables[_HOST_BOX_SCOPE_VARIABLE]
|
||||
if not isinstance(private_scope, str) or not private_scope.strip():
|
||||
raise BoxValidationError('Box execution requires a Host conversation scope.')
|
||||
return self._hash_box_session_scope(f'host:{private_scope}')
|
||||
|
||||
forced_template = self._forced_box_session_id_template()
|
||||
if forced_template:
|
||||
template = forced_template
|
||||
else:
|
||||
template = (
|
||||
(query.pipeline_config or {})
|
||||
.get('ai', {})
|
||||
.get('local-agent', {})
|
||||
.get('box-session-id-template', '{launcher_type}_{launcher_id}')
|
||||
)
|
||||
variables = dict(query.variables or {})
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
if hasattr(launcher_type, 'value'):
|
||||
launcher_type = launcher_type.value
|
||||
|
||||
sender_id = getattr(query, 'sender_id', None)
|
||||
query_id = getattr(query, 'query_id', None)
|
||||
variables.setdefault('query_id', str(query_id or 'unknown'))
|
||||
variables.setdefault('launcher_type', str(launcher_type or 'query'))
|
||||
variables.setdefault('launcher_id', str(launcher_id or query_id or 'unknown'))
|
||||
variables.setdefault('sender_id', str(sender_id or launcher_id or query_id or 'unknown'))
|
||||
variables.setdefault('global', 'global')
|
||||
return template.format_map(collections.defaultdict(lambda: 'unknown', variables))
|
||||
|
||||
@staticmethod
|
||||
def _hash_box_session_scope(scope: str) -> str:
|
||||
digest = hashlib.sha256(scope.encode('utf-8')).hexdigest()
|
||||
return f'{_BOX_SESSION_ID_PREFIX}{digest}'
|
||||
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.
|
||||
@@ -826,6 +789,9 @@ class BoxService:
|
||||
_EXEC_FALLBACK_MAX_BYTES = 256 * 1024
|
||||
|
||||
def _attachment_query_key(self, query: pipeline_query.Query) -> str:
|
||||
binding = getattr(query, '_box_binding', None)
|
||||
if binding is not None:
|
||||
return binding.io_scope
|
||||
query_uuid = str(getattr(query, 'query_uuid', '') or '').strip()
|
||||
if query_uuid:
|
||||
if query_uuid in {'.', '..'} or '/' in query_uuid or '\\' in query_uuid or '\x00' in query_uuid:
|
||||
@@ -1837,16 +1803,6 @@ class BoxService:
|
||||
raw = str(self._local_config().get('image', '') or '').strip()
|
||||
return raw or None
|
||||
|
||||
def _forced_box_session_id_template(self) -> str:
|
||||
"""Return the operator-forced sandbox scope template, if configured."""
|
||||
|
||||
limitation = (
|
||||
(self.ap.instance_config.data or {}).get('system', {}).get('limitation', {})
|
||||
if getattr(self.ap, 'instance_config', None) is not None
|
||||
else {}
|
||||
)
|
||||
return str(limitation.get('force_box_session_id_template', '') or '').strip()
|
||||
|
||||
def _load_workspace_quota_mb(self) -> int | None:
|
||||
raw_value = self._local_config().get('workspace_quota_mb')
|
||||
if raw_value in (None, ''):
|
||||
@@ -2073,51 +2029,6 @@ class BoxService:
|
||||
and error.get('workspace_uuid') == execution_context.workspace_uuid
|
||||
]
|
||||
|
||||
def get_system_guidance(self, query: pipeline_query.Query | int | str | None = None) -> str:
|
||||
"""Return LLM system-prompt guidance for the exec tool.
|
||||
|
||||
All execution-specific prompt text is kept here so that callers
|
||||
(e.g. LocalRunner) stay free of box domain knowledge.
|
||||
|
||||
``query`` is the current turn's pipeline query. When provided,
|
||||
the guidance ALWAYS advertises the per-query outbox path so the agent
|
||||
knows how to deliver generated files back to the user — even on turns
|
||||
where the user sent no inbound attachment (e.g. "generate a QR code"),
|
||||
which is exactly when the inbound-attachment note never fires. Outbound
|
||||
collection in the wrapper runs on every turn regardless of inbound
|
||||
files, so without this the file would be produced and silently dropped.
|
||||
"""
|
||||
guidance = (
|
||||
'When the exec tool is available, use it for exact calculations, statistics, structured data parsing, '
|
||||
'and code execution instead of estimating mentally. If the user provides numbers, tables, CSV-like text, '
|
||||
'JSON, or other data and asks for a computed answer, prefer running a short Python script via exec '
|
||||
'and then answer from the tool result. Unless the user explicitly asks for the script, code, or implementation '
|
||||
'details, do not include the generated script in the final answer; return the result and a brief explanation only.'
|
||||
)
|
||||
if self.default_workspace:
|
||||
guidance += (
|
||||
' A default workspace is mounted at /workspace for file tasks. When the user asks to read, create, or '
|
||||
'modify local files in the working directory, use exec with /workspace paths directly; do not ask the '
|
||||
'user for directory parameters unless they explicitly need a different directory.'
|
||||
)
|
||||
if query is not None:
|
||||
if not isinstance(query, (int, str)):
|
||||
query_key = self._attachment_query_key(query)
|
||||
else:
|
||||
# Backwards compatibility for OSS callers/tests that passed
|
||||
# the old process-local integer identity. Cloud callers must
|
||||
# pass the full Query so an opaque UUID is always advertised.
|
||||
if self._cloud_managed:
|
||||
raise BoxValidationError('Cloud outbox guidance requires a pipeline Query')
|
||||
query_key = str(query)
|
||||
outbox_dir = f'{self.OUTBOX_MOUNT_DIR}/{query_key}'
|
||||
guidance += (
|
||||
f' If you produce any file (image, audio, document, etc.) that should be sent back to the user, '
|
||||
f'write it into {outbox_dir}/ (create the directory if needed). Every file placed there will be '
|
||||
'delivered to the user automatically; do not paste file contents or base64 into your reply.'
|
||||
)
|
||||
return guidance
|
||||
|
||||
async def get_backend_status(self) -> dict:
|
||||
"""Return instance-level backend readiness without tenant resource data."""
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from urllib.parse import urlsplit
|
||||
PLANNER_VERSION = '3'
|
||||
|
||||
_TARGETS = {
|
||||
'local-agent': ('LocalAgent', '0.1.6'),
|
||||
'local-agent': ('LocalAgent', '0.1.7'),
|
||||
'dify-service-api': ('DifyAgent', '0.1.7'),
|
||||
'coze-api': ('CozeAgent', '0.1.7'),
|
||||
'dashscope-app-api': ('DashScopeAgent', '0.1.7'),
|
||||
@@ -339,11 +339,12 @@ def _validate_local(result, section):
|
||||
_block(result, 'invalid_type', f'{prefix}.model')
|
||||
if not _valid_prompt(section.get('prompt')):
|
||||
_block(result, 'local.prompt_shape', f'{prefix}.prompt')
|
||||
if section.get('box-session-id-template') not in (None, '', '{launcher_type}_{launcher_id}'):
|
||||
_block(result, 'local.box_scope', f'{prefix}.box-session-id-template')
|
||||
_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, 'invalid_type', f'{prefix}.box-session-id-template')
|
||||
_warn(result, 'local.box_state_reset', f'{prefix}.box-session-id-template')
|
||||
|
||||
|
||||
@@ -543,7 +544,6 @@ def _assemble(result, legacy, section, config, preferences):
|
||||
selected['user-id-source'] = _IDENTITY_SOURCES[legacy]
|
||||
if legacy == 'local-agent':
|
||||
selected.pop('max-round', None)
|
||||
selected.pop('box-session-id-template', None)
|
||||
singular = selected.pop('knowledge-base', '')
|
||||
if not selected['knowledge-bases'] and singular and singular != '__none__':
|
||||
selected['knowledge-bases'] = [singular]
|
||||
|
||||
@@ -147,12 +147,14 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
if result.all_content is not None:
|
||||
result = result.model_copy(update={'content': result.all_content})
|
||||
elif is_stream and isinstance(result, provider_message.Message):
|
||||
attachments = result.attachments
|
||||
result = provider_message.MessageChunk.model_validate(
|
||||
{
|
||||
**result.model_dump(),
|
||||
'is_final': True,
|
||||
}
|
||||
)
|
||||
result.attachments = attachments
|
||||
|
||||
result.resp_message_id = str(resp_message_id)
|
||||
|
||||
|
||||
@@ -25,50 +25,6 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
async def initialize(self, pipeline_config: dict):
|
||||
pass
|
||||
|
||||
def _is_final_assistant_message(self, result) -> bool:
|
||||
"""Whether *result* is the agent's final, tool-call-free answer.
|
||||
|
||||
Intermediate streaming chunks and tool-call rounds must NOT trigger
|
||||
outbound attachment collection — only the terminal assistant message.
|
||||
"""
|
||||
if getattr(result, 'role', None) != 'assistant':
|
||||
return False
|
||||
if result.tool_calls:
|
||||
return False
|
||||
if isinstance(result, provider_message.MessageChunk):
|
||||
return bool(result.is_final)
|
||||
return True
|
||||
|
||||
async def _append_outbound_attachments(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> None:
|
||||
"""Collect sandbox outbox files and append them to *message_chain*.
|
||||
|
||||
Runs at most once per query (guarded by a query variable) and never
|
||||
raises into the pipeline — attachment delivery is best-effort.
|
||||
"""
|
||||
if query.variables.get('_sandbox_outbound_collected'):
|
||||
return
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None or not getattr(box_service, 'available', False):
|
||||
return
|
||||
query.variables['_sandbox_outbound_collected'] = True
|
||||
try:
|
||||
attachments = await box_service.collect_outbound_attachments(query)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Outbound attachment collection failed: {e}')
|
||||
return
|
||||
for att in attachments:
|
||||
att_type = att.get('type')
|
||||
if att_type == 'Image':
|
||||
message_chain.append(platform_message.Image(base64=att['base64']))
|
||||
elif att_type == 'Voice':
|
||||
message_chain.append(platform_message.Voice(base64=att['base64']))
|
||||
else:
|
||||
message_chain.append(platform_message.File(name=att.get('name', 'file'), base64=att['base64']))
|
||||
|
||||
async def process(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
@@ -105,7 +61,7 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
|
||||
reply_text = ''
|
||||
|
||||
if result.content: # 有内容
|
||||
if result.content or result.attachments: # 有内容
|
||||
reply_text = str(result.get_content_platform_message_chain())
|
||||
|
||||
# ============= 触发插件事件 ===============
|
||||
@@ -140,11 +96,6 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
reply_chain = result.get_content_platform_message_chain()
|
||||
is_plugin_reply = False
|
||||
|
||||
# Attach files the agent produced in the sandbox
|
||||
# outbox, but only on the terminal assistant message.
|
||||
if self._is_final_assistant_message(result):
|
||||
await self._append_outbound_attachments(query, reply_chain)
|
||||
|
||||
query.resp_message_chain.append(reply_chain)
|
||||
if is_plugin_reply:
|
||||
plugin_diagnostics.record_last_plugin_response_source(
|
||||
@@ -162,9 +113,8 @@ class ResponseWrapper(stage.PipelineStage):
|
||||
isinstance(result, provider_message.MessageChunk) and result.is_final and not result.tool_calls
|
||||
):
|
||||
# Final streaming chunk with no text content but
|
||||
# possibly carrying sandbox outbox attachments.
|
||||
# no implicit file collection.
|
||||
reply_chain = platform_message.MessageChain([])
|
||||
await self._append_outbound_attachments(query, reply_chain)
|
||||
query.resp_message_chain.append(reply_chain)
|
||||
yield entities.StageProcessResult(
|
||||
result_type=entities.ResultType.CONTINUE,
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Authenticated plugin Box resources and invocation file operations."""
|
||||
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction as Action
|
||||
from langbot_plugin.runtime.io.handler import ActionResponse
|
||||
from ..box.runner import RunnerBoxService
|
||||
from .agent_run_support import _validate_agent_run_session
|
||||
|
||||
|
||||
def register(h):
|
||||
async def dispatch(action, data):
|
||||
from .handler import _resolve_action_query
|
||||
|
||||
action_context, _ = await h._require_plugin_action_context()
|
||||
context = h._execution_context(action_context)
|
||||
query = None
|
||||
run_id = data.get('run_id')
|
||||
context_actions = {Action.BIND_BOX, Action.IMPORT_BOX_ATTACHMENTS, Action.EXPORT_BOX_FILES}
|
||||
if action in context_actions and not run_id:
|
||||
return ActionResponse.error('run_id is required for Box context operations')
|
||||
if run_id:
|
||||
session, error = await _validate_agent_run_session(
|
||||
run_id,
|
||||
data.get('caller_plugin_identity'),
|
||||
h.ap,
|
||||
'Box API',
|
||||
api_capability='box',
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
query = _resolve_action_query(data, session, h.ap, action_context)
|
||||
if query is None:
|
||||
return ActionResponse.error('The current execution context has expired')
|
||||
service = RunnerBoxService(h.ap.box_service)
|
||||
if action == Action.GET_BOX_STATUS:
|
||||
return ActionResponse.success(await service.status(context))
|
||||
if action == Action.LIST_BOXES:
|
||||
return ActionResponse.success(
|
||||
{'items': [service.public_session(s) for s in await service.sessions(context)]}
|
||||
)
|
||||
if action == Action.ACQUIRE_BOX:
|
||||
request = {k: v for k, v in data.items() if k not in {'run_id', 'caller_plugin_identity'}}
|
||||
return ActionResponse.success(await service.acquire(context, request, query))
|
||||
if action == Action.BIND_BOX:
|
||||
return ActionResponse.success(await service.bind(context, query, run_id, data['box_id']))
|
||||
if action == Action.IMPORT_BOX_ATTACHMENTS:
|
||||
return ActionResponse.success(await service.import_attachments(query, data.get('attachment_ids')))
|
||||
if action == Action.EXPORT_BOX_FILES:
|
||||
return ActionResponse.success(await service.export_files(query))
|
||||
raise ValueError('Unsupported Box action')
|
||||
|
||||
def register_action(action):
|
||||
@h.action(action)
|
||||
async def invoke(data):
|
||||
try:
|
||||
return await dispatch(action, data)
|
||||
except Exception as exc:
|
||||
return ActionResponse.error(f'{type(exc).__name__}: {exc}')
|
||||
|
||||
for action in (
|
||||
Action.GET_BOX_STATUS,
|
||||
Action.LIST_BOXES,
|
||||
Action.ACQUIRE_BOX,
|
||||
Action.BIND_BOX,
|
||||
Action.IMPORT_BOX_ATTACHMENTS,
|
||||
Action.EXPORT_BOX_FILES,
|
||||
):
|
||||
register_action(action)
|
||||
@@ -1631,6 +1631,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
task_context: taskmgr.TaskContext | None,
|
||||
version: str | None = None,
|
||||
) -> tuple[bytes | None, str | None]:
|
||||
"""Return a plugin package, or install an MCP/skill and return none."""
|
||||
|
||||
@@ -1640,6 +1641,22 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
timeout=15,
|
||||
event_hooks=httpclient.httpx_response_limit_hooks(_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES),
|
||||
) as client:
|
||||
if version is not None:
|
||||
if (
|
||||
not isinstance(version, str)
|
||||
or not version
|
||||
or any(
|
||||
c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._+-' for c in version
|
||||
)
|
||||
):
|
||||
raise ValueError('Invalid plugin version')
|
||||
_status, package = await _marketplace_get(
|
||||
client,
|
||||
f'{space_url}/api/v1/marketplace/plugins/download/{plugin_author}/{plugin_name}/{version}',
|
||||
max_bytes=_MARKETPLACE_PLUGIN_DOWNLOAD_MAX_BYTES,
|
||||
)
|
||||
return package, version
|
||||
|
||||
mcp_status, mcp_body = await _marketplace_get(
|
||||
client,
|
||||
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}',
|
||||
@@ -1726,11 +1743,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if task_context is not None:
|
||||
task_context.set_current_action('downloading plugin package')
|
||||
task_context.metadata['progress_percent'] = 15
|
||||
version_options = {'version': install_info['plugin_version']} if install_info.get('plugin_version') else {}
|
||||
file_bytes, version = await self._download_marketplace_package(
|
||||
execution_context,
|
||||
plugin_author,
|
||||
plugin_name,
|
||||
task_context,
|
||||
**version_options,
|
||||
)
|
||||
if file_bytes is None:
|
||||
return
|
||||
|
||||
@@ -1188,7 +1188,18 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
output_lock = None
|
||||
output_lock_acquired = False
|
||||
try:
|
||||
if data.get('file_ids') is not None:
|
||||
from ..box.runner import exported_message, binding_for
|
||||
|
||||
query = _resolve_action_query(data, session, self.ap, action_context)
|
||||
output_lock = binding_for(query).lock
|
||||
await output_lock.acquire()
|
||||
output_lock_acquired = True
|
||||
files = exported_message(query, data['file_ids'])
|
||||
data = {**data, 'params': {**(data.get('params') or {}), 'message': files.model_dump(mode='json')}}
|
||||
tool_name, parameters, message = resolve_platform_api_call(
|
||||
session, data.get('bot_uuid'), data['action'], data.get('params') or {}, data.get('context_tool')
|
||||
)
|
||||
@@ -1208,9 +1219,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
parameters,
|
||||
message_chain=message,
|
||||
)
|
||||
if data.get('file_ids') is not None:
|
||||
exported_message(query, data['file_ids'], consume=True)
|
||||
return handler.ActionResponse.success(data={'result': _serialize_plugin_api_result(result)})
|
||||
except (ValueError, KeyError, TypeError) as exc:
|
||||
return handler.ActionResponse.error(message=str(exc))
|
||||
finally:
|
||||
if output_lock_acquired:
|
||||
output_lock.release()
|
||||
|
||||
@self.action(PluginToRuntimeAction.SEND_MESSAGE)
|
||||
async def send_message(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
@@ -2634,6 +2650,9 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
agent_pull_actions.register(self)
|
||||
runner_actions.register(self)
|
||||
from . import box_actions
|
||||
|
||||
box_actions.register(self)
|
||||
agent_state_actions.register(self)
|
||||
|
||||
@self.action(CommonAction.PING)
|
||||
|
||||
@@ -264,6 +264,9 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
|
||||
from ....box.runner import binding_for
|
||||
|
||||
binding_for(query)
|
||||
require_sandbox = getattr(
|
||||
getattr(self.ap, 'box_service', None),
|
||||
'require_workspace_sandbox',
|
||||
|
||||
@@ -106,12 +106,6 @@ system:
|
||||
max_pipelines: -1
|
||||
max_extensions: -1
|
||||
max_knowledge_bases: -1
|
||||
# When set to a non-empty string, every pipeline is forced to use this
|
||||
# Box sandbox-scope template regardless of its own configuration, and
|
||||
# the per-pipeline "Sandbox Scope" selector is locked in the web UI.
|
||||
# Used by SaaS deployments to confine a tenant to a single shared
|
||||
# sandbox (set to '{global}'). Empty string = no restriction.
|
||||
force_box_session_id_template: ''
|
||||
task_retention:
|
||||
# Keep at most this many completed async task records in memory
|
||||
completed_limit: 200
|
||||
|
||||
Reference in New Issue
Block a user