mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-02 05:45:20 +08:00
feat(api-tools): runner workspace operations delivery and execution API (LANG-227)
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
parent
24038395dd
commit
4d51fc1802
@@ -114,6 +114,7 @@ class AgentResources(typing.TypedDict):
|
||||
skills: list[SkillResource]
|
||||
storage: StorageResource
|
||||
platform_capabilities: dict[str, typing.Any]
|
||||
workspace_operations: typing.NotRequired[list[dict[str, typing.Any]]]
|
||||
|
||||
|
||||
class AgentRuntimeContext(typing.TypedDict):
|
||||
|
||||
@@ -129,6 +129,9 @@ class ResourcePolicy(pydantic.BaseModel):
|
||||
allow_workspace_storage: bool = False
|
||||
"""Whether workspace storage is allowed."""
|
||||
|
||||
allowed_workspace_operations: list[str] = pydantic.Field(default_factory=list)
|
||||
"""Workspace operations explicitly authorized for this processor."""
|
||||
|
||||
|
||||
class StatePolicy(pydantic.BaseModel):
|
||||
"""State policy for agent binding.
|
||||
|
||||
@@ -294,6 +294,9 @@ class AgentRunOrchestrator:
|
||||
execution_query=execution_query,
|
||||
platform_context=freeze_platform_context(event),
|
||||
reply_streams=reply_streams,
|
||||
processor_type=binding.processor_type,
|
||||
processor_id=binding.processor_id,
|
||||
trigger_actor=event.actor.actor_id if event.actor else None,
|
||||
)
|
||||
|
||||
event_log_id = await self.journal.write_event_log(
|
||||
|
||||
@@ -47,7 +47,7 @@ class QueryEntryAdapter:
|
||||
|
||||
INTERNAL_PREFIX = '_'
|
||||
SENSITIVE_PATTERNS = ('secret', 'token', 'key', 'password', 'credential', 'api_key', 'apikey')
|
||||
PERMISSION_VARS = ('_pipeline_bound_plugins', '_authorized', '_permission')
|
||||
PERMISSION_VARS = ('_pipeline_bound_plugins', '_pipeline_workspace_operations', '_authorized', '_permission')
|
||||
EVENT_DATA_MAX_STRING_BYTES = 512
|
||||
|
||||
@classmethod
|
||||
@@ -139,6 +139,7 @@ class QueryEntryAdapter:
|
||||
resolved_tool_sources=cls._extract_allowed_tool_sources(query),
|
||||
resolved_kb_uuids=cls._extract_allowed_kbs(query),
|
||||
resolved_skill_names=cls._extract_allowed_skills(query),
|
||||
allowed_workspace_operations=cls._extract_allowed_workspace_operations(query),
|
||||
)
|
||||
|
||||
# Build state policy
|
||||
@@ -749,3 +750,14 @@ class QueryEntryAdapter:
|
||||
if not isinstance(bound_skills, list):
|
||||
return []
|
||||
return [str(skill_name) for skill_name in bound_skills if skill_name]
|
||||
|
||||
@classmethod
|
||||
def _extract_allowed_workspace_operations(
|
||||
cls,
|
||||
query: pipeline_query.Query,
|
||||
) -> list[str] | None:
|
||||
"""Extract pipeline-visible workspace operation IDs from query."""
|
||||
variables = getattr(query, 'variables', None)
|
||||
if not variables or '_pipeline_workspace_operations' not in variables:
|
||||
return None
|
||||
return ResourcePolicyProjector.normalize_names(variables.get('_pipeline_workspace_operations'))
|
||||
|
||||
@@ -116,8 +116,14 @@ class AgentResourceBuilder:
|
||||
descriptor,
|
||||
)
|
||||
storage = self._build_storage_from_binding(manifest_perms, binding)
|
||||
workspace_operations = await self._build_workspace_operations_from_binding(
|
||||
execution_context,
|
||||
event,
|
||||
binding,
|
||||
descriptor,
|
||||
)
|
||||
|
||||
return {
|
||||
resources: AgentResources = {
|
||||
'models': models,
|
||||
'tools': tools,
|
||||
'knowledge_bases': knowledge_bases,
|
||||
@@ -125,6 +131,101 @@ class AgentResourceBuilder:
|
||||
'storage': storage,
|
||||
'platform_capabilities': platform_capabilities,
|
||||
}
|
||||
if workspace_operations is not None:
|
||||
resources['workspace_operations'] = workspace_operations
|
||||
|
||||
return resources
|
||||
|
||||
async def _build_workspace_operations_from_binding(
|
||||
self,
|
||||
execution_context: ExecutionContext,
|
||||
event: AgentEventEnvelope,
|
||||
binding: AgentBinding,
|
||||
descriptor: RunnerDescriptor,
|
||||
) -> list[dict[str, typing.Any]] | None:
|
||||
"""Build authorized workspace operations for this run.
|
||||
|
||||
Delivered as an independent 'workspace_operations' resource field (never added to tools).
|
||||
Delivery conditions:
|
||||
- Runner manifest declares workspace_operations permission, OR
|
||||
- Builtin LocalAgent with user-configured operations.
|
||||
"""
|
||||
is_local_agent = (
|
||||
getattr(descriptor, 'plugin_author', None) == 'langbot-team'
|
||||
and getattr(descriptor, 'plugin_name', None) == 'LocalAgent'
|
||||
) or getattr(descriptor, 'id', '') == 'plugin:langbot-team/LocalAgent/default'
|
||||
|
||||
manifest_declares = False
|
||||
perms = getattr(descriptor, 'permissions', None)
|
||||
if perms is not None and getattr(perms, 'workspace_operations', None):
|
||||
manifest_declares = True
|
||||
else:
|
||||
raw_manifest = getattr(descriptor, 'raw_manifest', None) or {}
|
||||
manifest_perms = raw_manifest.get('permissions') or {}
|
||||
if manifest_perms.get('workspace_operations'):
|
||||
manifest_declares = True
|
||||
|
||||
if not (is_local_agent or manifest_declares):
|
||||
return None
|
||||
|
||||
allowed_ops = binding.resource_policy.allowed_workspace_operations
|
||||
if not allowed_ops:
|
||||
return []
|
||||
|
||||
from ...api.application.processor_context import build_processor_request_context
|
||||
from ...api.application.granted_operations import list_granted
|
||||
|
||||
workspace_uuid = execution_context.workspace_uuid or event.workspace_id or ''
|
||||
processor_kind = binding.processor_type or 'agent'
|
||||
processor_uuid = binding.processor_id or event.event_id
|
||||
trigger_actor = event.actor.actor_id if event.actor else None
|
||||
|
||||
try:
|
||||
if getattr(self.ap, 'workspace_service', None) is not None:
|
||||
processor_context = await build_processor_request_context(
|
||||
self.ap,
|
||||
workspace_uuid=workspace_uuid,
|
||||
processor_kind=processor_kind,
|
||||
processor_uuid=processor_uuid,
|
||||
run_id=event.event_id,
|
||||
operation_ids=allowed_ops,
|
||||
trigger_actor=trigger_actor,
|
||||
)
|
||||
else:
|
||||
from ...api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
|
||||
principal = PrincipalContext(
|
||||
principal_type=PrincipalType.PROCESSOR,
|
||||
processor_kind=processor_kind,
|
||||
processor_uuid=processor_uuid,
|
||||
run_id=event.event_id,
|
||||
trigger_actor=trigger_actor,
|
||||
)
|
||||
workspace = WorkspaceContext(
|
||||
workspace_uuid=workspace_uuid,
|
||||
permissions=frozenset(),
|
||||
)
|
||||
processor_context = RequestContext(
|
||||
instance_uuid='test-instance',
|
||||
placement_generation=1,
|
||||
request_id=event.event_id,
|
||||
auth_type='processor',
|
||||
principal=principal,
|
||||
workspace=workspace,
|
||||
allowed_operation_ids=frozenset(allowed_ops),
|
||||
)
|
||||
granted_ops = list_granted(
|
||||
self.ap,
|
||||
processor_context,
|
||||
consumer='processor',
|
||||
granted=allowed_ops,
|
||||
)
|
||||
return [op.as_dict() for op in granted_ops]
|
||||
except Exception as e:
|
||||
getattr(self.ap, 'logger', None) and self.ap.logger.warning(
|
||||
f'Failed to resolve workspace operations for run: {e}'
|
||||
)
|
||||
return []
|
||||
|
||||
async def _build_models_from_binding(
|
||||
self,
|
||||
|
||||
@@ -24,6 +24,7 @@ class ResourcePolicyProjector:
|
||||
allowed_platform_tool_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||
allowed_host_tool_names: collections.abc.Iterable[typing.Any] | None = None,
|
||||
override_runner_tools: bool = False,
|
||||
allowed_workspace_operations: collections.abc.Iterable[typing.Any] | None = None,
|
||||
) -> ResourcePolicy:
|
||||
"""Project standard resource fields without depending on a runner ID.
|
||||
|
||||
@@ -74,6 +75,7 @@ class ResourcePolicyProjector:
|
||||
allow_all_tools=allow_all_tools,
|
||||
allowed_kb_uuids=allowed_kb_uuids,
|
||||
allowed_skill_names=cls.normalize_optional_names(resolved_skill_names),
|
||||
allowed_workspace_operations=cls.normalize_names(allowed_workspace_operations),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -50,6 +50,10 @@ class RunAuthorizationSnapshot(typing.TypedDict):
|
||||
platform_context: dict[str, typing.Any]
|
||||
authorized_ids: dict[str, set[str]]
|
||||
authorized_operations: dict[str, dict[str, set[str]]]
|
||||
processor_type: typing.NotRequired[str | None]
|
||||
processor_id: typing.NotRequired[str | None]
|
||||
trigger_actor: typing.NotRequired[str | None]
|
||||
workspace_operations: typing.NotRequired[list[dict[str, typing.Any]]]
|
||||
|
||||
|
||||
SteeringQueueItem = dict[str, typing.Any]
|
||||
@@ -79,6 +83,11 @@ class AgentRunSession(typing.TypedDict):
|
||||
status: AgentRunSessionStatus
|
||||
reply_streams: typing.Any
|
||||
steering_queue: list[SteeringQueueItem]
|
||||
processor_type: typing.NotRequired[str | None]
|
||||
processor_id: typing.NotRequired[str | None]
|
||||
trigger_actor: typing.NotRequired[str | None]
|
||||
workspace_id: typing.NotRequired[str | None]
|
||||
workspace_operations: typing.NotRequired[list[dict[str, typing.Any]]]
|
||||
|
||||
|
||||
class AgentRunSessionRegistry:
|
||||
@@ -117,6 +126,9 @@ class AgentRunSessionRegistry:
|
||||
execution_query: pipeline_query.Query | None = None,
|
||||
platform_context: dict[str, typing.Any] | None = None,
|
||||
reply_streams: typing.Any = None,
|
||||
processor_type: str | None = None,
|
||||
processor_id: str | None = None,
|
||||
trigger_actor: str | None = None,
|
||||
) -> None:
|
||||
"""Register a new agent run session.
|
||||
|
||||
@@ -162,6 +174,10 @@ class AgentRunSessionRegistry:
|
||||
'platform_context': copy.deepcopy(platform_context or {}),
|
||||
'authorized_ids': self._build_authorized_ids(resources_snapshot),
|
||||
'authorized_operations': self._build_authorized_operations(resources_snapshot),
|
||||
'processor_type': processor_type,
|
||||
'processor_id': processor_id,
|
||||
'trigger_actor': trigger_actor,
|
||||
'workspace_operations': copy.deepcopy(resources_snapshot.get('workspace_operations', [])),
|
||||
}
|
||||
|
||||
session: AgentRunSession = {
|
||||
@@ -177,6 +193,11 @@ class AgentRunSessionRegistry:
|
||||
'last_activity_at': now,
|
||||
},
|
||||
'steering_queue': [],
|
||||
'processor_type': processor_type,
|
||||
'processor_id': processor_id,
|
||||
'trigger_actor': trigger_actor,
|
||||
'workspace_id': workspace_id,
|
||||
'workspace_operations': copy.deepcopy(resources_snapshot.get('workspace_operations', [])),
|
||||
}
|
||||
|
||||
async with self._lock:
|
||||
|
||||
@@ -653,6 +653,33 @@ EXECUTION_OPERATIONS = (
|
||||
'runner_admin',
|
||||
True,
|
||||
),
|
||||
ExecutionOperation(
|
||||
'list_application_operations',
|
||||
Empty,
|
||||
_output(
|
||||
'ExecutionListApplicationOperationsOutput',
|
||||
operations=(list[JsonValue], ...),
|
||||
domains=(list[JsonValue], ...),
|
||||
),
|
||||
'run',
|
||||
False,
|
||||
),
|
||||
ExecutionOperation(
|
||||
'call_application_operation',
|
||||
_input(
|
||||
'ExecutionCallApplicationOperationInput',
|
||||
operation_id=(Text, ...),
|
||||
arguments=(Object, {}),
|
||||
),
|
||||
_output(
|
||||
'ExecutionCallApplicationOperationOutput',
|
||||
ok=(bool, ...),
|
||||
result=(JsonValue | None, None),
|
||||
error=(Object | None, None),
|
||||
),
|
||||
'run',
|
||||
True,
|
||||
),
|
||||
)
|
||||
_BY_ID = {operation.operation_id: operation for operation in EXECUTION_OPERATIONS}
|
||||
|
||||
|
||||
@@ -299,6 +299,7 @@ class AgentService:
|
||||
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
|
||||
allowed_host_tool_names=config.get('allowed_tools'),
|
||||
override_runner_tools='allowed_tools' in config,
|
||||
allowed_workspace_operations=config.get('allowed_workspace_operations'),
|
||||
),
|
||||
state_policy=StatePolicy(
|
||||
state_scopes=['conversation', 'actor', 'subject', 'runner'],
|
||||
|
||||
@@ -804,7 +804,10 @@ class PipelineMigrationService:
|
||||
if tool_grant:
|
||||
if not config_schema.uses_host_tools(descriptor) or 'call' not in getattr(permissions, 'tools', []):
|
||||
raise MigrationError('runner_resource_unavailable')
|
||||
policy = ResourcePolicyProjector.from_runner_config(runner_config)
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
runner_config,
|
||||
allowed_workspace_operations=[],
|
||||
)
|
||||
tools = await AgentResourceBuilder(self.ap)._build_tools_from_binding(
|
||||
execution,
|
||||
permissions,
|
||||
|
||||
@@ -60,6 +60,7 @@ class Controller:
|
||||
query.pipeline_config = pipeline.pipeline_entity.config
|
||||
query.variables['_pipeline_bound_plugins'] = pipeline.bound_plugins
|
||||
query.variables['_pipeline_bound_mcp_servers'] = pipeline.bound_mcp_servers
|
||||
query.variables['_pipeline_workspace_operations'] = getattr(pipeline, 'workspace_operations', [])
|
||||
if getattr(getattr(query, 'launcher_type', None), 'value', None) == 'group':
|
||||
from .resprule.resprule import GroupRespondRuleCheckStage
|
||||
from .entities import ResultType
|
||||
|
||||
@@ -128,6 +128,7 @@ class RuntimePipeline:
|
||||
extensions_prefs = normalize_extension_preferences(pipeline_entity.extensions_preferences)
|
||||
self.enable_all_plugins = extensions_prefs['enable_all_plugins'] is True
|
||||
self.enable_all_mcp_servers = extensions_prefs['enable_all_mcp_servers'] is True
|
||||
self.workspace_operations = extensions_prefs.get('workspace_operations', [])
|
||||
pipeline_config = pipeline_entity.config or {}
|
||||
runner_config: dict[str, typing.Any] = {}
|
||||
runner_id = RunnerConfigResolver.resolve_runner_id(pipeline_config)
|
||||
@@ -196,6 +197,7 @@ class RuntimePipeline:
|
||||
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
|
||||
query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments
|
||||
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled
|
||||
query.variables['_pipeline_workspace_operations'] = self.workspace_operations
|
||||
# Telemetry: the bindings this lane runs with. None means "all enabled",
|
||||
# which the *_all flags make explicit in the chain record.
|
||||
try:
|
||||
|
||||
@@ -105,6 +105,9 @@ class PreProcessor(stage.PipelineStage):
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
runner_config,
|
||||
resolved_tool_names=ResourcePolicyProjector.extract_tool_names(catalog),
|
||||
allowed_workspace_operations=(query.variables or {}).get('_pipeline_workspace_operations')
|
||||
if isinstance(getattr(query, 'variables', None), dict)
|
||||
else None,
|
||||
)
|
||||
catalog = ResourcePolicyProjector.filter_tools(catalog, policy)
|
||||
ToolManager.bind_query_tool_sources(query, catalog)
|
||||
|
||||
@@ -894,6 +894,7 @@ class RuntimeBot:
|
||||
allowed_platform_tool_names=resolve_agent_platform_tool_names(config, event_type),
|
||||
allowed_host_tool_names=config.get('allowed_tools'),
|
||||
override_runner_tools='allowed_tools' in config,
|
||||
allowed_workspace_operations=config.get('allowed_workspace_operations'),
|
||||
),
|
||||
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
|
||||
delivery_policy=DeliveryPolicy(
|
||||
|
||||
@@ -66,6 +66,15 @@ from .agent_run_support import (
|
||||
)
|
||||
|
||||
|
||||
class _RuntimeActionName:
|
||||
def __init__(self, value: str):
|
||||
self.value = value
|
||||
|
||||
|
||||
def _plugin_runtime_action(name: str, value: str) -> Any:
|
||||
return getattr(PluginToRuntimeAction, name, _RuntimeActionName(value))
|
||||
|
||||
|
||||
_HOST_RESERVED_QUERY_VAR_PREFIXES = (
|
||||
'_host_',
|
||||
'_pipeline_bound_',
|
||||
@@ -2819,6 +2828,118 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
},
|
||||
)
|
||||
|
||||
@self.action(_plugin_runtime_action('LIST_APPLICATION_OPERATIONS', 'list_application_operations'))
|
||||
async def list_application_operations(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
run_id = data.get('run_id')
|
||||
if not run_id:
|
||||
return handler.ActionResponse.error(message='run_id is required')
|
||||
session_registry = get_session_registry()
|
||||
session = await session_registry.get(run_id)
|
||||
if not session:
|
||||
return handler.ActionResponse.error(message=f'Run session {run_id} not found or expired')
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
if caller_plugin_identity and caller_plugin_identity != session.get('plugin_identity'):
|
||||
return handler.ActionResponse.error(message=f'Plugin identity mismatch for run_id {run_id}')
|
||||
|
||||
resources = session.get('authorization', {}).get('resources', {})
|
||||
operations = resources.get('workspace_operations') or session.get('workspace_operations', [])
|
||||
from ..api.application.granted_operations import domains
|
||||
|
||||
serialized_ops = [op.as_dict() if hasattr(op, 'as_dict') else op for op in operations]
|
||||
domains_summary = domains(serialized_ops)
|
||||
return handler.ActionResponse.success(
|
||||
data={
|
||||
'operations': serialized_ops,
|
||||
'domains': domains_summary,
|
||||
}
|
||||
)
|
||||
|
||||
@self.action(_plugin_runtime_action('CALL_APPLICATION_OPERATION', 'call_application_operation'))
|
||||
async def call_application_operation(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
run_id = data.get('run_id')
|
||||
if not run_id:
|
||||
return handler.ActionResponse.error(message='run_id is required')
|
||||
session_registry = get_session_registry()
|
||||
session = await session_registry.get(run_id)
|
||||
if not session:
|
||||
return handler.ActionResponse.error(message=f'Run session {run_id} not found or expired')
|
||||
caller_plugin_identity = data.get('caller_plugin_identity')
|
||||
if caller_plugin_identity and caller_plugin_identity != session.get('plugin_identity'):
|
||||
return handler.ActionResponse.error(message=f'Plugin identity mismatch for run_id {run_id}')
|
||||
|
||||
operation_id = data.get('operation_id')
|
||||
if not operation_id or not isinstance(operation_id, str):
|
||||
return handler.ActionResponse.error(message='operation_id is required')
|
||||
arguments = data.get('arguments', {})
|
||||
if arguments is None:
|
||||
arguments = {}
|
||||
if not isinstance(arguments, dict):
|
||||
return handler.ActionResponse.error(message='arguments must be an object')
|
||||
|
||||
authorization = session.get('authorization', {})
|
||||
workspace_id = authorization.get('workspace_id') or session.get('workspace_id')
|
||||
if not workspace_id:
|
||||
return handler.ActionResponse.error(message='workspace_id not found in run session')
|
||||
|
||||
processor_type = authorization.get('processor_type') or session.get('processor_type', 'agent')
|
||||
processor_id = authorization.get('processor_id') or session.get('processor_id') or run_id
|
||||
trigger_actor = authorization.get('trigger_actor') or session.get('trigger_actor')
|
||||
|
||||
# Frozen granted operations from session
|
||||
resources = authorization.get('resources', {})
|
||||
frozen_ops = resources.get('workspace_operations') or session.get('workspace_operations', [])
|
||||
granted_ids = [op.get('id') if isinstance(op, dict) else getattr(op, 'id', str(op)) for op in frozen_ops]
|
||||
|
||||
from ..api.application.processor_context import build_processor_request_context
|
||||
import langbot.pkg.api.application.granted_operations as granted_operations
|
||||
|
||||
try:
|
||||
if getattr(self.ap, 'workspace_service', None) is not None:
|
||||
processor_context = await build_processor_request_context(
|
||||
self.ap,
|
||||
workspace_uuid=workspace_id,
|
||||
processor_kind=processor_type,
|
||||
processor_uuid=processor_id,
|
||||
run_id=run_id,
|
||||
operation_ids=granted_ids,
|
||||
trigger_actor=trigger_actor,
|
||||
)
|
||||
else:
|
||||
from ..api.context import PrincipalContext, PrincipalType, RequestContext, WorkspaceContext
|
||||
|
||||
principal = PrincipalContext(
|
||||
principal_type=PrincipalType.PROCESSOR,
|
||||
processor_kind=processor_type,
|
||||
processor_uuid=processor_id,
|
||||
run_id=run_id,
|
||||
trigger_actor=trigger_actor,
|
||||
)
|
||||
workspace = WorkspaceContext(
|
||||
workspace_uuid=workspace_id,
|
||||
permissions=frozenset(),
|
||||
)
|
||||
processor_context = RequestContext(
|
||||
instance_uuid='test-instance',
|
||||
placement_generation=1,
|
||||
request_id=str(uuid.uuid4()),
|
||||
auth_type='processor',
|
||||
principal=principal,
|
||||
workspace=workspace,
|
||||
allowed_operation_ids=frozenset(granted_ids),
|
||||
)
|
||||
|
||||
result = await granted_operations.call(
|
||||
self.ap,
|
||||
processor_context,
|
||||
operation_id=operation_id,
|
||||
arguments=arguments,
|
||||
consumer='processor',
|
||||
granted=granted_ids,
|
||||
)
|
||||
return handler.ActionResponse.success(data=result)
|
||||
except Exception as e:
|
||||
return handler.ActionResponse.error(message=f'Application operation execution failed: {e}')
|
||||
|
||||
self._secure_plugin_actions()
|
||||
|
||||
@contextlib.contextmanager
|
||||
|
||||
Reference in New Issue
Block a user