mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
fix(agent): harden runner integration and QA
This commit is contained in:
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user