feat(agent-runner): normalize binding config boundaries

This commit is contained in:
huanghuoguoguo
2026-06-02 15:40:57 +08:00
parent 93cd852061
commit 4d4ccfabd5
8 changed files with 185 additions and 108 deletions
@@ -234,7 +234,21 @@ LangBot 应提供事实源能力:
AgentRunner 可以读取这些能力,但不能被迫使用 LangBot 作为唯一记忆系统。 AgentRunner 可以读取这些能力,但不能被迫使用 LangBot 作为唯一记忆系统。
### 4.8 External harness resource projection ### 4.8 Prompt / Instruction Package(占位)
旧 Pipeline 入口目前可以把 preprocessing 后的有效 prompt 放进 adapter metadata
这是为了保持旧入口行为,不是长期协议。目标形态应是 Host 保存或生成一个
run-scoped instruction packagerunner 通过 Host API 拉取:
- Host 负责记录静态绑定 prompt、host hook / user plugin 产生的 instruction
fragment、来源和审计信息。
- `ctx.context.available_apis.prompt_get` 只表示拉取能力是否可用。
- Runner 拉取 instruction package 后,仍由 runner 自己决定如何与 history、RAG、
tool 结果、memory 和当前输入组装最终模型 prompt。
- Host 不实现通用 agentic prompt assembler,也不把 Pipeline adapter prompt 作为
长期业务输入契约。
### 4.9 External harness resource projection
Claude Code、Codex、Kimi Code 等外部 harness runner 可能不会直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源投影到自己的 harness 中执行。Host 侧仍要保持统一边界: Claude Code、Codex、Kimi Code 等外部 harness runner 可能不会直接调用 LangBot 的 model/tool loop,而是把 LangBot 事件和授权资源投影到自己的 harness 中执行。Host 侧仍要保持统一边界:
@@ -670,6 +670,8 @@ Pipeline adapter 负责:
- 从 Pipeline config 构造临时 AgentBinding。 - 从 Pipeline config 构造临时 AgentBinding。
- 从当前 runner binding config 构造 `ctx.config` - 从当前 runner binding config 构造 `ctx.config`
- 保留必要的 legacy adapter metadata,但不定义历史窗口、prompt 组装或 agentic context 策略。 - 保留必要的 legacy adapter metadata,但不定义历史窗口、prompt 组装或 agentic context 策略。
- 后续若需要传递 preprocessing / hook 后的有效指令,应通过 Host prompt/instruction
package pull API 暴露能力位和引用,而不是继续把 prompt 推入 `ctx.adapter.extra`
- 将 Query-only 字段放入 `adapter` - 将 Query-only 字段放入 `adapter`
Runner 不应长期依赖 `adapter`。新 runner 应只依赖 event-first context 和 Host APIs。 Runner 不应长期依赖 `adapter`。新 runner 应只依赖 event-first context 和 Host APIs。
@@ -1,4 +1,5 @@
"""Configuration migration for agent runner IDs.""" """Configuration migration for agent runner IDs."""
from __future__ import annotations from __future__ import annotations
import typing import typing
@@ -113,10 +114,33 @@ class ConfigMigration:
if old_runner_name: if old_runner_name:
old_config = ai_config.get(old_runner_name, {}) old_config = ai_config.get(old_runner_name, {})
if old_config: if old_config:
return old_config return ConfigMigration.normalize_runner_config_for_migration(runner_id, old_config)
return {} return {}
@staticmethod
def normalize_runner_config_for_migration(
runner_id: str,
runner_config: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Normalize released legacy runner config before storing binding config.
Runtime code should not carry aliases. This helper is intentionally used
only by config migration so AgentRunner implementations can consume the
current manifest-defined field names.
"""
normalized = dict(runner_config)
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
legacy_kb = normalized.pop('knowledge-base', None)
if 'knowledge-bases' not in normalized:
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
normalized['knowledge-bases'] = [legacy_kb]
elif legacy_kb is not None:
normalized['knowledge-bases'] = []
return normalized
@staticmethod @staticmethod
def get_old_runner_name(runner_id: str) -> str | None: def get_old_runner_name(runner_id: str) -> str | None:
"""Get old runner name from mapped runner ID. """Get old runner name from mapped runner ID.
@@ -188,6 +212,7 @@ class ConfigMigration:
if not resolved_config: if not resolved_config:
resolved_config = ConfigMigration.resolve_legacy_runner_config(pipeline_config, runner_id) resolved_config = ConfigMigration.resolve_legacy_runner_config(pipeline_config, runner_id)
if resolved_config: if resolved_config:
resolved_config = ConfigMigration.normalize_runner_config_for_migration(runner_id, resolved_config)
runner_configs[runner_id] = resolved_config runner_configs[runner_id] = resolved_config
# Remove old runner config block # Remove old runner config block
for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items(): for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
@@ -1,4 +1,5 @@
"""Agent run context builder for provisioning AgentRunContext envelopes.""" """Agent run context builder for provisioning AgentRunContext envelopes."""
from __future__ import annotations from __future__ import annotations
import uuid import uuid
@@ -19,6 +20,7 @@ DEFAULT_RUNNER_TIMEOUT_SECONDS = 300
class AgentTrigger(typing.TypedDict): class AgentTrigger(typing.TypedDict):
"""Agent trigger information.""" """Agent trigger information."""
type: str type: str
source: str # 'pipeline' or 'event_router' source: str # 'pipeline' or 'event_router'
timestamp: int | None timestamp: int | None
@@ -26,6 +28,7 @@ class AgentTrigger(typing.TypedDict):
class ConversationContext(typing.TypedDict): class ConversationContext(typing.TypedDict):
"""Conversation context.""" """Conversation context."""
conversation_id: str | None conversation_id: str | None
thread_id: str | None thread_id: str | None
launcher_type: str | None launcher_type: str | None
@@ -39,6 +42,7 @@ class ConversationContext(typing.TypedDict):
class AgentInput(typing.TypedDict): class AgentInput(typing.TypedDict):
"""Agent input.""" """Agent input."""
text: str | None text: str | None
contents: list[dict[str, typing.Any]] contents: list[dict[str, typing.Any]]
message_chain: dict[str, typing.Any] | None message_chain: dict[str, typing.Any] | None
@@ -47,6 +51,7 @@ class AgentInput(typing.TypedDict):
class AgentRunState(typing.TypedDict): class AgentRunState(typing.TypedDict):
"""Agent run state with 4 scopes.""" """Agent run state with 4 scopes."""
conversation: dict[str, typing.Any] conversation: dict[str, typing.Any]
actor: dict[str, typing.Any] actor: dict[str, typing.Any]
subject: dict[str, typing.Any] subject: dict[str, typing.Any]
@@ -58,6 +63,7 @@ class AgentRunState(typing.TypedDict):
class ModelResource(typing.TypedDict): class ModelResource(typing.TypedDict):
"""Model resource payload.""" """Model resource payload."""
model_id: str model_id: str
model_type: str | None model_type: str | None
provider: str | None provider: str | None
@@ -65,6 +71,7 @@ class ModelResource(typing.TypedDict):
class ToolResource(typing.TypedDict): class ToolResource(typing.TypedDict):
"""Tool resource payload.""" """Tool resource payload."""
tool_name: str tool_name: str
tool_type: str | None tool_type: str | None
description: str | None description: str | None
@@ -72,6 +79,7 @@ class ToolResource(typing.TypedDict):
class KnowledgeBaseResource(typing.TypedDict): class KnowledgeBaseResource(typing.TypedDict):
"""Knowledge base resource payload.""" """Knowledge base resource payload."""
kb_id: str kb_id: str
kb_name: str | None kb_name: str | None
kb_type: str | None kb_type: str | None
@@ -79,6 +87,7 @@ class KnowledgeBaseResource(typing.TypedDict):
class FileResource(typing.TypedDict): class FileResource(typing.TypedDict):
"""File resource payload.""" """File resource payload."""
file_id: str file_id: str
file_name: str | None file_name: str | None
mime_type: str | None mime_type: str | None
@@ -87,12 +96,14 @@ class FileResource(typing.TypedDict):
class StorageResource(typing.TypedDict): class StorageResource(typing.TypedDict):
"""Storage resource payload.""" """Storage resource payload."""
plugin_storage: bool plugin_storage: bool
workspace_storage: bool workspace_storage: bool
class AgentResources(typing.TypedDict): class AgentResources(typing.TypedDict):
"""Agent resources payload.""" """Agent resources payload."""
models: list[ModelResource] models: list[ModelResource]
tools: list[ToolResource] tools: list[ToolResource]
knowledge_bases: list[KnowledgeBaseResource] knowledge_bases: list[KnowledgeBaseResource]
@@ -103,6 +114,7 @@ class AgentResources(typing.TypedDict):
class AgentRuntimeContext(typing.TypedDict): class AgentRuntimeContext(typing.TypedDict):
"""Agent runtime context.""" """Agent runtime context."""
langbot_version: str | None langbot_version: str | None
sdk_protocol_version: str sdk_protocol_version: str
query_id: int | None query_id: int | None
@@ -119,6 +131,7 @@ class AgentRunContextPayload(typing.TypedDict):
Note: The 'config' field contains the binding config from ai.runner_config[runner_id], 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). which is Pipeline's configuration for this specific runner binding (not plugin instance config).
""" """
run_id: str run_id: str
trigger: AgentTrigger trigger: AgentTrigger
conversation: ConversationContext | None conversation: ConversationContext | None
@@ -237,7 +250,9 @@ class AgentRunContextBuilder:
'text': event.input.text, 'text': event.input.text,
'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents], 'contents': [c.model_dump(mode='json') if hasattr(c, 'model_dump') else c for c in event.input.contents],
'message_chain': event.input.message_chain, 'message_chain': event.input.message_chain,
'attachments': [a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments], 'attachments': [
a.model_dump(mode='json') if hasattr(a, 'model_dump') else a for a in event.input.attachments
],
} }
# Build context access (no history inlined by default for Protocol v1) # Build context access (no history inlined by default for Protocol v1)
@@ -245,9 +260,7 @@ class AgentRunContextBuilder:
context_access = await self._build_context_access(event, descriptor, binding) context_access = await self._build_context_access(event, descriptor, binding)
# Build state snapshot from persistent state store (event-first Protocol v1) # Build state snapshot from persistent state store (event-first Protocol v1)
persistent_state_store = get_persistent_state_store( persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
self.ap.persistence_mgr.get_db_engine()
)
state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor) state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor)
# Build runtime context # Build runtime context
@@ -261,6 +274,10 @@ class AgentRunContextBuilder:
'bot_id': event.bot_id, 'bot_id': event.bot_id,
'workspace_id': event.workspace_id, 'workspace_id': event.workspace_id,
'streaming_supported': event.delivery.supports_streaming, 'streaming_supported': event.delivery.supports_streaming,
'model_context_window_tokens': None,
# TODO(model-info): populate model_context_window_tokens after
# LiteLLM/model metadata lands. Runners fall back to their
# binding config until Host can provide the real window.
}, },
} }
@@ -375,6 +392,7 @@ class AgentRunContextBuilder:
if conversation_id: if conversation_id:
try: try:
from .transcript_store import TranscriptStore from .transcript_store import TranscriptStore
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine()) store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
latest_cursor = await store.get_latest_cursor(conversation_id) latest_cursor = await store.get_latest_cursor(conversation_id)
@@ -406,5 +424,6 @@ class AgentRunContextBuilder:
'artifact_read': artifact_read_enabled, 'artifact_read': artifact_read_enabled,
'state': state_enabled, 'state': state_enabled,
'storage': True, 'storage': True,
'prompt_get': False,
}, },
} }
@@ -31,6 +31,21 @@ def is_plugin_runner_id(runner_id: str) -> bool:
return runner_id.startswith('plugin:') return runner_id.startswith('plugin:')
def normalize_runner_config_for_migration(runner_id: str, runner_config: dict) -> dict:
"""Normalize released legacy runner fields before storing binding config."""
normalized = dict(runner_config)
if runner_id == OLD_RUNNER_TO_PLUGIN_RUNNER_ID['local-agent']:
legacy_kb = normalized.pop('knowledge-base', None)
if 'knowledge-bases' not in normalized:
if isinstance(legacy_kb, str) and legacy_kb and legacy_kb not in {'__none__', '__none'}:
normalized['knowledge-bases'] = [legacy_kb]
elif legacy_kb is not None:
normalized['knowledge-bases'] = []
return normalized
def migrate_pipeline_config(config: dict) -> dict: def migrate_pipeline_config(config: dict) -> dict:
"""Migrate pipeline config to new format.""" """Migrate pipeline config to new format."""
new_config = dict(config) new_config = dict(config)
@@ -44,7 +59,13 @@ def migrate_pipeline_config(config: dict) -> dict:
# Check for new format first # Check for new format first
runner_id = runner_config.get('id') runner_id = runner_config.get('id')
if runner_id and is_plugin_runner_id(runner_id): if runner_id and is_plugin_runner_id(runner_id):
# Already in new format, no need to migrate if runner_id in runner_configs:
runner_configs[runner_id] = normalize_runner_config_for_migration(
runner_id,
runner_configs[runner_id],
)
ai_config['runner_config'] = runner_configs
new_config['ai'] = ai_config
return new_config return new_config
# Check for old format # Check for old format
@@ -67,14 +88,14 @@ def migrate_pipeline_config(config: dict) -> dict:
if old_runner_name in ai_config: if old_runner_name in ai_config:
old_runner_config = ai_config[old_runner_name] old_runner_config = ai_config[old_runner_name]
if old_runner_config: if old_runner_config:
runner_configs[runner_id] = old_runner_config runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, old_runner_config)
# Remove old config block after migration # Remove old config block after migration
del ai_config[old_runner_name] del ai_config[old_runner_name]
# Also check if runner_id has config under other old name formats # 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(): for old_name, mapped_id in OLD_RUNNER_TO_PLUGIN_RUNNER_ID.items():
if mapped_id == runner_id and old_name in ai_config: if mapped_id == runner_id and old_name in ai_config:
runner_configs[runner_id] = ai_config[old_name] runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, ai_config[old_name])
# Remove old config block after migration # Remove old config block after migration
del ai_config[old_name] del ai_config[old_name]
@@ -111,7 +132,7 @@ def upgrade() -> None:
if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True): if json.dumps(config, sort_keys=True) != json.dumps(migrated_config, sort_keys=True):
conn.execute( conn.execute(
sa.text('UPDATE pipelines SET config = :config WHERE uuid = :uuid'), sa.text('UPDATE pipelines SET config = :config WHERE uuid = :uuid'),
{'config': json.dumps(migrated_config), 'uuid': pipeline_uuid} {'config': json.dumps(migrated_config), 'uuid': pipeline_uuid},
) )
except Exception: except Exception:
# Skip invalid configs # Skip invalid configs
@@ -121,4 +142,4 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
"""Downgrade is not supported for data migration.""" """Downgrade is not supported for data migration."""
# No downgrade - keep configs in new format # No downgrade - keep configs in new format
pass pass
@@ -1,4 +1,5 @@
"""Tests for agent runner config migration.""" """Tests for agent runner config migration."""
from __future__ import annotations from __future__ import annotations
@@ -155,6 +156,7 @@ class TestResolveRunnerConfig:
'local-agent': { 'local-agent': {
'model': 'uuid-123', 'model': 'uuid-123',
'max_round': 10, 'max_round': 10,
'knowledge-base': 'kb-123',
}, },
}, },
} }
@@ -163,7 +165,8 @@ class TestResolveRunnerConfig:
pipeline_config, pipeline_config,
'plugin:langbot/local-agent/default', 'plugin:langbot/local-agent/default',
) )
assert config == {'model': 'uuid-123', 'max_round': 10} assert config == {'model': 'uuid-123', 'max_round': 10, 'knowledge-bases': ['kb-123']}
assert 'knowledge-base' not in config
def test_resolve_no_config(self): def test_resolve_no_config(self):
"""Resolve runner config when not found.""" """Resolve runner config when not found."""
@@ -21,6 +21,7 @@ class TestMigratePipelineConfig:
'local-agent': { 'local-agent': {
'model': {'primary': 'model-uuid', 'fallbacks': []}, 'model': {'primary': 'model-uuid', 'fallbacks': []},
'max-round': 10, 'max-round': 10,
'knowledge-base': 'kb-uuid',
'prompt': [{'role': 'system', 'content': 'Hello'}], 'prompt': [{'role': 'system', 'content': 'Hello'}],
}, },
}, },
@@ -35,6 +36,8 @@ class TestMigratePipelineConfig:
# Config should be in runner_config # Config should be in runner_config
assert 'plugin:langbot/local-agent/default' in migrated['ai']['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 assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['knowledge-bases'] == ['kb-uuid']
assert 'knowledge-base' not in migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
# Expire-time preserved # Expire-time preserved
assert migrated['ai']['runner']['expire-time'] == 0 assert migrated['ai']['runner']['expire-time'] == 0
@@ -85,6 +88,26 @@ class TestMigratePipelineConfig:
assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default' assert migrated['ai']['runner']['id'] == 'plugin:langbot/local-agent/default'
assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10 assert migrated['ai']['runner_config']['plugin:langbot/local-agent/default']['max-round'] == 10
def test_new_format_local_agent_config_normalizes_legacy_kb_key(self):
"""Migration should normalize legacy KB aliases before runtime."""
config = {
'ai': {
'runner': {
'id': 'plugin:langbot/local-agent/default',
},
'runner_config': {
'plugin:langbot/local-agent/default': {
'knowledge-base': 'kb-legacy',
},
},
},
}
migrated = ConfigMigration.migrate_pipeline_config(config)
runner_config = migrated['ai']['runner_config']['plugin:langbot/local-agent/default']
assert runner_config == {'knowledge-bases': ['kb-legacy']}
def test_migrate_all_old_runners(self): def test_migrate_all_old_runners(self):
"""All old runner names should be migrated.""" """All old runner names should be migrated."""
old_runners = [ old_runners = [
+66 -96
View File
@@ -10,6 +10,7 @@ Authorization paths:
1. AgentRunner calls: has run_id, validates against session_registry 1. AgentRunner calls: has run_id, validates against session_registry
2. Regular plugin calls: no run_id, unscoped plugin action path 2. Regular plugin calls: no run_id, unscoped plugin action path
""" """
from __future__ import annotations from __future__ import annotations
import pytest import pytest
@@ -26,6 +27,7 @@ from .conftest import make_resources
class MockModel: class MockModel:
"""Mock LLM model for testing.""" """Mock LLM model for testing."""
def __init__(self, uuid: str): def __init__(self, uuid: str):
self.uuid = uuid self.uuid = uuid
self.provider = MagicMock() self.provider = MagicMock()
@@ -34,6 +36,7 @@ class MockModel:
class MockEmbeddingModel: class MockEmbeddingModel:
"""Mock embedding model for testing.""" """Mock embedding model for testing."""
def __init__(self, uuid: str): def __init__(self, uuid: str):
self.uuid = uuid self.uuid = uuid
self.provider = MagicMock() self.provider = MagicMock()
@@ -41,6 +44,7 @@ class MockEmbeddingModel:
class MockKnowledgeBase: class MockKnowledgeBase:
"""Mock knowledge base for testing.""" """Mock knowledge base for testing."""
def __init__(self, uuid: str, name: str = 'KB'): def __init__(self, uuid: str, name: str = 'KB'):
self.knowledge_base_entity = MagicMock() self.knowledge_base_entity = MagicMock()
self.knowledge_base_entity.description = f'{name} description' self.knowledge_base_entity.description = f'{name} description'
@@ -57,6 +61,7 @@ class MockKnowledgeBase:
class MockQuery: class MockQuery:
"""Mock query for testing.""" """Mock query for testing."""
def __init__(self, query_id: int = 1): def __init__(self, query_id: int = 1):
self.query_id = query_id self.query_id = query_id
self.session = MagicMock() self.session = MagicMock()
@@ -81,6 +86,7 @@ class MockQuery:
class MockApplication: class MockApplication:
"""Mock Application for testing.""" """Mock Application for testing."""
def __init__(self): def __init__(self):
self.logger = MagicMock() self.logger = MagicMock()
self.logger.debug = MagicMock() self.logger.debug = MagicMock()
@@ -125,6 +131,7 @@ class FakeAgentRunnerRegistry:
class MockConnection: class MockConnection:
"""Mock connection for testing.""" """Mock connection for testing."""
pass pass
@@ -155,6 +162,7 @@ class TestPipelineKnowledgeBaseScope:
class MockDisconnectCallback: class MockDisconnectCallback:
"""Mock disconnect callback for testing.""" """Mock disconnect callback for testing."""
async def __call__(self): async def __call__(self):
return True return True
@@ -489,25 +497,25 @@ class TestHandlerAuthorizationErrorMessages:
def test_model_not_authorized_error_message(self): def test_model_not_authorized_error_message(self):
"""Error message should mention model not authorized.""" """Error message should mention model not authorized."""
expected_msg = "Model model_999 is not authorized for this agent run" expected_msg = 'Model model_999 is not authorized for this agent run'
assert 'not authorized' in expected_msg assert 'not authorized' in expected_msg
assert 'model_999' in expected_msg assert 'model_999' in expected_msg
def test_tool_not_authorized_error_message(self): def test_tool_not_authorized_error_message(self):
"""Error message should mention tool not authorized.""" """Error message should mention tool not authorized."""
expected_msg = "Tool image_gen is not authorized for this agent run" expected_msg = 'Tool image_gen is not authorized for this agent run'
assert 'not authorized' in expected_msg assert 'not authorized' in expected_msg
assert 'image_gen' in expected_msg assert 'image_gen' in expected_msg
def test_kb_not_authorized_error_message(self): def test_kb_not_authorized_error_message(self):
"""Error message should mention kb not authorized.""" """Error message should mention kb not authorized."""
expected_msg = "Knowledge base kb_999 is not authorized for this agent run" expected_msg = 'Knowledge base kb_999 is not authorized for this agent run'
assert 'not authorized' in expected_msg assert 'not authorized' in expected_msg
assert 'kb_999' in expected_msg assert 'kb_999' in expected_msg
def test_session_not_found_error_message(self): def test_session_not_found_error_message(self):
"""Error message should mention session not found.""" """Error message should mention session not found."""
expected_msg = "Run session run_xyz not found or expired" expected_msg = 'Run session run_xyz not found or expired'
assert 'not found' in expected_msg assert 'not found' in expected_msg
assert 'run_xyz' in expected_msg assert 'run_xyz' in expected_msg
@@ -585,8 +593,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
# Should resolve to plugin:langbot/local-agent/default # Should resolve to plugin:langbot/local-agent/default
assert 'local-agent' in runner_id assert 'local-agent' in runner_id
def test_retrieve_kb_fix_backward_compat_knowledge_base(self): def test_retrieve_kb_legacy_single_key_is_migration_only(self):
"""Fix should handle backward compat for old 'knowledge-base' field.""" """Old singular knowledge-base config is normalized before runtime."""
from langbot.pkg.agent.runner.config_migration import ConfigMigration from langbot.pkg.agent.runner.config_migration import ConfigMigration
pipeline_config = { pipeline_config = {
@@ -602,17 +610,11 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
}, },
} }
runner_id = ConfigMigration.resolve_runner_id(pipeline_config) migrated = ConfigMigration.migrate_pipeline_config(pipeline_config)
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id) runner_id = ConfigMigration.resolve_runner_id(migrated)
runner_config = ConfigMigration.resolve_runner_config(migrated, runner_id)
# Handler.py checks both knowledge-bases and knowledge-base assert runner_config == {'knowledge-bases': ['kb_single']}
allowed_kbs = runner_config.get('knowledge-bases', [])
if not allowed_kbs:
old_kb = runner_config.get('knowledge-base', '')
if old_kb and old_kb != '__none__':
allowed_kbs = [old_kb]
assert 'kb_single' in allowed_kbs
class TestHandlerActionAuthorization: class TestHandlerActionAuthorization:
@@ -1059,10 +1061,12 @@ class TestResourceTypeValidation:
async def test_model_resource_validation(self): async def test_model_resource_validation(self):
"""Model resource: correct model_id validation.""" """Model resource: correct model_id validation."""
registry = AgentRunSessionRegistry() registry = AgentRunSessionRegistry()
resources = make_resources(models=[ resources = make_resources(
{'model_id': 'model_001'}, models=[
{'model_id': 'model_002'}, {'model_id': 'model_001'},
]) {'model_id': 'model_002'},
]
)
await registry.register( await registry.register(
run_id='run_model_validation', run_id='run_model_validation',
@@ -1087,10 +1091,12 @@ class TestResourceTypeValidation:
async def test_tool_resource_validation(self): async def test_tool_resource_validation(self):
"""Tool resource: correct tool_name validation.""" """Tool resource: correct tool_name validation."""
registry = AgentRunSessionRegistry() registry = AgentRunSessionRegistry()
resources = make_resources(tools=[ resources = make_resources(
{'tool_name': 'web_search'}, tools=[
{'tool_name': 'image_gen'}, {'tool_name': 'web_search'},
]) {'tool_name': 'image_gen'},
]
)
await registry.register( await registry.register(
run_id='run_tool_validation', run_id='run_tool_validation',
@@ -1115,10 +1121,12 @@ class TestResourceTypeValidation:
async def test_knowledge_base_resource_validation(self): async def test_knowledge_base_resource_validation(self):
"""Knowledge base resource: correct kb_id validation.""" """Knowledge base resource: correct kb_id validation."""
registry = AgentRunSessionRegistry() registry = AgentRunSessionRegistry()
resources = make_resources(knowledge_bases=[ resources = make_resources(
{'kb_id': 'kb_001'}, knowledge_bases=[
{'kb_id': 'kb_002'}, {'kb_id': 'kb_001'},
]) {'kb_id': 'kb_002'},
]
)
await registry.register( await registry.register(
run_id='run_kb_validation', run_id='run_kb_validation',
@@ -1262,6 +1270,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization returns session when resource is authorized.""" """_validate_run_authorization returns session when resource is authorized."""
# Use global session registry (same as _validate_run_authorization) # Use global session registry (same as _validate_run_authorization)
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}]) resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1280,12 +1289,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock() mock_ap = MagicMock()
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_validate_test_helper', 'model', 'model_001', mock_ap)
'run_validate_test_helper',
'model',
'model_001',
mock_ap
)
# Should return session, no error # Should return session, no error
assert session is not None assert session is not None
@@ -1303,12 +1307,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
mock_ap.logger.warning = MagicMock() mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_nonexistent_helper', 'model', 'model_001', mock_ap)
'run_nonexistent_helper',
'model',
'model_001',
mock_ap
)
# Should return no session, error response # Should return no session, error response
assert session is None assert session is None
@@ -1321,6 +1320,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization returns error when resource not allowed.""" """_validate_run_authorization returns error when resource not allowed."""
# Use global session registry # Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}]) resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1342,7 +1342,7 @@ class TestValidateRunAuthorizationHelper:
'run_unauthorized_helper', 'run_unauthorized_helper',
'model', 'model',
'model_999', # Not in resources 'model_999', # Not in resources
mock_ap mock_ap,
) )
# Should return no session, error response # Should return no session, error response
@@ -1358,6 +1358,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization works for tool resource type.""" """_validate_run_authorization works for tool resource type."""
# Use global session registry # Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(tools=[{'tool_name': 'web_search'}]) resources = make_resources(tools=[{'tool_name': 'web_search'}])
@@ -1374,12 +1375,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock() mock_ap = MagicMock()
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_tool_test_helper', 'tool', 'web_search', mock_ap)
'run_tool_test_helper',
'tool',
'web_search',
mock_ap
)
assert session is not None assert session is not None
assert error is None assert error is None
@@ -1391,6 +1387,7 @@ class TestValidateRunAuthorizationHelper:
"""_validate_run_authorization works for knowledge_base resource type.""" """_validate_run_authorization works for knowledge_base resource type."""
# Use global session registry # Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}]) resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
@@ -1407,12 +1404,7 @@ class TestValidateRunAuthorizationHelper:
mock_ap = MagicMock() mock_ap = MagicMock()
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_kb_test_helper', 'knowledge_base', 'kb_001', mock_ap)
'run_kb_test_helper',
'knowledge_base',
'kb_001',
mock_ap
)
assert session is not None assert session is not None
assert error is None assert error is None
@@ -1545,6 +1537,7 @@ class TestFilesResourcePermission:
async def test_files_resource_type_now_implemented(self): async def test_files_resource_type_now_implemented(self):
"""'files' resource type is now implemented in is_resource_allowed.""" """'files' resource type is now implemented in is_resource_allowed."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(files=[{'file_id': 'file_001'}]) resources = make_resources(files=[{'file_id': 'file_001'}])
@@ -1577,6 +1570,7 @@ class TestRealActionHandlerSimulation:
"""Simulate INVOKE_LLM action handler authorization flow.""" """Simulate INVOKE_LLM action handler authorization flow."""
# Use global session registry # Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}]) resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1595,12 +1589,7 @@ class TestRealActionHandlerSimulation:
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
# Step 1: Validate authorization # Step 1: Validate authorization
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_invoke_llm_flow_sim', 'model', 'model_001', mock_ap)
'run_invoke_llm_flow_sim',
'model',
'model_001',
mock_ap
)
# Should pass authorization # Should pass authorization
assert session is not None assert session is not None
@@ -1615,6 +1604,7 @@ class TestRealActionHandlerSimulation:
"""Simulate INVOKE_LLM handler rejecting unauthorized model.""" """Simulate INVOKE_LLM handler rejecting unauthorized model."""
# Use global session registry # Use global session registry
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}]) resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1633,12 +1623,7 @@ class TestRealActionHandlerSimulation:
mock_ap.logger.warning = MagicMock() mock_ap.logger.warning = MagicMock()
# Try to access unauthorized model # Try to access unauthorized model
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_reject_model_sim', 'model', 'model_999', mock_ap)
'run_reject_model_sim',
'model',
'model_999',
mock_ap
)
# Should reject # Should reject
assert session is None assert session is None
@@ -1659,10 +1644,7 @@ class TestRealActionHandlerSimulation:
# Try to validate with non-existent run_id # Try to validate with non-existent run_id
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization(
'run_nonexistent_session_flow', 'run_nonexistent_session_flow', 'model', 'model_001', mock_ap
'model',
'model_001',
mock_ap
) )
# Should return error # Should return error
@@ -1683,6 +1665,7 @@ class TestStoragePermissionValidation:
async def test_plugin_storage_allowed_when_permitted(self): async def test_plugin_storage_allowed_when_permitted(self):
"""_validate_run_authorization allows 'plugin' storage when permitted.""" """_validate_run_authorization allows 'plugin' storage when permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False}) resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False})
@@ -1699,12 +1682,7 @@ class TestStoragePermissionValidation:
mock_ap = MagicMock() mock_ap = MagicMock()
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_plugin_storage_auth', 'storage', 'plugin', mock_ap)
'run_plugin_storage_auth',
'storage',
'plugin',
mock_ap
)
assert session is not None assert session is not None
assert error is None assert error is None
@@ -1715,6 +1693,7 @@ class TestStoragePermissionValidation:
async def test_plugin_storage_denied_when_not_permitted(self): async def test_plugin_storage_denied_when_not_permitted(self):
"""_validate_run_authorization denies 'plugin' storage when not permitted.""" """_validate_run_authorization denies 'plugin' storage when not permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
@@ -1732,12 +1711,7 @@ class TestStoragePermissionValidation:
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
mock_ap.logger.warning = MagicMock() mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_plugin_storage_denied', 'storage', 'plugin', mock_ap)
'run_plugin_storage_denied',
'storage',
'plugin',
mock_ap
)
assert session is None assert session is None
assert error is not None assert error is not None
@@ -1749,6 +1723,7 @@ class TestStoragePermissionValidation:
async def test_workspace_storage_allowed_when_permitted(self): async def test_workspace_storage_allowed_when_permitted(self):
"""_validate_run_authorization allows 'workspace' storage when permitted.""" """_validate_run_authorization allows 'workspace' storage when permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True}) resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True})
@@ -1766,10 +1741,7 @@ class TestStoragePermissionValidation:
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization(
'run_workspace_storage_auth', 'run_workspace_storage_auth', 'storage', 'workspace', mock_ap
'storage',
'workspace',
mock_ap
) )
assert session is not None assert session is not None
@@ -1781,6 +1753,7 @@ class TestStoragePermissionValidation:
async def test_workspace_storage_denied_when_not_permitted(self): async def test_workspace_storage_denied_when_not_permitted(self):
"""_validate_run_authorization denies 'workspace' storage when not permitted.""" """_validate_run_authorization denies 'workspace' storage when not permitted."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False}) resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
@@ -1799,10 +1772,7 @@ class TestStoragePermissionValidation:
mock_ap.logger.warning = MagicMock() mock_ap.logger.warning = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization(
'run_workspace_storage_denied', 'run_workspace_storage_denied', 'storage', 'workspace', mock_ap
'storage',
'workspace',
mock_ap
) )
assert session is None assert session is None
@@ -1823,6 +1793,7 @@ class TestFilePermissionValidation:
async def test_file_allowed_when_in_resources(self): async def test_file_allowed_when_in_resources(self):
"""_validate_run_authorization allows file when in resources.""" """_validate_run_authorization allows file when in resources."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(files=[{'file_id': 'file_001'}]) resources = make_resources(files=[{'file_id': 'file_001'}])
@@ -1839,12 +1810,7 @@ class TestFilePermissionValidation:
mock_ap = MagicMock() mock_ap = MagicMock()
mock_ap.logger = MagicMock() mock_ap.logger = MagicMock()
session, error = await _validate_run_authorization( session, error = await _validate_run_authorization('run_file_auth', 'file', 'file_001', mock_ap)
'run_file_auth',
'file',
'file_001',
mock_ap
)
assert session is not None assert session is not None
assert error is None assert error is None
@@ -1855,6 +1821,7 @@ class TestFilePermissionValidation:
async def test_file_denied_when_not_in_resources(self): async def test_file_denied_when_not_in_resources(self):
"""_validate_run_authorization denies file when not in resources.""" """_validate_run_authorization denies file when not in resources."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(files=[{'file_id': 'file_001'}]) resources = make_resources(files=[{'file_id': 'file_001'}])
@@ -1876,7 +1843,7 @@ class TestFilePermissionValidation:
'run_file_denied', 'run_file_denied',
'file', 'file',
'file_999', # Not in resources 'file_999', # Not in resources
mock_ap mock_ap,
) )
assert session is None assert session is None
@@ -1898,6 +1865,7 @@ class TestCallerPluginIdentityValidation:
async def test_same_plugin_identity_allowed(self): async def test_same_plugin_identity_allowed(self):
"""_validate_run_authorization allows when caller matches session.""" """_validate_run_authorization allows when caller matches session."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}]) resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1931,6 +1899,7 @@ class TestCallerPluginIdentityValidation:
async def test_different_plugin_identity_denied(self): async def test_different_plugin_identity_denied(self):
"""_validate_run_authorization denies when caller differs from session.""" """_validate_run_authorization denies when caller differs from session."""
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}]) resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -1967,6 +1936,7 @@ class TestCallerPluginIdentityValidation:
"""_validate_run_authorization allows when caller_plugin_identity not provided.""" """_validate_run_authorization allows when caller_plugin_identity not provided."""
# Unscoped plugin path: if caller_plugin_identity is None, skip identity check # Unscoped plugin path: if caller_plugin_identity is None, skip identity check
from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.session_registry import get_session_registry
registry = get_session_registry() registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}]) resources = make_resources(models=[{'model_id': 'model_001'}])