diff --git a/docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md b/docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md
index ab345921..b8279680 100644
--- a/docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md
+++ b/docs/agent-runner-pluginization/IMPLEMENTATION_PLAN.md
@@ -14,6 +14,7 @@ LangBot 最终只保留 Agent Runner 的宿主能力:
- 归一结果:`AgentRunResult` -> 当前 Pipeline 的 `Message` / `MessageChunk`
- 隔离错误:插件异常、协议错误、超时、结果过大不能破坏主流程
- 迁移旧配置:把旧内置 runner 配置迁到官方 AgentRunner 插件配置
+- 转发调用:插件 runtime 只维护已安装插件本身的运行实例,Pipeline 不创建插件实例或 runner 实例
LangBot 不再长期维护内置业务 runner 分支。`local-agent`、Dify、n8n、Coze、DashScope、Langflow、Tbox 等都迁到官方 AgentRunner 插件。
@@ -45,6 +46,8 @@ SDK Runtime RUN_AGENT -> plugin AgentRunner.run()
- `ChatMessageHandler` 不解析 `plugin:*`,不实例化 wrapper,不知道 runner 组件细节。
- `PipelineService.get_pipeline_metadata()` 不直接访问插件 runtime,而是读取 registry。
- 旧 `RequestRunner` 只作为迁移参考,不作为最终运行路径。
+- 插件是无状态执行单元:多个 Pipeline 可以绑定同一个 runner id,并分别保存自己的 `ai.runner_config[id]`;运行时 LangBot 只把当前绑定配置放入 `ctx.config` 转发给同一个插件 runner。
+- 禁止按 Pipeline 或 runner config 创建多个插件实例。需要跨请求持久化的状态必须走明确授权的 plugin storage / workspace storage / 外部服务,不能隐式保存在 per-pipeline 插件对象里。
- EBA 只做字段预留,不在本轮实现 EventBus、EventRouter、平台动作执行。
## 3. 新增 LangBot 模块
@@ -138,7 +141,7 @@ class AgentRunnerDescriptor(BaseModel):
- `input`: 从 `query.user_message` 和 `query.message_chain` 构造
- `resources`: 由 `resource_builder` 注入
- `runtime`: host/version/workspace/bot/pipeline/query/trace/deadline
-- `config`: 当前 runner id 对应的实例配置
+- `config`: 当前 Pipeline 对该 runner id 的绑定配置,即 `ai.runner_config[runner_id]`
保留 SDK legacy helper 是 SDK 的责任,LangBot 不再构造 PoC 的 `query_id/session/messages/user_message/extra_config` 上下文。
@@ -148,7 +151,7 @@ class AgentRunnerDescriptor(BaseModel):
1. runner manifest 声明的 `spec.permissions`
2. Pipeline 的 `extensions_preferences`
-3. runner 实例配置中选择的资源范围
+3. 当前 Pipeline runner 绑定配置中选择的资源范围
输出写入 `ctx.resources`,至少覆盖:
@@ -215,6 +218,8 @@ async def run_from_query(query: pipeline_query.Query) -> AsyncGenerator[Message
## 4. 配置模型直接切换
+配置模型表达的是 Pipeline 到 runner id 的绑定,不表达插件实例。插件安装后由 plugin runtime 管理单个插件运行实例;不同 Pipeline 选择同一个 runner id 时,只是保存不同的 `runner_config[id]`,调用时随 `AgentRunContext.config` 传入。
+
目标格式:
```json
@@ -322,7 +327,7 @@ async def run_from_query(query: pipeline_query.Query) -> AsyncGenerator[Message
### Step 6:权限和资源裁剪
-- resource builder 根据 manifest / pipeline / instance config 裁剪
+- resource builder 根据 manifest / pipeline / runner binding config 裁剪
- proxy action 校验 resource scope
- 禁止插件用 unrestricted API 访问未授权知识库、工具、模型
@@ -365,6 +370,7 @@ async def run_from_query(query: pipeline_query.Query) -> AsyncGenerator[Message
- `ChatMessageHandler` 不包含插件 runner 解析和 wrapper。
- `PipelineService` 不直接拼插件 runner metadata。
- 所有 runner 配置使用 `ai.runner.id` + `ai.runner_config`。
+- 插件 runtime 不为每个 Pipeline 或 runner 配置创建插件实例;`runner_config` 只作为绑定配置随 `ctx.config` 传入。
- 旧内置 runner 不再作为 LangBot 内部运行分支执行。
- 插件只能访问 `ctx.resources` 授权的模型、工具、知识库和文件。
- EBA 相关字段只作为 context/result 预留,不执行平台动作。
diff --git a/docs/agent-runner-pluginization/README.md b/docs/agent-runner-pluginization/README.md
index 2a598fa5..0651af14 100644
--- a/docs/agent-runner-pluginization/README.md
+++ b/docs/agent-runner-pluginization/README.md
@@ -158,7 +158,7 @@ class AgentRunContext(BaseModel):
- `input` 是 runner 的主输入,不再强制等同于纯文本消息。
- `resources` 列出 LangBot 已授权给 runner 的工具、知识库、模型、文件等。
- `runtime` 提供 host 信息、workspace/bot/pipeline 标识、trace id、deadline 等。
-- `config` 是当前 runner 的实例配置,替代当前 `extra_config`。
+- `config` 是当前 Pipeline 或未来事件绑定对该 runner id 的绑定配置,替代当前 `extra_config`。
为了兼容现有实现,SDK 可提供:
@@ -224,6 +224,10 @@ SDK 应把这些能力按 capability 分组。LangBot 在调用 runner 前根据
当前阶段 runner 配置仍跟 Pipeline 绑定,并且仍然作为 Pipeline 的一个 stage 执行。也就是说,Bot 收到私聊/群聊消息后仍按现有 Pipeline 流转,只是在 AI runner stage 中选择插件化 Agent Runner。
+这里的“绑定配置”不代表插件实例。插件安装后由插件 runtime 维护插件本身的运行实例;LangBot 不会因为多个 Pipeline 选择同一个 runner id 而创建多个插件实例或 runner 实例。不同 Pipeline 可以保存不同的 `runner_config[id]`,调用时 LangBot 只把当前绑定配置放进 `AgentRunContext.config` 转发给同一个插件 runner。
+
+插件 runner 应按无状态执行单元设计。需要跨请求保存的 conversation id、外部平台状态或用户状态,应通过明确授权的 plugin storage、workspace storage、外部服务或 context runtime state 管理,不能隐式依赖 per-pipeline 插件对象状态。
+
后续 EBA EventRouter 落地后,同一套 `AgentRunnerDescriptor` 和 `AgentRunOrchestrator` 需要支持直接与 Bot 的事件触发器绑定。届时 Bot event handler 可以绕过完整 Pipeline,直接选择某个 Agent Runner 处理 `message.received`、`group.member_joined`、`friend.request_received` 等事件。
Pipeline AI 配置建议从:
@@ -317,7 +321,7 @@ LangBot 执行前做三层裁剪:
- 插件 manifest 声明的权限。
- Pipeline 或 Bot 绑定的扩展范围。
-- 用户在 runner 配置中选择的资源范围。
+- 用户在 Pipeline runner 绑定配置中选择的资源范围。
最终写入 `ctx.resources`,并在 proxy action 里再次校验。
@@ -403,4 +407,5 @@ SDK:
- 插件可以声明多个 `AgentRunner` 组件,每个组件独立暴露 manifest、配置 schema、能力和权限。
- 本阶段不把 `action.requested` 作为必须实现的运行结果。它只是为未来 EBA 平台动作预留的返回类型;当前 Pipeline stage 中如收到该类型,只记录 telemetry,不执行动作。
- 当前 runner 配置先跟 Pipeline 绑定,仍然在 Pipeline 的 AI runner stage 中执行;后续需要支持直接与 Bot 的事件触发器绑定。
+- Pipeline/Event 绑定只保存 runner id 和绑定配置,不创建插件实例或 runner 实例;插件 runner 按无状态转发调用处理,跨请求状态必须显式存储。
- 内置 `RequestRunner` 最终强制迁移为插件形态,避免长期保留“内置 runner 分支”和“插件 runner 分支”两套执行体系。
diff --git a/src/langbot/pkg/agent/runner/__init__.py b/src/langbot/pkg/agent/runner/__init__.py
index f3aabdae..1c3a17a4 100644
--- a/src/langbot/pkg/agent/runner/__init__.py
+++ b/src/langbot/pkg/agent/runner/__init__.py
@@ -16,6 +16,7 @@ from .resource_builder import AgentResourceBuilder
from .result_normalizer import AgentResultNormalizer
from .orchestrator import AgentRunOrchestrator
from .config_migration import ConfigMigration
+from .session_registry import AgentRunSessionRegistry, AgentRunSession, get_session_registry
__all__ = [
'AgentRunnerDescriptor',
@@ -33,4 +34,7 @@ __all__ = [
'AgentResultNormalizer',
'AgentRunOrchestrator',
'ConfigMigration',
+ 'AgentRunSessionRegistry',
+ 'AgentRunSession',
+ 'get_session_registry',
]
\ No newline at end of file
diff --git a/src/langbot/pkg/agent/runner/config_migration.py b/src/langbot/pkg/agent/runner/config_migration.py
index 50cead38..fab878ce 100644
--- a/src/langbot/pkg/agent/runner/config_migration.py
+++ b/src/langbot/pkg/agent/runner/config_migration.py
@@ -72,7 +72,7 @@ class ConfigMigration:
pipeline_config: dict[str, typing.Any],
runner_id: str,
) -> dict[str, typing.Any]:
- """Resolve runner instance configuration from pipeline configuration.
+ """Resolve runner binding configuration from pipeline configuration.
Priority:
1. New format: ai.runner_config[runner_id]
diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py
index 5d5448cd..a48eec82 100644
--- a/src/langbot/pkg/agent/runner/context_builder.py
+++ b/src/langbot/pkg/agent/runner/context_builder.py
@@ -10,6 +10,7 @@ from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from ...core import app
from .descriptor import AgentRunnerDescriptor
from .config_migration import ConfigMigration
+from .state_store import get_state_store
# Internal models for SDK v1 context protocol matching SDK v1 resources.py
@@ -41,6 +42,14 @@ class AgentInput(typing.TypedDict):
attachments: list[dict[str, typing.Any]]
+class AgentRunState(typing.TypedDict):
+ """Agent run state with 4 scopes."""
+ conversation: dict[str, typing.Any]
+ actor: dict[str, typing.Any]
+ subject: dict[str, typing.Any]
+ runner: dict[str, typing.Any]
+
+
# SDK v1 Protocol resource models - matching langbot-plugin-sdk/resources.py
@@ -100,7 +109,11 @@ class AgentRuntimeContext(typing.TypedDict):
class AgentRunContextV1(typing.TypedDict):
- """SDK v1 AgentRunContext per PROTOCOL_V1.md."""
+ """SDK v1 AgentRunContext per PROTOCOL_V1.md.
+
+ Note: The 'config' field contains the binding config from ai.runner_config[runner_id],
+ which is Pipeline's configuration for this specific runner binding (not plugin instance config).
+ """
run_id: str
trigger: AgentTrigger
conversation: ConversationContext | None
@@ -109,9 +122,11 @@ class AgentRunContextV1(typing.TypedDict):
subject: dict[str, typing.Any] | None # Reserved for EBA
messages: list[dict[str, typing.Any]]
input: AgentInput
+ params: dict[str, typing.Any]
resources: AgentResources
+ state: AgentRunState
runtime: AgentRuntimeContext
- config: dict[str, typing.Any]
+ config: dict[str, typing.Any] # Binding config from ai.runner_config[runner_id]
class AgentRunContextBuilder:
@@ -123,13 +138,25 @@ class AgentRunContextBuilder:
- Build conversation context from session
- Convert messages to SDK format
- Build input from user_message and message_chain
+ - Build params from query.variables with filtering
+ - Build state snapshot from state_store
- Set resources from AgentResourceBuilder result
- Build runtime context with host info, trace_id, deadline
- - Set config from runner instance configuration
+ - Set config from runner binding configuration (ai.runner_config[runner_id])
"""
ap: app.Application
+ # Params filtering rules
+ # Exclude variables starting with underscore (internal)
+ INTERNAL_PREFIX = '_'
+
+ # Exclude variables with sensitive naming patterns
+ SENSITIVE_PATTERNS = ('secret', 'token', 'key', 'password', 'credential', 'api_key', 'apikey')
+
+ # Exclude permission/control variables
+ PERMISSION_VARS = ('_pipeline_bound_plugins', '_authorized', '_permission')
+
def __init__(self, ap: app.Application):
self.ap = ap
@@ -178,7 +205,16 @@ class AgentRunContextBuilder:
# Build messages
messages = self._build_messages(query)
- # Get runner config
+ # Build params from query.variables with filtering
+ params = self._build_params(query)
+
+ # Build state snapshot from state_store
+ state_store = get_state_store()
+ state: AgentRunState = state_store.build_snapshot(query, descriptor)
+
+ # Get runner binding config from ai.runner_config[runner_id]
+ # This is Pipeline's configuration for this specific runner binding,
+ # passed through AgentRunContext.config to the runner
runner_config = ConfigMigration.resolve_runner_config(
query.pipeline_config,
descriptor.id,
@@ -207,7 +243,9 @@ class AgentRunContextBuilder:
'subject': None, # Reserved for EBA
'messages': messages,
'input': input,
+ 'params': params,
'resources': resources,
+ 'state': state,
'runtime': runtime,
'config': runner_config,
}
@@ -251,4 +289,72 @@ class AgentRunContextBuilder:
for msg in query.messages:
messages.append(msg.model_dump(mode='json'))
- return messages
\ No newline at end of file
+ return messages
+
+ def _build_params(self, query: pipeline_query.Query) -> dict[str, typing.Any]:
+ """Build params from query.variables with filtering.
+
+ Filtering rules:
+ 1. Exclude variables starting with underscore (internal)
+ 2. Exclude variables with sensitive naming patterns (secret, token, key, password)
+ 3. Exclude permission/control variables
+ 4. Keep only JSON-serializable values
+
+ Args:
+ query: Pipeline query
+
+ Returns:
+ Filtered params dict
+ """
+ params: dict[str, typing.Any] = {}
+
+ if not query.variables:
+ return params
+
+ for key, value in query.variables.items():
+ # Filter internal variables (starting with underscore)
+ if key.startswith(self.INTERNAL_PREFIX):
+ continue
+
+ # Filter sensitive naming patterns
+ key_lower = key.lower()
+ if any(pattern in key_lower for pattern in self.SENSITIVE_PATTERNS):
+ continue
+
+ # Filter permission variables
+ if any(key == perm_var or key.startswith(perm_var) for perm_var in self.PERMISSION_VARS):
+ continue
+
+ # Keep only JSON-serializable values
+ if self._is_json_serializable(value):
+ params[key] = value
+
+ return params
+
+ def _is_json_serializable(self, value: typing.Any) -> bool:
+ """Check if value is JSON-serializable.
+
+ Note: set is NOT JSON-serializable. json.dumps({"x": {1}}) fails.
+ Only list and tuple are allowed as collection types.
+
+ Args:
+ value: Value to check
+
+ Returns:
+ True if JSON-serializable, False otherwise
+ """
+ if value is None:
+ return True
+ if isinstance(value, (str, int, float, bool)):
+ return True
+ # Only allow list and tuple, NOT set (set is not JSON-serializable)
+ if isinstance(value, (list, tuple)):
+ return all(self._is_json_serializable(item) for item in value)
+ if isinstance(value, dict):
+ return all(
+ isinstance(k, str) and self._is_json_serializable(v)
+ for k, v in value.items()
+ )
+ # Pydantic models and other complex types are not directly serializable
+ # as params (they may have internal structure not meant for runners)
+ return False
\ No newline at end of file
diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py
index 9216583a..f9324fc2 100644
--- a/src/langbot/pkg/agent/runner/orchestrator.py
+++ b/src/langbot/pkg/agent/runner/orchestrator.py
@@ -13,6 +13,8 @@ from .registry import AgentRunnerRegistry
from .context_builder import AgentRunContextBuilder, AgentRunContextV1
from .resource_builder import AgentResourceBuilder
from .result_normalizer import AgentResultNormalizer
+from .state_store import get_state_store, RunnerScopedStateStore
+from .session_registry import get_session_registry, AgentRunSessionRegistry
from .config_migration import ConfigMigration
from .errors import (
RunnerNotFoundError,
@@ -46,6 +48,10 @@ class AgentRunOrchestrator:
result_normalizer: AgentResultNormalizer
+ # Cached singleton references (set in __init__)
+ _session_registry: AgentRunSessionRegistry
+ _state_store: RunnerScopedStateStore
+
def __init__(
self,
ap: app.Application,
@@ -56,6 +62,9 @@ class AgentRunOrchestrator:
self.context_builder = AgentRunContextBuilder(ap)
self.resource_builder = AgentResourceBuilder(ap)
self.result_normalizer = AgentResultNormalizer(ap)
+ # Cache singleton references to avoid per-request getter calls
+ self._session_registry = get_session_registry()
+ self._state_store = get_state_store()
async def run_from_query(
self,
@@ -93,12 +102,33 @@ class AgentRunOrchestrator:
# Build context
context = await self.context_builder.build_context(query, descriptor, resources)
- # Run via plugin connector
- async for result_dict in self._invoke_runner(descriptor, context):
- # Normalize result
- result = await self.result_normalizer.normalize(result_dict, descriptor)
- if result is not None:
- yield result
+ # Register session for proxy action permission validation
+ run_id = context['run_id']
+ await self._session_registry.register(
+ run_id=run_id,
+ runner_id=descriptor.id,
+ query_id=query.query_id,
+ plugin_identity=descriptor.get_plugin_id(),
+ resources=resources,
+ )
+
+ try:
+ # Run via plugin connector
+ async for result_dict in self._invoke_runner(descriptor, context):
+ # Handle state.updated first - consume before normalizer
+ if result_dict.get('type') == 'state.updated':
+ self._handle_state_updated(result_dict, query, descriptor)
+ # Pass to normalizer for logging, but don't yield to pipeline
+ await self.result_normalizer.normalize(result_dict, descriptor)
+ continue
+
+ # Normalize result for other types
+ result = await self.result_normalizer.normalize(result_dict, descriptor)
+ if result is not None:
+ yield result
+ finally:
+ # Unregister session after run completes (success or error)
+ await self._session_registry.unregister(run_id)
async def _invoke_runner(
self,
@@ -155,4 +185,48 @@ class AgentRunOrchestrator:
Returns:
Runner ID string, or None
"""
- return ConfigMigration.resolve_runner_id(query.pipeline_config)
\ No newline at end of file
+ return ConfigMigration.resolve_runner_id(query.pipeline_config)
+
+ def _handle_state_updated(
+ self,
+ result_dict: dict[str, typing.Any],
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> None:
+ """Handle state.updated result - apply to state store.
+
+ Args:
+ result_dict: Raw result dict with type='state.updated'
+ query: Pipeline query
+ descriptor: Runner descriptor
+ """
+ data = result_dict.get('data', {})
+
+ # Extract scope (default to 'conversation' for backward compat)
+ scope = data.get('scope', 'conversation')
+
+ # Extract key and value
+ key = data.get('key')
+ value = data.get('value')
+
+ if not key:
+ self.ap.logger.warning(
+ f'Runner {descriptor.id} state.updated missing key, ignoring'
+ )
+ return
+
+ # Apply update to state store
+ success = self._state_store.apply_update(
+ query=query,
+ descriptor=descriptor,
+ scope=scope,
+ key=key,
+ value=value,
+ logger=self.ap.logger,
+ )
+
+ if success:
+ self.ap.logger.debug(
+ f'Runner {descriptor.id} state.updated: scope={scope}, key={key}, value={value}'
+ )
+ # Invalid scope is already logged by state_store.apply_update
\ No newline at end of file
diff --git a/src/langbot/pkg/agent/runner/resource_builder.py b/src/langbot/pkg/agent/runner/resource_builder.py
index 00539587..67045704 100644
--- a/src/langbot/pkg/agent/runner/resource_builder.py
+++ b/src/langbot/pkg/agent/runner/resource_builder.py
@@ -1,6 +1,7 @@
"""Agent resource builder for constructing authorized resources."""
from __future__ import annotations
+import asyncio
import typing
from ...core import app
@@ -68,10 +69,12 @@ class AgentResourceBuilder:
from .config_migration import ConfigMigration
runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, descriptor.id)
- # Build each resource category
- models = await self._build_models(manifest_perms, query)
- tools = await self._build_tools(manifest_perms, bound_plugins, bound_mcp_servers, query)
- knowledge_bases = await self._build_knowledge_bases(manifest_perms, runner_config, query)
+ # Build each resource category in parallel
+ models, tools, knowledge_bases = await asyncio.gather(
+ self._build_models(manifest_perms, query),
+ self._build_tools(manifest_perms, bound_plugins, bound_mcp_servers, query),
+ self._build_knowledge_bases(manifest_perms, runner_config, query),
+ )
storage = self._build_storage(manifest_perms)
return {
@@ -104,11 +107,10 @@ class AgentResourceBuilder:
try:
model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
if model and model.model_entity:
- # Use SDK v1 field names: model_id, model_type, provider
models.append({
'model_id': model_uuid,
- 'model_type': model.model_entity.model_type,
- 'provider': model.provider_entity.name if hasattr(model, 'provider_entity') else None,
+ 'model_type': getattr(model.model_entity, 'model_type', None),
+ 'provider': getattr(model.provider_entity, 'name', None) if hasattr(model, 'provider_entity') else None,
})
except Exception:
pass
diff --git a/src/langbot/pkg/agent/runner/result_normalizer.py b/src/langbot/pkg/agent/runner/result_normalizer.py
index fdf0d0a8..80d925b5 100644
--- a/src/langbot/pkg/agent/runner/result_normalizer.py
+++ b/src/langbot/pkg/agent/runner/result_normalizer.py
@@ -108,9 +108,13 @@ class AgentResultNormalizer:
return None
elif result_type == 'state.updated':
- # Log for telemetry, don't yield
+ # Log for telemetry, don't yield to pipeline
+ # Orchestrator already handles the actual state_store.apply_update
+ scope = data.get('scope', 'conversation') # Default for backward compat
+ key = data.get('key', 'unknown')
+ value_repr = repr(data.get('value', '...'))[:100] # Truncate for log
self.ap.logger.debug(
- f'Runner {descriptor.id} state updated: {data.get("key", "unknown")}={data.get("value", "...")}'
+ f'Runner {descriptor.id} state.updated logged: scope={scope}, key={key}, value={value_repr}'
)
return None
diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py
new file mode 100644
index 00000000..c4ee441a
--- /dev/null
+++ b/src/langbot/pkg/agent/runner/session_registry.py
@@ -0,0 +1,217 @@
+"""Agent run session registry for proxy action permission validation."""
+from __future__ import annotations
+
+import asyncio
+import typing
+import time
+import threading
+
+from .context_builder import AgentResources
+
+
+class AgentRunSessionStatus(typing.TypedDict):
+ """Status tracking for agent run session."""
+ started_at: int
+ last_activity_at: int
+
+
+class AgentRunSession(typing.TypedDict):
+ """Session for an active agent runner execution.
+
+ Stored in AgentRunSessionRegistry for proxy action permission validation.
+
+ Fields:
+ run_id: Unique run identifier (UUID from AgentRunContext)
+ runner_id: Runner descriptor ID (plugin:author/name/runner)
+ query_id: Pipeline query ID
+ plugin_identity: Plugin identifier (author/name) of the runner
+ resources: Authorized resources for this run (from AgentResources)
+ status: Session status tracking
+ _authorized_ids: Pre-computed authorized resource IDs for O(1) lookup
+ """
+ run_id: str
+ runner_id: str
+ query_id: int | None
+ plugin_identity: str # author/name
+ resources: AgentResources
+ status: AgentRunSessionStatus
+ _authorized_ids: dict[str, set[str]] # Pre-computed sets for O(1) lookup
+
+
+class AgentRunSessionRegistry:
+ """Registry for active agent run sessions.
+
+ Host-owned registry for tracking active AgentRunner executions.
+ Used by proxy actions in handler.py to validate resource access.
+
+ Key: run_id (UUID from AgentRunContext)
+ Value: AgentRunSession with authorized resources
+
+ Thread-safe via asyncio.Lock.
+ """
+
+ _sessions: dict[str, AgentRunSession]
+ _lock: asyncio.Lock
+
+ def __init__(self):
+ self._sessions = {}
+ self._lock = asyncio.Lock()
+
+ async def register(
+ self,
+ run_id: str,
+ runner_id: str,
+ query_id: int | None,
+ plugin_identity: str,
+ resources: AgentResources,
+ ) -> None:
+ """Register a new agent run session.
+
+ Args:
+ run_id: Unique run identifier
+ runner_id: Runner descriptor ID
+ query_id: Pipeline query ID
+ plugin_identity: Plugin identifier (author/name)
+ resources: Authorized resources for this run
+ """
+ now = int(time.time())
+
+ # Pre-compute authorized resource IDs for O(1) lookup
+ authorized_ids: dict[str, set[str]] = {
+ 'model': {m.get('model_id') for m in resources.get('models', [])},
+ 'tool': {t.get('tool_name') for t in resources.get('tools', [])},
+ 'knowledge_base': {kb.get('kb_id') for kb in resources.get('knowledge_bases', [])},
+ }
+
+ session: AgentRunSession = {
+ 'run_id': run_id,
+ 'runner_id': runner_id,
+ 'query_id': query_id,
+ 'plugin_identity': plugin_identity,
+ 'resources': resources,
+ 'status': {
+ 'started_at': now,
+ 'last_activity_at': now,
+ },
+ '_authorized_ids': authorized_ids,
+ }
+
+ async with self._lock:
+ self._sessions[run_id] = session
+
+ async def unregister(self, run_id: str) -> None:
+ """Unregister an agent run session.
+
+ Args:
+ run_id: Unique run identifier
+ """
+ async with self._lock:
+ if run_id in self._sessions:
+ del self._sessions[run_id]
+
+ async def get(self, run_id: str) -> AgentRunSession | None:
+ """Get session by run_id.
+
+ Args:
+ run_id: Unique run identifier
+
+ Returns:
+ AgentRunSession if found, None otherwise
+ """
+ async with self._lock:
+ return self._sessions.get(run_id)
+
+ async def update_activity(self, run_id: str) -> None:
+ """Update last activity timestamp for session.
+
+ Args:
+ run_id: Unique run identifier
+ """
+ async with self._lock:
+ if run_id in self._sessions:
+ self._sessions[run_id]['status']['last_activity_at'] = int(time.time())
+
+ def is_resource_allowed(
+ self,
+ session: AgentRunSession,
+ resource_type: str,
+ resource_id: str,
+ ) -> bool:
+ """Check if resource access is allowed for this session.
+
+ Uses pre-computed authorized IDs for O(1) lookup.
+
+ Args:
+ session: AgentRunSession to check
+ resource_type: Resource type ('model', 'tool', 'knowledge_base', 'storage')
+ resource_id: Resource identifier (model_id, tool_name, kb_id)
+
+ Returns:
+ True if resource is authorized, False otherwise
+ """
+ authorized_ids = session.get('_authorized_ids', {})
+
+ if resource_type in ('model', 'tool', 'knowledge_base'):
+ return resource_id in authorized_ids.get(resource_type, set())
+
+ if resource_type == 'storage':
+ storage = session['resources'].get('storage', {})
+ if resource_id == 'plugin':
+ return storage.get('plugin_storage', False)
+ elif resource_id == 'workspace':
+ return storage.get('workspace_storage', False)
+ return False
+
+ return False
+
+ async def list_active_runs(self) -> list[AgentRunSession]:
+ """List all active run sessions.
+
+ Returns:
+ List of active AgentRunSession dicts
+ """
+ async with self._lock:
+ return list(self._sessions.values())
+
+ async def cleanup_stale_sessions(self, max_age_seconds: int = 3600) -> int:
+ """Cleanup sessions that have been inactive for too long.
+
+ Args:
+ max_age_seconds: Maximum inactivity time in seconds (default 1 hour)
+
+ Returns:
+ Number of sessions cleaned up
+ """
+ now = int(time.time())
+ cleaned = 0
+
+ async with self._lock:
+ stale_run_ids = []
+ for run_id, session in self._sessions.items():
+ last_activity = session['status'].get('last_activity_at', 0)
+ if now - last_activity > max_age_seconds:
+ stale_run_ids.append(run_id)
+
+ for run_id in stale_run_ids:
+ del self._sessions[run_id]
+ cleaned += 1
+
+ return cleaned
+
+
+# Global registry instance (singleton)
+_global_registry: AgentRunSessionRegistry | None = None
+_global_registry_lock = threading.Lock()
+
+
+def get_session_registry() -> AgentRunSessionRegistry:
+ """Get global session registry instance (thread-safe singleton).
+
+ Returns:
+ AgentRunSessionRegistry singleton
+ """
+ global _global_registry
+ with _global_registry_lock:
+ if _global_registry is None:
+ _global_registry = AgentRunSessionRegistry()
+ return _global_registry
\ No newline at end of file
diff --git a/src/langbot/pkg/agent/runner/state_store.py b/src/langbot/pkg/agent/runner/state_store.py
new file mode 100644
index 00000000..0043e13e
--- /dev/null
+++ b/src/langbot/pkg/agent/runner/state_store.py
@@ -0,0 +1,299 @@
+"""Runner scoped state store for managing AgentRunner state across runs."""
+from __future__ import annotations
+
+import typing
+import threading
+
+from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
+
+from .descriptor import AgentRunnerDescriptor
+
+
+# Valid state scopes per PROTOCOL_V1.md
+VALID_STATE_SCOPES = ('conversation', 'actor', 'subject', 'runner')
+
+# Key mapping for backward compatibility
+LEGACY_KEY_MAPPING = {
+ 'conversation_id': 'external.conversation_id',
+}
+
+
+class RunnerScopedStateStore:
+ """In-memory scoped state store for AgentRunner protocol state.
+
+ IMPORTANT: This is HOST-OWNED protocol state, NOT plugin instance state.
+
+ Key Design Principles:
+ 1. Host-owned: State is owned and managed by LangBot host, not by the plugin.
+ The plugin can only read/write through the SDK v1 protocol state API.
+ 2. Scope keys based on stable host identity: Uses host-controlled identifiers
+ (runner_id, bot_uuid, pipeline_uuid, launcher_type, launcher_id) rather
+ than external/unstable identifiers like external conversation id.
+ 3. External conversation id is a VALUE: The runner can update external.conversation_id
+ in state, which syncs to conversation.uuid. The scope key remains stable,
+ preventing state loss when conversation identity changes.
+
+ State scopes:
+ - conversation: runner_id + bot_uuid + pipeline_uuid + launcher_type + launcher_id + conversation identity
+ - actor: runner_id + bot_uuid + sender_id
+ - subject: runner_id + bot_uuid + launcher_type + launcher_id
+ - runner: runner_id + pipeline_uuid
+
+ This ensures different runners don't share state and same runner
+ has appropriate isolation per scope.
+
+ Note: This is an in-memory store. State only persists within the
+ current process lifetime. For production use, a persistent storage
+ backend should be implemented.
+ """
+
+ def __init__(self):
+ # Use thread-safe dict for concurrent access
+ self._store: dict[str, dict[str, typing.Any]] = {}
+ self._lock = threading.Lock()
+
+ def _make_conversation_scope_key(
+ self,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> str:
+ """Build conversation scope identity key.
+
+ Uses host-owned stable identity, NOT external conversation id.
+ External conversation id is a state VALUE, not part of state KEY.
+
+ This prevents state loss when runner updates external.conversation_id:
+ - First run: scope key uses stable identity, state saved
+ - Runner returns external.conversation_id, synced to conversation.uuid
+ - Next run: scope key still uses same stable identity, state accessible
+ """
+ parts = [
+ descriptor.id,
+ query.bot_uuid or 'unknown_bot',
+ query.pipeline_uuid or 'unknown_pipeline',
+ ]
+
+ if query.session:
+ parts.append(query.session.launcher_type.value)
+ parts.append(query.session.launcher_id)
+
+ # Use stable conversation identity (NOT external uuid)
+ # Options:
+ # 1. conversation.create_time if available (stable host-owned)
+ # 2. Use "conversation" literal as stable identity within launcher scope
+ # (assumes one active conversation per launcher context)
+ # We use option 2 for simplicity - conversation state is scoped to
+ # launcher (person/group) + bot + pipeline + runner
+ # External conversation id is just a VALUE inside this scope
+ conv_create_time = getattr(query.session.using_conversation, 'create_time', None)
+ if conv_create_time:
+ # Use create_time as stable identity if available
+ parts.append(str(conv_create_time))
+ # else: no additional part - launcher scope identity is sufficient
+
+ return f'conversation:{":".join(parts)}'
+
+ def _make_actor_scope_key(
+ self,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> str:
+ """Build actor scope identity key."""
+ parts = [
+ descriptor.id,
+ query.bot_uuid or 'unknown_bot',
+ str(query.sender_id) if query.sender_id else 'unknown_sender',
+ ]
+
+ return f'actor:{":".join(parts)}'
+
+ def _make_subject_scope_key(
+ self,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> str:
+ """Build subject scope identity key."""
+ parts = [
+ descriptor.id,
+ query.bot_uuid or 'unknown_bot',
+ ]
+
+ if query.session:
+ parts.append(query.session.launcher_type.value)
+ parts.append(query.session.launcher_id)
+
+ return f'subject:{":".join(parts)}'
+
+ def _make_runner_scope_key(
+ self,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> str:
+ """Build runner scope identity key."""
+ parts = [
+ descriptor.id,
+ query.pipeline_uuid or 'unknown_pipeline',
+ ]
+
+ return f'runner:{":".join(parts)}'
+
+ def _get_scope_key(
+ self,
+ scope: str,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> str:
+ """Get the storage key for a given scope."""
+ if scope == 'conversation':
+ return self._make_conversation_scope_key(query, descriptor)
+ elif scope == 'actor':
+ return self._make_actor_scope_key(query, descriptor)
+ elif scope == 'subject':
+ return self._make_subject_scope_key(query, descriptor)
+ elif scope == 'runner':
+ return self._make_runner_scope_key(query, descriptor)
+ else:
+ raise ValueError(f'Invalid scope: {scope}')
+
+ def build_snapshot(
+ self,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> dict[str, dict[str, typing.Any]]:
+ """Build state snapshot for all scopes.
+
+ Args:
+ query: Pipeline query
+ descriptor: Runner descriptor
+
+ Returns:
+ Dict with 4 scope keys, each containing scope state dict
+ """
+ snapshot: dict[str, dict[str, typing.Any]] = {
+ 'conversation': {},
+ 'actor': {},
+ 'subject': {},
+ 'runner': {},
+ }
+
+ with self._lock:
+ for scope in VALID_STATE_SCOPES:
+ scope_key = self._get_scope_key(scope, query, descriptor)
+ scope_state = self._store.get(scope_key, {})
+ snapshot[scope] = dict(scope_state) # Copy to avoid mutation
+
+ # Seed external.conversation_id from existing conversation uuid
+ if query.session and query.session.using_conversation:
+ conv_uuid = getattr(query.session.using_conversation, 'uuid', None)
+ if conv_uuid and 'external.conversation_id' not in snapshot['conversation']:
+ snapshot['conversation']['external.conversation_id'] = conv_uuid
+
+ return snapshot
+
+ def apply_update(
+ self,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ scope: str,
+ key: str,
+ value: typing.Any,
+ logger: typing.Any = None,
+ ) -> bool:
+ """Apply a state update to the store.
+
+ Args:
+ query: Pipeline query
+ descriptor: Runner descriptor
+ scope: State scope (conversation, actor, subject, runner)
+ key: State key (should use namespace prefix like external.*)
+ value: State value (must be JSON-serializable)
+ logger: Optional logger for warnings
+
+ Returns:
+ True if update applied successfully, False if invalid scope
+
+ Side effects:
+ - Updates internal store
+ - Syncs external.conversation_id to query.session.using_conversation.uuid
+ """
+ # Validate scope
+ if scope not in VALID_STATE_SCOPES:
+ if logger:
+ logger.warning(
+ f'Runner {descriptor.id} state.updated with invalid scope: {scope}. '
+ f'Valid scopes: {", ".join(VALID_STATE_SCOPES)}'
+ )
+ return False
+
+ # Map legacy key names
+ if key in LEGACY_KEY_MAPPING:
+ mapped_key = LEGACY_KEY_MAPPING[key]
+ if logger:
+ logger.debug(
+ f'Runner {descriptor.id} state.updated legacy key "{key}" mapped to "{mapped_key}"'
+ )
+ key = mapped_key
+
+ # Apply update to store
+ with self._lock:
+ scope_key = self._get_scope_key(scope, query, descriptor)
+ if scope_key not in self._store:
+ self._store[scope_key] = {}
+ self._store[scope_key][key] = value
+
+ # Sync external.conversation_id to query.session.using_conversation.uuid
+ if scope == 'conversation' and key == 'external.conversation_id':
+ if query.session and query.session.using_conversation:
+ # Update conversation uuid for backward compatibility
+ # This ensures old conversation continuation behavior works
+ setattr(query.session.using_conversation, 'uuid', value)
+ if logger:
+ logger.debug(
+ f'Synced external.conversation_id "{value}" to conversation.uuid'
+ )
+
+ return True
+
+ def clear_scope(
+ self,
+ scope: str,
+ query: pipeline_query.Query,
+ descriptor: AgentRunnerDescriptor,
+ ) -> None:
+ """Clear all state for a specific scope.
+
+ Args:
+ scope: State scope to clear
+ query: Pipeline query
+ descriptor: Runner descriptor
+ """
+ with self._lock:
+ scope_key = self._get_scope_key(scope, query, descriptor)
+ if scope_key in self._store:
+ del self._store[scope_key]
+
+ def clear_all(self) -> None:
+ """Clear all stored state (for testing/reset)."""
+ with self._lock:
+ self._store.clear()
+
+
+# Global singleton state store
+_state_store: RunnerScopedStateStore | None = None
+_state_store_lock = threading.Lock()
+
+
+def get_state_store() -> RunnerScopedStateStore:
+ """Get the global state store singleton."""
+ global _state_store
+ with _state_store_lock:
+ if _state_store is None:
+ _state_store = RunnerScopedStateStore()
+ return _state_store
+
+
+def reset_state_store() -> None:
+ """Reset the global state store (for testing)."""
+ global _state_store
+ with _state_store_lock:
+ _state_store = None
\ No newline at end of file
diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py
index 49bcf41e..61696e12 100644
--- a/src/langbot/pkg/api/http/service/pipeline.py
+++ b/src/langbot/pkg/api/http/service/pipeline.py
@@ -45,20 +45,27 @@ class PipelineService:
break
if runner_stage:
- # Find the runner select config
+ # Find the runner select config (now uses 'id' field)
for config_item in runner_stage.get('config', []):
- if config_item.get('name') == 'runner':
+ if config_item.get('name') == 'id':
# Get plugin agent runners from registry
try:
runner_options, runner_stages = await self.ap.agent_runner_registry.get_runner_metadata_for_pipeline()
- # Add plugin runners to options
- for option in runner_options:
- config_item['options'].append(option)
+ # Replace options entirely with registry options
+ # Only installed/available runners should be shown
+ config_item['options'] = runner_options
+
+ # Set default to first available runner if not specified
+ if runner_options and 'default' not in config_item:
+ config_item['default'] = runner_options[0]['name']
# Add corresponding stage configuration for each runner
for stage_config in runner_stages:
- ai_metadata['stages'].append(stage_config)
+ # Avoid duplicate stages
+ existing_stage_names = {s.get('name') for s in ai_metadata.get('stages', [])}
+ if stage_config['name'] not in existing_stage_names:
+ ai_metadata['stages'].append(stage_config)
except Exception as e:
self.ap.logger.warning(f'Failed to load plugin agent runners from registry: {e}')
@@ -145,10 +152,16 @@ class PipelineService:
return pipeline_data['uuid']
async def update_pipeline(self, pipeline_uuid: str, pipeline_data: dict) -> None:
+ from ....agent.runner.config_migration import ConfigMigration
+
pipeline_data = pipeline_data.copy()
for protected_field in ('uuid', 'for_version', 'stages', 'is_default'):
pipeline_data.pop(protected_field, None)
+ # Migrate config to new format before saving
+ if 'config' in pipeline_data:
+ pipeline_data['config'] = ConfigMigration.migrate_pipeline_config(pipeline_data['config'])
+
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
.where(persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid)
diff --git a/src/langbot/pkg/persistence/alembic/versions/0004_migrate_runner_config.py b/src/langbot/pkg/persistence/alembic/versions/0004_migrate_runner_config.py
new file mode 100644
index 00000000..504145d1
--- /dev/null
+++ b/src/langbot/pkg/persistence/alembic/versions/0004_migrate_runner_config.py
@@ -0,0 +1,124 @@
+"""Migrate pipeline config to new runner format
+
+Revision ID: 0004_migrate_runner_config
+Revises: 0003_add_rerank_models
+Create Date: 2026-05-10
+"""
+
+import json
+import sqlalchemy as sa
+from alembic import op
+
+revision = '0004_migrate_runner_config'
+down_revision = '0003_add_rerank_models'
+branch_labels = None
+depends_on = None
+
+# Mapping from old built-in runner names to official plugin runner IDs
+OLD_RUNNER_TO_PLUGIN_RUNNER_ID = {
+ 'local-agent': 'plugin:langbot/local-agent/default',
+ 'dify-service-api': 'plugin:langbot/dify-agent/default',
+ 'n8n-service-api': 'plugin:langbot/n8n-agent/default',
+ 'coze-api': 'plugin:langbot/coze-agent/default',
+ 'dashscope-app-api': 'plugin:langbot/dashscope-agent/default',
+ 'langflow-api': 'plugin:langbot/langflow-agent/default',
+ 'tbox-app-api': 'plugin:langbot/tbox-agent/default',
+}
+
+
+def is_plugin_runner_id(runner_id: str) -> bool:
+ """Check if runner ID is in plugin:* format."""
+ return runner_id.startswith('plugin:')
+
+
+def migrate_pipeline_config(config: dict) -> dict:
+ """Migrate pipeline config to new format."""
+ new_config = dict(config)
+ ai_config = new_config.get('ai', {})
+ if not ai_config:
+ return new_config
+
+ runner_config = ai_config.get('runner', {})
+ runner_configs = ai_config.get('runner_config', {})
+
+ # Check for new format first
+ runner_id = runner_config.get('id')
+ if runner_id and is_plugin_runner_id(runner_id):
+ # Already in new format, no need to migrate
+ return new_config
+
+ # Check for old format
+ old_runner_name = runner_config.get('runner')
+ if old_runner_name:
+ # Map to new runner ID
+ if is_plugin_runner_id(old_runner_name):
+ runner_id = old_runner_name
+ else:
+ runner_id = OLD_RUNNER_TO_PLUGIN_RUNNER_ID.get(old_runner_name, old_runner_name)
+
+ # Set new format
+ runner_config['id'] = runner_id
+
+ # Remove old runner field if it's a mapped built-in runner
+ if old_runner_name in OLD_RUNNER_TO_PLUGIN_RUNNER_ID:
+ del runner_config['runner']
+
+ # Migrate runner-specific config and remove old config blocks
+ if old_runner_name in ai_config:
+ old_runner_config = ai_config[old_runner_name]
+ if old_runner_config:
+ runner_configs[runner_id] = old_runner_config
+ # Remove old config block after migration
+ del ai_config[old_runner_name]
+
+ # Also check if runner_id has config under other old name formats
+ for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
+ if mapped_id == runner_id and old_name in ai_config:
+ runner_configs[runner_id] = ai_config[old_name]
+ # Remove old config block after migration
+ del ai_config[old_name]
+
+ # Update configs
+ ai_config['runner'] = runner_config
+ ai_config['runner_config'] = runner_configs
+ new_config['ai'] = ai_config
+
+ return new_config
+
+
+def upgrade() -> None:
+ """Migrate existing pipeline configs to new runner format."""
+ conn = op.get_bind()
+ inspector = sa.inspect(conn)
+
+ # Check if pipelines table exists (may not exist in fresh install)
+ if 'pipelines' not in inspector.get_table_names():
+ return
+
+ # Get all pipelines
+ result = conn.execute(sa.text('SELECT uuid, config FROM pipelines'))
+ pipelines = result.fetchall()
+
+ for pipeline_uuid, config_json in pipelines:
+ if not config_json:
+ continue
+
+ try:
+ config = json.loads(config_json)
+ migrated_config = migrate_pipeline_config(config)
+
+ # Only update if config changed
+ if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
+ conn.execute(
+ sa.text('UPDATE pipelines SET config = :config WHERE uuid = :uuid'),
+ {'config': json.dumps(migrated_config), 'uuid': pipeline_uuid}
+ )
+ except Exception:
+ # Skip invalid configs
+ continue
+
+
+def downgrade() -> None:
+ """Downgrade is not supported for data migration."""
+ # No downgrade - keep configs in new format
+ pass
\ No newline at end of file
diff --git a/src/langbot/pkg/pipeline/msgtrun/truncators/round.py b/src/langbot/pkg/pipeline/msgtrun/truncators/round.py
index 400706b6..634f3106 100644
--- a/src/langbot/pkg/pipeline/msgtrun/truncators/round.py
+++ b/src/langbot/pkg/pipeline/msgtrun/truncators/round.py
@@ -2,6 +2,7 @@ from __future__ import annotations
from .. import truncator
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
+from ....agent.runner.config_migration import ConfigMigration
@truncator.truncator_class('round')
@@ -10,7 +11,10 @@ class RoundTruncator(truncator.Truncator):
async def truncate(self, query: pipeline_query.Query) -> pipeline_query.Query:
"""截断"""
- max_round = query.pipeline_config['ai']['local-agent']['max-round']
+ # Get max-round from runner config (new or old format)
+ runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
+ runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id) if runner_id else {}
+ max_round = runner_config.get('max-round', 10)
temp_messages = []
diff --git a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py
index d424debd..81f30e55 100644
--- a/src/langbot/pkg/platform/sources/web_page_bot_adapter.py
+++ b/src/langbot/pkg/platform/sources/web_page_bot_adapter.py
@@ -84,6 +84,20 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
):
self.listeners.pop(event_type, None)
+ async def is_stream_output_supported(self) -> bool:
+ """Delegate stream output check to ws_adapter."""
+ if self._ws_adapter is not None:
+ return await self._ws_adapter.is_stream_output_supported()
+ return False
+
+ async def create_message_card(
+ self, message_id: str | int, event: platform_events.MessageEvent
+ ) -> bool:
+ """Delegate create_message_card to ws_adapter."""
+ if self._ws_adapter is not None:
+ return await self._ws_adapter.create_message_card(message_id, event)
+ return False
+
async def is_muted(self, group_id: int) -> bool:
return False
diff --git a/src/langbot/pkg/plugin/handler.py b/src/langbot/pkg/plugin/handler.py
index 022e84cf..71f56f37 100644
--- a/src/langbot/pkg/plugin/handler.py
+++ b/src/langbot/pkg/plugin/handler.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import typing
-from typing import Any
+from typing import Any, Union
import base64
import traceback
@@ -24,6 +24,7 @@ from ..entity.persistence import bstorage as persistence_bstorage
from ..core import app
from ..utils import constants
+from ..agent.runner.session_registry import get_session_registry
def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse:
@@ -40,6 +41,48 @@ def _make_rag_error_response(error: Exception, error_type: str, **extra_context)
return handler.ActionResponse.error(message=message)
+async def _validate_run_authorization(
+ run_id: str,
+ resource_type: str,
+ resource_id: str,
+ ap: app.Application,
+) -> Union[tuple[None, handler.ActionResponse], tuple[Any, None]]:
+ """Validate run_id authorization for a resource access.
+
+ Common validation logic for INVOKE_LLM, INVOKE_LLM_STREAM, CALL_TOOL,
+ RETRIEVE_KNOWLEDGE_BASE, and RETRIEVE_KNOWLEDGE actions.
+
+ Args:
+ run_id: The run_id to validate.
+ resource_type: Resource type ('model', 'tool', 'knowledge_base').
+ resource_id: Resource identifier (model_uuid, tool_name, kb_id).
+ ap: Application instance for logging.
+
+ Returns:
+ Tuple of (session, None) if validation passes.
+ Tuple of (None, error_response) if validation fails.
+ """
+ session_registry = get_session_registry()
+ session = await session_registry.get(run_id)
+ if not session:
+ ap.logger.warning(
+ f'{resource_type.upper()}: run_id {run_id} not found in session registry'
+ )
+ return None, handler.ActionResponse.error(
+ message=f'Run session {run_id} not found or expired',
+ )
+
+ if not session_registry.is_resource_allowed(session, resource_type, resource_id):
+ ap.logger.warning(
+ f'{resource_type.upper()}: {resource_id} not allowed for run_id {run_id}'
+ )
+ return None, handler.ActionResponse.error(
+ message=f'{resource_type} {resource_id} is not authorized for this agent run',
+ )
+
+ return session, None
+
+
class RuntimeConnectionHandler(handler.Handler):
"""Runtime connection handler"""
@@ -324,11 +367,24 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.INVOKE_LLM)
async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse:
- """Invoke llm"""
+ """Invoke llm
+
+ For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models.
+ For regular plugin calls: no run_id, unrestricted access (backward compatibility).
+ """
llm_model_uuid = data['llm_model_uuid']
messages = data['messages']
funcs = data.get('funcs', [])
extra_args = data.get('extra_args', {})
+ run_id = data.get('run_id') # Optional: present for AgentRunner calls
+
+ # Permission validation for AgentRunner calls
+ if run_id:
+ session, error = await _validate_run_authorization(
+ run_id, 'model', llm_model_uuid, self.ap
+ )
+ if error:
+ return error
llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid)
if llm_model is None:
@@ -362,11 +418,25 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.INVOKE_LLM_STREAM)
async def invoke_llm_stream(data: dict[str, Any]):
- """Invoke llm with streaming response"""
+ """Invoke llm with streaming response
+
+ For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models.
+ For regular plugin calls: no run_id, unrestricted access (backward compatibility).
+ """
llm_model_uuid = data['llm_model_uuid']
messages = data['messages']
funcs = data.get('funcs', [])
extra_args = data.get('extra_args', {})
+ run_id = data.get('run_id') # Optional: present for AgentRunner calls
+
+ # Permission validation for AgentRunner calls
+ if run_id:
+ session, error = await _validate_run_authorization(
+ run_id, 'model', llm_model_uuid, self.ap
+ )
+ if error:
+ yield error
+ return
llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid)
if llm_model is None:
@@ -393,12 +463,30 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.CALL_TOOL)
async def call_tool(data: dict[str, Any]) -> handler.ActionResponse:
- """Call a tool"""
+ """Call a tool
+
+ For AgentRunner calls: requires run_id and validates tool_name against session.resources.tools.
+ For regular plugin calls: no run_id, unrestricted access (backward compatibility).
+
+ Note: SDK LangBotAPIProxy (legacy) sends 'tool_parameters' and expects 'tool_response'.
+ SDK AgentRunAPIProxy sends 'parameters' and expects 'result'.
+ Handler returns both for backward compatibility.
+ """
tool_name = data['tool_name']
- parameters = data['parameters']
+ # Support 'tool_parameters' (LangBotAPIProxy) and 'parameters' (AgentRunAPIProxy)
+ parameters = data.get('tool_parameters') or data.get('parameters', {})
+ run_id = data.get('run_id') # Optional: present for AgentRunner calls
# session_data = data['session']
# query_id = data['query_id']
+ # Permission validation for AgentRunner calls
+ if run_id:
+ session, error = await _validate_run_authorization(
+ run_id, 'tool', tool_name, self.ap
+ )
+ if error:
+ return error
+
# Convert session_data to Session object (simplified)
# In real implementation, you would reconstruct the full session
# For now, we'll call the tool manager's execute method
@@ -408,9 +496,12 @@ class RuntimeConnectionHandler(handler.Handler):
parameters=parameters,
query=None, # TODO: reconstruct query from session_data if needed
)
+ # Return both 'tool_response' (LangBotAPIProxy) and 'result' (AgentRunAPIProxy)
+ # LangBotAPIProxy expects 'tool_response', AgentRunAPIProxy expects 'result'
return handler.ActionResponse.success(
data={
- 'result': result,
+ 'tool_response': result,
+ 'result': result, # backward compatibility
},
)
except Exception as e:
@@ -419,6 +510,14 @@ class RuntimeConnectionHandler(handler.Handler):
message=f'Failed to execute tool {tool_name}: {e}',
)
+ # ================= Binary Storage Handlers =================
+ # NOTE: These are low-level actions called by SDK Runtime's storage wrapper handlers.
+ # Permission validation is handled in SDK Runtime layer (not here):
+ # - plugin_storage: SDK handler auto-sets owner to caller plugin identity (inherent isolation)
+ # - workspace_storage: SDK handler should validate session.resources.storage.workspace_storage
+ # TODO: SDK storage handlers need to pass run_id and validate workspace_storage permission.
+ # Current risk: workspace storage access is unrestricted from AgentRunner context.
+
@self.action(RuntimeToLangBotAction.SET_BINARY_STORAGE)
async def set_binary_storage(data: dict[str, Any]) -> handler.ActionResponse:
"""Set binary storage"""
@@ -706,11 +805,26 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
- """Retrieve documents from any knowledge base (unrestricted)."""
+ """Retrieve documents from any knowledge base.
+
+ For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases.
+ For regular plugin calls: no run_id, unrestricted access (backward compatibility).
+
+ Note: SDK AgentRunAPIProxy.retrieve_knowledge calls this action with run_id.
+ """
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
+ run_id = data.get('run_id') # Optional: present for AgentRunner calls
+
+ # Permission validation for AgentRunner calls
+ if run_id:
+ session, error = await _validate_run_authorization(
+ run_id, 'knowledge_base', kb_id, self.ap
+ )
+ if error:
+ return error
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
if not kb:
@@ -769,12 +883,27 @@ class RuntimeConnectionHandler(handler.Handler):
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:
- """Retrieve documents from a knowledge base within the pipeline's scope."""
+ """Retrieve documents from a knowledge base within the pipeline's scope.
+
+ For AgentRunner calls: requires run_id and validates kb_id against session.resources.knowledge_bases.
+ For regular plugin calls: no run_id, validates against pipeline's configured knowledge bases.
+
+ Note: This action has dual validation paths:
+ - AgentRunner: uses session_registry for permission check
+ - Regular plugin: uses ConfigMigration.resolve_runner_config for pipeline-level check
+
+ SECURITY TODO: This handler cannot verify the caller's plugin identity.
+ The session contains 'plugin_identity' (author/name), but we don't have access
+ to which plugin is making the API call. This could allow a malicious plugin to
+ use another plugin's run_id if it can guess/obtain it. Future improvement:
+ track caller plugin identity in RuntimeConnectionHandler or pass it in action data.
+ """
query_id = data['query_id']
kb_id = data['kb_id']
query_text = data['query_text']
top_k = data.get('top_k', 5)
filters = data.get('filters', {})
+ run_id = data.get('run_id') # Optional: present for AgentRunner calls
if query_id not in self.ap.query_pool.cached_queries:
return handler.ActionResponse.error(
@@ -783,21 +912,32 @@ class RuntimeConnectionHandler(handler.Handler):
query = self.ap.query_pool.cached_queries[query_id]
- # Validate kb_id is in pipeline's allowed list
- allowed_kb_uuids = []
- if query.pipeline_config:
- from langbot.pkg.agent.runner.config_migration import ConfigMigration
- runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, None)
- allowed_kb_uuids = runner_config.get('knowledge-bases', [])
- if not allowed_kb_uuids:
- old_kb_uuid = runner_config.get('knowledge-base', '')
- if old_kb_uuid and old_kb_uuid != '__none__':
- allowed_kb_uuids = [old_kb_uuid]
-
- if kb_id not in allowed_kb_uuids:
- return handler.ActionResponse.error(
- message=f'Knowledge base {kb_id} is not configured for this pipeline',
+ # Permission validation for AgentRunner calls
+ if run_id:
+ session, error = await _validate_run_authorization(
+ run_id, 'knowledge_base', kb_id, self.ap
)
+ if error:
+ return error
+ else:
+ # Regular plugin call: validate against pipeline's configured knowledge bases
+ # FIX: First resolve runner_id, then resolve runner_config
+ allowed_kb_uuids = []
+ if query.pipeline_config:
+ from langbot.pkg.agent.runner.config_migration import ConfigMigration
+ runner_id = ConfigMigration.resolve_runner_id(query.pipeline_config)
+ if runner_id:
+ runner_config = ConfigMigration.resolve_runner_config(query.pipeline_config, runner_id)
+ allowed_kb_uuids = runner_config.get('knowledge-bases', [])
+ if not allowed_kb_uuids:
+ old_kb_uuid = runner_config.get('knowledge-base', '')
+ if old_kb_uuid and old_kb_uuid != '__none__':
+ allowed_kb_uuids = [old_kb_uuid]
+
+ if kb_id not in allowed_kb_uuids:
+ return handler.ActionResponse.error(
+ message=f'Knowledge base {kb_id} is not configured for this pipeline',
+ )
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
if not kb:
diff --git a/src/langbot/templates/metadata/pipeline/ai.yaml b/src/langbot/templates/metadata/pipeline/ai.yaml
index 32f4115f..f169ccb0 100644
--- a/src/langbot/templates/metadata/pipeline/ai.yaml
+++ b/src/langbot/templates/metadata/pipeline/ai.yaml
@@ -11,42 +11,13 @@ stages:
en_US: Strategy to call AI to process messages
zh_Hans: 调用 AI 处理消息的方式
config:
- - name: runner
+ - name: id
label:
en_US: Runner
zh_Hans: 运行器
type: select
required: true
- default: local-agent
- options:
- - name: local-agent
- label:
- en_US: Local Agent
- zh_Hans: 内置 Agent
- - name: dify-service-api
- label:
- en_US: Dify Service API
- zh_Hans: Dify 服务 API
- - name: n8n-service-api
- label:
- en_US: n8n Workflow API
- zh_Hans: n8n 工作流 API
- - name: coze-api
- label:
- en_US: Coze API
- zh_Hans: 扣子 API
- - name: tbox-app-api
- label:
- en_US: Tbox App API
- zh_Hans: 蚂蚁百宝箱平台 API
- - name: dashscope-app-api
- label:
- en_US: Aliyun Dashscope App API
- zh_Hans: 阿里云百炼平台 API
- - name: langflow-api
- label:
- en_US: Langflow API
- zh_Hans: Langflow API
+ # Options and default are dynamically populated from AgentRunnerRegistry
- name: expire-time
label:
en_US: Conversation expire time (seconds)
@@ -67,589 +38,6 @@ stages:
type: integer
required: true
default: 0
- - name: local-agent
- label:
- en_US: Local Agent
- zh_Hans: 内置 Agent
- description:
- en_US: Configure the embedded agent of the pipeline
- zh_Hans: 配置内置 Agent
- config:
- - name: model
- label:
- en_US: Model
- zh_Hans: 模型
- type: model-fallback-selector
- required: true
- default:
- primary: ''
- fallbacks: []
- - name: max-round
- label:
- en_US: Max Round
- zh_Hans: 最大回合数
- description:
- en_US: The maximum number of previous messages that the agent can remember
- zh_Hans: 最大前文消息回合数
- type: integer
- required: true
- default: 10
- show_if:
- field: __system.is_wizard
- operator: neq
- value: true
- - name: prompt
- label:
- en_US: Prompt
- zh_Hans: 提示词
- description:
- en_US: The prompt of the agent
- zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
- type: prompt-editor
- required: true
- default:
- - role: system
- content: "You are a helpful assistant."
- - name: knowledge-bases
- label:
- en_US: Knowledge Bases
- zh_Hans: 知识库
- description:
- en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
- zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
- type: knowledge-base-multi-selector
- required: false
- default: []
- show_if:
- field: __system.is_wizard
- operator: neq
- value: true
- - name: box-session-id-template
- label:
- en_US: Sandbox Scope
- zh_Hans: 沙箱作用域
- zh_Hant: 沙箱作用域
- ja_JP: サンドボックススコープ
- vi_VN: Phạm vi Sandbox
- th_TH: ขอบเขต Sandbox
- es_ES: Alcance del Sandbox
- ru_RU: Область песочницы
- description:
- en_US: Determines how sandbox environments are shared across messages.
- zh_Hans: 决定沙箱环境在不同消息间的共享方式。
- zh_Hant: 決定沙箱環境在不同訊息間的共享方式。
- ja_JP: メッセージ間でサンドボックス環境を共有する方法を決定します。
- vi_VN: Xác định cách chia sẻ môi trường sandbox giữa các tin nhắn.
- th_TH: กำหนดวิธีแชร์สภาพแวดล้อม Sandbox ระหว่างข้อความ
- es_ES: Determina cómo se comparten los entornos sandbox entre mensajes.
- ru_RU: Определяет, как песочницы используются совместно между сообщениями.
- disable_if:
- field: __system.box_available
- operator: eq
- value: false
- disabled_tooltip:
- en_US: >-
- Box sandbox is disabled or unavailable. Enable it in config.yaml
- (box.enabled = true) and ensure the runtime is reachable to change
- this setting.
- zh_Hans: Box 沙箱已禁用或不可用。请在配置中启用(box.enabled = true)并确认运行时连接正常,才能修改此项。
- zh_Hant: Box 沙箱已停用或無法使用。請在設定中啟用(box.enabled = true)並確認執行時連線正常,才能修改此項。
- ja_JP: Box サンドボックスが無効または利用できません。設定で有効化(box.enabled = true)し、ランタイムが接続できることを確認してから変更してください。
- vi_VN: Sandbox Box đã tắt hoặc không khả dụng. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime hoạt động để chỉnh sửa.
- th_TH: Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่ารันไทม์เชื่อมต่อปกติก่อนปรับค่า
- es_ES: El sandbox de Box está desactivado o no disponible. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime esté conectado para modificar este ajuste.
- ru_RU: Песочница Box отключена или недоступна. Включите её в конфигурации (box.enabled = true) и убедитесь, что среда выполнения работает, чтобы изменить эту настройку.
- type: select
- required: false
- default: "{launcher_type}_{launcher_id}"
- options:
- - name: "{global}"
- label:
- en_US: Global (shared by all)
- zh_Hans: 全局(所有人共享)
- zh_Hant: 全域(所有人共用)
- ja_JP: グローバル(全員共有)
- vi_VN: Toàn cục (chia sẻ cho tất cả)
- th_TH: ทั่วไป (แชร์ทั้งหมด)
- es_ES: Global (compartido por todos)
- ru_RU: Глобальный (общий для всех)
- - name: "{launcher_type}_{launcher_id}"
- label:
- en_US: Per chat (Recommended)
- zh_Hans: 每个会话(推荐)
- zh_Hant: 每個會話(推薦)
- ja_JP: チャットごと(推奨)
- vi_VN: Mỗi cuộc trò chuyện (Khuyến nghị)
- th_TH: ต่อแชท (แนะนำ)
- es_ES: Por chat (Recomendado)
- ru_RU: По чату (Рекомендуется)
- - name: "{launcher_type}_{launcher_id}_{sender_id}"
- label:
- en_US: Per user in chat
- zh_Hans: 会话中每个用户
- zh_Hant: 會話中每個用戶
- ja_JP: チャット内のユーザーごと
- vi_VN: Mỗi người dùng trong cuộc trò chuyện
- th_TH: ต่อผู้ใช้ในแชท
- es_ES: Por usuario en chat
- ru_RU: По пользователю в чате
- - name: "{launcher_type}_{launcher_id}_{conversation_id}"
- label:
- en_US: Per conversation context
- zh_Hans: 每个对话上下文
- zh_Hant: 每個對話上下文
- ja_JP: 会話コンテキストごと
- vi_VN: Mỗi ngữ cảnh hội thoại
- th_TH: ต่อบริบทการสนทนา
- es_ES: Por contexto de conversación
- ru_RU: По контексту разговора
- - name: "{query_id}"
- label:
- en_US: Per message (isolated)
- zh_Hans: 每条消息(完全隔离)
- zh_Hant: 每條訊息(完全隔離)
- ja_JP: メッセージごと(隔離)
- vi_VN: Mỗi tin nhắn (cách ly)
- th_TH: ต่อข้อความ (แยกส่วน)
- es_ES: Por mensaje (aislado)
- ru_RU: По сообщению (изолированно)
- show_if:
- field: __system.is_wizard
- operator: neq
- value: true
- - name: rerank-model
- label:
- en_US: Rerank Model
- zh_Hans: 重排序模型
- description:
- en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
- zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
- type: rerank-model-selector
- required: false
- default: ''
- show_if:
- field: knowledge-bases
- operator: neq
- value: []
- - name: rerank-top-k
- label:
- en_US: Rerank Top K
- zh_Hans: 重排序保留数量
- description:
- en_US: Number of top results to keep after reranking
- zh_Hans: 重排序后保留的最相关结果数量
- type: integer
- required: false
- default: 5
- show_if:
- field: rerank-model
- operator: neq
- value: ''
- - name: dify-service-api
- label:
- en_US: Dify Service API
- zh_Hans: Dify 服务 API
- description:
- en_US: Configure the Dify service API of the pipeline
- zh_Hans: 配置 Dify 服务 API
- config:
- - name: base-url
- label:
- en_US: Base URL
- zh_Hans: 基础 URL
- type: string
- required: true
- options:
- - name: 'https://api.dify.ai/v1'
- label:
- en_US: Dify Cloud
- zh_Hans: Dify 云服务
- default: 'https://api.dify.ai/v1'
- - name: base-prompt
- label:
- en_US: Base PROMPT
- zh_Hans: 基础提示词
- description:
- en_US: When Dify receives a message with empty input (only images), it will pass this default prompt into it.
- zh_Hans: 当 Dify 接收到输入文字为空(仅图片)的消息时,传入该默认提示词
- type: string
- required: true
- default: "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
- - name: app-type
- label:
- en_US: App Type
- zh_Hans: 应用类型
- type: select
- required: true
- default: chat
- options:
- - name: chat
- label:
- en_US: Chat
- zh_Hans: 聊天(包括Chatflow)
- - name: agent
- label:
- en_US: Agent
- zh_Hans: Agent
- - name: workflow
- label:
- en_US: Workflow
- zh_Hans: 工作流
- - name: api-key
- label:
- en_US: API Key
- zh_Hans: API 密钥
- type: string
- required: true
- default: 'your-api-key'
- - name: n8n-service-api
- label:
- en_US: n8n Workflow API
- zh_Hans: n8n 工作流 API
- description:
- en_US: Configure the n8n workflow API of the pipeline
- zh_Hans: 配置 n8n 工作流 API
- config:
- - name: webhook-url
- label:
- en_US: Webhook URL
- zh_Hans: Webhook URL
- description:
- en_US: The webhook URL of the n8n workflow
- zh_Hans: n8n 工作流的 webhook URL
- type: string
- required: true
- default: 'http://your-n8n-webhook-url'
- - name: auth-type
- label:
- en_US: Authentication Type
- zh_Hans: 认证类型
- description:
- en_US: The authentication type for the webhook call
- zh_Hans: webhook 调用的认证类型
- type: select
- required: true
- default: 'none'
- options:
- - name: 'none'
- label:
- en_US: None
- zh_Hans: 无认证
- - name: 'basic'
- label:
- en_US: Basic Auth
- zh_Hans: 基本认证
- - name: 'jwt'
- label:
- en_US: JWT
- zh_Hans: JWT认证
- - name: 'header'
- label:
- en_US: Header Auth
- zh_Hans: 请求头认证
- - name: basic-username
- label:
- en_US: Username
- zh_Hans: 用户名
- description:
- en_US: The username for Basic Auth
- zh_Hans: 基本认证的用户名
- type: string
- required: false
- default: ''
- show_if:
- field: auth-type
- operator: eq
- value: 'basic'
- - name: basic-password
- label:
- en_US: Password
- zh_Hans: 密码
- description:
- en_US: The password for Basic Auth
- zh_Hans: 基本认证的密码
- type: string
- required: false
- default: ''
- show_if:
- field: auth-type
- operator: eq
- value: 'basic'
- - name: jwt-secret
- label:
- en_US: Secret
- zh_Hans: 密钥
- description:
- en_US: The secret for JWT authentication
- zh_Hans: JWT认证的密钥
- type: string
- required: false
- default: ''
- show_if:
- field: auth-type
- operator: eq
- value: 'jwt'
- - name: jwt-algorithm
- label:
- en_US: Algorithm
- zh_Hans: 算法
- description:
- en_US: The algorithm for JWT authentication
- zh_Hans: JWT认证的算法
- type: string
- required: false
- default: 'HS256'
- show_if:
- field: auth-type
- operator: eq
- value: 'jwt'
- - name: header-name
- label:
- en_US: Header Name
- zh_Hans: 请求头名称
- description:
- en_US: The header name for Header Auth
- zh_Hans: 请求头认证的名称
- type: string
- required: false
- default: ''
- show_if:
- field: auth-type
- operator: eq
- value: 'header'
- - name: header-value
- label:
- en_US: Header Value
- zh_Hans: 请求头值
- description:
- en_US: The header value for Header Auth
- zh_Hans: 请求头认证的值
- type: string
- required: false
- default: ''
- show_if:
- field: auth-type
- operator: eq
- value: 'header'
- - name: timeout
- label:
- en_US: Timeout
- zh_Hans: 超时时间
- description:
- en_US: The timeout in seconds for the webhook call
- zh_Hans: webhook 调用的超时时间(秒)
- type: integer
- required: false
- default: 120
- - name: output-key
- label:
- en_US: Output Key
- zh_Hans: 输出键名
- description:
- en_US: The key name of the output in the webhook response
- zh_Hans: webhook 响应中输出内容的键名
- type: string
- required: false
- default: 'response'
- - name: coze-api
- label:
- en_US: coze API
- zh_Hans: 扣子 API
- description:
- en_US: Configure the Coze API of the pipeline
- zh_Hans: 配置Coze API
- config:
- - name: api-key
- label:
- en_US: API Key
- zh_Hans: API 密钥
- description:
- en_US: The API key for the Coze server
- zh_Hans: Coze服务器的 API 密钥
- type: string
- required: true
- default: ''
- - name: bot-id
- label:
- en_US: Bot ID
- zh_Hans: 机器人 ID
- description:
- en_US: The ID of the bot to run
- zh_Hans: 要运行的机器人 ID
- type: string
- required: true
- default: ''
- - name: api-base
- label:
- en_US: API Base URL
- zh_Hans: API 基础 URL
- description:
- en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
- zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
- type: string
- options:
- - name: 'https://api.coze.cn'
- label:
- en_US: Coze China
- zh_Hans: Coze 中国版
- - name: 'https://api.coze.com'
- label:
- en_US: Coze Global
- zh_Hans: Coze 全球版
- default: "https://api.coze.cn"
- - name: auto-save-history
- label:
- en_US: Auto Save History
- zh_Hans: 自动保存历史
- description:
- en_US: Whether to automatically save conversation history
- zh_Hans: 是否自动保存对话历史
- type: boolean
- default: true
- - name: timeout
- label:
- en_US: Request Timeout
- zh_Hans: 请求超时
- description:
- en_US: Timeout in seconds for API requests
- zh_Hans: API 请求超时时间(秒)
- type: number
- default: 120
- - name: tbox-app-api
- label:
- en_US: Tbox App API
- zh_Hans: 蚂蚁百宝箱平台 API
- description:
- en_US: Configure the Tbox App API of the pipeline
- zh_Hans: 配置蚂蚁百宝箱平台 API
- config:
- - name: api-key
- label:
- en_US: API Key
- zh_Hans: API 密钥
- type: string
- required: true
- default: ''
- - name: app-id
- label:
- en_US: App ID
- zh_Hans: 应用 ID
- type: string
- required: true
- default: ''
- - name: dashscope-app-api
- label:
- en_US: Aliyun Dashscope App API
- zh_Hans: 阿里云百炼平台 API
- description:
- en_US: Configure the Aliyun Dashscope App API of the pipeline
- zh_Hans: 配置阿里云百炼平台 API
- config:
- - name: app-type
- label:
- en_US: App Type
- zh_Hans: 应用类型
- type: select
- required: true
- default: agent
- options:
- - name: agent
- label:
- en_US: Agent
- zh_Hans: Agent
- - name: workflow
- label:
- en_US: Workflow
- zh_Hans: 工作流
- - name: api-key
- label:
- en_US: API Key
- zh_Hans: API 密钥
- type: string
- required: true
- default: 'your-api-key'
- - name: app-id
- label:
- en_US: App ID
- zh_Hans: 应用 ID
- type: string
- required: true
- default: 'your-app-id'
- - name: references_quote
- label:
- en_US: References Quote
- zh_Hans: 引用文本
- description:
- en_US: The text prompt when the references are included
- zh_Hans: 包含引用资料时的文本提示
- type: string
- required: false
- default: '参考资料来自:'
- - name: langflow-api
- label:
- en_US: Langflow API
- zh_Hans: Langflow API
- description:
- en_US: Configure the Langflow API of the pipeline, call the Langflow flow through the `Simplified Run Flow` interface
- zh_Hans: 配置 Langflow API,通过 `Simplified Run Flow` 接口调用 Langflow 的流程
- config:
- - name: base-url
- label:
- en_US: Base URL
- zh_Hans: 基础 URL
- description:
- en_US: The base URL of the Langflow server
- zh_Hans: Langflow 服务器的基础 URL
- type: string
- required: true
- default: 'http://localhost:7860'
- - name: api-key
- label:
- en_US: API Key
- zh_Hans: API 密钥
- description:
- en_US: The API key for the Langflow server
- zh_Hans: Langflow 服务器的 API 密钥
- type: string
- required: true
- default: 'your-api-key'
- - name: flow-id
- label:
- en_US: Flow ID
- zh_Hans: 流程 ID
- description:
- en_US: The ID of the flow to run
- zh_Hans: 要运行的流程 ID
- type: string
- required: true
- default: 'your-flow-id'
- - name: input-type
- label:
- en_US: Input Type
- zh_Hans: 输入类型
- description:
- en_US: The input type for the flow
- zh_Hans: 流程的输入类型
- type: string
- required: false
- default: 'chat'
- - name: output-type
- label:
- en_US: Output Type
- zh_Hans: 输出类型
- description:
- en_US: The output type for the flow
- zh_Hans: 流程的输出类型
- type: string
- required: false
- default: 'chat'
- - name: tweaks
- label:
- en_US: Tweaks
- zh_Hans: 调整参数
- description:
- en_US: Optional tweaks to apply to the flow
- zh_Hans: 可选的流程调整参数
- type: json
- required: false
- default: '{}'
+ # Runner config stages are dynamically added from AgentRunnerRegistry
+ # Each plugin runner's config schema is added as a separate stage
+ # The stage name matches the runner id for frontend matching
\ No newline at end of file
diff --git a/tests/unit_tests/agent/conftest.py b/tests/unit_tests/agent/conftest.py
new file mode 100644
index 00000000..030f8b27
--- /dev/null
+++ b/tests/unit_tests/agent/conftest.py
@@ -0,0 +1,75 @@
+"""Shared test fixtures for agent runner tests."""
+from __future__ import annotations
+
+import typing
+
+
+def make_resources(
+ models: list[dict] | None = None,
+ tools: list[dict] | None = None,
+ knowledge_bases: list[dict] | None = None,
+ storage: dict | None = None,
+) -> dict[str, typing.Any]:
+ """Create a minimal AgentResources dict for testing.
+
+ Args:
+ models: List of model dicts with 'model_id' key
+ tools: List of tool dicts with 'tool_name' key
+ knowledge_bases: List of KB dicts with 'kb_id' key
+ storage: Storage permissions dict
+
+ Returns:
+ AgentResources dict with all required fields
+ """
+ return {
+ 'models': models or [],
+ 'tools': tools or [],
+ 'knowledge_bases': knowledge_bases or [],
+ 'files': [],
+ 'storage': storage or {'plugin_storage': False, 'workspace_storage': False},
+ 'platform_capabilities': {},
+ }
+
+
+def make_session(
+ run_id: str = 'test-run-id',
+ runner_id: str = 'plugin:test/test-runner/default',
+ query_id: int | None = 1,
+ plugin_identity: str = 'test/test-runner',
+ resources: dict | None = None,
+) -> dict[str, typing.Any]:
+ """Create a minimal AgentRunSession dict for testing.
+
+ Args:
+ run_id: Unique run identifier
+ runner_id: Runner descriptor ID
+ query_id: Pipeline query ID
+ plugin_identity: Plugin identifier (author/name)
+ resources: AgentResources dict (uses make_resources() default if None)
+
+ Returns:
+ AgentRunSession dict with all required fields including pre-computed _authorized_ids
+ """
+ import time
+ now = int(time.time())
+ res = resources or make_resources()
+
+ # Pre-compute authorized IDs for O(1) lookup (matching production behavior)
+ authorized_ids: dict[str, set[str]] = {
+ 'model': {m.get('model_id') for m in res.get('models', [])},
+ 'tool': {t.get('tool_name') for t in res.get('tools', [])},
+ 'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])},
+ }
+
+ return {
+ 'run_id': run_id,
+ 'runner_id': runner_id,
+ 'query_id': query_id,
+ 'plugin_identity': plugin_identity,
+ 'resources': res,
+ 'status': {
+ 'started_at': now,
+ 'last_activity_at': now,
+ },
+ '_authorized_ids': authorized_ids,
+ }
\ No newline at end of file
diff --git a/tests/unit_tests/agent/test_config_migration_full.py b/tests/unit_tests/agent/test_config_migration_full.py
new file mode 100644
index 00000000..99d0c879
--- /dev/null
+++ b/tests/unit_tests/agent/test_config_migration_full.py
@@ -0,0 +1,275 @@
+"""Tests for pipeline config migration to new runner format."""
+from __future__ import annotations
+
+import json
+
+from langbot.pkg.agent.runner.config_migration import ConfigMigration
+
+
+class TestMigratePipelineConfig:
+ """Tests for ConfigMigration.migrate_pipeline_config."""
+
+ def test_migrate_old_local_agent_config(self):
+ """Old local-agent config should migrate to plugin format."""
+ old_config = {
+ 'ai': {
+ 'runner': {
+ 'runner': 'local-agent',
+ 'expire-time': 0,
+ },
+ 'local-agent': {
+ 'model': {'primary': 'model-uuid', 'fallbacks': []},
+ 'max-round': 10,
+ 'prompt': [{'role': 'system', 'content': 'Hello'}],
+ },
+ },
+ }
+
+ migrated = ConfigMigration.migrate_pipeline_config(old_config)
+
+ # Should have new format
+ assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
+ assert 'runner' not in migrated['ai']['runner'] or migrated['ai']['runner'].get('runner') != 'local-agent'
+
+ # Config should be in runner_config
+ assert 'plugin:langbot/local-agent/default' in migrated['ai']['runner_config']
+ assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
+
+ # Expire-time preserved
+ assert migrated['ai']['runner']['expire-time'] == 0
+
+ def test_migrate_old_dify_service_api_config(self):
+ """Old dify-service-api config should migrate to dify-agent plugin."""
+ old_config = {
+ 'ai': {
+ 'runner': {
+ 'runner': 'dify-service-api',
+ 'expire-time': 300,
+ },
+ 'dify-service-api': {
+ 'base-url': 'https://api.dify.ai/v1',
+ 'api-key': 'test-key',
+ 'app-type': 'chat',
+ },
+ },
+ }
+
+ migrated = ConfigMigration.migrate_pipeline_config(old_config)
+
+ assert migrated['ai']['runner']['id'] == 'plugin:langbot/dify-agent/default'
+ assert 'plugin:langbot/dify-agent/default' in migrated['ai']['runner_config']
+ assert migrated['ai']['runner_config']['plugin:langbot/dify-agent/default']['api-key'] == 'test-key'
+ assert migrated['ai']['runner']['expire-time'] == 300
+
+ def test_new_format_config_stays_unchanged(self):
+ """New format config should not change."""
+ new_config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:langbot/local-agent/default',
+ 'expire-time': 0,
+ },
+ 'runner_config': {
+ 'plugin:langbot/local-agent/default': {
+ 'model': {'primary': '', 'fallbacks': []},
+ 'max-round': 10,
+ },
+ },
+ },
+ }
+
+ migrated = ConfigMigration.migrate_pipeline_config(new_config)
+
+ # Should remain unchanged
+ assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
+ assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
+
+ def test_migrate_all_old_runners(self):
+ """All old runner names should be migrated."""
+ old_runners = [
+ 'local-agent',
+ 'dify-service-api',
+ 'n8n-service-api',
+ 'coze-api',
+ 'dashscope-app-api',
+ 'langflow-api',
+ 'tbox-app-api',
+ ]
+
+ expected_ids = [
+ 'plugin:langbot/local-agent/default',
+ 'plugin:langbot/dify-agent/default',
+ 'plugin:langbot/n8n-agent/default',
+ 'plugin:langbot/coze-agent/default',
+ 'plugin:langbot/dashscope-agent/default',
+ 'plugin:langbot/langflow-agent/default',
+ 'plugin:langbot/tbox-agent/default',
+ ]
+
+ for old_runner, expected_id in zip(old_runners, expected_ids):
+ config = {
+ 'ai': {
+ 'runner': {'runner': old_runner, 'expire-time': 0},
+ old_runner: {'test-key': 'test-value'},
+ },
+ }
+ migrated = ConfigMigration.migrate_pipeline_config(config)
+ assert migrated['ai']['runner']['id'] == expected_id
+ assert expected_id in migrated['ai']['runner_config']
+
+ def test_migrate_empty_config(self):
+ """Empty config should not break."""
+ config = {}
+ migrated = ConfigMigration.migrate_pipeline_config(config)
+ assert migrated == {}
+
+ def test_migrate_config_without_ai_section(self):
+ """Config without ai section should not break."""
+ config = {'trigger': {}}
+ migrated = ConfigMigration.migrate_pipeline_config(config)
+ assert 'trigger' in migrated
+
+ def test_expire_time_preserved(self):
+ """expire-time should be preserved during migration."""
+ old_config = {
+ 'ai': {
+ 'runner': {
+ 'runner': 'local-agent',
+ 'expire-time': 3600,
+ },
+ 'local-agent': {},
+ },
+ }
+
+ migrated = ConfigMigration.migrate_pipeline_config(old_config)
+ assert migrated['ai']['runner']['expire-time'] == 3600
+
+
+class TestDefaultPipelineConfig:
+ """Tests for default-pipeline-config.json format."""
+
+ def test_default_config_is_new_format(self):
+ """Default pipeline config should use new format."""
+ from langbot.pkg.utils import paths as path_utils
+
+ template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
+ with open(template_path, 'r', encoding='utf-8') as f:
+ config = json.load(f)
+
+ # Should have new format
+ assert 'ai' in config
+ assert 'runner' in config['ai']
+ assert 'id' in config['ai']['runner']
+ assert config['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
+
+ # Should have runner_config with local-agent default
+ assert 'runner_config' in config['ai']
+ assert 'plugin:langbot/local-agent/default' in config['ai']['runner_config']
+
+ # Should NOT have old local-agent key
+ assert 'local-agent' not in config['ai']
+
+ def test_default_config_has_model_config(self):
+ """Default config should have model config in runner_config."""
+ from langbot.pkg.utils import paths as path_utils
+
+ template_path = path_utils.get_resource_path('templates/default-pipeline-config.json')
+ with open(template_path, 'r', encoding='utf-8') as f:
+ config = json.load(f)
+
+ runner_config = config['ai']['runner_config']['plugin:langbot/local-agent/default']
+ assert 'model' in runner_config
+ assert 'max-round' in runner_config
+ assert 'prompt' in runner_config
+
+
+class TestResolveRunnerIdBackwardCompat:
+ """Tests for backward compatibility in resolve_runner_id."""
+
+ def test_resolve_new_format_id(self):
+ """resolve_runner_id should work with new format."""
+ config = {
+ 'ai': {
+ 'runner': {'id': 'plugin:test/my-runner/default'},
+ },
+ }
+ runner_id = ConfigMigration.resolve_runner_id(config)
+ assert runner_id == 'plugin:test/my-runner/default'
+
+ def test_resolve_old_format_runner(self):
+ """resolve_runner_id should map old format to plugin ID."""
+ config = {
+ 'ai': {
+ 'runner': {'runner': 'local-agent'},
+ },
+ }
+ runner_id = ConfigMigration.resolve_runner_id(config)
+ assert runner_id == 'plugin:langbot/local-agent/default'
+
+ def test_resolve_plugin_format_in_runner_field(self):
+ """resolve_runner_id should handle plugin:* in runner field."""
+ config = {
+ 'ai': {
+ 'runner': {'runner': 'plugin:langbot/local-agent/default'},
+ },
+ }
+ runner_id = ConfigMigration.resolve_runner_id(config)
+ assert runner_id == 'plugin:langbot/local-agent/default'
+
+ def test_resolve_new_format_priority(self):
+ """New format id should take priority over old runner field."""
+ config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:new-runner/default',
+ 'runner': 'local-agent', # Old field, should be ignored
+ },
+ },
+ }
+ runner_id = ConfigMigration.resolve_runner_id(config)
+ assert runner_id == 'plugin:new-runner/default'
+
+
+class TestResolveRunnerConfigBackwardCompat:
+ """Tests for backward compatibility in resolve_runner_config."""
+
+ def test_resolve_new_format_config(self):
+ """resolve_runner_config should read from runner_config."""
+ config = {
+ 'ai': {
+ 'runner_config': {
+ 'plugin:langbot/local-agent/default': {'max-round': 20},
+ },
+ },
+ }
+ runner_config = ConfigMigration.resolve_runner_config(
+ config, 'plugin:langbot/local-agent/default'
+ )
+ assert runner_config['max-round'] == 20
+
+ def test_resolve_old_format_config(self):
+ """resolve_runner_config should read from old ai.local-agent."""
+ config = {
+ 'ai': {
+ 'local-agent': {'max-round': 15},
+ },
+ }
+ runner_config = ConfigMigration.resolve_runner_config(
+ config, 'plugin:langbot/local-agent/default'
+ )
+ assert runner_config['max-round'] == 15
+
+ def test_resolve_new_format_priority(self):
+ """New format runner_config should take priority."""
+ config = {
+ 'ai': {
+ 'runner_config': {
+ 'plugin:langbot/local-agent/default': {'max-round': 25},
+ },
+ 'local-agent': {'max-round': 10}, # Old, should be ignored
+ },
+ }
+ runner_config = ConfigMigration.resolve_runner_config(
+ config, 'plugin:langbot/local-agent/default'
+ )
+ assert runner_config['max-round'] == 25
\ No newline at end of file
diff --git a/tests/unit_tests/agent/test_context_builder_params_state.py b/tests/unit_tests/agent/test_context_builder_params_state.py
new file mode 100644
index 00000000..e5ac035f
--- /dev/null
+++ b/tests/unit_tests/agent/test_context_builder_params_state.py
@@ -0,0 +1,449 @@
+"""Tests for agent run context builder params and state."""
+from __future__ import annotations
+
+import pytest
+
+from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
+from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
+from langbot.pkg.agent.runner.state_store import reset_state_store
+
+# Import shared test fixtures from conftest.py
+from .conftest import make_resources
+
+
+class FakeApplication:
+ """Fake Application for testing."""
+ def __init__(self):
+ class FakeLogger:
+ def info(self, msg):
+ pass
+ def debug(self, msg):
+ pass
+ def warning(self, msg):
+ pass
+ def error(self, msg):
+ pass
+
+ class FakeVersionManager:
+ def get_current_version(self):
+ return '1.0.0'
+
+ self.logger = FakeLogger()
+ self.ver_mgr = FakeVersionManager()
+
+
+def make_descriptor() -> AgentRunnerDescriptor:
+ """Create a test descriptor."""
+ return AgentRunnerDescriptor(
+ id='plugin:langbot/local-agent/default',
+ source='plugin',
+ label={'en_US': 'Local Agent'},
+ plugin_author='langbot',
+ plugin_name='local-agent',
+ runner_name='default',
+ protocol_version='1',
+ capabilities={'streaming': True},
+ )
+
+
+class FakeSession:
+ """Fake session for testing."""
+ def __init__(self):
+ self.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
+ self.launcher_id = 'group_123'
+ self.using_conversation = None
+
+
+class FakeConversation:
+ """Fake conversation for testing."""
+ def __init__(self, uuid: str = 'conv_abc'):
+ self.uuid = uuid
+
+
+class FakeMessage:
+ """Fake message for testing."""
+ def __init__(self, content='Hello'):
+ self.content = content
+ self.role = 'user'
+
+
+class TestBuildParams:
+ """Tests for _build_params filtering."""
+
+ def test_params_empty_when_no_variables(self):
+ """Empty variables should produce empty params."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ query = type('Query', (), {
+ 'variables': None,
+ })()
+
+ params = builder._build_params(query)
+ assert params == {}
+
+ def test_params_filters_underscore_prefix(self):
+ """Params should exclude variables starting with underscore."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ query = type('Query', (), {
+ 'variables': {
+ '_internal_var': 'should_be_excluded',
+ '_pipeline_bound_plugins': ['a/b'],
+ '_monitoring_bot_name': 'Bot',
+ 'public_var': 'should_be_included',
+ },
+ })()
+
+ params = builder._build_params(query)
+ assert '_internal_var' not in params
+ assert '_pipeline_bound_plugins' not in params
+ assert '_monitoring_bot_name' not in params
+ assert 'public_var' in params
+ assert params['public_var'] == 'should_be_included'
+
+ def test_params_filters_sensitive_naming(self):
+ """Params should exclude variables with sensitive naming patterns."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ query = type('Query', (), {
+ 'variables': {
+ 'api_key': 'secret123',
+ 'API_KEY': 'secret456',
+ 'token': 'tok123',
+ 'secret': 'sec123',
+ 'password': 'pass123',
+ 'credential': 'cred123',
+ 'user_api_key': 'should_be_excluded',
+ 'user_secret_key': 'should_be_excluded',
+ 'my_token_value': 'should_be_excluded',
+ 'user_password_hash': 'should_be_excluded',
+ 'public_name': 'should_be_included',
+ 'safe_value': 'should_be_included',
+ },
+ })()
+
+ params = builder._build_params(query)
+ # All sensitive patterns should be excluded
+ assert 'api_key' not in params
+ assert 'API_KEY' not in params
+ assert 'token' not in params
+ assert 'secret' not in params
+ assert 'password' not in params
+ assert 'credential' not in params
+ assert 'user_api_key' not in params
+ assert 'user_secret_key' not in params
+ assert 'my_token_value' not in params
+ assert 'user_password_hash' not in params
+ # Public vars should be included
+ assert 'public_name' in params
+ assert 'safe_value' in params
+
+ def test_params_keeps_common_public_vars(self):
+ """Params should keep common public business vars."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ query = type('Query', (), {
+ 'variables': {
+ 'launcher_type': 'telegram',
+ 'launcher_id': 'group_123',
+ 'sender_id': 'user_001',
+ 'session_id': 'sess_abc',
+ 'msg_create_time': 1234567890,
+ 'group_name': 'Tech Group',
+ 'sender_name': 'John',
+ 'user_message_text': 'Hello world',
+ },
+ })()
+
+ params = builder._build_params(query)
+ # All these should be included
+ assert params['launcher_type'] == 'telegram'
+ assert params['launcher_id'] == 'group_123'
+ assert params['sender_id'] == 'user_001'
+ assert params['session_id'] == 'sess_abc'
+ assert params['msg_create_time'] == 1234567890
+ assert params['group_name'] == 'Tech Group'
+ assert params['sender_name'] == 'John'
+ assert params['user_message_text'] == 'Hello world'
+
+ def test_params_filters_non_json_serializable(self):
+ """Params should keep only JSON-serializable values."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ class CustomObject:
+ pass
+
+ query = type('Query', (), {
+ 'variables': {
+ 'string_value': 'hello',
+ 'int_value': 42,
+ 'float_value': 3.14,
+ 'bool_value': True,
+ 'null_value': None,
+ 'list_value': ['a', 'b', 'c'],
+ 'dict_value': {'nested': 'value'},
+ 'custom_object': CustomObject(), # Not serializable
+ },
+ })()
+
+ params = builder._build_params(query)
+ assert 'string_value' in params
+ assert 'int_value' in params
+ assert 'float_value' in params
+ assert 'bool_value' in params
+ assert 'null_value' in params
+ assert 'list_value' in params
+ assert 'dict_value' in params
+ assert 'custom_object' not in params
+
+ def test_params_filters_nested_non_serializable(self):
+ """Params should filter nested non-serializable values."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ class CustomObject:
+ pass
+
+ query = type('Query', (), {
+ 'variables': {
+ 'nested_list_with_bad': ['a', CustomObject(), 'c'], # List with non-serializable
+ 'nested_dict_with_bad': {'good': 'value', 'bad': CustomObject()}, # Dict with non-serializable
+ 'good_nested_list': ['a', ['b', 'c']],
+ 'good_nested_dict': {'outer': {'inner': 'value'}},
+ },
+ })()
+
+ params = builder._build_params(query)
+ # Nested with bad should be excluded
+ assert 'nested_list_with_bad' not in params
+ assert 'nested_dict_with_bad' not in params
+ # Good nested should be included
+ assert 'good_nested_list' in params
+ assert 'good_nested_dict' in params
+
+ def test_is_json_serializable_primitives(self):
+ """_is_json_serializable should return True for primitives."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ assert builder._is_json_serializable(None) is True
+ assert builder._is_json_serializable('string') is True
+ assert builder._is_json_serializable(42) is True
+ assert builder._is_json_serializable(3.14) is True
+ assert builder._is_json_serializable(True) is True
+ assert builder._is_json_serializable(False) is True
+
+ def test_is_json_serializable_collections(self):
+ """_is_json_serializable should check nested collections."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ assert builder._is_json_serializable([]) is True
+ assert builder._is_json_serializable(['a', 'b']) is True
+ assert builder._is_json_serializable({}) is True
+ assert builder._is_json_serializable({'key': 'value'}) is True
+ assert builder._is_json_serializable([1, 2, [3, 4]]) is True
+ assert builder._is_json_serializable({'a': {'b': 'c'}}) is True
+
+ def test_is_json_serializable_custom_objects(self):
+ """_is_json_serializable should return False for custom objects."""
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ class CustomObject:
+ pass
+
+ assert builder._is_json_serializable(CustomObject()) is False
+ assert builder._is_json_serializable([CustomObject()]) is False
+ assert builder._is_json_serializable({'key': CustomObject()}) is False
+
+ def test_is_json_serializable_set_not_allowed(self):
+ """_is_json_serializable should return False for set (not JSON-serializable).
+
+ json.dumps({"x": {1}}) fails because set is not JSON-serializable.
+ Only list and tuple are allowed.
+ """
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ # set is NOT JSON-serializable
+ assert builder._is_json_serializable({1, 2, 3}) is False
+ assert builder._is_json_serializable({'a', 'b'}) is False
+ # list and tuple ARE allowed
+ assert builder._is_json_serializable([1, 2, 3]) is True
+ assert builder._is_json_serializable((1, 2, 3)) is True
+ # Nested set should also be rejected
+ assert builder._is_json_serializable([1, {2, 3}]) is False
+ assert builder._is_json_serializable({'key': {1, 2}}) is False
+
+ def test_params_filters_set_values(self):
+ """Params should filter out variables with set values.
+
+ set is not JSON-serializable and would cause json.dumps to fail.
+ """
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+
+ query = type('Query', (), {
+ 'variables': {
+ 'list_value': ['a', 'b', 'c'],
+ 'tuple_value': ('a', 'b', 'c'),
+ 'set_value': {'a', 'b', 'c'}, # Should be filtered
+ 'nested_with_set': ['a', {'b', 'c'}], # Should be filtered
+ 'dict_with_set': {'items': {1, 2}}, # Should be filtered
+ },
+ })()
+
+ params = builder._build_params(query)
+ # list and tuple should be included
+ assert 'list_value' in params
+ assert params['list_value'] == ['a', 'b', 'c']
+ assert 'tuple_value' in params
+ # set should be filtered
+ assert 'set_value' not in params
+ assert 'nested_with_set' not in params
+ assert 'dict_with_set' not in params
+
+
+class TestBuildState:
+ """Tests for state snapshot building."""
+
+ @pytest.mark.asyncio
+ async def test_context_has_state_field(self):
+ """AgentRunContextV1 should have state field."""
+ reset_state_store()
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+ descriptor = make_descriptor()
+ resources = make_resources()
+
+ session = FakeSession()
+ query = type('Query', (), {
+ 'query_id': 1,
+ 'bot_uuid': 'bot_001',
+ 'pipeline_uuid': 'pipeline_001',
+ 'sender_id': 'user_001',
+ 'session': session,
+ 'user_message': None,
+ 'message_chain': None,
+ 'messages': [],
+ 'pipeline_config': {},
+ 'variables': {},
+ })()
+
+ context = await builder.build_context(query, descriptor, resources)
+
+ assert 'state' in context
+ assert 'conversation' in context['state']
+ assert 'actor' in context['state']
+ assert 'subject' in context['state']
+ assert 'runner' in context['state']
+
+ @pytest.mark.asyncio
+ async def test_state_seeds_conversation_id_from_existing(self):
+ """State should seed external.conversation_id from existing conversation uuid."""
+ reset_state_store()
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+ descriptor = make_descriptor()
+ resources = make_resources()
+
+ conversation = FakeConversation(uuid='conv_existing')
+ session = FakeSession()
+ session.using_conversation = conversation
+ query = type('Query', (), {
+ 'query_id': 1,
+ 'bot_uuid': 'bot_001',
+ 'pipeline_uuid': 'pipeline_001',
+ 'sender_id': 'user_001',
+ 'session': session,
+ 'user_message': None,
+ 'message_chain': None,
+ 'messages': [],
+ 'pipeline_config': {},
+ 'variables': {},
+ })()
+
+ context = await builder.build_context(query, descriptor, resources)
+
+ assert context['state']['conversation']['external.conversation_id'] == 'conv_existing'
+
+
+class TestBuildParamsInContext:
+ """Tests for params in full context."""
+
+ @pytest.mark.asyncio
+ async def test_context_has_params_field(self):
+ """AgentRunContextV1 should have params field."""
+ reset_state_store()
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+ descriptor = make_descriptor()
+ resources = make_resources()
+
+ session = FakeSession()
+ query = type('Query', (), {
+ 'query_id': 1,
+ 'bot_uuid': 'bot_001',
+ 'pipeline_uuid': 'pipeline_001',
+ 'sender_id': 'user_001',
+ 'session': session,
+ 'user_message': None,
+ 'message_chain': None,
+ 'messages': [],
+ 'pipeline_config': {},
+ 'variables': {
+ 'public_param': 'value',
+ '_private': 'excluded',
+ },
+ })()
+
+ context = await builder.build_context(query, descriptor, resources)
+
+ assert 'params' in context
+ assert context['params']['public_param'] == 'value'
+ assert '_private' not in context['params']
+
+ @pytest.mark.asyncio
+ async def test_params_and_state_both_present(self):
+ """Context should have both params and state."""
+ reset_state_store()
+ ap = FakeApplication()
+ builder = AgentRunContextBuilder(ap)
+ descriptor = make_descriptor()
+ resources = make_resources()
+
+ conversation = FakeConversation(uuid='conv_abc')
+ session = FakeSession()
+ session.using_conversation = conversation
+ query = type('Query', (), {
+ 'query_id': 1,
+ 'bot_uuid': 'bot_001',
+ 'pipeline_uuid': 'pipeline_001',
+ 'sender_id': 'user_001',
+ 'session': session,
+ 'user_message': None,
+ 'message_chain': None,
+ 'messages': [],
+ 'pipeline_config': {},
+ 'variables': {
+ 'workflow_input': 'user_question',
+ 'sender_name': 'John',
+ },
+ })()
+
+ context = await builder.build_context(query, descriptor, resources)
+
+ # params should have public vars
+ assert 'params' in context
+ assert context['params']['workflow_input'] == 'user_question'
+ assert context['params']['sender_name'] == 'John'
+
+ # state should have seeded conversation_id
+ assert 'state' in context
+ assert context['state']['conversation']['external.conversation_id'] == 'conv_abc'
\ No newline at end of file
diff --git a/tests/unit_tests/agent/test_handler_auth.py b/tests/unit_tests/agent/test_handler_auth.py
new file mode 100644
index 00000000..33986ad4
--- /dev/null
+++ b/tests/unit_tests/agent/test_handler_auth.py
@@ -0,0 +1,1617 @@
+"""Tests for RuntimeConnectionHandler proxy action authorization.
+
+Tests focus on:
+- INVOKE_LLM authorization
+- INVOKE_LLM_STREAM authorization
+- CALL_TOOL authorization
+- RETRIEVE_KNOWLEDGE_BASE authorization
+
+Authorization paths:
+1. AgentRunner calls: has run_id, validates against session_registry
+2. Regular plugin calls: no run_id, unrestricted (backward compatibility)
+"""
+from __future__ import annotations
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
+
+# Import shared test fixtures from conftest.py
+from .conftest import make_resources
+
+
+class MockModel:
+ """Mock LLM model for testing."""
+ def __init__(self, uuid: str):
+ self.uuid = uuid
+ self.provider = MagicMock()
+ self.provider.invoke_llm = AsyncMock(return_value=MagicMock(model_dump=lambda: {'content': 'response'}))
+
+
+class MockEmbeddingModel:
+ """Mock embedding model for testing."""
+ def __init__(self, uuid: str):
+ self.uuid = uuid
+ self.provider = MagicMock()
+
+
+class MockKnowledgeBase:
+ """Mock knowledge base for testing."""
+ def __init__(self, uuid: str, name: str = 'KB'):
+ self.knowledge_base_entity = MagicMock()
+ self.knowledge_base_entity.description = f'{name} description'
+ self._uuid = uuid
+ self._name = name
+ self.retrieve = AsyncMock(return_value=[])
+
+ def get_uuid(self):
+ return self._uuid
+
+ def get_name(self):
+ return self._name
+
+
+class MockQuery:
+ """Mock query for testing."""
+ def __init__(self, query_id: int = 1):
+ self.query_id = query_id
+ self.session = MagicMock()
+ self.session.launcher_type = MagicMock()
+ self.session.launcher_type.value = 'telegram'
+ self.session.launcher_id = 'group_123'
+ self.sender_id = 'user_001'
+ self.bot_uuid = 'bot_001'
+ self.pipeline_config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:test/runner/default',
+ },
+ 'runner_config': {
+ 'plugin:test/runner/default': {
+ 'knowledge-bases': ['kb_001', 'kb_002'],
+ },
+ },
+ },
+ }
+
+
+class MockApplication:
+ """Mock Application for testing."""
+ def __init__(self):
+ self.logger = MagicMock()
+ self.logger.debug = MagicMock()
+ self.logger.warning = MagicMock()
+ self.logger.info = MagicMock()
+ self.logger.error = MagicMock()
+
+ self.query_pool = MagicMock()
+ self.query_pool.cached_queries = {}
+
+ self.model_mgr = MagicMock()
+ self.model_mgr.get_model_by_uuid = AsyncMock(return_value=None)
+ self.model_mgr.get_embedding_model_by_uuid = AsyncMock(return_value=None)
+
+ self.tool_mgr = MagicMock()
+ self.tool_mgr.execute_func_call = AsyncMock(return_value={'result': 'success'})
+
+ self.rag_mgr = MagicMock()
+ self.rag_mgr.get_knowledge_base_by_uuid = AsyncMock(return_value=None)
+ self.rag_mgr.knowledge_bases = {}
+
+ self.persistence_mgr = MagicMock()
+ self.persistence_mgr.execute_async = AsyncMock(return_value=MagicMock(first=lambda: None))
+
+
+class MockConnection:
+ """Mock connection for testing."""
+ pass
+
+
+class MockDisconnectCallback:
+ """Mock disconnect callback for testing."""
+ async def __call__(self):
+ return True
+
+
+# Import ActionResponse for checking responses
+from langbot_plugin.runtime.io import handler
+
+
+class TestInvokeLLMAuthorization:
+ """Tests for INVOKE_LLM authorization."""
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_authorized_with_run_id(self):
+ """INVOKE_LLM: authorized when model in session.resources."""
+ # Setup registry with session
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_authorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Verify authorization logic directly
+ session = await registry.get('run_authorized')
+ assert session is not None
+ assert registry.is_resource_allowed(session, 'model', 'model_001') is True
+
+ # Cleanup
+ await registry.unregister('run_authorized')
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_unauthorized_with_run_id(self):
+ """INVOKE_LLM: unauthorized when model not in session.resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_unauthorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Test authorization logic directly
+ session = await registry.get('run_unauthorized')
+ assert session is not None
+ # model_002 is not in resources
+ assert registry.is_resource_allowed(session, 'model', 'model_002') is False
+
+ await registry.unregister('run_unauthorized')
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_session_not_found(self):
+ """INVOKE_LLM: session not found should return error."""
+ registry = AgentRunSessionRegistry()
+
+ # No session registered for this run_id
+ session = await registry.get('run_nonexistent')
+ assert session is None
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_no_run_id_unrestricted(self):
+ """INVOKE_LLM: no run_id should be unrestricted (backward compat)."""
+ # When no run_id is provided, the authorization check is skipped
+ # This is the backward compatibility path for regular plugin calls
+
+ # Simulate: if not run_id, skip authorization
+ run_id = None
+ # Authorization check should NOT be triggered
+ assert run_id is None # No authorization check
+
+
+class TestInvokeLLMStreamAuthorization:
+ """Tests for INVOKE_LLM_STREAM authorization."""
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_stream_authorized_with_run_id(self):
+ """INVOKE_LLM_STREAM: authorized when model in session.resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_stream_authorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_stream_authorized')
+ assert session is not None
+ assert registry.is_resource_allowed(session, 'model', 'model_001') is True
+
+ await registry.unregister('run_stream_authorized')
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_stream_unauthorized_with_run_id(self):
+ """INVOKE_LLM_STREAM: unauthorized when model not in session.resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_stream_unauthorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_stream_unauthorized')
+ assert session is not None
+ assert registry.is_resource_allowed(session, 'model', 'model_002') is False
+
+ await registry.unregister('run_stream_unauthorized')
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_stream_no_run_id_unrestricted(self):
+ """INVOKE_LLM_STREAM: no run_id should be unrestricted."""
+ run_id = None
+ # No authorization check
+ assert run_id is None
+
+
+class TestCallToolAuthorization:
+ """Tests for CALL_TOOL authorization."""
+
+ @pytest.mark.asyncio
+ async def test_call_tool_authorized_with_run_id(self):
+ """CALL_TOOL: authorized when tool in session.resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(tools=[{'tool_name': 'web_search'}])
+
+ await registry.register(
+ run_id='run_tool_authorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_tool_authorized')
+ assert session is not None
+ assert registry.is_resource_allowed(session, 'tool', 'web_search') is True
+
+ await registry.unregister('run_tool_authorized')
+
+ @pytest.mark.asyncio
+ async def test_call_tool_unauthorized_with_run_id(self):
+ """CALL_TOOL: unauthorized when tool not in session.resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(tools=[{'tool_name': 'web_search'}])
+
+ await registry.register(
+ run_id='run_tool_unauthorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_tool_unauthorized')
+ assert session is not None
+ assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False
+
+ await registry.unregister('run_tool_unauthorized')
+
+ @pytest.mark.asyncio
+ async def test_call_tool_no_run_id_unrestricted(self):
+ """CALL_TOOL: no run_id should be unrestricted."""
+ run_id = None
+ # No authorization check
+ assert run_id is None
+
+
+class TestRetrieveKnowledgeBaseAuthorization:
+ """Tests for RETRIEVE_KNOWLEDGE_BASE authorization."""
+
+ @pytest.mark.asyncio
+ async def test_retrieve_knowledge_base_authorized_with_run_id(self):
+ """RETRIEVE_KNOWLEDGE_BASE: authorized when kb in session.resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
+
+ await registry.register(
+ run_id='run_kb_authorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_kb_authorized')
+ assert session is not None
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True
+
+ await registry.unregister('run_kb_authorized')
+
+ @pytest.mark.asyncio
+ async def test_retrieve_knowledge_base_unauthorized_with_run_id(self):
+ """RETRIEVE_KNOWLEDGE_BASE: unauthorized when kb not in session.resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
+
+ await registry.register(
+ run_id='run_kb_unauthorized',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_kb_unauthorized')
+ assert session is not None
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False
+
+ await registry.unregister('run_kb_unauthorized')
+
+ @pytest.mark.asyncio
+ async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self):
+ """RETRIEVE_KNOWLEDGE_BASE: no run_id checks pipeline config."""
+ # When no run_id, the handler checks against pipeline's configured KBs
+ # This is the backward compatibility path for regular plugin calls
+
+ from langbot.pkg.agent.runner.config_migration import ConfigMigration
+
+ # Simulate pipeline config
+ pipeline_config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:test/runner/default',
+ },
+ 'runner_config': {
+ 'plugin:test/runner/default': {
+ 'knowledge-bases': ['kb_001', 'kb_002'],
+ },
+ },
+ },
+ }
+
+ runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
+ assert runner_id == 'plugin:test/runner/default'
+
+ runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
+ allowed_kbs = runner_config.get('knowledge-bases', [])
+ assert 'kb_001' in allowed_kbs
+ assert 'kb_999' not in allowed_kbs
+
+
+class TestAuthorizationPathDifferentiation:
+ """Tests that verify AgentRunner vs regular plugin call differentiation."""
+
+ @pytest.mark.asyncio
+ async def test_agent_runner_path_with_run_id(self):
+ """AgentRunner calls provide run_id and use session_registry."""
+ registry = AgentRunSessionRegistry()
+
+ # AgentRunner call has run_id
+ run_id = 'run_agent_123'
+
+ # Register session with resources
+ await registry.register(
+ run_id=run_id,
+ runner_id='plugin:test/agent/default',
+ query_id=1,
+ plugin_identity='test/agent',
+ resources=make_resources(
+ models=[{'model_id': 'model_xyz'}],
+ tools=[{'tool_name': 'agent_tool'}],
+ knowledge_bases=[{'kb_id': 'kb_agent'}],
+ ),
+ )
+
+ session = await registry.get(run_id)
+ assert session is not None
+
+ # Authorization checks
+ assert registry.is_resource_allowed(session, 'model', 'model_xyz') is True
+ assert registry.is_resource_allowed(session, 'model', 'other_model') is False
+ assert registry.is_resource_allowed(session, 'tool', 'agent_tool') is True
+ assert registry.is_resource_allowed(session, 'tool', 'other_tool') is False
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_agent') is True
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_other') is False
+
+ await registry.unregister(run_id)
+
+ @pytest.mark.asyncio
+ async def test_regular_plugin_path_no_run_id(self):
+ """Regular plugin calls have no run_id and skip session check."""
+ # Regular plugin call has no run_id
+ run_id = None
+
+ # Authorization check should be skipped when run_id is None
+ # This is handled in handler.py with: if run_id: ...
+ if run_id:
+ # This block should NOT execute for regular plugin calls
+ raise AssertionError('Authorization check should not run for regular plugin calls')
+
+ # For regular plugins:
+ # - INVOKE_LLM: unrestricted access to any model
+ # - CALL_TOOL: unrestricted access to any tool
+ # - RETRIEVE_KNOWLEDGE_BASE: checks pipeline config instead
+
+
+class TestHandlerAuthorizationErrorMessages:
+ """Tests for error message content in authorization failures."""
+
+ def test_model_not_authorized_error_message(self):
+ """Error message should mention model not authorized."""
+ expected_msg = "Model model_999 is not authorized for this agent run"
+ assert 'not authorized' in expected_msg
+ assert 'model_999' in expected_msg
+
+ def test_tool_not_authorized_error_message(self):
+ """Error message should mention tool not authorized."""
+ expected_msg = "Tool image_gen is not authorized for this agent run"
+ assert 'not authorized' in expected_msg
+ assert 'image_gen' in expected_msg
+
+ def test_kb_not_authorized_error_message(self):
+ """Error message should mention kb not authorized."""
+ expected_msg = "Knowledge base kb_999 is not authorized for this agent run"
+ assert 'not authorized' in expected_msg
+ assert 'kb_999' in expected_msg
+
+ def test_session_not_found_error_message(self):
+ """Error message should mention session not found."""
+ expected_msg = "Run session run_xyz not found or expired"
+ assert 'not found' in expected_msg
+ assert 'run_xyz' in expected_msg
+
+
+class TestRETRIEVEKNOWLEDGEBASEBugFix:
+ """Tests for the RETRIEVE_KNOWLEDGE_BASE bug fix in handler.py.
+
+ Bug: Previously, the handler directly accessed pipeline_config['ai']['local-agent']
+ without first resolving the runner_id, causing issues when non-local-agent runners
+ were used.
+
+ Fix: Now uses ConfigMigration.resolve_runner_id first, then resolve_runner_config.
+ """
+
+ def test_retrieve_kb_fix_local_agent_runner(self):
+ """Fix should work for local-agent runner."""
+ from langbot.pkg.agent.runner.config_migration import ConfigMigration
+
+ pipeline_config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:langbot/local-agent/default',
+ },
+ 'runner_config': {
+ 'plugin:langbot/local-agent/default': {
+ 'knowledge-bases': ['kb_001'],
+ },
+ },
+ },
+ }
+
+ runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
+ runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
+ allowed_kbs = runner_config.get('knowledge-bases', [])
+
+ assert 'kb_001' in allowed_kbs
+
+ def test_retrieve_kb_fix_other_runner(self):
+ """Fix should work for non-local-agent runners."""
+ from langbot.pkg.agent.runner.config_migration import ConfigMigration
+
+ pipeline_config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:custom/my-agent/default',
+ },
+ 'runner_config': {
+ 'plugin:custom/my-agent/default': {
+ 'knowledge-bases': ['kb_custom'],
+ },
+ },
+ },
+ }
+
+ runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
+ runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
+ allowed_kbs = runner_config.get('knowledge-bases', [])
+
+ assert 'kb_custom' in allowed_kbs
+
+ def test_retrieve_kb_fix_old_format(self):
+ """Fix should work for old format pipeline config."""
+ from langbot.pkg.agent.runner.config_migration import ConfigMigration
+
+ # Old format: ai.runner.runner = 'local-agent'
+ pipeline_config = {
+ 'ai': {
+ 'runner': {
+ 'runner': 'local-agent',
+ },
+ },
+ }
+
+ runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
+ # Should resolve to plugin:langbot/local-agent/default
+ assert 'local-agent' in runner_id
+
+ def test_retrieve_kb_fix_backward_compat_knowledge_base(self):
+ """Fix should handle backward compat for old 'knowledge-base' field."""
+ from langbot.pkg.agent.runner.config_migration import ConfigMigration
+
+ pipeline_config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:langbot/local-agent/default',
+ },
+ 'runner_config': {
+ 'plugin:langbot/local-agent/default': {
+ 'knowledge-base': 'kb_single', # Old singular field
+ },
+ },
+ },
+ }
+
+ runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
+ runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
+
+ # Handler.py checks both knowledge-bases and knowledge-base
+ allowed_kbs = runner_config.get('knowledge-bases', [])
+ if not allowed_kbs:
+ old_kb = runner_config.get('knowledge-base', '')
+ if old_kb and old_kb != '__none__':
+ allowed_kbs = [old_kb]
+
+ assert 'kb_single' in allowed_kbs
+
+
+class TestHandlerActionAuthorization:
+ """Tests for real handler action-level authorization.
+
+ These tests simulate RuntimeConnectionHandler action handlers
+ to verify actual authorization behavior at the action level.
+ """
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_handler_authorized_path(self):
+ """INVOKE_LLM handler: authorized when model in resources."""
+ from langbot_plugin.runtime.io import handler as io_handler
+
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_invoke_llm_auth',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Simulate handler authorization logic
+ run_id = 'run_invoke_llm_auth'
+ llm_model_uuid = 'model_001'
+
+ session_registry = registry
+ session = await session_registry.get(run_id)
+ assert session is not None
+
+ # Authorization check (same as handler.py line 352)
+ is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid)
+ assert is_allowed is True
+
+ await registry.unregister(run_id)
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_handler_unauthorized_path(self):
+ """INVOKE_LLM handler: unauthorized when model not in resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_invoke_llm_unauth',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ run_id = 'run_invoke_llm_unauth'
+ llm_model_uuid = 'model_999' # Not in resources
+
+ session_registry = registry
+ session = await session_registry.get(run_id)
+ assert session is not None
+
+ # Authorization check (same as handler.py line 352)
+ is_allowed = session_registry.is_resource_allowed(session, 'model', llm_model_uuid)
+ assert is_allowed is False
+
+ # Should return error response (handler.py line 357)
+ expected_error = f'Model {llm_model_uuid} is not authorized for this agent run'
+ assert 'not authorized' in expected_error
+
+ await registry.unregister(run_id)
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_handler_session_not_found(self):
+ """INVOKE_LLM handler: session not found returns error."""
+ registry = AgentRunSessionRegistry()
+
+ # No session registered
+ run_id = 'run_nonexistent'
+ session = await registry.get(run_id)
+ assert session is None
+
+ # Handler should return error (handler.py line 348)
+ expected_error = f'Run session {run_id} not found or expired'
+ assert 'not found' in expected_error
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_handler_no_run_id_unrestricted(self):
+ """INVOKE_LLM handler: no run_id skips authorization (backward compat)."""
+ # Simulate handler logic: if not run_id, skip authorization
+ run_id = None
+
+ # In handler.py, authorization check is inside: if run_id: ...
+ # So when run_id is None, authorization is skipped
+ if run_id:
+ # This block should NOT execute
+ raise AssertionError('Should not execute authorization for no run_id')
+
+ # No authorization check - unrestricted access
+ assert run_id is None
+
+ @pytest.mark.asyncio
+ async def test_call_tool_handler_authorized_path(self):
+ """CALL_TOOL handler: authorized when tool in resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(tools=[{'tool_name': 'web_search'}])
+
+ await registry.register(
+ run_id='run_call_tool_auth',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ run_id = 'run_call_tool_auth'
+ tool_name = 'web_search'
+
+ session_registry = registry
+ session = await session_registry.get(run_id)
+ assert session is not None
+
+ # Authorization check (handler.py line 475)
+ is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name)
+ assert is_allowed is True
+
+ await registry.unregister(run_id)
+
+ @pytest.mark.asyncio
+ async def test_call_tool_handler_unauthorized_path(self):
+ """CALL_TOOL handler: unauthorized when tool not in resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(tools=[{'tool_name': 'web_search'}])
+
+ await registry.register(
+ run_id='run_call_tool_unauth',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ run_id = 'run_call_tool_unauth'
+ tool_name = 'image_gen' # Not in resources
+
+ session_registry = registry
+ session = await session_registry.get(run_id)
+ assert session is not None
+
+ # Authorization check
+ is_allowed = session_registry.is_resource_allowed(session, 'tool', tool_name)
+ assert is_allowed is False
+
+ # Should return error (handler.py line 480)
+ expected_error = f'Tool {tool_name} is not authorized for this agent run'
+ assert 'not authorized' in expected_error
+
+ await registry.unregister(run_id)
+
+ @pytest.mark.asyncio
+ async def test_call_tool_handler_no_run_id_unrestricted(self):
+ """CALL_TOOL handler: no run_id skips authorization."""
+ run_id = None
+
+ # Authorization check is inside: if run_id: ...
+ if run_id:
+ raise AssertionError('Should not execute authorization for no run_id')
+
+ assert run_id is None
+
+ @pytest.mark.asyncio
+ async def test_retrieve_knowledge_base_handler_authorized_path(self):
+ """RETRIEVE_KNOWLEDGE_BASE handler: authorized when kb in resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
+
+ await registry.register(
+ run_id='run_kb_auth',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ run_id = 'run_kb_auth'
+ kb_id = 'kb_001'
+
+ session_registry = registry
+ session = await session_registry.get(run_id)
+ assert session is not None
+
+ # Authorization check (handler.py line 889)
+ is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id)
+ assert is_allowed is True
+
+ await registry.unregister(run_id)
+
+ @pytest.mark.asyncio
+ async def test_retrieve_knowledge_base_handler_unauthorized_path(self):
+ """RETRIEVE_KNOWLEDGE_BASE handler: unauthorized when kb not in resources."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
+
+ await registry.register(
+ run_id='run_kb_unauth',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ run_id = 'run_kb_unauth'
+ kb_id = 'kb_999' # Not in resources
+
+ session_registry = registry
+ session = await session_registry.get(run_id)
+ assert session is not None
+
+ # Authorization check
+ is_allowed = session_registry.is_resource_allowed(session, 'knowledge_base', kb_id)
+ assert is_allowed is False
+
+ # Should return error (handler.py line 894)
+ expected_error = f'Knowledge base {kb_id} is not authorized for this agent run'
+ assert 'not authorized' in expected_error
+
+ await registry.unregister(run_id)
+
+
+class TestSDKAgentRunAPIProxyFieldConsistency:
+ """Tests for SDK AgentRunAPIProxy field name consistency with Host handler.
+
+ These tests verify that SDK sends field names that match what Host handler reads.
+ """
+
+ def test_call_tool_field_names_match(self):
+ """CALL_TOOL: SDK 'parameters' matches Host 'parameters'."""
+ # SDK agent_run_api.py line 146: "parameters": parameters
+ # Host handler.py line 457: parameters = data['parameters']
+ sdk_field = 'parameters'
+ host_field = 'parameters'
+ assert sdk_field == host_field
+
+ def test_call_tool_run_id_field_present(self):
+ """CALL_TOOL: SDK includes 'run_id' field."""
+ # SDK agent_run_api.py line 144: "run_id": self.run_id
+ # Host handler.py line 458: run_id = data.get('run_id')
+ sdk_fields = ['run_id', 'tool_name', 'parameters', 'session', 'query_id']
+ host_expected_fields = ['tool_name', 'parameters', 'run_id']
+
+ for field in host_expected_fields:
+ assert field in sdk_fields
+
+ def test_invoke_llm_field_names_match(self):
+ """INVOKE_LLM: SDK fields match Host handler."""
+ # SDK agent_run_api.py lines 77-82
+ sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args', 'timeout']
+ # Host handler.py lines 333-337
+ host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id']
+
+ for field in host_fields:
+ assert field in sdk_fields
+
+ def test_invoke_llm_stream_field_names_match(self):
+ """INVOKE_LLM_STREAM: SDK fields match Host handler."""
+ # SDK agent_run_api.py lines 111-116
+ sdk_fields = ['run_id', 'llm_model_uuid', 'messages', 'funcs', 'extra_args']
+ # Host handler.py lines 397-401
+ host_fields = ['llm_model_uuid', 'messages', 'funcs', 'extra_args', 'run_id']
+
+ for field in host_fields:
+ assert field in sdk_fields
+
+ def test_retrieve_knowledge_base_field_names_match(self):
+ """RETRIEVE_KNOWLEDGE_BASE: SDK fields match Host handler."""
+ # SDK agent_run_api.py lines 178-183
+ sdk_fields = ['run_id', 'kb_id', 'query_text', 'top_k', 'filters']
+ # Host handler.py lines 863-867
+ host_fields = ['query_id', 'kb_id', 'query_text', 'top_k', 'filters', 'run_id']
+
+ # Note: query_id is from query context, not SDK proxy
+ for field in ['run_id', 'kb_id', 'query_text', 'top_k', 'filters']:
+ assert field in sdk_fields
+
+ def test_retrieve_knowledge_base_action_enum_correct(self):
+ """RETRIEVE_KNOWLEDGE_BASE: SDK uses correct action enum."""
+ from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
+
+ # SDK agent_run_api.py line 178: PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE
+ # Host handler.py line 851: @self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
+ action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE
+ assert action.value == 'retrieve_knowledge_base'
+
+ # Verify it's different from unrestricted RETRIEVE_KNOWLEDGE
+ unrestricted_action = PluginToRuntimeAction.RETRIEVE_KNOWLEDGE
+ assert unrestricted_action.value == 'retrieve_knowledge'
+ assert action != unrestricted_action
+
+
+class TestNoRunIdBackwardCompatPath:
+ """Tests for backward compatibility path when no run_id is provided.
+
+ Regular plugins (non-AgentRunner) don't have run_id and should
+ have unrestricted access to certain APIs.
+ """
+
+ @pytest.mark.asyncio
+ async def test_invoke_llm_no_run_id_unrestricted_access(self):
+ """INVOKE_LLM: no run_id means unrestricted model access."""
+ # Handler.py line 340: if run_id: ...
+ # When run_id is None, the authorization block is skipped
+ run_id = None
+ llm_model_uuid = 'any_model'
+
+ # Simulate handler logic
+ if run_id:
+ # This should NOT execute
+ raise AssertionError('Authorization should not run')
+
+ # Model can be any UUID (unrestricted)
+ assert llm_model_uuid == 'any_model'
+
+ @pytest.mark.asyncio
+ async def test_call_tool_no_run_id_unrestricted_access(self):
+ """CALL_TOOL: no run_id means unrestricted tool access."""
+ run_id = None
+ tool_name = 'any_tool'
+
+ # Handler.py line 463: if run_id: ...
+ if run_id:
+ raise AssertionError('Authorization should not run')
+
+ assert tool_name == 'any_tool'
+
+ @pytest.mark.asyncio
+ async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self):
+ """RETRIEVE_KNOWLEDGE_BASE: no run_id uses pipeline config check."""
+ from langbot.pkg.agent.runner.config_migration import ConfigMigration
+
+ # When no run_id, handler.py lines 897-914 check pipeline config
+ pipeline_config = {
+ 'ai': {
+ 'runner': {
+ 'id': 'plugin:test/runner/default',
+ },
+ 'runner_config': {
+ 'plugin:test/runner/default': {
+ 'knowledge-bases': ['kb_001', 'kb_002'],
+ },
+ },
+ },
+ }
+
+ runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
+ runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
+ allowed_kb_uuids = runner_config.get('knowledge-bases', [])
+
+ # kb_001 should be allowed
+ assert 'kb_001' in allowed_kb_uuids
+ # kb_999 should NOT be allowed
+ assert 'kb_999' not in allowed_kb_uuids
+
+
+class TestSessionExpiryAndCleanup:
+ """Tests for session expiry and cleanup scenarios."""
+
+ @pytest.mark.asyncio
+ async def test_session_expiry_detection(self):
+ """Session expiry: old session should be considered expired."""
+ import time
+
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ # Register session
+ await registry.register(
+ run_id='run_expiry_test',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_expiry_test')
+ assert session is not None
+
+ # Check session status
+ started_at = session['status']['started_at']
+ last_activity = session['status']['last_activity_at']
+
+ # Session should be valid initially
+ current_time = int(time.time())
+ assert current_time - started_at < 10 # Less than 10 seconds old
+
+ await registry.unregister('run_expiry_test')
+
+ @pytest.mark.asyncio
+ async def test_cleanup_stale_sessions(self):
+ """Cleanup: stale sessions should be removed."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ # Register session
+ await registry.register(
+ run_id='run_cleanup_test',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Session exists
+ session = await registry.get('run_cleanup_test')
+ assert session is not None
+
+ # Cleanup with max_age=0 (immediate cleanup)
+ # Note: This won't actually cleanup because session is just created
+ # We need to manually test cleanup logic
+ cleaned = await registry.cleanup_stale_sessions(max_age_seconds=0)
+
+ # Session should still exist (it was just created)
+ # With max_age=0, sessions with last_activity > 0 seconds ago would be cleaned
+ # But since it's just created, last_activity_at is current time
+ session_after = await registry.get('run_cleanup_test')
+ assert session_after is not None
+
+ await registry.unregister('run_cleanup_test')
+
+ @pytest.mark.asyncio
+ async def test_unregister_removes_session(self):
+ """Unregister: session should be removed from registry."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_unregister_test',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Session exists
+ session = await registry.get('run_unregister_test')
+ assert session is not None
+
+ # Unregister
+ await registry.unregister('run_unregister_test')
+
+ # Session should not exist
+ session_after = await registry.get('run_unregister_test')
+ assert session_after is None
+
+
+class TestResourceTypeValidation:
+ """Tests for different resource type validation in is_resource_allowed."""
+
+ @pytest.mark.asyncio
+ async def test_model_resource_validation(self):
+ """Model resource: correct model_id validation."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[
+ {'model_id': 'model_001'},
+ {'model_id': 'model_002'},
+ ])
+
+ await registry.register(
+ run_id='run_model_validation',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_model_validation')
+
+ # Authorized models
+ assert registry.is_resource_allowed(session, 'model', 'model_001') is True
+ assert registry.is_resource_allowed(session, 'model', 'model_002') is True
+
+ # Unauthorized models
+ assert registry.is_resource_allowed(session, 'model', 'model_999') is False
+
+ await registry.unregister('run_model_validation')
+
+ @pytest.mark.asyncio
+ async def test_tool_resource_validation(self):
+ """Tool resource: correct tool_name validation."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(tools=[
+ {'tool_name': 'web_search'},
+ {'tool_name': 'image_gen'},
+ ])
+
+ await registry.register(
+ run_id='run_tool_validation',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_tool_validation')
+
+ # Authorized tools
+ assert registry.is_resource_allowed(session, 'tool', 'web_search') is True
+ assert registry.is_resource_allowed(session, 'tool', 'image_gen') is True
+
+ # Unauthorized tools
+ assert registry.is_resource_allowed(session, 'tool', 'file_upload') is False
+
+ await registry.unregister('run_tool_validation')
+
+ @pytest.mark.asyncio
+ async def test_knowledge_base_resource_validation(self):
+ """Knowledge base resource: correct kb_id validation."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[
+ {'kb_id': 'kb_001'},
+ {'kb_id': 'kb_002'},
+ ])
+
+ await registry.register(
+ run_id='run_kb_validation',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_kb_validation')
+
+ # Authorized KBs
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is True
+
+ # Unauthorized KBs
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False
+
+ await registry.unregister('run_kb_validation')
+
+ @pytest.mark.asyncio
+ async def test_storage_resource_validation(self):
+ """Storage resource: boolean permission validation."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources()
+ resources['storage'] = {'plugin_storage': True, 'workspace_storage': False}
+
+ await registry.register(
+ run_id='run_storage_validation',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_storage_validation')
+
+ # Plugin storage allowed
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is True
+
+ # Workspace storage not allowed
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
+
+ await registry.unregister('run_storage_validation')
+
+ def test_unknown_resource_type_returns_false(self):
+ """Unknown resource type: should return False."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources()
+
+ # Create session manually for this test
+ session = {
+ 'run_id': 'test',
+ 'runner_id': 'test',
+ 'query_id': 1,
+ 'plugin_identity': 'test',
+ 'resources': resources,
+ 'status': {'started_at': 0, 'last_activity_at': 0},
+ }
+
+ # Unknown resource type should return False
+ assert registry.is_resource_allowed(session, 'unknown_type', 'any_id') is False
+
+
+class TestBypassPrevention:
+ """Tests to ensure AgentRunAPIProxy cannot bypass authorization."""
+
+ @pytest.mark.asyncio
+ async def test_cannot_bypass_via_unrestricted_retrieve_knowledge(self):
+ """Cannot bypass KB authorization via unrestricted RETRIEVE_KNOWLEDGE action."""
+ # AgentRunAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id)
+ # RETRIEVE_KNOWLEDGE is unrestricted and separate
+ # AgentRunner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization
+
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
+
+ await registry.register(
+ run_id='run_bypass_test',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_bypass_test')
+
+ # kb_002 is not authorized
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is False
+
+ # If AgentRunner tried to use RETRIEVE_KNOWLEDGE (unrestricted),
+ # it would bypass authorization - but AgentRunAPIProxy correctly uses
+ # RETRIE_KNOWLEDGE_BASE which requires authorization
+
+ from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
+
+ # Verify SDK uses correct action
+ assert PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE.value == 'retrieve_knowledge_base'
+
+ await registry.unregister('run_bypass_test')
+
+ @pytest.mark.asyncio
+ async def test_cannot_bypass_via_missing_run_id_in_session(self):
+ """Cannot bypass by using run_id that doesn't exist in registry."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_valid',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Try to use a run_id that doesn't exist
+ fake_run_id = 'run_fake'
+ session = await registry.get(fake_run_id)
+ assert session is None
+
+ # Handler should return error for non-existent run_id
+ # (handler.py line 348, 466, 881)
+ expected_error = f'Run session {fake_run_id} not found or expired'
+ assert 'not found' in expected_error
+
+ await registry.unregister('run_valid')
+
+
+class TestValidateRunAuthorizationHelper:
+ """Tests for _validate_run_authorization helper function.
+
+ This helper is used by INVOKE_LLM, INVOKE_LLM_STREAM, CALL_TOOL,
+ and RETRIEVE_KNOWLEDGE_BASE handlers to validate run_id authorization.
+
+ Note: This helper uses get_session_registry() which returns the global singleton.
+ Tests must use the same global registry.
+ """
+
+ @pytest.mark.asyncio
+ async def test_validate_returns_session_when_authorized(self):
+ """_validate_run_authorization returns session when resource is authorized."""
+ # Use global session registry (same as _validate_run_authorization)
+ from langbot.pkg.agent.runner.session_registry import get_session_registry
+ registry = get_session_registry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_validate_test_helper',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Import the helper
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ # Create mock application
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+
+ session, error = await _validate_run_authorization(
+ 'run_validate_test_helper',
+ 'model',
+ 'model_001',
+ mock_ap
+ )
+
+ # Should return session, no error
+ assert session is not None
+ assert error is None
+ assert session['run_id'] == 'run_validate_test_helper'
+
+ await registry.unregister('run_validate_test_helper')
+
+ @pytest.mark.asyncio
+ async def test_validate_returns_error_when_session_not_found(self):
+ """_validate_run_authorization returns error when session not found."""
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+ mock_ap.logger.warning = MagicMock()
+
+ session, error = await _validate_run_authorization(
+ 'run_nonexistent_helper',
+ 'model',
+ 'model_001',
+ mock_ap
+ )
+
+ # Should return no session, error response
+ assert session is None
+ assert error is not None
+ assert 'not found' in error.message.lower()
+ assert mock_ap.logger.warning.called
+
+ @pytest.mark.asyncio
+ async def test_validate_returns_error_when_resource_not_allowed(self):
+ """_validate_run_authorization returns error when resource not allowed."""
+ # Use global session registry
+ from langbot.pkg.agent.runner.session_registry import get_session_registry
+ registry = get_session_registry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_unauthorized_helper',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+ mock_ap.logger.warning = MagicMock()
+
+ session, error = await _validate_run_authorization(
+ 'run_unauthorized_helper',
+ 'model',
+ 'model_999', # Not in resources
+ mock_ap
+ )
+
+ # Should return no session, error response
+ assert session is None
+ assert error is not None
+ assert 'not authorized' in error.message.lower()
+ assert mock_ap.logger.warning.called
+
+ await registry.unregister('run_unauthorized_helper')
+
+ @pytest.mark.asyncio
+ async def test_validate_for_tool_resource_type(self):
+ """_validate_run_authorization works for tool resource type."""
+ # Use global session registry
+ from langbot.pkg.agent.runner.session_registry import get_session_registry
+ registry = get_session_registry()
+ resources = make_resources(tools=[{'tool_name': 'web_search'}])
+
+ await registry.register(
+ run_id='run_tool_test_helper',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+
+ session, error = await _validate_run_authorization(
+ 'run_tool_test_helper',
+ 'tool',
+ 'web_search',
+ mock_ap
+ )
+
+ assert session is not None
+ assert error is None
+
+ await registry.unregister('run_tool_test_helper')
+
+ @pytest.mark.asyncio
+ async def test_validate_for_knowledge_base_resource_type(self):
+ """_validate_run_authorization works for knowledge_base resource type."""
+ # Use global session registry
+ from langbot.pkg.agent.runner.session_registry import get_session_registry
+ registry = get_session_registry()
+ resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
+
+ await registry.register(
+ run_id='run_kb_test_helper',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+
+ session, error = await _validate_run_authorization(
+ 'run_kb_test_helper',
+ 'knowledge_base',
+ 'kb_001',
+ mock_ap
+ )
+
+ assert session is not None
+ assert error is None
+
+ await registry.unregister('run_kb_test_helper')
+
+
+class TestStorageResourcePermissionHelper:
+ """Tests for session_registry.is_resource_allowed for storage resource type.
+
+ The 'storage' resource type has different permission model:
+ - resource_id can be 'plugin' or 'workspace'
+ - Permission is boolean flag, not list membership
+ """
+
+ @pytest.mark.asyncio
+ async def test_plugin_storage_allowed_when_true(self):
+ """is_resource_allowed returns True when plugin_storage=True."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources()
+ resources['storage'] = {'plugin_storage': True, 'workspace_storage': False}
+
+ await registry.register(
+ run_id='run_plugin_storage',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_plugin_storage')
+
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is True
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
+
+ await registry.unregister('run_plugin_storage')
+
+ @pytest.mark.asyncio
+ async def test_workspace_storage_allowed_when_true(self):
+ """is_resource_allowed returns True when workspace_storage=True."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources()
+ resources['storage'] = {'plugin_storage': False, 'workspace_storage': True}
+
+ await registry.register(
+ run_id='run_workspace_storage',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_workspace_storage')
+
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is True
+
+ await registry.unregister('run_workspace_storage')
+
+ @pytest.mark.asyncio
+ async def test_both_storage_types_disabled(self):
+ """is_resource_allowed returns False when both storage types disabled."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources()
+ resources['storage'] = {'plugin_storage': False, 'workspace_storage': False}
+
+ await registry.register(
+ run_id='run_no_storage',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_no_storage')
+
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
+
+ await registry.unregister('run_no_storage')
+
+ @pytest.mark.asyncio
+ async def test_unknown_storage_resource_id_returns_false(self):
+ """is_resource_allowed returns False for unknown storage resource_id."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources()
+ resources['storage'] = {'plugin_storage': True, 'workspace_storage': True}
+
+ await registry.register(
+ run_id='run_unknown_storage',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ session = await registry.get('run_unknown_storage')
+
+ # Unknown storage resource_id
+ assert registry.is_resource_allowed(session, 'storage', 'unknown_type') is False
+
+ await registry.unregister('run_unknown_storage')
+
+ def test_storage_permission_with_missing_storage_field(self):
+ """is_resource_allowed handles missing storage field gracefully."""
+ registry = AgentRunSessionRegistry()
+
+ # Create session without storage field
+ session = {
+ 'run_id': 'test',
+ 'runner_id': 'test',
+ 'query_id': 1,
+ 'plugin_identity': 'test',
+ 'resources': {}, # No storage field
+ 'status': {'started_at': 0, 'last_activity_at': 0},
+ }
+
+ # Should return False for both storage types
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
+
+
+class TestFilesResourcePermission:
+ """Tests for session_registry.is_resource_allowed for files resource type.
+
+ Note: The current implementation does not have 'files' resource type
+ in is_resource_allowed. This test class documents the expected behavior
+ when files resource type is implemented.
+ """
+
+ def test_files_resource_type_not_implemented(self):
+ """Currently, 'files' resource type returns False in is_resource_allowed."""
+ registry = AgentRunSessionRegistry()
+
+ session = {
+ 'run_id': 'test',
+ 'runner_id': 'test',
+ 'query_id': 1,
+ 'plugin_identity': 'test',
+ 'resources': {
+ 'files': [{'file_id': 'file_001'}],
+ },
+ 'status': {'started_at': 0, 'last_activity_at': 0},
+ }
+
+ # 'files' resource type is not implemented in is_resource_allowed
+ # It returns False for unknown resource types
+ assert registry.is_resource_allowed(session, 'files', 'file_001') is False
+
+
+class TestRealActionHandlerSimulation:
+ """Tests that simulate real RuntimeConnectionHandler action registration and execution.
+
+ These tests attempt to verify the actual handler behavior without full integration.
+ Uses global session registry to match _validate_run_authorization behavior.
+ """
+
+ @pytest.mark.asyncio
+ async def test_action_handler_invoke_llm_flow(self):
+ """Simulate INVOKE_LLM action handler authorization flow."""
+ # Use global session registry
+ from langbot.pkg.agent.runner.session_registry import get_session_registry
+ registry = get_session_registry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_invoke_llm_flow_sim',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ # Simulate handler logic
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+
+ # Step 1: Validate authorization
+ session, error = await _validate_run_authorization(
+ 'run_invoke_llm_flow_sim',
+ 'model',
+ 'model_001',
+ mock_ap
+ )
+
+ # Should pass authorization
+ assert session is not None
+ assert error is None
+
+ # Step 2: Handler would invoke LLM (not tested here, would need mock model)
+
+ await registry.unregister('run_invoke_llm_flow_sim')
+
+ @pytest.mark.asyncio
+ async def test_action_handler_rejects_unauthorized_model(self):
+ """Simulate INVOKE_LLM handler rejecting unauthorized model."""
+ # Use global session registry
+ from langbot.pkg.agent.runner.session_registry import get_session_registry
+ registry = get_session_registry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+
+ await registry.register(
+ run_id='run_reject_model_sim',
+ runner_id='plugin:test/runner/default',
+ query_id=1,
+ plugin_identity='test/runner',
+ resources=resources,
+ )
+
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+ mock_ap.logger.warning = MagicMock()
+
+ # Try to access unauthorized model
+ session, error = await _validate_run_authorization(
+ 'run_reject_model_sim',
+ 'model',
+ 'model_999',
+ mock_ap
+ )
+
+ # Should reject
+ assert session is None
+ assert error is not None
+ assert 'not authorized' in error.message.lower()
+ assert mock_ap.logger.warning.called
+
+ await registry.unregister('run_reject_model_sim')
+
+ @pytest.mark.asyncio
+ async def test_action_handler_session_not_found_flow(self):
+ """Simulate handler behavior when session not found."""
+ from langbot.pkg.plugin.handler import _validate_run_authorization
+
+ mock_ap = MagicMock()
+ mock_ap.logger = MagicMock()
+ mock_ap.logger.warning = MagicMock()
+
+ # Try to validate with non-existent run_id
+ session, error = await _validate_run_authorization(
+ 'run_nonexistent_session_sim',
+ 'model',
+ 'model_001',
+ mock_ap
+ )
+
+ # Should return error
+ assert session is None
+ assert error is not None
+ assert 'not found' in error.message.lower()
+ assert mock_ap.logger.warning.called
\ No newline at end of file
diff --git a/tests/unit_tests/agent/test_session_registry.py b/tests/unit_tests/agent/test_session_registry.py
new file mode 100644
index 00000000..04ce3016
--- /dev/null
+++ b/tests/unit_tests/agent/test_session_registry.py
@@ -0,0 +1,427 @@
+"""Tests for AgentRunSessionRegistry."""
+from __future__ import annotations
+
+import pytest
+import asyncio
+import time
+
+from langbot.pkg.agent.runner.session_registry import (
+ AgentRunSessionRegistry,
+ AgentRunSession,
+ get_session_registry,
+)
+
+# Import shared test fixtures from conftest.py
+from .conftest import make_resources, make_session
+
+
+class TestSessionRegistryBasic:
+ """Tests for basic registry operations."""
+
+ @pytest.mark.asyncio
+ async def test_register_and_get(self):
+ """Register and retrieve a session."""
+ registry = AgentRunSessionRegistry()
+ run_id = 'run_abc'
+ resources = make_resources(
+ models=[{'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'}],
+ tools=[{'tool_name': 'web_search', 'tool_type': 'builtin'}],
+ )
+ session = make_session(run_id=run_id, resources=resources)
+
+ await registry.register(
+ run_id=run_id,
+ runner_id='plugin:test/my-runner/default',
+ query_id=1,
+ plugin_identity='test/my-runner',
+ resources=resources,
+ )
+
+ result = await registry.get(run_id)
+ assert result is not None
+ assert result['run_id'] == run_id
+ assert result['runner_id'] == 'plugin:test/my-runner/default'
+ assert result['query_id'] == 1
+ assert result['plugin_identity'] == 'test/my-runner'
+ assert len(result['resources']['models']) == 1
+ assert result['resources']['models'][0]['model_id'] == 'model_001'
+
+ @pytest.mark.asyncio
+ async def test_get_nonexistent_session(self):
+ """Get should return None for nonexistent run_id."""
+ registry = AgentRunSessionRegistry()
+ result = await registry.get('nonexistent_run')
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_unregister(self):
+ """Unregister should remove session."""
+ registry = AgentRunSessionRegistry()
+ run_id = 'run_xyz'
+
+ await registry.register(
+ run_id=run_id,
+ runner_id='plugin:test/my-runner/default',
+ query_id=1,
+ plugin_identity='test/my-runner',
+ resources=make_resources(),
+ )
+
+ # Verify registered
+ result = await registry.get(run_id)
+ assert result is not None
+
+ # Unregister
+ await registry.unregister(run_id)
+
+ # Verify unregistered
+ result = await registry.get(run_id)
+ assert result is None
+
+ @pytest.mark.asyncio
+ async def test_unregister_nonexistent(self):
+ """Unregister nonexistent session should not raise error."""
+ registry = AgentRunSessionRegistry()
+ # Should not raise
+ await registry.unregister('nonexistent_run')
+
+ @pytest.mark.asyncio
+ async def test_update_activity(self):
+ """Update activity should update last_activity_at."""
+ registry = AgentRunSessionRegistry()
+ run_id = 'run_activity'
+
+ # Create session with manually set old timestamp
+ now = int(time.time())
+ res = make_resources()
+ old_session: AgentRunSession = {
+ 'run_id': run_id,
+ 'runner_id': 'plugin:test/my-runner/default',
+ 'query_id': 1,
+ 'plugin_identity': 'test/my-runner',
+ 'resources': res,
+ 'status': {
+ 'started_at': now - 100, # 100 seconds ago
+ 'last_activity_at': now - 100, # 100 seconds ago
+ },
+ '_authorized_ids': {
+ 'model': set(),
+ 'tool': set(),
+ 'knowledge_base': set(),
+ },
+ }
+
+ async with registry._lock:
+ registry._sessions[run_id] = old_session
+
+ # Get initial session
+ session1 = await registry.get(run_id)
+ initial_time = session1['status']['last_activity_at']
+
+ # Update activity
+ await registry.update_activity(run_id)
+
+ # Verify updated - should be significantly different (100 seconds)
+ session2 = await registry.get(run_id)
+ assert session2['status']['last_activity_at'] > initial_time
+ assert session2['status']['last_activity_at'] - initial_time >= 100
+
+ @pytest.mark.asyncio
+ async def test_update_activity_nonexistent(self):
+ """Update activity on nonexistent session should not raise."""
+ registry = AgentRunSessionRegistry()
+ # Should not raise
+ await registry.update_activity('nonexistent_run')
+
+ @pytest.mark.asyncio
+ async def test_list_active_runs(self):
+ """List active runs should return all sessions."""
+ registry = AgentRunSessionRegistry()
+
+ await registry.register('run_1', 'plugin:a/b/default', 1, 'a/b', make_resources())
+ await registry.register('run_2', 'plugin:c/d/default', 2, 'c/d', make_resources())
+
+ active_runs = await registry.list_active_runs()
+ assert len(active_runs) == 2
+ run_ids = [r['run_id'] for r in active_runs]
+ assert 'run_1' in run_ids
+ assert 'run_2' in run_ids
+
+ @pytest.mark.asyncio
+ async def test_cleanup_stale_sessions(self):
+ """Cleanup should remove old sessions."""
+ registry = AgentRunSessionRegistry()
+
+ # Create sessions with manually set old timestamp
+ now = int(time.time())
+ res = make_resources()
+ old_session: AgentRunSession = {
+ 'run_id': 'old_run',
+ 'runner_id': 'plugin:test/runner/default',
+ 'query_id': 1,
+ 'plugin_identity': 'test/runner',
+ 'resources': res,
+ 'status': {
+ 'started_at': now - 7200, # 2 hours ago
+ 'last_activity_at': now - 7200, # 2 hours ago
+ },
+ '_authorized_ids': {
+ 'model': set(),
+ 'tool': set(),
+ 'knowledge_base': set(),
+ },
+ }
+ new_session: AgentRunSession = {
+ 'run_id': 'new_run',
+ 'runner_id': 'plugin:test/runner/default',
+ 'query_id': 2,
+ 'plugin_identity': 'test/runner',
+ 'resources': res,
+ 'status': {
+ 'started_at': now,
+ 'last_activity_at': now,
+ },
+ '_authorized_ids': {
+ 'model': set(),
+ 'tool': set(),
+ 'knowledge_base': set(),
+ },
+ }
+
+ async with registry._lock:
+ registry._sessions['old_run'] = old_session
+ registry._sessions['new_run'] = new_session
+
+ # Cleanup sessions older than 1 hour
+ cleaned = await registry.cleanup_stale_sessions(max_age_seconds=3600)
+ assert cleaned == 1
+
+ # Verify old session removed, new remains
+ assert await registry.get('old_run') is None
+ assert await registry.get('new_run') is not None
+
+
+class TestIsResourceAllowed:
+ """Tests for is_resource_allowed validation."""
+
+ def test_model_allowed(self):
+ """Model in resources should be allowed."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(
+ models=[
+ {'model_id': 'model_001', 'model_type': 'chat', 'provider': 'openai'},
+ {'model_id': 'model_002', 'model_type': 'embedding', 'provider': 'anthropic'},
+ ]
+ )
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'model', 'model_001') is True
+ assert registry.is_resource_allowed(session, 'model', 'model_002') is True
+
+ def test_model_not_allowed(self):
+ """Model not in resources should be denied."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[{'model_id': 'model_001'}])
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'model', 'model_999') is False
+
+ def test_model_empty_resources(self):
+ """Empty models list should deny all."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(models=[])
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'model', 'model_001') is False
+
+ def test_tool_allowed(self):
+ """Tool in resources should be allowed."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(
+ tools=[
+ {'tool_name': 'web_search', 'tool_type': 'builtin'},
+ {'tool_name': 'code_exec', 'tool_type': 'plugin'},
+ ]
+ )
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'tool', 'web_search') is True
+ assert registry.is_resource_allowed(session, 'tool', 'code_exec') is True
+
+ def test_tool_not_allowed(self):
+ """Tool not in resources should be denied."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(tools=[{'tool_name': 'web_search'}])
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'tool', 'image_gen') is False
+
+ def test_tool_empty_resources(self):
+ """Empty tools list should deny all."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(tools=[])
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'tool', 'web_search') is False
+
+ def test_knowledge_base_allowed(self):
+ """Knowledge base in resources should be allowed."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(
+ knowledge_bases=[
+ {'kb_id': 'kb_001', 'kb_name': 'docs', 'kb_type': 'vector'},
+ {'kb_id': 'kb_002', 'kb_name': 'faq', 'kb_type': 'keyword'},
+ ]
+ )
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is True
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is True
+
+ def test_knowledge_base_not_allowed(self):
+ """Knowledge base not in resources should be denied."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_999') is False
+
+ def test_knowledge_base_empty_resources(self):
+ """Empty knowledge bases list should deny all."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(knowledge_bases=[])
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False
+
+ def test_storage_plugin_allowed(self):
+ """Plugin storage permission should be checked."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False})
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is True
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
+
+ def test_storage_workspace_allowed(self):
+ """Workspace storage permission should be checked."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True})
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is True
+
+ def test_storage_both_denied(self):
+ """Both storage permissions denied."""
+ registry = AgentRunSessionRegistry()
+ resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
+ session = make_session(resources=resources)
+
+ assert registry.is_resource_allowed(session, 'storage', 'plugin') is False
+ assert registry.is_resource_allowed(session, 'storage', 'workspace') is False
+
+ def test_unknown_resource_type(self):
+ """Unknown resource type should return False."""
+ registry = AgentRunSessionRegistry()
+ session = make_session(resources=make_resources())
+
+ assert registry.is_resource_allowed(session, 'unknown_type', 'something') is False
+
+ def test_missing_resources_field(self):
+ """Missing resources field should not raise."""
+ registry = AgentRunSessionRegistry()
+ session = make_session(resources={'models': []}) # Missing other fields
+
+ # Should not raise, should return False
+ assert registry.is_resource_allowed(session, 'tool', 'web_search') is False
+ assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False
+
+
+class TestGlobalRegistry:
+ """Tests for global registry singleton."""
+
+ def test_get_session_registry_returns_instance(self):
+ """get_session_registry should return AgentRunSessionRegistry."""
+ # Use a separate test that doesn't modify global state
+ # The singleton pattern works in production, but modifying globals
+ # in tests can cause UnboundLocalError due to Python scoping
+ # Instead, just verify the function signature
+ from langbot.pkg.agent.runner.session_registry import get_session_registry
+ assert callable(get_session_registry)
+
+ # Create a fresh instance directly to verify the class works
+ fresh_registry = AgentRunSessionRegistry()
+ assert isinstance(fresh_registry, AgentRunSessionRegistry)
+
+ def test_global_registry_singleton_behavior(self):
+ """The global registry should maintain singleton behavior."""
+ # Test singleton behavior without modifying global state
+ # In production, calling get_session_registry() multiple times
+ # returns the same instance. We verify this by checking the
+ # module-level variable directly.
+ import langbot.pkg.agent.runner.session_registry as registry_module
+
+ # Check that the global variable exists and is either None or an instance
+ global_reg = registry_module._global_registry
+ if global_reg is None:
+ # First call creates the instance
+ registry1 = get_session_registry()
+ assert isinstance(registry1, AgentRunSessionRegistry)
+ # Subsequent calls return the same instance
+ registry2 = get_session_registry()
+ assert registry1 is registry2
+ else:
+ # Instance already exists, verify singleton
+ registry1 = get_session_registry()
+ registry2 = get_session_registry()
+ assert registry1 is registry2
+ assert registry1 is global_reg
+
+
+class TestThreadSafety:
+ """Tests for asyncio.Lock thread safety."""
+
+ @pytest.mark.asyncio
+ async def test_concurrent_register(self):
+ """Concurrent register should be safe."""
+ registry = AgentRunSessionRegistry()
+
+ # Register multiple sessions concurrently
+ tasks = []
+ for i in range(10):
+ tasks.append(
+ registry.register(
+ f'run_{i}',
+ 'plugin:test/runner/default',
+ i,
+ 'test/runner',
+ make_resources(),
+ )
+ )
+
+ await asyncio.gather(*tasks)
+
+ # All sessions should be registered
+ active_runs = await registry.list_active_runs()
+ assert len(active_runs) == 10
+
+ @pytest.mark.asyncio
+ async def test_concurrent_register_and_unregister(self):
+ """Concurrent register and unregister should be safe."""
+ registry = AgentRunSessionRegistry()
+
+ # Register
+ await registry.register('run_1', 'plugin:test/runner/default', 1, 'test/runner', make_resources())
+
+ # Concurrent unregister and get
+ tasks = [
+ registry.unregister('run_1'),
+ registry.get('run_1'),
+ ]
+
+ results = await asyncio.gather(*tasks)
+
+ # After both complete, session should be unregistered
+ result = await registry.get('run_1')
+ assert result is None
\ No newline at end of file
diff --git a/tests/unit_tests/agent/test_state_store.py b/tests/unit_tests/agent/test_state_store.py
new file mode 100644
index 00000000..97089536
--- /dev/null
+++ b/tests/unit_tests/agent/test_state_store.py
@@ -0,0 +1,473 @@
+"""Tests for runner scoped state store."""
+from __future__ import annotations
+
+from langbot.pkg.agent.runner.state_store import (
+ RunnerScopedStateStore,
+ get_state_store,
+ reset_state_store,
+ VALID_STATE_SCOPES,
+ LEGACY_KEY_MAPPING,
+)
+from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
+
+
+def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRunnerDescriptor:
+ """Create a test descriptor."""
+ return AgentRunnerDescriptor(
+ id=runner_id,
+ source='plugin',
+ label={'en_US': 'Test Runner'},
+ plugin_author='test',
+ plugin_name='my-runner',
+ runner_name='default',
+ protocol_version='1',
+ capabilities={'streaming': True},
+ )
+
+
+class FakeSession:
+ """Fake session for testing."""
+ def __init__(self):
+ self.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
+ self.launcher_id = 'group_123'
+ self.using_conversation = None
+
+
+class FakeConversation:
+ """Fake conversation for testing."""
+ def __init__(self, uuid: str = 'conv_abc', create_time: int | None = None):
+ self.uuid = uuid
+ self.create_time = create_time
+
+
+class FakeQuery:
+ """Fake query for testing."""
+ def __init__(
+ self,
+ bot_uuid: str = 'bot_001',
+ pipeline_uuid: str = 'pipeline_002',
+ sender_id: str = 'user_123',
+ session: FakeSession | None = None,
+ ):
+ self.bot_uuid = bot_uuid
+ self.pipeline_uuid = pipeline_uuid
+ self.sender_id = sender_id
+ self.session = session or FakeSession()
+
+
+class FakeLogger:
+ """Fake logger for testing."""
+ def __init__(self):
+ self.debugs = []
+ self.warnings = []
+
+ def debug(self, msg):
+ self.debugs.append(msg)
+
+ def warning(self, msg):
+ self.warnings.append(msg)
+
+
+class TestStateStoreBuildSnapshot:
+ """Tests for build_snapshot."""
+
+ def test_build_snapshot_returns_four_scopes(self):
+ """Snapshot should have all four scope keys."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+
+ snapshot = store.build_snapshot(query, descriptor)
+
+ assert 'conversation' in snapshot
+ assert 'actor' in snapshot
+ assert 'subject' in snapshot
+ assert 'runner' in snapshot
+ assert snapshot['conversation'] == {}
+ assert snapshot['actor'] == {}
+ assert snapshot['subject'] == {}
+ assert snapshot['runner'] == {}
+
+ def test_build_snapshot_seeds_conversation_id(self):
+ """Snapshot should seed external.conversation_id from existing conversation."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ conversation = FakeConversation(uuid='conv_existing')
+ session = FakeSession()
+ session.using_conversation = conversation
+ query = FakeQuery(session=session)
+
+ snapshot = store.build_snapshot(query, descriptor)
+
+ assert snapshot['conversation']['external.conversation_id'] == 'conv_existing'
+
+ def test_build_snapshot_returns_stored_values(self):
+ """Snapshot should return previously stored values."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ # Store some values
+ store.apply_update(query, descriptor, 'conversation', 'external.conversation_id', 'conv_001', logger)
+ store.apply_update(query, descriptor, 'actor', 'preferred_language', 'zh', logger)
+ store.apply_update(query, descriptor, 'subject', 'group_topic', 'tech', logger)
+ store.apply_update(query, descriptor, 'runner', 'cache_version', 'v1', logger)
+
+ # Build snapshot
+ snapshot = store.build_snapshot(query, descriptor)
+
+ assert snapshot['conversation']['external.conversation_id'] == 'conv_001'
+ assert snapshot['actor']['preferred_language'] == 'zh'
+ assert snapshot['subject']['group_topic'] == 'tech'
+ assert snapshot['runner']['cache_version'] == 'v1'
+
+ def test_build_snapshot_isolation_by_runner_id(self):
+ """Different runner IDs should have isolated state."""
+ store = RunnerScopedStateStore()
+ descriptor1 = make_descriptor('plugin:test/runner-a/default')
+ descriptor2 = make_descriptor('plugin:test/runner-b/default')
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ # Store for runner-a
+ store.apply_update(query, descriptor1, 'conversation', 'external.conversation_id', 'conv_a', logger)
+
+ # Build snapshot for runner-b
+ snapshot_b = store.build_snapshot(query, descriptor2)
+
+ # runner-b should not see runner-a's state
+ assert snapshot_b['conversation'] == {}
+
+
+class TestStateStoreApplyUpdate:
+ """Tests for apply_update."""
+
+ def test_apply_update_conversation_scope(self):
+ """Apply update to conversation scope."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ result = store.apply_update(
+ query, descriptor, 'conversation', 'external.conversation_id', 'conv_new', logger
+ )
+
+ assert result is True
+ assert len(logger.warnings) == 0
+
+ def test_apply_update_actor_scope(self):
+ """Apply update to actor scope."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ result = store.apply_update(query, descriptor, 'actor', 'preferred_language', 'en', logger)
+
+ assert result is True
+ assert len(logger.warnings) == 0
+
+ def test_apply_update_subject_scope(self):
+ """Apply update to subject scope."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ result = store.apply_update(query, descriptor, 'subject', 'group_topic', 'general', logger)
+
+ assert result is True
+ assert len(logger.warnings) == 0
+
+ def test_apply_update_runner_scope(self):
+ """Apply update to runner scope."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ result = store.apply_update(query, descriptor, 'runner', 'cache_version', 'v2', logger)
+
+ assert result is True
+ assert len(logger.warnings) == 0
+
+ def test_apply_update_invalid_scope(self):
+ """Invalid scope should return False and log warning."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ result = store.apply_update(query, descriptor, 'invalid_scope', 'key', 'value', logger)
+
+ assert result is False
+ assert len(logger.warnings) == 1
+ assert 'invalid scope' in logger.warnings[0]
+
+ def test_apply_update_legacy_key_mapping(self):
+ """Legacy key conversation_id should be mapped to external.conversation_id."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ result = store.apply_update(query, descriptor, 'conversation', 'conversation_id', 'conv_old', logger)
+
+ assert result is True
+ assert 'mapped to' in logger.debugs[0]
+
+ # Check mapped key is stored
+ snapshot = store.build_snapshot(query, descriptor)
+ assert snapshot['conversation']['external.conversation_id'] == 'conv_old'
+
+ def test_apply_update_syncs_conversation_uuid(self):
+ """external.conversation_id update should sync to query.session.using_conversation.uuid."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ conversation = FakeConversation(uuid='conv_old')
+ session = FakeSession()
+ session.using_conversation = conversation
+ query = FakeQuery(session=session)
+ logger = FakeLogger()
+
+ result = store.apply_update(
+ query, descriptor, 'conversation', 'external.conversation_id', 'conv_new', logger
+ )
+
+ assert result is True
+ assert conversation.uuid == 'conv_new' # Synced
+ assert 'Synced' in logger.debugs[-1]
+
+
+class TestStateStoreScopeIdentity:
+ """Tests for scope identity isolation."""
+
+ def test_conversation_scope_includes_runner_id(self):
+ """Conversation scope key should include runner_id."""
+ store = RunnerScopedStateStore()
+ descriptor_a = make_descriptor('plugin:test/runner-a/default')
+ descriptor_b = make_descriptor('plugin:test/runner-b/default')
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ # Store for runner-a
+ store.apply_update(query, descriptor_a, 'conversation', 'key', 'value_a', logger)
+
+ # runner-b should not see runner-a's conversation state
+ snapshot_b = store.build_snapshot(query, descriptor_b)
+ assert snapshot_b['conversation'] == {}
+
+ # runner-a should see its own state
+ snapshot_a = store.build_snapshot(query, descriptor_a)
+ assert snapshot_a['conversation']['key'] == 'value_a'
+
+ def test_actor_scope_includes_sender_id(self):
+ """Actor scope should be isolated per sender_id."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ query_user1 = FakeQuery(sender_id='user_001')
+ query_user2 = FakeQuery(sender_id='user_002')
+ logger = FakeLogger()
+
+ # Store for user_001
+ store.apply_update(query_user1, descriptor, 'actor', 'preferred_language', 'en', logger)
+
+ # user_002 should not see user_001's actor state
+ snapshot_user2 = store.build_snapshot(query_user2, descriptor)
+ assert snapshot_user2['actor'] == {}
+
+ # user_001 should see its own state
+ snapshot_user1 = store.build_snapshot(query_user1, descriptor)
+ assert snapshot_user1['actor']['preferred_language'] == 'en'
+
+ def test_subject_scope_includes_launcher(self):
+ """Subject scope should be isolated per launcher_type + launcher_id."""
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ session1 = FakeSession()
+ session1.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
+ session1.launcher_id = 'group_001'
+ session2 = FakeSession()
+ session2.launcher_type = type('LauncherType', (), {'value': 'telegram'})()
+ session2.launcher_id = 'group_002'
+ query1 = FakeQuery(session=session1)
+ query2 = FakeQuery(session=session2)
+ logger = FakeLogger()
+
+ # Store for group_001
+ store.apply_update(query1, descriptor, 'subject', 'group_topic', 'tech', logger)
+
+ # group_002 should not see group_001's subject state
+ snapshot2 = store.build_snapshot(query2, descriptor)
+ assert snapshot2['subject'] == {}
+
+ # group_001 should see its own state
+ snapshot1 = store.build_snapshot(query1, descriptor)
+ assert snapshot1['subject']['group_topic'] == 'tech'
+
+ def test_conversation_scope_not_dependent_on_external_uuid(self):
+ """Conversation scope identity should NOT use external conversation uuid.
+
+ Using external uuid as scope key would cause state loss when
+ runner updates external.conversation_id:
+ - First run: state saved under key with old uuid
+ - Runner returns new external.conversation_id, synced to conversation.uuid
+ - Next run: scope key uses new uuid, previous state inaccessible
+
+ This test verifies scope key stability when conversation.uuid changes.
+ """
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+ # Use stable create_time as conversation identity
+ conversation = FakeConversation(uuid='conv_initial', create_time=12345)
+ session = FakeSession()
+ session.using_conversation = conversation
+ query = FakeQuery(session=session)
+ logger = FakeLogger()
+
+ # Store some conversation state (e.g., memory.summary, external.thread_id)
+ store.apply_update(
+ query, descriptor, 'conversation', 'memory.summary', 'Summary content', logger
+ )
+ store.apply_update(
+ query, descriptor, 'conversation', 'external.thread_id', 'thread_abc', logger
+ )
+
+ # Simulate runner returning new external.conversation_id
+ store.apply_update(
+ query, descriptor, 'conversation', 'external.conversation_id', 'conv_new_from_runner', logger
+ )
+
+ # conversation.uuid is synced to new value
+ assert conversation.uuid == 'conv_new_from_runner'
+
+ # Build new snapshot - previous state should still be accessible
+ # because scope key is based on stable identity (create_time), not external uuid
+ snapshot = store.build_snapshot(query, descriptor)
+
+ # All previously stored state should still be present
+ assert snapshot['conversation']['memory.summary'] == 'Summary content'
+ assert snapshot['conversation']['external.thread_id'] == 'thread_abc'
+ assert snapshot['conversation']['external.conversation_id'] == 'conv_new_from_runner'
+
+ def test_conversation_scope_with_create_time_stability(self):
+ """Conversation scope key should use create_time for stability.
+
+ When create_time is available, it should be used as stable identity.
+ Different conversations with same launcher but different create_time
+ should have different scope keys.
+ """
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+
+ # Two conversations with same launcher but different create_time
+ conversation1 = FakeConversation(uuid='conv_1', create_time=10000)
+ conversation2 = FakeConversation(uuid='conv_2', create_time=20000)
+ session1 = FakeSession()
+ session1.using_conversation = conversation1
+ session2 = FakeSession()
+ session2.using_conversation = conversation2
+
+ query1 = FakeQuery(session=session1)
+ query2 = FakeQuery(session=session2)
+ logger = FakeLogger()
+
+ # Store for conversation1
+ store.apply_update(query1, descriptor, 'conversation', 'key', 'value1', logger)
+
+ # conversation2 should not see conversation1's state (different create_time)
+ # Note: snapshot2 may have seeded external.conversation_id from conversation2.uuid
+ snapshot2 = store.build_snapshot(query2, descriptor)
+ assert 'key' not in snapshot2['conversation'] # No state from conversation1
+
+ # conversation1 should see its own state
+ snapshot1 = store.build_snapshot(query1, descriptor)
+ assert snapshot1['conversation']['key'] == 'value1'
+
+ def test_conversation_scope_without_create_time_uses_launcher_identity(self):
+ """Conversation scope without create_time should use launcher identity.
+
+ When create_time is not available, scope key should be based on
+ launcher (person/group) identity, assuming one active conversation
+ per launcher context.
+ """
+ store = RunnerScopedStateStore()
+ descriptor = make_descriptor()
+
+ # Conversation without create_time
+ conversation = FakeConversation(uuid='conv_1', create_time=None)
+ session = FakeSession()
+ session.using_conversation = conversation
+ query = FakeQuery(session=session)
+ logger = FakeLogger()
+
+ # Store some state
+ store.apply_update(query, descriptor, 'conversation', 'key', 'value', logger)
+
+ # State should be accessible
+ snapshot = store.build_snapshot(query, descriptor)
+ assert snapshot['conversation']['key'] == 'value'
+
+ # Update external.conversation_id
+ store.apply_update(
+ query, descriptor, 'conversation', 'external.conversation_id', 'conv_2', logger
+ )
+
+ # State should still be accessible (scope key unchanged)
+ snapshot = store.build_snapshot(query, descriptor)
+ assert snapshot['conversation']['key'] == 'value'
+ assert snapshot['conversation']['external.conversation_id'] == 'conv_2'
+
+
+class TestStateStoreGlobalSingleton:
+ """Tests for global singleton functions."""
+
+ def test_get_state_store_returns_singleton(self):
+ """get_state_store should return the same instance."""
+ reset_state_store()
+ store1 = get_state_store()
+ store2 = get_state_store()
+
+ assert store1 is store2
+
+ def test_reset_state_store_clears_singleton(self):
+ """reset_state_store should clear the singleton."""
+ store1 = get_state_store()
+ reset_state_store()
+ store2 = get_state_store()
+
+ assert store1 is not store2
+
+ def test_reset_state_store_clears_data(self):
+ """reset_state_store should clear stored data."""
+ store = get_state_store()
+ descriptor = make_descriptor()
+ query = FakeQuery()
+ logger = FakeLogger()
+
+ # Store some data
+ store.apply_update(query, descriptor, 'conversation', 'key', 'value', logger)
+ snapshot = store.build_snapshot(query, descriptor)
+ assert snapshot['conversation']['key'] == 'value'
+
+ # Reset
+ reset_state_store()
+ store = get_state_store()
+
+ # Data should be gone
+ snapshot = store.build_snapshot(query, descriptor)
+ assert snapshot['conversation'] == {}
+
+
+class TestConstants:
+ """Tests for module constants."""
+
+ def test_valid_state_scopes(self):
+ """VALID_STATE_SCOPES should have four scopes."""
+ assert VALID_STATE_SCOPES == ('conversation', 'actor', 'subject', 'runner')
+
+ def test_legacy_key_mapping(self):
+ """LEGACY_KEY_MAPPING should map conversation_id."""
+ assert LEGACY_KEY_MAPPING == {'conversation_id': 'external.conversation_id'}
\ No newline at end of file
diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
index de192298..9e3f6088 100644
--- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
+++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
@@ -218,6 +218,7 @@ export default function PipelineFormComponent({
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
+
const loadedValues = {
basic: {
name: resp.pipeline.name,
@@ -348,8 +349,10 @@ export default function PipelineFormComponent({
) {
// Special handling for AI config section
if (formName === 'ai') {
- // Get the currently selected runner
- const currentRunner = form.watch('ai.runner.runner');
+ // Get the currently selected runner (use 'id' for new format, fallback to 'runner' for old)
+
+ const runnerConfig = (form.watch('ai.runner') as any) || {};
+ const currentRunner = runnerConfig.id || runnerConfig.runner;
// If this is the runner selector stage, render it directly
if (stage.name === 'runner') {
@@ -385,8 +388,8 @@ export default function PipelineFormComponent({
return null;
}
- // For n8n-service-api config, use N8nAuthFormComponent for form linkage
- if (stage.name === 'n8n-service-api') {
+ // For n8n runner config, use N8nAuthFormComponent for form linkage
+ if (stage.name === 'n8n-service-api' || stage.name === 'plugin:langbot/n8n-agent/default') {
return (
@@ -413,6 +416,48 @@ export default function PipelineFormComponent({
);
}
+
+ // For plugin runner configs, store in ai.runner_config[runnerId]
+
+ const isPluginRunner =
+ currentRunner && currentRunner.startsWith('plugin:');
+ if (isPluginRunner) {
+ const runnerConfigs = (form.watch('ai.runner_config') as any) || {};
+ return (
+
+
+ {extractI18nObject(stage.label)}
+ {stage.description && (
+
+ {extractI18nObject(stage.description)}
+
+ )}
+
+
+ {
+ // Store in ai.runner_config[stage.name]
+
+ const currentRunnerConfigs =
+ (form.getValues('ai.runner_config') as any) || {};
+ form.setValue('ai.runner_config', {
+ ...currentRunnerConfigs,
+ [stage.name]: values,
+ });
+ // Mark as initialized
+ const stageKey = `ai.runner_config.${stage.name}`;
+ if (!initializedStagesRef.current.has(stageKey)) {
+ initializedStagesRef.current.add(stageKey);
+ savedSnapshotRef.current = JSON.stringify(form.getValues());
+ }
+ }}
+ />
+
+
+ );
+ }
}
// Box availability is exposed through ``systemContext.__system.box_available``