fix(agent): harden runner integration and QA

This commit is contained in:
huanghuoguoguo
2026-08-01 09:23:41 +08:00
parent 55f42a4ebc
commit 79611a5513
38 changed files with 1315 additions and 132 deletions
+14 -1
View File
@@ -235,6 +235,15 @@ class AgentRunnerRegistry:
runners = await self.list_runners(context, bound_plugins=None)
descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None:
# The runtime launches installed plugins asynchronously, so an
# early non-empty discovery can still be only a partial snapshot.
runners = await self.list_runners(
context,
bound_plugins=None,
use_cache=False,
)
descriptor = next((item for item in runners if item.id == runner_id), None)
if descriptor is None:
raise RunnerNotFoundError(runner_id)
@@ -255,7 +264,11 @@ class AgentRunnerRegistry:
Returns runner options and their config schemas for the DynamicForm.
"""
# Get all runners (no bound plugin filter for metadata listing)
runners = await self.list_runners(context, bound_plugins=None)
runners = await self.list_runners(
context,
bound_plugins=None,
use_cache=False,
)
options = []
stages = []
+5
View File
@@ -559,6 +559,11 @@ class BoxService:
namespace = box_namespace(self._action_context(context))
return os.path.join(self.default_workspace, 'tenants', namespace)
def workspace_host_path(self, context: TenantContext) -> str | None:
"""Return the host path mounted as /workspace for one execution context."""
return self._tenant_workspace(context)
async def execute_spec_payload(
self,
spec_payload: dict,
@@ -0,0 +1,149 @@
"""repair an ownerless local Workspace after tenancy migration
Revision ID: 0017_local_owner_repair
Revises: 0016_agent_workspace
Create Date: 2026-07-31
"""
from __future__ import annotations
import datetime
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0017_local_owner_repair'
down_revision = '0016_agent_workspace'
branch_labels = None
depends_on = None
def _table_names(conn: sa.Connection) -> set[str]:
return set(sa.inspect(conn).get_table_names())
def upgrade() -> None:
conn = op.get_bind()
required = {'metadata', 'users', 'workspaces', 'workspace_memberships'}
if not required.issubset(_table_names(conn)):
return
metadata = sa.table(
'metadata',
sa.column('key', sa.String(255)),
sa.column('value', sa.String(255)),
)
instance_uuid = conn.execute(
sa.select(metadata.c.value).where(metadata.c.key == 'instance_uuid')
).scalar_one_or_none()
if not isinstance(instance_uuid, str) or not instance_uuid.strip():
return
workspaces = sa.table(
'workspaces',
sa.column('uuid', sa.String(36)),
sa.column('instance_uuid', sa.String(255)),
sa.column('source', sa.String(32)),
sa.column('created_by_account_uuid', sa.String(36)),
)
workspace_uuids = conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid.strip(),
workspaces.c.source == 'local',
)
).scalars().all()
if not workspace_uuids:
return
if len(workspace_uuids) > 1:
raise RuntimeError(f'Multiple local Workspaces exist for instance {instance_uuid!r}')
workspace_uuid = workspace_uuids[0]
if conn.dialect.name == 'postgresql':
conn.execute(
sa.text("SELECT set_config('langbot.workspace_uuid', :workspace_uuid, true)"),
{'workspace_uuid': workspace_uuid},
)
memberships = sa.table(
'workspace_memberships',
sa.column('uuid', sa.String(36)),
sa.column('workspace_uuid', sa.String(36)),
sa.column('account_uuid', sa.String(36)),
sa.column('role', sa.String(32)),
sa.column('status', sa.String(32)),
sa.column('joined_at', sa.DateTime()),
sa.column('projection_revision', sa.BigInteger()),
)
active_owner = conn.execute(
sa.select(memberships.c.account_uuid).where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.role == 'owner',
memberships.c.status == 'active',
)
).scalar_one_or_none()
if active_owner is not None:
conn.execute(
workspaces.update()
.where(workspaces.c.uuid == workspace_uuid)
.where(workspaces.c.created_by_account_uuid.is_(None))
.values(created_by_account_uuid=active_owner)
)
return
users = sa.table(
'users',
sa.column('id', sa.Integer()),
sa.column('uuid', sa.String(36)),
sa.column('status', sa.String(32)),
)
owner_account_uuid = conn.execute(
sa.select(users.c.uuid)
.where(users.c.status == 'active')
.order_by(users.c.id)
.limit(1)
).scalar_one_or_none()
if owner_account_uuid is None:
return
now = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
membership = conn.execute(
sa.select(memberships.c.uuid, memberships.c.joined_at).where(
memberships.c.workspace_uuid == workspace_uuid,
memberships.c.account_uuid == owner_account_uuid,
)
).first()
if membership is None:
conn.execute(
memberships.insert().values(
uuid=str(uuid.uuid4()),
workspace_uuid=workspace_uuid,
account_uuid=owner_account_uuid,
role='owner',
status='active',
joined_at=now,
projection_revision=0,
)
)
else:
conn.execute(
memberships.update()
.where(memberships.c.uuid == membership.uuid)
.values(
role='owner',
status='active',
joined_at=membership.joined_at or now,
)
)
conn.execute(
workspaces.update()
.where(workspaces.c.uuid == workspace_uuid)
.values(created_by_account_uuid=owner_account_uuid)
)
def downgrade() -> None:
# The repair restores a required invariant; downgrading the revision should
# not deliberately recreate an ownerless Workspace.
pass
+1 -3
View File
@@ -180,9 +180,7 @@ class Controller:
)
self.ap.query_pool.condition.notify_all()
continue
if selected_query: # 找到
queries.remove(selected_query)
else: # 没找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
if selected_query is None: # 找到 说明:没有请求 或者 所有query对应的session都已达到并发上限
await self.ap.query_pool.condition.wait()
continue
@@ -537,7 +537,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
Image / Voice / File components uploaded from the web client carry a
storage key in ``path``. Resolve it to a base64 data URI so downstream
stages (multimodal LLM input and the Box sandbox inbox) have a usable
payload, then drop the now-consumed storage object.
payload. Keep the storage key for browser history; the configured
storage-retention cleanup removes expired uploads.
Args:
message_chain_obj: 消息链对象列表
@@ -592,12 +593,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
mime_type = mimetypes.guess_type(comp_path)[0] or 'application/octet-stream'
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.delete_scoped_object_key(
execution_context,
comp_path,
expected_owner_type='upload_image',
)
component['path'] = ''
except Exception as e:
await self.logger.error(f'Failed to load {comp_type} file {comp_path}: {e}')
raise
+14 -11
View File
@@ -994,7 +994,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
self._connected.clear()
runtime_handler = getattr(self, 'handler', None)
if runtime_handler is not None:
with contextlib.suppress(Exception):
with contextlib.suppress(Exception, asyncio.CancelledError):
await runtime_handler.close()
if getattr(self, 'handler', None) is runtime_handler:
del self.handler
@@ -1015,7 +1015,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
del self.handler_task
close_ctrl = getattr(getattr(self, 'ctrl', None), 'close', None)
if close_ctrl is not None:
with contextlib.suppress(Exception):
with contextlib.suppress(Exception, asyncio.CancelledError):
await close_ctrl()
async def aclose(self) -> None:
@@ -1717,6 +1717,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
except Exception:
await self._delete_artifact_if_unreferenced(execution_context, artifact_digest)
raise
if not previous_was_durable and self.runtime_profile == 'oss_dev':
bridge = self._legacy_oss_bridge_binding(execution_context)
try:
with runtime_handler.installation_scope(bridge):
async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
runtime_handler.register_installation_binding(
binding,
plugin_author=plugin_author,
@@ -1731,14 +1739,6 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
self._workspace_installations.setdefault(binding.workspace_uuid, set()).add(binding.installation_uuid)
if previous_digest is not None and previous_digest != artifact_digest:
await self._delete_artifact_if_unreferenced(execution_context, previous_digest)
if previous_digest is not None and not previous_was_durable and self.runtime_profile == 'oss_dev':
bridge = self._legacy_oss_bridge_binding(execution_context)
try:
with runtime_handler.installation_scope(bridge):
async for _ in runtime_handler.delete_plugin(plugin_author, plugin_name):
pass
except Exception as exc:
self.ap.logger.debug(f'Legacy OSS plugin cleanup skipped: {exc}')
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
await self._refresh_agent_runner_registry()
@@ -2213,7 +2213,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
)
if not isinstance(workspace_id, str) or not workspace_id.strip():
raise ValueError('AgentRunner execution requires a Workspace')
await self.require_workspace_context(workspace_id)
execution_context = await self._current_execution_context()
if workspace_id.strip() != execution_context.workspace_uuid:
raise WorkspaceNotFoundError('Plugin resource not found')
await self.require_workspace_context(execution_context)
binding = await self._target_binding(
plugin_author,
plugin_name,
+23 -5
View File
@@ -474,6 +474,7 @@ _RUNTIME_SCOPED_ACTIONS = frozenset(
RuntimeToLangBotAction.GET_PLUGIN_SETTINGS.value,
}
)
_OUTBOUND_INSTALLATION_CONTEXT_UNSET = object()
class RuntimeConnectionHandler(handler.Handler):
@@ -873,10 +874,12 @@ class RuntimeConnectionHandler(handler.Handler):
):
super().__init__(connection, disconnect_callback)
self.ap = ap
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
self._outbound_installation_context: contextvars.ContextVar[
InstallationBinding | None | object
] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=None,
default=_OUTBOUND_INSTALLATION_CONTEXT_UNSET,
)
)
self._installation_bindings: dict[
@@ -2075,15 +2078,27 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)
async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:
action_context, _ = await self._require_plugin_action_context()
action_context, identity = await self._require_plugin_action_context()
execution_context = self._execution_context(action_context)
installation_binding = InstallationBinding(
instance_uuid=action_context.instance_uuid,
workspace_uuid=action_context.workspace_uuid,
placement_generation=action_context.placement_generation,
installation_uuid=identity.installation_uuid,
runtime_revision=identity.runtime_revision,
artifact_digest=identity.artifact_digest,
)
storage_path = data['storage_path']
try:
content_bytes = await self.ap.rag_runtime_service.get_file_stream(
execution_context,
storage_path,
)
file_key = await self.send_file(content_bytes, '')
file_key = await self.send_file(
content_bytes,
'',
action_context=installation_binding,
)
return handler.ActionResponse.success(data={'file_key': file_key})
except Exception as e:
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
@@ -2427,10 +2442,13 @@ class RuntimeConnectionHandler(handler.Handler):
) -> InstallationBinding | ActionContext | None:
if action_context is not None:
return super().resolve_outbound_action_context(action_context)
scoped_context = self._outbound_installation_context.get()
if scoped_context is not _OUTBOUND_INSTALLATION_CONTEXT_UNSET:
return typing.cast(InstallationBinding | None, scoped_context)
inbound_context = self.current_action_context
if inbound_context is not None:
return inbound_context
return self._outbound_installation_context.get()
return None
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get()
@@ -487,10 +487,15 @@ class BoxStdioSessionRuntime:
)
def _shared_workspace_host_path(self) -> str:
default_workspace = getattr(self.ap.box_service, 'default_workspace', None)
if not default_workspace:
raise RuntimeError('Box default workspace is required for shared MCP host_path staging')
shared_host_path = normalize_host_path(default_workspace)
workspace_host_path = getattr(self.ap.box_service, 'workspace_host_path', None)
if callable(workspace_host_path):
shared_workspace = workspace_host_path(self.owner.execution_context)
else:
# Compatibility for older BoxService embedders used by plugins and tests.
shared_workspace = getattr(self.ap.box_service, 'default_workspace', None)
if not shared_workspace:
raise RuntimeError('Box Workspace host path is required for shared MCP host_path staging')
shared_host_path = normalize_host_path(shared_workspace)
os.makedirs(shared_host_path, exist_ok=True)
return shared_host_path