mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-03 20:44:36 +00:00
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:
@@ -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 预留,不执行平台动作。
|
||||
|
||||
@@ -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 分支”两套执行体系。
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
217
src/langbot/pkg/agent/runner/session_registry.py
Normal file
217
src/langbot/pkg/agent/runner/session_registry.py
Normal 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
|
||||
299
src/langbot/pkg/agent/runner/state_store.py
Normal file
299
src/langbot/pkg/agent/runner/state_store.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 = []
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
75
tests/unit_tests/agent/conftest.py
Normal file
75
tests/unit_tests/agent/conftest.py
Normal 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,
|
||||
}
|
||||
275
tests/unit_tests/agent/test_config_migration_full.py
Normal file
275
tests/unit_tests/agent/test_config_migration_full.py
Normal 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
|
||||
449
tests/unit_tests/agent/test_context_builder_params_state.py
Normal file
449
tests/unit_tests/agent/test_context_builder_params_state.py
Normal 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'
|
||||
1617
tests/unit_tests/agent/test_handler_auth.py
Normal file
1617
tests/unit_tests/agent/test_handler_auth.py
Normal file
File diff suppressed because it is too large
Load Diff
427
tests/unit_tests/agent/test_session_registry.py
Normal file
427
tests/unit_tests/agent/test_session_registry.py
Normal 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
|
||||
473
tests/unit_tests/agent/test_state_store.py
Normal file
473
tests/unit_tests/agent/test_state_store.py
Normal 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'}
|
||||
@@ -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``
|
||||
|
||||
Reference in New Issue
Block a user