perf(agent-runner): improve session registry and orchestrator efficiency

- Add pre-computed _authorized_ids (frozenset) at session registration for O(1) lookup
- Refactor is_resource_allowed() from linear search to set membership check
- Add thread-safe locking to get_session_registry() singleton
- Cache _session_registry and _state_store references in orchestrator __init__
- Add asyncio.gather() for parallel resource building in AgentResourceBuilder
- Create shared test fixtures in tests/unit_tests/agent/conftest.py
- Update test files to import from shared conftest.py

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
huanghuoguoguo
2026-05-11 21:45:26 +08:00
parent d6b8f48e73
commit dc82fb584a
23 changed files with 4438 additions and 677 deletions

View File

@@ -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 预留,不执行平台动作。

View File

@@ -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 分支”两套执行体系。

View File

@@ -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',
]

View File

@@ -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]

View File

@@ -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
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

View File

@@ -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)
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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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'

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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'}

View File

@@ -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 (
<Card key={stage.name}>
<CardHeader>
@@ -413,6 +416,48 @@ export default function PipelineFormComponent({
</Card>
);
}
// 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 (
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={runnerConfigs[stage.name] || {}}
onSubmit={(values) => {
// 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());
}
}}
/>
</CardContent>
</Card>
);
}
}
// Box availability is exposed through ``systemContext.__system.box_available``