feat: delegate sandbox policy to runners and simplify pipeline migration

This commit is contained in:
RockChinQ
2026-09-17 23:05:27 +08:00
parent 8c119bc4b6
commit b264d46d77
57 changed files with 2111 additions and 1751 deletions
@@ -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:
+24 -49
View File
@@ -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
+233
View File
@@ -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
]
}
+10 -99
View File
@@ -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)
+2 -52
View File
@@ -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,
+67
View File
@@ -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)
+19
View File
@@ -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
+19
View File
@@ -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',
-6
View File
@@ -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