mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 04:54:36 +00:00
feat(agent-runner): normalize binding config boundaries
This commit is contained in:
@@ -234,7 +234,21 @@ LangBot 应提供事实源能力:
|
||||
|
||||
AgentRunner 可以读取这些能力,但不能被迫使用 LangBot 作为唯一记忆系统。
|
||||
|
||||
### 4.8 External harness resource projection
|
||||
### 4.8 Prompt / Instruction Package(占位)
|
||||
|
||||
旧 Pipeline 入口目前可以把 preprocessing 后的有效 prompt 放进 adapter metadata,
|
||||
这是为了保持旧入口行为,不是长期协议。目标形态应是 Host 保存或生成一个
|
||||
run-scoped instruction package,runner 通过 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 侧仍要保持统一边界:
|
||||
|
||||
|
||||
@@ -670,6 +670,8 @@ Pipeline adapter 负责:
|
||||
- 从 Pipeline config 构造临时 AgentBinding。
|
||||
- 从当前 runner binding config 构造 `ctx.config`。
|
||||
- 保留必要的 legacy adapter metadata,但不定义历史窗口、prompt 组装或 agentic context 策略。
|
||||
- 后续若需要传递 preprocessing / hook 后的有效指令,应通过 Host prompt/instruction
|
||||
package pull API 暴露能力位和引用,而不是继续把 prompt 推入 `ctx.adapter.extra`。
|
||||
- 将 Query-only 字段放入 `adapter`。
|
||||
|
||||
Runner 不应长期依赖 `adapter`。新 runner 应只依赖 event-first context 和 Host APIs。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Configuration migration for agent runner IDs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
@@ -113,10 +114,33 @@ class ConfigMigration:
|
||||
if old_runner_name:
|
||||
old_config = ai_config.get(old_runner_name, {})
|
||||
if old_config:
|
||||
return old_config
|
||||
return ConfigMigration.normalize_runner_config_for_migration(runner_id, old_config)
|
||||
|
||||
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
|
||||
def get_old_runner_name(runner_id: str) -> str | None:
|
||||
"""Get old runner name from mapped runner ID.
|
||||
@@ -188,6 +212,7 @@ class ConfigMigration:
|
||||
if not resolved_config:
|
||||
resolved_config = ConfigMigration.resolve_legacy_runner_config(pipeline_config, runner_id)
|
||||
if resolved_config:
|
||||
resolved_config = ConfigMigration.normalize_runner_config_for_migration(runner_id, resolved_config)
|
||||
runner_configs[runner_id] = resolved_config
|
||||
# Remove old runner config block
|
||||
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."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -19,6 +20,7 @@ DEFAULT_RUNNER_TIMEOUT_SECONDS = 300
|
||||
|
||||
class AgentTrigger(typing.TypedDict):
|
||||
"""Agent trigger information."""
|
||||
|
||||
type: str
|
||||
source: str # 'pipeline' or 'event_router'
|
||||
timestamp: int | None
|
||||
@@ -26,6 +28,7 @@ class AgentTrigger(typing.TypedDict):
|
||||
|
||||
class ConversationContext(typing.TypedDict):
|
||||
"""Conversation context."""
|
||||
|
||||
conversation_id: str | None
|
||||
thread_id: str | None
|
||||
launcher_type: str | None
|
||||
@@ -39,6 +42,7 @@ class ConversationContext(typing.TypedDict):
|
||||
|
||||
class AgentInput(typing.TypedDict):
|
||||
"""Agent input."""
|
||||
|
||||
text: str | None
|
||||
contents: list[dict[str, typing.Any]]
|
||||
message_chain: dict[str, typing.Any] | None
|
||||
@@ -47,6 +51,7 @@ class AgentInput(typing.TypedDict):
|
||||
|
||||
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]
|
||||
@@ -58,6 +63,7 @@ class AgentRunState(typing.TypedDict):
|
||||
|
||||
class ModelResource(typing.TypedDict):
|
||||
"""Model resource payload."""
|
||||
|
||||
model_id: str
|
||||
model_type: str | None
|
||||
provider: str | None
|
||||
@@ -65,6 +71,7 @@ class ModelResource(typing.TypedDict):
|
||||
|
||||
class ToolResource(typing.TypedDict):
|
||||
"""Tool resource payload."""
|
||||
|
||||
tool_name: str
|
||||
tool_type: str | None
|
||||
description: str | None
|
||||
@@ -72,6 +79,7 @@ class ToolResource(typing.TypedDict):
|
||||
|
||||
class KnowledgeBaseResource(typing.TypedDict):
|
||||
"""Knowledge base resource payload."""
|
||||
|
||||
kb_id: str
|
||||
kb_name: str | None
|
||||
kb_type: str | None
|
||||
@@ -79,6 +87,7 @@ class KnowledgeBaseResource(typing.TypedDict):
|
||||
|
||||
class FileResource(typing.TypedDict):
|
||||
"""File resource payload."""
|
||||
|
||||
file_id: str
|
||||
file_name: str | None
|
||||
mime_type: str | None
|
||||
@@ -87,12 +96,14 @@ class FileResource(typing.TypedDict):
|
||||
|
||||
class StorageResource(typing.TypedDict):
|
||||
"""Storage resource payload."""
|
||||
|
||||
plugin_storage: bool
|
||||
workspace_storage: bool
|
||||
|
||||
|
||||
class AgentResources(typing.TypedDict):
|
||||
"""Agent resources payload."""
|
||||
|
||||
models: list[ModelResource]
|
||||
tools: list[ToolResource]
|
||||
knowledge_bases: list[KnowledgeBaseResource]
|
||||
@@ -103,6 +114,7 @@ class AgentResources(typing.TypedDict):
|
||||
|
||||
class AgentRuntimeContext(typing.TypedDict):
|
||||
"""Agent runtime context."""
|
||||
|
||||
langbot_version: str | None
|
||||
sdk_protocol_version: str
|
||||
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],
|
||||
which is Pipeline's configuration for this specific runner binding (not plugin instance config).
|
||||
"""
|
||||
|
||||
run_id: str
|
||||
trigger: AgentTrigger
|
||||
conversation: ConversationContext | None
|
||||
@@ -237,7 +250,9 @@ class AgentRunContextBuilder:
|
||||
'text': event.input.text,
|
||||
'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,
|
||||
'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)
|
||||
@@ -245,9 +260,7 @@ class AgentRunContextBuilder:
|
||||
context_access = await self._build_context_access(event, descriptor, binding)
|
||||
|
||||
# Build state snapshot from persistent state store (event-first Protocol v1)
|
||||
persistent_state_store = get_persistent_state_store(
|
||||
self.ap.persistence_mgr.get_db_engine()
|
||||
)
|
||||
persistent_state_store = get_persistent_state_store(self.ap.persistence_mgr.get_db_engine())
|
||||
state: AgentRunState = await persistent_state_store.build_snapshot_from_event(event, binding, descriptor)
|
||||
|
||||
# Build runtime context
|
||||
@@ -261,6 +274,10 @@ class AgentRunContextBuilder:
|
||||
'bot_id': event.bot_id,
|
||||
'workspace_id': event.workspace_id,
|
||||
'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:
|
||||
try:
|
||||
from .transcript_store import TranscriptStore
|
||||
|
||||
store = TranscriptStore(self.ap.persistence_mgr.get_db_engine())
|
||||
|
||||
latest_cursor = await store.get_latest_cursor(conversation_id)
|
||||
@@ -406,5 +424,6 @@ class AgentRunContextBuilder:
|
||||
'artifact_read': artifact_read_enabled,
|
||||
'state': state_enabled,
|
||||
'storage': True,
|
||||
'prompt_get': False,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -31,6 +31,21 @@ def is_plugin_runner_id(runner_id: str) -> bool:
|
||||
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:
|
||||
"""Migrate pipeline config to new format."""
|
||||
new_config = dict(config)
|
||||
@@ -44,7 +59,13 @@ def migrate_pipeline_config(config: dict) -> dict:
|
||||
# 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
|
||||
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
|
||||
|
||||
# Check for old format
|
||||
@@ -67,14 +88,14 @@ def migrate_pipeline_config(config: dict) -> dict:
|
||||
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
|
||||
runner_configs[runner_id] = normalize_runner_config_for_migration(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]
|
||||
runner_configs[runner_id] = normalize_runner_config_for_migration(runner_id, ai_config[old_name])
|
||||
# Remove old config block after migration
|
||||
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):
|
||||
conn.execute(
|
||||
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:
|
||||
# Skip invalid configs
|
||||
@@ -121,4 +142,4 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
"""Downgrade is not supported for data migration."""
|
||||
# No downgrade - keep configs in new format
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for agent runner config migration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -155,6 +156,7 @@ class TestResolveRunnerConfig:
|
||||
'local-agent': {
|
||||
'model': 'uuid-123',
|
||||
'max_round': 10,
|
||||
'knowledge-base': 'kb-123',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -163,7 +165,8 @@ class TestResolveRunnerConfig:
|
||||
pipeline_config,
|
||||
'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):
|
||||
"""Resolve runner config when not found."""
|
||||
|
||||
@@ -21,6 +21,7 @@ class TestMigratePipelineConfig:
|
||||
'local-agent': {
|
||||
'model': {'primary': 'model-uuid', 'fallbacks': []},
|
||||
'max-round': 10,
|
||||
'knowledge-base': 'kb-uuid',
|
||||
'prompt': [{'role': 'system', 'content': 'Hello'}],
|
||||
},
|
||||
},
|
||||
@@ -35,6 +36,8 @@ class TestMigratePipelineConfig:
|
||||
# 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
|
||||
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
|
||||
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_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):
|
||||
"""All old runner names should be migrated."""
|
||||
old_runners = [
|
||||
|
||||
@@ -10,6 +10,7 @@ Authorization paths:
|
||||
1. AgentRunner calls: has run_id, validates against session_registry
|
||||
2. Regular plugin calls: no run_id, unscoped plugin action path
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -26,6 +27,7 @@ from .conftest import make_resources
|
||||
|
||||
class MockModel:
|
||||
"""Mock LLM model for testing."""
|
||||
|
||||
def __init__(self, uuid: str):
|
||||
self.uuid = uuid
|
||||
self.provider = MagicMock()
|
||||
@@ -34,6 +36,7 @@ class MockModel:
|
||||
|
||||
class MockEmbeddingModel:
|
||||
"""Mock embedding model for testing."""
|
||||
|
||||
def __init__(self, uuid: str):
|
||||
self.uuid = uuid
|
||||
self.provider = MagicMock()
|
||||
@@ -41,6 +44,7 @@ class MockEmbeddingModel:
|
||||
|
||||
class MockKnowledgeBase:
|
||||
"""Mock knowledge base for testing."""
|
||||
|
||||
def __init__(self, uuid: str, name: str = 'KB'):
|
||||
self.knowledge_base_entity = MagicMock()
|
||||
self.knowledge_base_entity.description = f'{name} description'
|
||||
@@ -57,6 +61,7 @@ class MockKnowledgeBase:
|
||||
|
||||
class MockQuery:
|
||||
"""Mock query for testing."""
|
||||
|
||||
def __init__(self, query_id: int = 1):
|
||||
self.query_id = query_id
|
||||
self.session = MagicMock()
|
||||
@@ -81,6 +86,7 @@ class MockQuery:
|
||||
|
||||
class MockApplication:
|
||||
"""Mock Application for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = MagicMock()
|
||||
self.logger.debug = MagicMock()
|
||||
@@ -125,6 +131,7 @@ class FakeAgentRunnerRegistry:
|
||||
|
||||
class MockConnection:
|
||||
"""Mock connection for testing."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@@ -155,6 +162,7 @@ class TestPipelineKnowledgeBaseScope:
|
||||
|
||||
class MockDisconnectCallback:
|
||||
"""Mock disconnect callback for testing."""
|
||||
|
||||
async def __call__(self):
|
||||
return True
|
||||
|
||||
@@ -489,25 +497,25 @@ class TestHandlerAuthorizationErrorMessages:
|
||||
|
||||
def test_model_not_authorized_error_message(self):
|
||||
"""Error message should mention model not authorized."""
|
||||
expected_msg = "Model model_999 is not authorized for this agent run"
|
||||
expected_msg = 'Model model_999 is not authorized for this agent run'
|
||||
assert 'not authorized' in expected_msg
|
||||
assert 'model_999' in expected_msg
|
||||
|
||||
def test_tool_not_authorized_error_message(self):
|
||||
"""Error message should mention tool not authorized."""
|
||||
expected_msg = "Tool image_gen is not authorized for this agent run"
|
||||
expected_msg = 'Tool image_gen is not authorized for this agent run'
|
||||
assert 'not authorized' in expected_msg
|
||||
assert 'image_gen' in expected_msg
|
||||
|
||||
def test_kb_not_authorized_error_message(self):
|
||||
"""Error message should mention kb not authorized."""
|
||||
expected_msg = "Knowledge base kb_999 is not authorized for this agent run"
|
||||
expected_msg = 'Knowledge base kb_999 is not authorized for this agent run'
|
||||
assert 'not authorized' in expected_msg
|
||||
assert 'kb_999' in expected_msg
|
||||
|
||||
def test_session_not_found_error_message(self):
|
||||
"""Error message should mention session not found."""
|
||||
expected_msg = "Run session run_xyz not found or expired"
|
||||
expected_msg = 'Run session run_xyz not found or expired'
|
||||
assert 'not found' in expected_msg
|
||||
assert 'run_xyz' in expected_msg
|
||||
|
||||
@@ -585,8 +593,8 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
# Should resolve to plugin:langbot/local-agent/default
|
||||
assert 'local-agent' in runner_id
|
||||
|
||||
def test_retrieve_kb_fix_backward_compat_knowledge_base(self):
|
||||
"""Fix should handle backward compat for old 'knowledge-base' field."""
|
||||
def test_retrieve_kb_legacy_single_key_is_migration_only(self):
|
||||
"""Old singular knowledge-base config is normalized before runtime."""
|
||||
from langbot.pkg.agent.runner.config_migration import ConfigMigration
|
||||
|
||||
pipeline_config = {
|
||||
@@ -602,17 +610,11 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
|
||||
},
|
||||
}
|
||||
|
||||
runner_id = ConfigMigration.resolve_runner_id(pipeline_config)
|
||||
runner_config = ConfigMigration.resolve_runner_config(pipeline_config, runner_id)
|
||||
migrated = ConfigMigration.migrate_pipeline_config(pipeline_config)
|
||||
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
|
||||
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
|
||||
assert runner_config == {'knowledge-bases': ['kb_single']}
|
||||
|
||||
|
||||
class TestHandlerActionAuthorization:
|
||||
@@ -1059,10 +1061,12 @@ class TestResourceTypeValidation:
|
||||
async def test_model_resource_validation(self):
|
||||
"""Model resource: correct model_id validation."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(models=[
|
||||
{'model_id': 'model_001'},
|
||||
{'model_id': 'model_002'},
|
||||
])
|
||||
resources = make_resources(
|
||||
models=[
|
||||
{'model_id': 'model_001'},
|
||||
{'model_id': 'model_002'},
|
||||
]
|
||||
)
|
||||
|
||||
await registry.register(
|
||||
run_id='run_model_validation',
|
||||
@@ -1087,10 +1091,12 @@ class TestResourceTypeValidation:
|
||||
async def test_tool_resource_validation(self):
|
||||
"""Tool resource: correct tool_name validation."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(tools=[
|
||||
{'tool_name': 'web_search'},
|
||||
{'tool_name': 'image_gen'},
|
||||
])
|
||||
resources = make_resources(
|
||||
tools=[
|
||||
{'tool_name': 'web_search'},
|
||||
{'tool_name': 'image_gen'},
|
||||
]
|
||||
)
|
||||
|
||||
await registry.register(
|
||||
run_id='run_tool_validation',
|
||||
@@ -1115,10 +1121,12 @@ class TestResourceTypeValidation:
|
||||
async def test_knowledge_base_resource_validation(self):
|
||||
"""Knowledge base resource: correct kb_id validation."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(knowledge_bases=[
|
||||
{'kb_id': 'kb_001'},
|
||||
{'kb_id': 'kb_002'},
|
||||
])
|
||||
resources = make_resources(
|
||||
knowledge_bases=[
|
||||
{'kb_id': 'kb_001'},
|
||||
{'kb_id': 'kb_002'},
|
||||
]
|
||||
)
|
||||
|
||||
await registry.register(
|
||||
run_id='run_kb_validation',
|
||||
@@ -1262,6 +1270,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
"""_validate_run_authorization returns session when resource is authorized."""
|
||||
# Use global session registry (same as _validate_run_authorization)
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
@@ -1280,12 +1289,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_validate_test_helper',
|
||||
'model',
|
||||
'model_001',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_validate_test_helper', 'model', 'model_001', mock_ap)
|
||||
|
||||
# Should return session, no error
|
||||
assert session is not None
|
||||
@@ -1303,12 +1307,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
mock_ap.logger = MagicMock()
|
||||
mock_ap.logger.warning = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_nonexistent_helper',
|
||||
'model',
|
||||
'model_001',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_nonexistent_helper', 'model', 'model_001', mock_ap)
|
||||
|
||||
# Should return no session, error response
|
||||
assert session is None
|
||||
@@ -1321,6 +1320,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
"""_validate_run_authorization returns error when resource not allowed."""
|
||||
# Use global session registry
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
@@ -1342,7 +1342,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
'run_unauthorized_helper',
|
||||
'model',
|
||||
'model_999', # Not in resources
|
||||
mock_ap
|
||||
mock_ap,
|
||||
)
|
||||
|
||||
# Should return no session, error response
|
||||
@@ -1358,6 +1358,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
"""_validate_run_authorization works for tool resource type."""
|
||||
# Use global session registry
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(tools=[{'tool_name': 'web_search'}])
|
||||
|
||||
@@ -1374,12 +1375,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_tool_test_helper',
|
||||
'tool',
|
||||
'web_search',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_tool_test_helper', 'tool', 'web_search', mock_ap)
|
||||
|
||||
assert session is not None
|
||||
assert error is None
|
||||
@@ -1391,6 +1387,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
"""_validate_run_authorization works for knowledge_base resource type."""
|
||||
# Use global session registry
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
|
||||
|
||||
@@ -1407,12 +1404,7 @@ class TestValidateRunAuthorizationHelper:
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_kb_test_helper',
|
||||
'knowledge_base',
|
||||
'kb_001',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_kb_test_helper', 'knowledge_base', 'kb_001', mock_ap)
|
||||
|
||||
assert session is not None
|
||||
assert error is None
|
||||
@@ -1545,6 +1537,7 @@ class TestFilesResourcePermission:
|
||||
async def test_files_resource_type_now_implemented(self):
|
||||
"""'files' resource type is now implemented in is_resource_allowed."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(files=[{'file_id': 'file_001'}])
|
||||
|
||||
@@ -1577,6 +1570,7 @@ class TestRealActionHandlerSimulation:
|
||||
"""Simulate INVOKE_LLM action handler authorization flow."""
|
||||
# Use global session registry
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
@@ -1595,12 +1589,7 @@ class TestRealActionHandlerSimulation:
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
# Step 1: Validate authorization
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_invoke_llm_flow_sim',
|
||||
'model',
|
||||
'model_001',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_invoke_llm_flow_sim', 'model', 'model_001', mock_ap)
|
||||
|
||||
# Should pass authorization
|
||||
assert session is not None
|
||||
@@ -1615,6 +1604,7 @@ class TestRealActionHandlerSimulation:
|
||||
"""Simulate INVOKE_LLM handler rejecting unauthorized model."""
|
||||
# Use global session registry
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
@@ -1633,12 +1623,7 @@ class TestRealActionHandlerSimulation:
|
||||
mock_ap.logger.warning = MagicMock()
|
||||
|
||||
# Try to access unauthorized model
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_reject_model_sim',
|
||||
'model',
|
||||
'model_999',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_reject_model_sim', 'model', 'model_999', mock_ap)
|
||||
|
||||
# Should reject
|
||||
assert session is None
|
||||
@@ -1659,10 +1644,7 @@ class TestRealActionHandlerSimulation:
|
||||
|
||||
# Try to validate with non-existent run_id
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_nonexistent_session_flow',
|
||||
'model',
|
||||
'model_001',
|
||||
mock_ap
|
||||
'run_nonexistent_session_flow', 'model', 'model_001', mock_ap
|
||||
)
|
||||
|
||||
# Should return error
|
||||
@@ -1683,6 +1665,7 @@ class TestStoragePermissionValidation:
|
||||
async def test_plugin_storage_allowed_when_permitted(self):
|
||||
"""_validate_run_authorization allows 'plugin' storage when permitted."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(storage={'plugin_storage': True, 'workspace_storage': False})
|
||||
|
||||
@@ -1699,12 +1682,7 @@ class TestStoragePermissionValidation:
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_plugin_storage_auth',
|
||||
'storage',
|
||||
'plugin',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_plugin_storage_auth', 'storage', 'plugin', mock_ap)
|
||||
|
||||
assert session is not None
|
||||
assert error is None
|
||||
@@ -1715,6 +1693,7 @@ class TestStoragePermissionValidation:
|
||||
async def test_plugin_storage_denied_when_not_permitted(self):
|
||||
"""_validate_run_authorization denies 'plugin' storage when not permitted."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
|
||||
|
||||
@@ -1732,12 +1711,7 @@ class TestStoragePermissionValidation:
|
||||
mock_ap.logger = MagicMock()
|
||||
mock_ap.logger.warning = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_plugin_storage_denied',
|
||||
'storage',
|
||||
'plugin',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_plugin_storage_denied', 'storage', 'plugin', mock_ap)
|
||||
|
||||
assert session is None
|
||||
assert error is not None
|
||||
@@ -1749,6 +1723,7 @@ class TestStoragePermissionValidation:
|
||||
async def test_workspace_storage_allowed_when_permitted(self):
|
||||
"""_validate_run_authorization allows 'workspace' storage when permitted."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': True})
|
||||
|
||||
@@ -1766,10 +1741,7 @@ class TestStoragePermissionValidation:
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_workspace_storage_auth',
|
||||
'storage',
|
||||
'workspace',
|
||||
mock_ap
|
||||
'run_workspace_storage_auth', 'storage', 'workspace', mock_ap
|
||||
)
|
||||
|
||||
assert session is not None
|
||||
@@ -1781,6 +1753,7 @@ class TestStoragePermissionValidation:
|
||||
async def test_workspace_storage_denied_when_not_permitted(self):
|
||||
"""_validate_run_authorization denies 'workspace' storage when not permitted."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(storage={'plugin_storage': False, 'workspace_storage': False})
|
||||
|
||||
@@ -1799,10 +1772,7 @@ class TestStoragePermissionValidation:
|
||||
mock_ap.logger.warning = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_workspace_storage_denied',
|
||||
'storage',
|
||||
'workspace',
|
||||
mock_ap
|
||||
'run_workspace_storage_denied', 'storage', 'workspace', mock_ap
|
||||
)
|
||||
|
||||
assert session is None
|
||||
@@ -1823,6 +1793,7 @@ class TestFilePermissionValidation:
|
||||
async def test_file_allowed_when_in_resources(self):
|
||||
"""_validate_run_authorization allows file when in resources."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(files=[{'file_id': 'file_001'}])
|
||||
|
||||
@@ -1839,12 +1810,7 @@ class TestFilePermissionValidation:
|
||||
mock_ap = MagicMock()
|
||||
mock_ap.logger = MagicMock()
|
||||
|
||||
session, error = await _validate_run_authorization(
|
||||
'run_file_auth',
|
||||
'file',
|
||||
'file_001',
|
||||
mock_ap
|
||||
)
|
||||
session, error = await _validate_run_authorization('run_file_auth', 'file', 'file_001', mock_ap)
|
||||
|
||||
assert session is not None
|
||||
assert error is None
|
||||
@@ -1855,6 +1821,7 @@ class TestFilePermissionValidation:
|
||||
async def test_file_denied_when_not_in_resources(self):
|
||||
"""_validate_run_authorization denies file when not in resources."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(files=[{'file_id': 'file_001'}])
|
||||
|
||||
@@ -1876,7 +1843,7 @@ class TestFilePermissionValidation:
|
||||
'run_file_denied',
|
||||
'file',
|
||||
'file_999', # Not in resources
|
||||
mock_ap
|
||||
mock_ap,
|
||||
)
|
||||
|
||||
assert session is None
|
||||
@@ -1898,6 +1865,7 @@ class TestCallerPluginIdentityValidation:
|
||||
async def test_same_plugin_identity_allowed(self):
|
||||
"""_validate_run_authorization allows when caller matches session."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
@@ -1931,6 +1899,7 @@ class TestCallerPluginIdentityValidation:
|
||||
async def test_different_plugin_identity_denied(self):
|
||||
"""_validate_run_authorization denies when caller differs from session."""
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
@@ -1967,6 +1936,7 @@ class TestCallerPluginIdentityValidation:
|
||||
"""_validate_run_authorization allows when caller_plugin_identity not provided."""
|
||||
# Unscoped plugin path: if caller_plugin_identity is None, skip identity check
|
||||
from langbot.pkg.agent.runner.session_registry import get_session_registry
|
||||
|
||||
registry = get_session_registry()
|
||||
resources = make_resources(models=[{'model_id': 'model_001'}])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user