feat(agent-runner): align protocol adapter terminology

This commit is contained in:
huanghuoguoguo
2026-05-24 09:13:15 +08:00
parent 32e42f04ea
commit cc911cc413
26 changed files with 471 additions and 342 deletions

View File

@@ -182,8 +182,8 @@ class TestDefaultPipelineConfig:
assert config['ai']['runner_config'] == {}
class TestResolveRunnerIdBackwardCompat:
"""Tests for backward compatibility in resolve_runner_id."""
class TestResolveRunnerIdAliases:
"""Tests for runner id alias resolution."""
def test_resolve_new_format_id(self):
"""resolve_runner_id should work with new format."""

View File

@@ -420,12 +420,12 @@ class TestBuildParamsInContext:
context = await builder.build_context(query, descriptor, resources)
# Protocol v1: params is in compatibility.extra
assert 'compatibility' in context
assert 'extra' in context['compatibility']
assert 'params' in context['compatibility']['extra']
assert context['compatibility']['extra']['params']['public_param'] == 'value'
assert '_private' not in context['compatibility']['extra']['params']
# Protocol v1: params is in adapter.extra
assert 'adapter' in context
assert 'extra' in context['adapter']
assert 'params' in context['adapter']['extra']
assert context['adapter']['extra']['params']['public_param'] == 'value'
assert '_private' not in context['adapter']['extra']['params']
@pytest.mark.asyncio
async def test_params_and_state_both_present(self):
@@ -457,12 +457,12 @@ class TestBuildParamsInContext:
context = await builder.build_context(query, descriptor, resources)
# Protocol v1: params is in compatibility.extra
assert 'compatibility' in context
assert 'extra' in context['compatibility']
assert 'params' in context['compatibility']['extra']
assert context['compatibility']['extra']['params']['workflow_input'] == 'user_question'
assert context['compatibility']['extra']['params']['sender_name'] == 'John'
# Protocol v1: params is in adapter.extra
assert 'adapter' in context
assert 'extra' in context['adapter']
assert 'params' in context['adapter']['extra']
assert context['adapter']['extra']['params']['workflow_input'] == 'user_question'
assert context['adapter']['extra']['params']['sender_name'] == 'John'
# state should have seeded conversation_id
assert 'state' in context
@@ -495,10 +495,10 @@ class TestBuildParamsInContext:
context = await builder.build_context(query, descriptor, resources)
# Protocol v1: prompt is in compatibility.extra
assert 'compatibility' in context
assert 'extra' in context['compatibility']
assert 'prompt' in context['compatibility']['extra']
assert context['compatibility']['extra']['prompt'][0]['content'] == 'Effective prompt'
# Protocol v1: prompt is in adapter.extra
assert 'adapter' in context
assert 'extra' in context['adapter']
assert 'prompt' in context['adapter']['extra']
assert context['adapter']['extra']['prompt'][0]['content'] == 'Effective prompt'
assert context['runtime']['metadata']['streaming_supported'] is True
assert context['runtime']['metadata']['remove_think'] is True

View File

@@ -200,7 +200,7 @@ class TestContextValidation:
assert 'delivery' in context_dict, "delivery is REQUIRED in Protocol v1"
assert 'context' in context_dict, "context (ContextAccess) is REQUIRED in Protocol v1"
assert 'bootstrap' in context_dict, "bootstrap should exist (can be None)"
assert 'compatibility' in context_dict, "compatibility should exist"
assert 'adapter' in context_dict, "adapter should exist"
assert 'metadata' in context_dict, "metadata should exist"
@pytest.mark.asyncio

View File

@@ -1,10 +1,10 @@
"""Tests for event-first Protocol v1 entities and Pipeline compatibility adapter.
"""Tests for event-first Protocol v1 entities and Pipeline adapter.
Tests cover:
1. Pipeline Query -> AgentEventEnvelope conversion
2. Pipeline config -> AgentBinding conversion
3. AgentRunContext not inlining full history by default
4. Legacy max-round only affecting bootstrap/compat adapter
4. Pipeline max-round only affecting bootstrap/adapter context
5. Event-first run() entry point
"""
from __future__ import annotations
@@ -49,7 +49,7 @@ from langbot.pkg.agent.runner.host_models import (
StatePolicy,
DeliveryPolicy,
)
from langbot.pkg.agent.runner.pipeline_compat_adapter import PipelineCompatAdapter
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
class TestPipelineQueryToEventEnvelope:
@@ -57,24 +57,24 @@ class TestPipelineQueryToEventEnvelope:
def test_query_to_event_basic_fields(self, mock_query):
"""Test basic field conversion from Query to Event envelope."""
event = PipelineCompatAdapter.query_to_event(mock_query)
event = PipelineAdapter.query_to_event(mock_query)
assert event.event_type == "message.received"
assert event.source == "pipeline_compat"
assert event.source == "pipeline_adapter"
assert event.bot_id == mock_query.bot_uuid
assert event.actor is not None
assert event.actor.actor_type == "user"
def test_query_to_event_input(self, mock_query):
"""Test input conversion from Query."""
event = PipelineCompatAdapter.query_to_event(mock_query)
event = PipelineAdapter.query_to_event(mock_query)
assert event.input is not None
assert event.input.text == "Hello world"
def test_query_to_event_conversation(self, mock_query):
"""Test conversation context extraction."""
event = PipelineCompatAdapter.query_to_event(mock_query)
event = PipelineAdapter.query_to_event(mock_query)
# Conversation may be None if no session
if event.conversation_id:
@@ -82,7 +82,7 @@ class TestPipelineQueryToEventEnvelope:
def test_query_to_event_delivery_context(self, mock_query):
"""Test delivery context extraction."""
event = PipelineCompatAdapter.query_to_event(mock_query)
event = PipelineAdapter.query_to_event(mock_query)
assert event.delivery is not None
assert event.delivery.surface == "platform"
@@ -94,7 +94,7 @@ class TestPipelineConfigToBinding:
def test_config_to_binding_runner_id(self, mock_query):
"""Test binding runner_id extraction."""
binding = PipelineCompatAdapter.pipeline_config_to_binding(
binding = PipelineAdapter.pipeline_config_to_binding(
mock_query, "plugin:author/plugin/runner"
)
@@ -102,7 +102,7 @@ class TestPipelineConfigToBinding:
def test_config_to_binding_scope(self, mock_query):
"""Test binding scope extraction."""
binding = PipelineCompatAdapter.pipeline_config_to_binding(
binding = PipelineAdapter.pipeline_config_to_binding(
mock_query, "plugin:test/plugin/runner"
)
@@ -110,8 +110,8 @@ class TestPipelineConfigToBinding:
assert binding.scope.scope_id == mock_query.pipeline_uuid
def test_config_to_binding_max_round(self, mock_query_with_max_round):
"""Test max_round extraction for compatibility adapter."""
binding = PipelineCompatAdapter.pipeline_config_to_binding(
"""Test max_round extraction for Pipeline adapter."""
binding = PipelineAdapter.pipeline_config_to_binding(
mock_query_with_max_round, "plugin:test/plugin/runner"
)
@@ -120,7 +120,7 @@ class TestPipelineConfigToBinding:
def test_config_to_binding_no_max_round(self, mock_query):
"""Test binding without max_round."""
binding = PipelineCompatAdapter.pipeline_config_to_binding(
binding = PipelineAdapter.pipeline_config_to_binding(
mock_query, "plugin:test/plugin/runner"
)
@@ -210,8 +210,8 @@ class TestAgentRunContextProtocolV1:
assert ctx.bootstrap is None or isinstance(ctx.bootstrap.messages, list)
class TestLegacyMaxRoundNotInProtocol:
"""Test that legacy max-round only affects compat adapter, not Protocol v1."""
class TestMaxRoundNotInProtocol:
"""Test that Pipeline max-round only affects adapter context, not Protocol v1."""
def test_max_round_not_in_sdk_context(self):
"""Test max-round is not a field in SDK AgentRunContext."""
@@ -221,8 +221,8 @@ class TestLegacyMaxRoundNotInProtocol:
assert "max_round" not in ctx_fields
assert "maxRound" not in ctx_fields
def test_max_round_in_compatibility_context(self):
"""Test max_round is in compatibility context, not main context."""
def test_max_round_in_adapter_context(self):
"""Test max_round is in adapter context, not main context."""
trigger = AgentTrigger(type="message.received")
event = AgentEventContext(
event_id="evt_1",
@@ -233,9 +233,9 @@ class TestLegacyMaxRoundNotInProtocol:
from langbot_plugin.api.entities.builtin.agent_runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.agent_runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.context import CompatibilityContext
from langbot_plugin.api.entities.builtin.agent_runner.context import AdapterContext
compat = CompatibilityContext(max_round=10)
adapter = AdapterContext(max_round=10)
ctx = AgentRunContext(
run_id="run_1",
@@ -245,20 +245,20 @@ class TestLegacyMaxRoundNotInProtocol:
delivery=DeliveryContext(surface="platform"),
resources=AgentResources(),
runtime=AgentRuntimeContext(),
compatibility=compat,
adapter=adapter,
)
# max_round is in compatibility context, not main context
assert ctx.compatibility is not None
assert ctx.compatibility.max_round == 10
# max_round is in adapter context, not main context
assert ctx.adapter is not None
assert ctx.adapter.max_round == 10
def test_binding_max_round_for_adapter_only(self, mock_query_with_max_round):
"""Test max_round in binding is for adapter use, not Protocol v1."""
binding = PipelineCompatAdapter.pipeline_config_to_binding(
binding = PipelineAdapter.pipeline_config_to_binding(
mock_query_with_max_round, "plugin:test/plugin/runner"
)
# max_round is in binding (Host-internal) for compat adapter
# max_round is in binding (Host-internal) for Pipeline adapter
assert binding.max_round == 10
# But SDK entities don't have it

View File

@@ -8,7 +8,7 @@ Tests focus on:
Authorization paths:
1. AgentRunner calls: has run_id, validates against session_registry
2. Regular plugin calls: no run_id, unrestricted (backward compatibility)
2. Regular plugin calls: no run_id, unscoped plugin action path
"""
from __future__ import annotations
@@ -220,7 +220,7 @@ class TestInvokeLLMAuthorization:
async def test_invoke_llm_no_run_id_unrestricted(self):
"""INVOKE_LLM: no run_id should be unrestricted (backward compat)."""
# When no run_id is provided, the authorization check is skipped
# This is the backward compatibility path for regular plugin calls
# This is the unscoped path for regular plugin calls
# Simulate: if not run_id, skip authorization
run_id = None
@@ -404,7 +404,7 @@ class TestRetrieveKnowledgeBaseAuthorization:
async def test_retrieve_knowledge_base_no_run_id_pipeline_check(self):
"""RETRIEVE_KNOWLEDGE_BASE: no run_id checks pipeline config."""
# When no run_id, the handler checks against pipeline's configured KBs
# This is the backward compatibility path for regular plugin calls
# This is the unscoped path for regular plugin calls
from langbot.pkg.agent.runner.config_migration import ConfigMigration
@@ -899,7 +899,7 @@ class TestSDKAgentRunAPIProxyFieldConsistency:
class TestNoRunIdBackwardCompatPath:
"""Tests for backward compatibility path when no run_id is provided.
"""Tests for unscoped plugin action path when no run_id is provided.
Regular plugins (non-AgentRunner) don't have run_id and should
have unrestricted access to certain APIs.
@@ -1965,7 +1965,7 @@ class TestCallerPluginIdentityValidation:
@pytest.mark.asyncio
async def test_no_caller_identity_allowed(self):
"""_validate_run_authorization allows when caller_plugin_identity not provided."""
# Backward compatibility: 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
registry = get_session_registry()
resources = make_resources(models=[{'model_id': 'model_001'}])
@@ -2000,7 +2000,7 @@ class TestCallerPluginIdentityValidation:
class TestBackwardCompatStorageNoRunId:
"""Tests for backward compatibility: storage actions without run_id.
"""Tests for unscoped storage actions without run_id.
Regular plugins (non-AgentRunner) don't have run_id and should
have unrestricted access to storage APIs.

View File

@@ -317,8 +317,8 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
context = plugin_connector.contexts[0]
assert context["config"]["timeout"] == 30
assert context["runtime"]["deadline_at"] is not None
# Protocol v1: params is in compatibility.extra
assert context["compatibility"]["extra"]["params"] == {"public_param": "visible"}
# Protocol v1: params is in adapter.extra
assert context["adapter"]["extra"]["params"] == {"public_param": "visible"}
assert context["event"]["event_type"] == "message.received"
# Note: source_event_type is in event.source_event_type, not event.data
# (event.data contains the raw event payload, not metadata)
@@ -341,8 +341,8 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
@pytest.mark.asyncio
async def test_orchestrator_packages_legacy_max_round_without_mutating_query(clean_agent_state):
"""Test that legacy max-round is packaged without mutating original query."""
async def test_orchestrator_packages_max_round_without_mutating_query(clean_agent_state):
"""Test that max-round is packaged without mutating original query."""
db_engine = clean_agent_state
descriptor = make_descriptor()
plugin_connector = FakePluginConnector(
@@ -370,7 +370,7 @@ async def test_orchestrator_packages_legacy_max_round_without_mutating_query(cle
assert len(messages) == 1
context = plugin_connector.contexts[0]
# Protocol v1: legacy messages are in bootstrap.messages
# Protocol v1: messages are in bootstrap.messages
assert context["bootstrap"] is not None
assert [message["content"] for message in context["bootstrap"]["messages"]] == [
"message 2",
@@ -378,8 +378,8 @@ async def test_orchestrator_packages_legacy_max_round_without_mutating_query(cle
"message 3",
"response 3",
]
# Also in compatibility.legacy_messages for legacy runners
assert [message["content"] for message in context["compatibility"]["legacy_messages"]] == [
# Also in adapter.adapter_messages for transition runners
assert [message["content"] for message in context["adapter"]["adapter_messages"]] == [
"message 2",
"response 2",
"message 3",
@@ -395,7 +395,7 @@ async def test_orchestrator_packages_legacy_max_round_without_mutating_query(cle
]
assert context["runtime"]["metadata"]["context_packaging"] == {
"policy": {
"mode": "legacy_max_round",
"mode": "max_round",
"max_round": 2,
},
"history": {
@@ -592,12 +592,12 @@ class TestPipelineCompatibilityQueryIdInSession:
assert session_during_run["query_id"] is None
class TestPipelineCompatibilityPromptAndParams:
"""Tests for prompt and params handling in Pipeline compatibility."""
class TestPipelineAdapterPromptAndParams:
"""Tests for prompt and params handling in Pipeline adapter."""
@pytest.mark.asyncio
async def test_prompt_in_compatibility_extra(self, clean_agent_state):
"""Pipeline prompt is placed in compatibility.extra.prompt."""
async def test_prompt_in_adapter_extra(self, clean_agent_state):
"""Pipeline prompt is placed in adapter.extra.prompt."""
from langbot_plugin.api.entities.builtin.provider import prompt as provider_prompt
db_engine = clean_agent_state
@@ -625,10 +625,10 @@ class TestPipelineCompatibilityPromptAndParams:
messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
# Prompt should be in compatibility.extra
assert "prompt" in context["compatibility"]["extra"]
assert len(context["compatibility"]["extra"]["prompt"]) == 1
assert context["compatibility"]["extra"]["prompt"][0]["role"] == "system"
# Prompt should be in adapter.extra
assert "prompt" in context["adapter"]["extra"]
assert len(context["adapter"]["extra"]["prompt"]) == 1
assert context["adapter"]["extra"]["prompt"][0]["role"] == "system"
# Top-level should NOT have prompt
assert "prompt" not in context
@@ -656,7 +656,7 @@ class TestPipelineCompatibilityPromptAndParams:
messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
assert context["compatibility"]["extra"]["params"] == {
assert context["adapter"]["extra"]["params"] == {
"public_param": "visible",
"another_param": 123,
}
@@ -686,7 +686,7 @@ class TestPipelineCompatibilityPromptAndParams:
messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
params = context["compatibility"]["extra"]["params"]
params = context["adapter"]["extra"]["params"]
assert "public_param" in params
assert "_internal_var" not in params
assert "_pipeline_bound_plugins" not in params
@@ -718,7 +718,7 @@ class TestPipelineCompatibilityPromptAndParams:
messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
params = context["compatibility"]["extra"]["params"]
params = context["adapter"]["extra"]["params"]
assert "public_param" in params
assert "api_token" not in params
assert "secret_key" not in params
@@ -750,14 +750,14 @@ class TestPipelineCompatibilityPromptAndParams:
messages = [message async for message in orchestrator.run_from_query(query)]
context = plugin_connector.contexts[0]
params = context["compatibility"]["extra"]["params"]
params = context["adapter"]["extra"]["params"]
assert "public_param" in params
assert "a_set" not in params
assert "a_lambda" not in params
class TestPipelineCompatibilityHostCapabilities:
"""Tests for event-first host capabilities via Pipeline compatibility path."""
class TestPipelineAdapterHostCapabilities:
"""Tests for event-first host capabilities via Pipeline adapter path."""
@pytest.mark.asyncio
async def test_state_updated_writes_to_persistent_store(self, clean_agent_state):
@@ -795,9 +795,9 @@ class TestPipelineCompatibilityHostCapabilities:
persistent_store = get_persistent_state_store(db_engine)
# Build snapshot to check if state was written
# Note: We need to rebuild the event and binding to query the store
from langbot.pkg.agent.runner.pipeline_compat_adapter import PipelineCompatAdapter
event = PipelineCompatAdapter.query_to_event(query)
binding = PipelineCompatAdapter.pipeline_config_to_binding(query, RUNNER_ID)
from langbot.pkg.agent.runner.pipeline_adapter import PipelineAdapter
event = PipelineAdapter.query_to_event(query)
binding = PipelineAdapter.pipeline_config_to_binding(query, RUNNER_ID)
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot["conversation"]["external.test_key"] == "test_value"

View File

@@ -6,7 +6,7 @@ from langbot.pkg.agent.runner.state_store import (
get_state_store,
reset_state_store,
VALID_STATE_SCOPES,
LEGACY_KEY_MAPPING,
STATE_KEY_ALIASES,
)
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, StatePolicy
@@ -219,8 +219,8 @@ class TestStateStoreApplyUpdate:
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."""
def test_apply_update_state_key_alias(self):
"""Alias key conversation_id should be mapped to external.conversation_id."""
store = RunnerScopedStateStore()
descriptor = make_descriptor()
query = FakeQuery()
@@ -481,9 +481,9 @@ class TestConstants:
"""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'}
def test_state_key_aliases(self):
"""STATE_KEY_ALIASES should map conversation_id."""
assert STATE_KEY_ALIASES == {'conversation_id': 'external.conversation_id'}
# ========== Event-first Protocol v1 tests ==========
@@ -781,8 +781,8 @@ class TestStateStoreEventFirstApplyUpdate:
assert len(logger.warnings) == 1
assert 'missing identity' in logger.warnings[0]
def test_apply_update_legacy_key_mapping(self):
"""Legacy key conversation_id should be mapped to external.conversation_id."""
def test_apply_update_state_key_alias(self):
"""Alias key conversation_id should be mapped to external.conversation_id."""
store = RunnerScopedStateStore()
descriptor = make_descriptor()
event = FakeEventEnvelope(conversation_id='conv_001')
@@ -1371,4 +1371,4 @@ class TestPersistentStateStore:
# Delete non-existent should return False
deleted_again = await persistent_store.state_delete(scope_key, 'key')
assert deleted_again is False
assert deleted_again is False