feat(runner): unify plugin execution across agents and event processors

This commit is contained in:
RockChinQ
2026-09-10 18:04:38 +08:00
parent 8903a40c41
commit f24a7c9bb2
223 changed files with 4091 additions and 3068 deletions
@@ -28,7 +28,7 @@ from tests.e2e.utils.process_manager import find_project_root
pytestmark = pytest.mark.e2e
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
LOCAL_RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
FAKE_PROVIDER_UUID = 'e2e-fake-provider'
FAKE_MODEL_UUID = 'e2e-fake-local-agent-model'
E2E_TOOL_NAME = 'e2e_lookup'
@@ -112,11 +112,11 @@ def _event(
text: str,
thread_id: str = 'e2e-local-agent-thread',
):
"""Build an AgentRunner event envelope for Local Agent E2E probes."""
"""Build an Runner event envelope for Local Agent E2E probes."""
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
return AgentEventEnvelope(
event_id=event_id,
@@ -163,7 +163,7 @@ def _binding(
return AgentBinding(
binding_id=binding_id,
scope=BindingScope(scope_type='global'),
runner_id=LOCAL_AGENT_RUNNER_ID,
runner_id=LOCAL_RUNNER_ID,
runner_config=config,
resource_policy=ResourcePolicy(
allowed_model_uuids=[FAKE_MODEL_UUID],
@@ -205,7 +205,7 @@ class _FakeToolManager:
del query
self.calls.append({'name': name, 'parameters': dict(parameters)})
return {
'value': f"tool-result:{parameters.get('query')}",
'value': f'tool-result:{parameters.get("query")}',
'source': 'fake-tool-manager',
}
@@ -402,7 +402,7 @@ async def _inject_fake_llm_model(ap) -> Any:
return fake_requester
async def _run_agent(ap, event, binding) -> list[Any]:
async def _run_runner(ap, event, binding) -> list[Any]:
"""Execute through the trusted Workspace context used by the real Host."""
execution_context = await ap.plugin_connector._current_execution_context()
return [
@@ -463,20 +463,20 @@ async def _boot_local_agent_app(tmpdir: Path):
)
execution_context = await ap.plugin_connector._current_execution_context()
runners = await ap.agent_runner_registry.list_runners(execution_context, use_cache=False)
if not any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners):
runners = await ap.runner_registry.list_runners(execution_context, use_cache=False)
if not any(runner.id == LOCAL_RUNNER_ID for runner in runners):
await ap.plugin_connector.install_plugin(
PluginInstallSource.LOCAL,
{'plugin_file': (tmpdir / 'langbot-local-agent.zip').read_bytes()},
)
for _ in range(60):
runners = await ap.agent_runner_registry.list_runners(execution_context, use_cache=False)
if any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners):
runners = await ap.runner_registry.list_runners(execution_context, use_cache=False)
if any(runner.id == LOCAL_RUNNER_ID for runner in runners):
break
await asyncio.sleep(1)
else:
raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered after installation')
raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation')
return ap, run_task
@@ -521,7 +521,7 @@ def _run_local_agent_probe(tmpdir: Path, probe):
return asyncio.run(_run())
def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger(
def test_local_runner_uses_host_fake_provider_and_persists_ledger(
local_agent_e2e_tmpdir,
local_agent_e2e_config_path,
local_agent_runtime_process,
@@ -536,7 +536,7 @@ def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger(
conversation_id='e2e-local-agent-conversation',
text='Say pong through the fake provider.',
)
messages = await _run_agent(ap, event, _binding())
messages = await _run_runner(ap, event, _binding())
return messages, list(fake_requester._count_tokens_payloads)
messages, token_payloads = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
@@ -553,13 +553,13 @@ def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger(
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
"SELECT run_id, status, runner_id, status_reason FROM agent_run WHERE event_id = ?",
'SELECT run_id, status, runner_id, status_reason FROM agent_run WHERE event_id = ?',
('e2e-local-agent-event-001',),
).fetchone()
assert run_row is not None
run_id, status, runner_id, status_reason = run_row
assert status == 'completed'
assert runner_id == LOCAL_AGENT_RUNNER_ID
assert runner_id == LOCAL_RUNNER_ID
assert status_reason == 'stop'
event_rows = conn.execute(
@@ -578,12 +578,12 @@ def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger(
assert transcript_rows[0][1] == 'Say pong through the fake provider.'
assert transcript_rows[1][1] == 'Fake LLM response'
assert transcript_rows[1][2] == run_id
assert transcript_rows[1][3] == LOCAL_AGENT_RUNNER_ID
assert transcript_rows[1][3] == LOCAL_RUNNER_ID
finally:
conn.close()
def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
def test_local_runner_executes_authorized_tool_loop_through_host_action(
local_agent_e2e_tmpdir,
local_agent_e2e_config_path,
local_agent_runtime_process,
@@ -613,7 +613,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
'tool-execution-mode': 'serial',
},
)
messages = await _run_agent(ap, event, binding)
messages = await _run_runner(ap, event, binding)
return messages, tool_mgr.calls, _invoke_payload_texts(fake_requester)
messages, tool_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
@@ -627,7 +627,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
"SELECT run_id, status, status_reason FROM agent_run WHERE event_id = ?",
'SELECT run_id, status, status_reason FROM agent_run WHERE event_id = ?',
('e2e-local-agent-tool-event-001',),
).fetchone()
assert run_row is not None
@@ -652,7 +652,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
conn.close()
def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action(
def test_local_runner_retrieves_authorized_rag_context_through_host_action(
local_agent_e2e_tmpdir,
local_agent_e2e_config_path,
local_agent_runtime_process,
@@ -679,7 +679,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action
'retrieval-top-k': 1,
},
)
messages = await _run_agent(ap, event, binding)
messages = await _run_runner(ap, event, binding)
return messages, fake_kb.retrieve_calls, _invoke_payload_texts(fake_requester)
messages, retrieve_calls, invoke_payload_texts = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
@@ -702,7 +702,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
"SELECT run_id, status FROM agent_run WHERE event_id = ?",
'SELECT run_id, status FROM agent_run WHERE event_id = ?',
('e2e-local-agent-rag-event-001',),
).fetchone()
assert run_row is not None
@@ -720,7 +720,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action
conn.close()
def test_local_agent_runner_compacts_history_and_persists_checkpoint(
def test_local_runner_compacts_history_and_persists_checkpoint(
local_agent_e2e_tmpdir,
local_agent_e2e_config_path,
local_agent_runtime_process,
@@ -745,8 +745,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
conversation_id='e2e-local-agent-compaction-conversation',
role='user' if index % 2 == 0 else 'assistant',
content=(
f'HIST_SENTINEL-{index} '
'This is intentionally long deterministic history for compaction. ' * 10
f'HIST_SENTINEL-{index} This is intentionally long deterministic history for compaction. ' * 10
),
thread_id='e2e-local-agent-thread',
item_type='message',
@@ -767,7 +766,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
'context-history-fetch-limit': 20,
},
)
messages = await _run_agent(ap, event, binding)
messages = await _run_runner(ap, event, binding)
return messages, _invoke_payload_texts(fake_requester), fake_requester._invoke_count
messages, invoke_payload_texts, invoke_count = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
@@ -782,7 +781,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
"SELECT run_id, status FROM agent_run WHERE event_id = ?",
'SELECT run_id, status FROM agent_run WHERE event_id = ?',
('e2e-local-agent-compaction-event-001',),
).fetchone()
assert run_row is not None
@@ -799,7 +798,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
assert event_types == ['message.completed', 'run.completed']
state_row = conn.execute(
"SELECT value_json FROM agent_runner_state WHERE state_key = 'runner.compaction.checkpoint'"
"SELECT value_json FROM runner_state WHERE state_key = 'runner.compaction.checkpoint'"
).fetchone()
assert state_row is not None
checkpoint = json.loads(state_row[0])
@@ -812,7 +811,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
conn.close()
def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
local_agent_e2e_tmpdir,
local_agent_e2e_config_path,
local_agent_runtime_process,
@@ -883,7 +882,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
'context-history-fetch-limit': 25,
},
)
messages = await _run_agent(ap, event, binding)
messages = await _run_runner(ap, event, binding)
return (
messages,
tool_mgr.calls,
@@ -926,7 +925,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
"SELECT run_id, status, status_reason FROM agent_run WHERE event_id = ?",
'SELECT run_id, status, status_reason FROM agent_run WHERE event_id = ?',
('e2e-local-agent-combo-event-001',),
).fetchone()
assert run_row is not None
@@ -952,15 +951,11 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
assert 'COMBO_FINAL' in event_rows[4][1]
state_rows = conn.execute(
"SELECT value_json FROM agent_runner_state WHERE state_key = 'runner.compaction.checkpoint'"
"SELECT value_json FROM runner_state WHERE state_key = 'runner.compaction.checkpoint'"
).fetchall()
checkpoints = [json.loads(row[0]) for row in state_rows]
checkpoint = next(
(
item
for item in checkpoints
if item.get('conversation_id') == 'e2e-local-agent-combo-conversation'
),
(item for item in checkpoints if item.get('conversation_id') == 'e2e-local-agent-combo-conversation'),
None,
)
assert checkpoint is not None
@@ -1,7 +1,7 @@
"""E2E tests for pluginized AgentRunner execution.
"""E2E tests for pluginized Runner execution.
This module starts the real LangBot backend with the plugin system enabled and
loads a deterministic AgentRunner plugin through the real SDK Plugin Runtime.
loads a deterministic Runner plugin through the real SDK Plugin Runtime.
"""
from __future__ import annotations
@@ -29,22 +29,22 @@ QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default'
@pytest.fixture(scope='session')
def agent_runner_e2e_port():
"""Port for the AgentRunner plugin-runtime E2E process."""
def runner_e2e_port():
"""Port for the Runner plugin-runtime E2E process."""
return 15310
@pytest.fixture(scope='session')
def agent_runner_e2e_tmpdir():
"""Create temporary directory for AgentRunner E2E testing."""
tmpdir = Path(tempfile.mkdtemp(prefix='langbot_agent_runner_e2e_'))
def runner_e2e_tmpdir():
"""Create temporary directory for Runner E2E testing."""
tmpdir = Path(tempfile.mkdtemp(prefix='langbot_runner_e2e_'))
yield tmpdir
shutil.rmtree(tmpdir, ignore_errors=True)
def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
"""Write a deterministic AgentRunner plugin used by this E2E."""
runner_dir = plugin_root / 'components' / 'agent_runner'
def _write_qa_runner_plugin(plugin_root: Path) -> None:
"""Write a deterministic Runner plugin used by this E2E."""
runner_dir = plugin_root / 'components' / 'runner'
runner_dir.mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets').mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets' / 'icon.svg').write_text(
@@ -61,27 +61,24 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
name: agent-runner-qa
version: 0.1.0
label:
en_US: AgentRunner QA
zh_Hans: AgentRunner QA
en_US: Runner QA
zh_Hans: Runner QA
description:
en_US: Deterministic AgentRunner E2E probe.
zh_Hans: 确定性的 AgentRunner E2E 探针
en_US: Deterministic Runner E2E probe.
zh_Hans: 确定性的 Runner E2E 探针
icon: assets/icon.svg
spec:
version: 0.1.0
config: []
components:
AgentRunner:
Runner:
fromDirs:
- path: components/agent_runner/
EventProcessor:
fromDirs:
- path: components/event_processor/
- path: components/runner/
pages: []
execution:
python:
path: main.py
attr: AgentRunnerQAPlugin
attr: RunnerQAPlugin
"""
).strip()
+ '\n',
@@ -95,7 +92,7 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
from langbot_plugin.api.definition.plugin import BasePlugin
class AgentRunnerQAPlugin(BasePlugin):
class RunnerQAPlugin(BasePlugin):
async def initialize(self) -> None:
pass
"""
@@ -107,7 +104,7 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
textwrap.dedent(
"""
apiVersion: langbot/v1
kind: AgentRunner
kind: Runner
metadata:
name: default
label:
@@ -124,7 +121,7 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
execution:
python:
path: default.py
attr: DefaultAgentRunner
attr: DefaultRunner
"""
).strip()
+ '\n',
@@ -137,42 +134,42 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
from typing import AsyncGenerator
from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult
from langbot_plugin.api.definition.components.runner.runner import Runner
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.result import RunnerResult
from langbot_plugin.api.entities.builtin.provider.message import Message
class DefaultAgentRunner(AgentRunner):
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]:
class DefaultRunner(Runner):
async def run(self, ctx: RunnerContext) -> AsyncGenerator[RunnerResult, None]:
text = ctx.input.to_text()
yield AgentRunResult.message_completed(
yield RunnerResult.message_completed(
ctx.run_id,
Message(role='assistant', content=f'e2e echo: {text}'),
)
yield AgentRunResult.state_updated(
yield RunnerResult.state_updated(
ctx.run_id,
'e2e.echo_count',
{'count': 1},
scope='conversation',
)
yield AgentRunResult.run_completed(ctx.run_id, finish_reason='stop')
yield RunnerResult.run_completed(ctx.run_id, finish_reason='stop')
"""
).strip()
+ '\n',
encoding='utf-8',
)
processor_dir = plugin_root / 'components' / 'event_processor'
processor_dir.mkdir(parents=True)
(processor_dir / 'default.yaml').write_text(
processor_dir = runner_dir
(processor_dir / 'welcome.yaml').write_text(
textwrap.dedent("""
apiVersion: langbot/v1
kind: EventProcessor
kind: Runner
metadata:
name: default
name: welcome
label: {en_US: Welcome processor, zh_Hans: Welcome processor}
spec:
usages: [event]
events: [group.member_joined]
config:
- name: greeting
@@ -184,20 +181,20 @@ def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
permissions:
tools: [detail, call]
execution:
python: {path: default.py, attr: WelcomeProcessor}
python: {path: welcome.py, attr: WelcomeProcessor}
""")
)
(processor_dir / 'default.py').write_text(
(processor_dir / 'welcome.py').write_text(
textwrap.dedent("""
from langbot_plugin.api.definition.components.event_processor import EventProcessor, EventProcessorContext
from langbot_plugin.api.definition.components.runner import Runner, RunnerContext
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
class WelcomeProcessor(EventProcessor):
class WelcomeProcessor(Runner):
async def initialize(self):
@self.handler(MemberJoinedEvent)
async def handle(ctx: EventProcessorContext):
await ctx.log('Handling ' + str(ctx.event.member.id))
result = await ctx.reply(ctx.config['greeting'] + ', ' + (ctx.event.member.nickname or str(ctx.event.member.id)))
async def handle(ctx: RunnerContext):
await ctx.log('Handling ' + str(ctx.platform_event.member.id))
result = await ctx.reply(ctx.config['greeting'] + ', ' + (ctx.platform_event.member.nickname or str(ctx.platform_event.member.id)))
await ctx.log('Reply simulated: ' + str(result.get('mock')))
""")
)
@@ -211,7 +208,7 @@ def _free_port() -> int:
@pytest.fixture(scope='session')
def agent_runner_runtime_ports():
def runner_runtime_ports():
"""Control/debug ports for the standalone plugin runtime."""
control_port = _free_port()
debug_port = _free_port()
@@ -221,17 +218,17 @@ def agent_runner_runtime_ports():
@pytest.fixture(scope='session')
def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port, agent_runner_runtime_ports):
"""Create a plugin-enabled config and deterministic AgentRunner fixture."""
config_path = create_minimal_config(agent_runner_e2e_tmpdir, port=agent_runner_e2e_port)
create_test_directories(agent_runner_e2e_tmpdir)
def runner_e2e_config_path(runner_e2e_tmpdir, runner_e2e_port, runner_runtime_ports):
"""Create a plugin-enabled config and deterministic Runner fixture."""
config_path = create_minimal_config(runner_e2e_tmpdir, port=runner_e2e_port)
create_test_directories(runner_e2e_tmpdir)
import yaml
with open(config_path, encoding='utf-8') as f:
config = yaml.safe_load(f)
config['api']['global_api_key'] = 'e2e-agent-runner-key'
runtime_control_port, _runtime_debug_port = agent_runner_runtime_ports
runtime_control_port, _runtime_debug_port = runner_runtime_ports
config['plugin']['enable'] = True
config['plugin']['runtime_ws_url'] = f'ws://127.0.0.1:{runtime_control_port}/control/ws'
config['plugin']['enable_marketplace'] = False
@@ -240,10 +237,10 @@ def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port,
with open(config_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(config, f, default_flow_style=False)
plugin_source = agent_runner_e2e_tmpdir / 'agent-runner-qa-package'
_write_qa_agent_runner_plugin(plugin_source)
plugin_source = runner_e2e_tmpdir / 'agent-runner-qa-package'
_write_qa_runner_plugin(plugin_source)
shutil.make_archive(
str(agent_runner_e2e_tmpdir / 'agent-runner-qa'),
str(runner_e2e_tmpdir / 'agent-runner-qa'),
'zip',
root_dir=plugin_source,
)
@@ -251,11 +248,11 @@ def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port,
@pytest.fixture(scope='session')
def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_ports):
def runner_runtime_process(runner_e2e_tmpdir, runner_runtime_ports):
"""Start the real SDK plugin runtime over WebSocket."""
control_port, debug_port = agent_runner_runtime_ports
stdout_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log'
stderr_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log'
control_port, debug_port = runner_runtime_ports
stdout_path = runner_e2e_tmpdir / 'plugin-runtime.stdout.log'
stderr_path = runner_e2e_tmpdir / 'plugin-runtime.stderr.log'
stdout_file = open(stdout_path, 'wb')
stderr_file = open(stderr_path, 'wb')
proc = subprocess.Popen(
@@ -269,7 +266,7 @@ def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_p
'--ws-debug-port',
str(debug_port),
],
cwd=agent_runner_e2e_tmpdir,
cwd=runner_e2e_tmpdir,
stdout=stdout_file,
stderr=stderr_file,
start_new_session=True,
@@ -286,18 +283,18 @@ def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_p
@pytest.fixture(scope='session')
def agent_runner_langbot_process(
agent_runner_e2e_config_path,
agent_runner_e2e_port,
agent_runner_e2e_tmpdir,
agent_runner_runtime_process,
def runner_langbot_process(
runner_e2e_config_path,
runner_e2e_port,
runner_e2e_tmpdir,
runner_runtime_process,
):
"""Start real LangBot with plugin runtime enabled."""
project_root = find_project_root()
proc = LangBotProcess(
project_root=project_root,
work_dir=agent_runner_e2e_tmpdir,
port=agent_runner_e2e_port,
work_dir=runner_e2e_tmpdir,
port=runner_e2e_port,
timeout=180,
debug=True,
cli_args=['--standalone-runtime'],
@@ -306,7 +303,7 @@ def agent_runner_langbot_process(
success = proc.start()
if not success:
stdout, stderr = proc.get_logs()
pytest.fail(f'LangBot failed to start with AgentRunner plugin runtime:\nstdout: {stdout}\nstderr: {stderr}')
pytest.fail(f'LangBot failed to start with Runner plugin runtime:\nstdout: {stdout}\nstderr: {stderr}')
yield proc
@@ -314,10 +311,10 @@ def agent_runner_langbot_process(
@pytest.fixture
def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process):
"""HTTP client for the AgentRunner E2E backend."""
def runner_client(runner_e2e_port, runner_langbot_process):
"""HTTP client for the Runner E2E backend."""
with httpx.Client(
base_url=f'http://127.0.0.1:{agent_runner_e2e_port}',
base_url=f'http://127.0.0.1:{runner_e2e_port}',
timeout=90.0,
trust_env=False,
) as client:
@@ -396,29 +393,25 @@ def _ensure_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> N
_install_qa_plugin(client, token, package_path)
def test_plugin_runtime_discovers_agent_runner(
agent_runner_client,
agent_runner_langbot_process,
agent_runner_e2e_tmpdir,
def test_plugin_runtime_discovers_runner(
runner_client,
runner_langbot_process,
runner_e2e_tmpdir,
):
"""Pipeline metadata should include the real runtime-discovered QA runner."""
token = _init_and_auth(agent_runner_client)
token = _init_and_auth(runner_client)
_ensure_qa_plugin(
agent_runner_client,
runner_client,
token,
agent_runner_e2e_tmpdir / 'agent-runner-qa.zip',
runner_e2e_tmpdir / 'agent-runner-qa.zip',
)
option_names = _wait_for_qa_runner(agent_runner_client, token)
option_names = _wait_for_qa_runner(runner_client, token)
if QA_RUNNER_ID in option_names:
return
host_stdout, host_stderr = agent_runner_langbot_process.get_logs()
runtime_stdout = (agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log').read_text(
encoding='utf-8', errors='replace'
)
runtime_stderr = (agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log').read_text(
encoding='utf-8', errors='replace'
)
host_stdout, host_stderr = runner_langbot_process.get_logs()
runtime_stdout = (runner_e2e_tmpdir / 'plugin-runtime.stdout.log').read_text(encoding='utf-8', errors='replace')
runtime_stderr = (runner_e2e_tmpdir / 'plugin-runtime.stderr.log').read_text(encoding='utf-8', errors='replace')
assert QA_RUNNER_ID in option_names, (
f'{QA_RUNNER_ID} was not discovered\n'
f'Host stdout (tail):\n{host_stdout[-20_000:]}\nHost stderr (tail):\n{host_stderr[-20_000:]}\n'
@@ -427,26 +420,26 @@ def test_plugin_runtime_discovers_agent_runner(
)
def test_host_orchestrator_runs_agent_runner_and_records_ledger(
agent_runner_client,
agent_runner_langbot_process,
agent_runner_e2e_tmpdir,
def test_host_orchestrator_runs_runner_and_records_ledger(
runner_client,
runner_langbot_process,
runner_e2e_tmpdir,
):
"""Create/configure/debug an Agent through HTTP and persist Runner side effects."""
del agent_runner_langbot_process
token = _init_and_auth(agent_runner_client)
del runner_langbot_process
token = _init_and_auth(runner_client)
_ensure_qa_plugin(
agent_runner_client,
runner_client,
token,
agent_runner_e2e_tmpdir / 'agent-runner-qa.zip',
runner_e2e_tmpdir / 'agent-runner-qa.zip',
)
headers = {'Authorization': f'Bearer {token}'}
create_response = agent_runner_client.post(
create_response = runner_client.post(
'/api/v1/agents',
headers=headers,
json={
'kind': 'agent',
'name': 'AgentRunner E2E Agent',
'name': 'Runner E2E Agent',
'description': 'Exercises the installed QA Runner.',
'emoji': 'QA',
'supported_event_patterns': ['message.*'],
@@ -462,7 +455,7 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
assert create_payload['code'] == 0, create_payload
agent_uuid = create_payload['data']['uuid']
get_response = agent_runner_client.get(f'/api/v1/agents/{agent_uuid}', headers=headers)
get_response = runner_client.get(f'/api/v1/agents/{agent_uuid}', headers=headers)
assert get_response.status_code == 200, get_response.text
stored_agent = get_response.json()['data']['agent']
assert stored_agent['config']['allowed_platform_tools'] == [
@@ -470,7 +463,7 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
'platform_get_user_info',
]
debug_response = agent_runner_client.post(
debug_response = runner_client.post(
f'/api/v1/agents/{agent_uuid}/debug',
headers=headers,
json={
@@ -486,7 +479,7 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
assert result['final_text'] == 'e2e echo: hello from orchestrator e2e'
assert result['outputs'][0]['role'] == 'assistant'
db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db'
db_path = runner_e2e_tmpdir / 'data' / 'langbot.db'
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
@@ -504,9 +497,7 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
}
assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types)
state_row = conn.execute(
"SELECT value_json FROM agent_runner_state WHERE state_key = 'e2e.echo_count'"
).fetchone()
state_row = conn.execute("SELECT value_json FROM runner_state WHERE state_key = 'e2e.echo_count'").fetchone()
assert state_row is not None
assert '"count": 1' in state_row[0]
finally:
@@ -514,17 +505,17 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
def test_event_processor_real_runtime_logs_actions_and_instance_isolation(
agent_runner_client,
agent_runner_e2e_tmpdir,
runner_client,
runner_e2e_tmpdir,
):
client = agent_runner_client
client = runner_client
token = _init_and_auth(client)
_ensure_qa_plugin(client, token, agent_runner_e2e_tmpdir / 'agent-runner-qa.zip')
_ensure_qa_plugin(client, token, runner_e2e_tmpdir / 'agent-runner-qa.zip')
headers = {'Authorization': f'Bearer {token}'}
metadata_response = client.get('/api/v1/agents/_/metadata', headers=headers).json()
assert metadata_response['code'] == 0, metadata_response
metadata = metadata_response['data']
ref = 'event_processor:e2e/agent-runner-qa/default'
ref = 'plugin:e2e/agent-runner-qa/welcome'
assert any(item['id'] == ref for item in metadata['event_processors']), metadata
assert ref not in _wait_for_qa_runner(client, token)
created = []
@@ -1,4 +1,4 @@
"""Tests for current AgentRunner config resolution."""
"""Tests for current Runner config resolution."""
from __future__ import annotations
@@ -1,4 +1,4 @@
"""Tests for persisted AgentRunner config templates."""
"""Tests for persisted Runner config templates."""
from __future__ import annotations
@@ -1,25 +1,27 @@
"""Tests for ContextAccess.state determination in AgentRunContextBuilder.
"""Tests for ContextAccess.state determination in RunnerContextBuilder.
Tests focus on:
- Event-first mode: state=True when enable_state=True and state_scopes non-empty
- Event-first mode: state=False when enable_state=False
- Legacy Query mode: state=False (no persistent state API)
"""
from __future__ import annotations
import pytest
from unittest.mock import MagicMock
from langbot.pkg.agent.runner.context_builder import AgentRunContextBuilder
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope, StatePolicy
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
class MockApplication:
"""Mock Application for testing."""
def __init__(self):
self.logger = MagicMock()
self.persistence_mgr = MagicMock()
@@ -28,8 +30,8 @@ class MockApplication:
def make_descriptor(
permissions: dict | None = None,
) -> AgentRunnerDescriptor:
return AgentRunnerDescriptor(
) -> RunnerDescriptor:
return RunnerDescriptor(
id='plugin:test/runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -91,7 +93,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call to _build_context_access
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -112,7 +114,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -133,7 +135,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -144,7 +146,7 @@ class TestContextAccessStateDetermination:
@pytest.mark.asyncio
async def test_no_binding_sets_state_false(self, mock_app, mock_event, mock_descriptor):
"""ContextAccess.state=False when no binding is provided."""
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call without binding
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding=None)
@@ -180,7 +182,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -201,7 +203,7 @@ class TestContextAccessStateDetermination:
),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -277,7 +279,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -301,7 +303,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -324,7 +326,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
# Real call
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -350,7 +352,7 @@ class TestContextAccessOtherAPIs:
state_policy=StatePolicy(enable_state=False, state_scopes=[]),
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
context_access = await builder._build_context_access(mock_event, mock_descriptor, binding)
@@ -1,4 +1,4 @@
"""Test that LangBot context builder output validates against SDK AgentRunContext."""
"""Test that LangBot context builder output validates against SDK RunnerContext."""
from __future__ import annotations
@@ -7,26 +7,26 @@ from types import SimpleNamespace
from unittest.mock import MagicMock, AsyncMock, patch
# SDK imports for validation
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.event import AgentEventContext
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAccess
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
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.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.event import AgentEventContext
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.context_access import ContextAccess
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
# LangBot imports
from langbot.pkg.agent.runner.context_builder import (
AgentRunContextBuilder,
RunnerContextBuilder,
AgentResources as BuilderResources,
)
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope, AgentBinding, BindingScope
from langbot.pkg.core import app
class TestContextValidation:
"""Test that context builder output validates against SDK AgentRunContext."""
"""Test that context builder output validates against SDK RunnerContext."""
def _make_mock_app(self):
"""Create a mock application."""
@@ -40,9 +40,9 @@ class TestContextValidation:
def _make_event_envelope(self) -> AgentEventEnvelope:
"""Create a test event envelope."""
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
return AgentEventEnvelope(
event_id='evt_1',
@@ -90,7 +90,7 @@ class TestContextValidation:
def _make_descriptor(self):
"""Create a mock runner descriptor."""
return AgentRunnerDescriptor(
return RunnerDescriptor(
id='plugin:test/plugin/runner',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -106,9 +106,9 @@ class TestContextValidation:
@pytest.mark.asyncio
async def test_build_context_from_event_validates(self):
"""Test that build_context_from_event output validates against SDK AgentRunContext."""
"""Test that build_context_from_event output validates against SDK RunnerContext."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -136,9 +136,9 @@ class TestContextValidation:
resources=resources,
)
# Validate it can be parsed by SDK AgentRunContext
# Validate it can be parsed by SDK RunnerContext
# This will raise ValidationError if invalid
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
# Verify required fields
assert validated.run_id is not None
@@ -179,13 +179,13 @@ class TestContextValidation:
@pytest.mark.asyncio
async def test_build_context_preserves_interaction_protocol_fields(self):
"""Validated submissions and delivery capabilities survive the final context projection."""
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
from langbot_plugin.api.entities.builtin.runner.interaction import (
InteractionDeliveryCapabilities,
InteractionSubmission,
)
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
event.event_type = 'interaction.submitted'
event.input.interaction = InteractionSubmission(
@@ -211,7 +211,7 @@ class TestContextValidation:
resources=self._make_resources(),
)
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.input.interaction is not None
assert validated.input.interaction.interaction_id == 'form-1'
assert validated.input.interaction.values == {'comment': 'looks good'}
@@ -228,7 +228,7 @@ class TestContextValidation:
model_entity=SimpleNamespace(context_length=128000),
)
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -281,7 +281,7 @@ class TestContextValidation:
model_entity=SimpleNamespace(context_length=None),
)
)
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
resources = self._make_resources()
resources['models'] = [
{
@@ -304,12 +304,12 @@ class TestContextValidation:
@pytest.mark.asyncio
async def test_build_context_preserves_subject_data_for_non_message_events(self):
"""Non-message EBA events keep subject.data instead of relying on message text."""
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput as EventInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = AgentEventEnvelope(
event_id='evt_recall_1',
event_type='message.recalled',
@@ -353,7 +353,7 @@ class TestContextValidation:
resources=resources,
)
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.event.event_type == 'message.recalled'
assert validated.input.text is None
@@ -366,7 +366,7 @@ class TestContextValidation:
async def test_build_context_from_event_has_no_legacy_top_level_fields(self):
"""Test that build_context_from_event does NOT have top-level messages/prompt/params."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -409,7 +409,7 @@ class TestContextValidation:
async def test_build_context_from_event_event_is_not_none(self):
"""Test that event field is NOT None in Protocol v1."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -440,14 +440,14 @@ class TestContextValidation:
assert context_dict.get('event') is not None, 'event is REQUIRED for Protocol v1'
# Validate
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.event is not None
@pytest.mark.asyncio
async def test_build_context_from_event_delivery_is_not_none(self):
"""Test that delivery field is NOT None in Protocol v1."""
mock_app = self._make_mock_app()
builder = AgentRunContextBuilder(mock_app)
builder = RunnerContextBuilder(mock_app)
event = self._make_event_envelope()
binding = self._make_binding()
@@ -478,5 +478,5 @@ class TestContextValidation:
assert context_dict.get('delivery') is not None, 'delivery is REQUIRED for Protocol v1'
# Validate
validated = AgentRunContext.model_validate(context_dict)
validated = RunnerContext.model_validate(context_dict)
assert validated.delivery is not None
@@ -3,7 +3,7 @@
Tests cover:
1. Query -> AgentEventEnvelope conversion
2. Current config -> AgentConfig projection and single-binding resolution
3. AgentRunContext not inlining full history by default
3. RunnerContext not inlining full history by default
4. LangBot Host not defining context-window controls
5. Event-first run() entry point
"""
@@ -14,14 +14,14 @@ import pytest
from unittest.mock import Mock
# Import SDK entities
from langbot_plugin.api.entities.builtin.agent_runner.event import (
from langbot_plugin.api.entities.builtin.runner.event import (
AgentEventContext,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.trigger import AgentTrigger
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.result import (
AgentRunResult,
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.trigger import AgentTrigger
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.entities.builtin.runner.result import (
RunnerResult,
)
# Import LangBot host models
@@ -263,8 +263,8 @@ class TestQueryConfigToAgentConfig:
AgentBindingResolver().resolve_one(event, [first, second])
class TestAgentRunContextProtocolV1:
"""Test AgentRunContext Protocol v1 behavior."""
class TestRunnerContextProtocolV1:
"""Test RunnerContext Protocol v1 behavior."""
def test_sdk_context_event_required(self):
"""Test that event is required in Protocol v1 context."""
@@ -275,11 +275,11 @@ class TestAgentRunContextProtocolV1:
source='platform',
)
input = AgentInput(text='Hello')
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.runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
ctx = AgentRunContext(
ctx = RunnerContext(
run_id='run_1',
trigger=trigger,
event=event,
@@ -293,7 +293,7 @@ class TestAgentRunContextProtocolV1:
assert ctx.event.event_type == 'message.received'
def test_sdk_context_has_no_history_message_fields(self):
"""AgentRunContext should not expose inline history message fields."""
"""RunnerContext should not expose inline history message fields."""
trigger = AgentTrigger(type='message.received')
event = AgentEventContext(
event_id='evt_1',
@@ -301,11 +301,11 @@ class TestAgentRunContextProtocolV1:
source='platform',
)
input = AgentInput(text='Hello')
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.runner.resources import AgentResources
from langbot_plugin.api.entities.builtin.runner.runtime import AgentRuntimeContext
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
ctx = AgentRunContext(
ctx = RunnerContext(
run_id='run_1',
trigger=trigger,
event=event,
@@ -315,8 +315,8 @@ class TestAgentRunContextProtocolV1:
runtime=AgentRuntimeContext(),
)
assert 'messages' not in AgentRunContext.model_fields
assert 'bootstrap' not in AgentRunContext.model_fields
assert 'messages' not in RunnerContext.model_fields
assert 'bootstrap' not in RunnerContext.model_fields
assert not hasattr(ctx, 'bootstrap')
@@ -324,20 +324,20 @@ class TestHostManagedHistoryNotInProtocol:
"""Test that Host-managed history payloads are not in Protocol v1."""
def test_messages_not_in_sdk_context_top_level(self):
"""AgentRunContext should not expose top-level history messages."""
ctx_fields = AgentRunContext.model_fields.keys()
"""RunnerContext should not expose top-level history messages."""
ctx_fields = RunnerContext.model_fields.keys()
assert 'messages' not in ctx_fields
class TestSDKResultProtocolV1:
"""Test SDK AgentRunResult for Protocol v1."""
"""Test SDK RunnerResult for Protocol v1."""
def test_result_requires_run_id(self):
"""Test result requires run_id for Protocol v1."""
from langbot_plugin.api.entities.builtin.provider.message import Message
result = AgentRunResult.message_completed(
result = RunnerResult.message_completed(
run_id='run_1',
message=Message(role='assistant', content='Hello'),
)
@@ -17,11 +17,11 @@ from langbot.pkg.agent.runner.host_models import (
from langbot.pkg.agent.runner.event_log_store import EventLogStore
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot_plugin.api.entities.builtin.agent_runner.event import (
from langbot_plugin.api.entities.builtin.runner.event import (
ActorContext,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
def make_event_envelope(
@@ -1,11 +1,11 @@
"""Tests for Host-only AgentRunner tool execution context."""
"""Tests for Host-only Runner tool execution context."""
from __future__ import annotations
import json
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
@@ -129,7 +129,7 @@ def test_prepare_box_scope_overwrites_untrusted_existing_scope():
assert json.loads(variables['_host_box_scope'])['target_id'] == 'user-1'
def test_project_mcp_resource_config_uses_independent_agent_runner_settings():
def test_project_mcp_resource_config_uses_independent_runner_settings():
query = pipeline_query.Query.model_construct(variables={})
attachments = [
{
+20 -20
View File
@@ -7,7 +7,7 @@ Tests focus on:
- RETRIEVE_KNOWLEDGE_BASE authorization
Authorization paths:
1. AgentRunner calls: has run_id, validates against session_registry
1. Runner calls: has run_id, validates against session_registry
2. Regular plugin calls: no run_id, unscoped plugin action path
"""
@@ -16,7 +16,7 @@ from __future__ import annotations
import pytest
from unittest.mock import AsyncMock, MagicMock
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.plugin.handler import _get_pipeline_knowledge_base_uuids
from langbot.pkg.api.http.context import ExecutionContext
@@ -122,9 +122,9 @@ class MockApplication:
self.persistence_mgr.execute_async = AsyncMock(return_value=MagicMock(first=lambda: None))
class FakeAgentRunnerRegistry:
class FakeRunnerRegistry:
async def get(self, context, runner_id, bound_plugins=None):
return AgentRunnerDescriptor(
return RunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -161,7 +161,7 @@ class TestPipelineKnowledgeBaseScope:
@pytest.mark.asyncio
async def test_uses_runner_schema_when_query_scope_not_preprocessed(self):
app = MockApplication()
app.agent_runner_registry = FakeAgentRunnerRegistry()
app.runner_registry = FakeRunnerRegistry()
query = MockQuery()
query.variables = {}
@@ -458,15 +458,15 @@ class TestRetrieveKnowledgeBaseAuthorization:
class TestAuthorizationPathDifferentiation:
"""Tests that verify AgentRunner vs regular plugin call differentiation."""
"""Tests that verify Runner vs regular plugin call differentiation."""
@pytest.mark.asyncio
async def test_agent_runner_path_with_run_id(self):
"""AgentRunner calls provide run_id and use session_registry."""
async def test_runner_path_with_run_id(self):
"""Runner calls provide run_id and use session_registry."""
registry = AgentRunSessionRegistry()
# AgentRunner call has run_id
run_id = 'run_agent_123'
# Runner call has run_id
run_id = 'run_runner_123'
# Register session with resources
await registry.register(
@@ -548,7 +548,7 @@ class TestRETRIEVEKNOWLEDGEBASEBugFix:
Fix: Now uses RunnerConfigResolver.resolve_runner_id first, then resolve_runner_config.
"""
def test_retrieve_kb_fix_local_agent_runner(self):
def test_retrieve_kb_fix_local_runner(self):
"""Fix should work for local-agent runner."""
from langbot.pkg.agent.runner.config_resolver import RunnerConfigResolver
@@ -830,8 +830,8 @@ class TestHandlerActionAuthorization:
await registry.unregister(run_id)
class TestSDKAgentRunAPIProxyFieldConsistency:
"""Tests for SDK AgentRunAPIProxy field name consistency with Host handler.
class TestSDKRunnerAPIProxyFieldConsistency:
"""Tests for SDK RunnerAPIProxy field name consistency with Host handler.
These tests verify that SDK sends field names that match what Host handler reads.
"""
@@ -901,7 +901,7 @@ class TestSDKAgentRunAPIProxyFieldConsistency:
class TestNoRunIdBackwardCompatPath:
"""Tests for unscoped plugin action path when no run_id is provided.
Regular plugins (non-AgentRunner) don't have run_id and should
Regular plugins (non-Runner) don't have run_id and should
have unrestricted access to certain APIs.
"""
@@ -1182,14 +1182,14 @@ class TestResourceTypeValidation:
class TestBypassPrevention:
"""Tests to ensure AgentRunAPIProxy cannot bypass authorization."""
"""Tests to ensure RunnerAPIProxy cannot bypass authorization."""
@pytest.mark.asyncio
async def test_cannot_bypass_via_unrestricted_retrieve_knowledge(self):
"""Cannot bypass KB authorization via unrestricted RETRIEVE_KNOWLEDGE action."""
# AgentRunAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id)
# RunnerAPIProxy uses RETRIEVE_KNOWLEDGE_BASE (with run_id)
# RETRIEVE_KNOWLEDGE is unrestricted and separate
# AgentRunner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization
# Runner should NOT use RETRIEVE_KNOWLEDGE to bypass authorization
registry = AgentRunSessionRegistry()
resources = make_resources(knowledge_bases=[{'kb_id': 'kb_001'}])
@@ -1207,8 +1207,8 @@ class TestBypassPrevention:
# kb_002 is not authorized
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_002') is False
# If AgentRunner tried to use RETRIEVE_KNOWLEDGE (unrestricted),
# it would bypass authorization - but AgentRunAPIProxy correctly uses
# If Runner tried to use RETRIEVE_KNOWLEDGE (unrestricted),
# it would bypass authorization - but RunnerAPIProxy correctly uses
# RETRIE_KNOWLEDGE_BASE which requires authorization
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
@@ -2006,7 +2006,7 @@ class TestCallerPluginIdentityValidation:
class TestBackwardCompatStorageNoRunId:
"""Tests for unscoped storage actions without run_id.
Regular plugins (non-AgentRunner) don't have run_id and should
Regular plugins (non-Runner) don't have run_id and should
have unrestricted access to storage APIs.
"""
@@ -1,4 +1,5 @@
"""Tests for AgentRunner history/event pull API authorization."""
"""Tests for Runner history/event pull API authorization."""
from __future__ import annotations
from unittest.mock import MagicMock
@@ -11,7 +12,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.entity.persistence import event_log as event_log_model
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.api.entities.builtin.agent_runner.page_results import (
from langbot_plugin.api.entities.builtin.runner.page_results import (
AgentEventRecord,
EventPage,
)
@@ -92,10 +93,12 @@ async def test_history_page_requires_runtime_capability(session_registry, db_eng
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await history_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not authorized' in result.message.lower()
@@ -107,11 +110,13 @@ async def test_history_page_rejects_cross_conversation(session_registry, db_engi
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
})
result = await history_page(
{
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not accessible' in result.message.lower()
@@ -123,12 +128,14 @@ async def test_history_search_rejects_filter_conversation_override(session_regis
handler = _handler(db_engine, session_registry)
history_search = handler.actions[PluginToRuntimeAction.HISTORY_SEARCH.value]
result = await history_search({
'run_id': 'run_1',
'query': 'hello',
'filters': {'conversation_id': 'conv_other'},
'caller_plugin_identity': 'test/runner',
})
result = await history_search(
{
'run_id': 'run_1',
'query': 'hello',
'filters': {'conversation_id': 'conv_other'},
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not accessible' in result.message.lower()
@@ -140,10 +147,12 @@ async def test_event_page_requires_runtime_capability(session_registry, db_engin
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not authorized' in result.message.lower()
@@ -155,11 +164,13 @@ async def test_event_page_rejects_cross_conversation(session_registry, db_engine
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'conversation_id': 'conv_other',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not accessible' in result.message.lower()
@@ -184,11 +195,13 @@ async def test_event_get_returns_sdk_record_projection(session_registry, db_engi
handler = _handler(db_engine, session_registry)
event_get = handler.actions[PluginToRuntimeAction.EVENT_GET.value]
result = await event_get({
'run_id': 'run_1',
'event_id': event_id,
'caller_plugin_identity': 'test/runner',
})
result = await event_get(
{
'run_id': 'run_1',
'event_id': event_id,
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
AgentEventRecord.model_validate(result.data)
@@ -216,10 +229,12 @@ async def test_event_page_returns_sdk_page_projection(session_registry, db_engin
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
page = EventPage.model_validate(result.data)
@@ -272,10 +287,12 @@ async def test_history_page_filters_run_scope_thread_and_bot(session_registry, d
handler = _handler(db_engine, session_registry)
history_page = handler.actions[PluginToRuntimeAction.HISTORY_PAGE.value]
result = await history_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await history_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
assert [item['content'] for item in result.data['items']] == ['visible']
@@ -317,10 +334,12 @@ async def test_event_page_filters_run_scope_thread_and_bot(session_registry, db_
handler = _handler(db_engine, session_registry)
event_page = handler.actions[PluginToRuntimeAction.EVENT_PAGE.value]
result = await event_page({
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
})
result = await event_page(
{
'run_id': 'run_1',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code == 0
assert [item['event_id'] for item in result.data['items']] == ['evt_visible']
@@ -8,10 +8,10 @@ import time
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.interaction import (
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.event import ActorContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.interaction import (
InteractionDeliveryCapabilities,
InteractionSubmission,
)
@@ -10,7 +10,7 @@ from unittest.mock import AsyncMock
import pytest
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
@@ -84,7 +84,7 @@ class FakePluginConnector:
self.contexts: list[dict] = []
self.sessions_during_run: list[dict | None] = []
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
async def run_runner(self, plugin_author, plugin_name, runner_name, context):
self.calls.append(
{
'plugin_author': plugin_author,
@@ -105,7 +105,7 @@ class FakePluginConnector:
class FakeRegistry:
def __init__(self, descriptor: AgentRunnerDescriptor):
def __init__(self, descriptor: RunnerDescriptor):
self.descriptor = descriptor
self.calls: list[dict] = []
@@ -162,8 +162,8 @@ class FakeConversation:
create_time = datetime.datetime(2026, 5, 15, 12, 0, 0)
def make_descriptor() -> AgentRunnerDescriptor:
return AgentRunnerDescriptor(
def make_descriptor() -> RunnerDescriptor:
return RunnerDescriptor(
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -798,7 +798,7 @@ async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_a
self.started = asyncio.Event()
self.release = asyncio.Event()
async def run_agent(self, plugin_author, plugin_name, runner_name, context):
async def run_runner(self, plugin_author, plugin_name, runner_name, context):
self.calls.append(
{
'plugin_author': plugin_author,
@@ -998,8 +998,8 @@ class TestQueryEntrySessionQueryId:
DeliveryPolicy,
ResourcePolicy,
)
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
db_engine = clean_agent_state
descriptor = make_descriptor()
@@ -2,7 +2,7 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.api.entities.builtin.agent_runner import (
from langbot_plugin.api.entities.builtin.runner import (
ActorContext,
AgentInput,
DeliveryContext,
+39 -37
View File
@@ -6,8 +6,8 @@ from unittest.mock import AsyncMock
import pytest
from langbot.pkg.agent.runner.registry import AgentRunnerRegistry
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.registry import RunnerRegistry
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerNotFoundError, RunnerNotAuthorizedError
from langbot.pkg.api.http.context import ExecutionContext
@@ -44,7 +44,7 @@ class FakeApplication:
async def require_workspace_context(self, context):
return context
async def list_agent_runners(self, bound_plugins=None):
async def list_runners(self, bound_plugins=None):
# Return sample runner data
return [
{
@@ -90,7 +90,7 @@ class FakeApplication:
'plugin_name': 'missing-name',
'runner_name': 'default',
'manifest': {
'kind': 'AgentRunner',
'kind': 'Runner',
'metadata': {}, # No name
'spec': {},
},
@@ -107,7 +107,7 @@ class TestRegistryDiscovery:
async def test_discover_valid_runners(self):
"""Discover valid runners from plugin runtime."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
@@ -122,7 +122,7 @@ class TestRegistryDiscovery:
async def test_discover_caches_results(self):
"""Discovery should cache results."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
# First discovery
runners1 = await registry.list_runners(TEST_CONTEXT, use_cache=True)
@@ -138,7 +138,7 @@ class TestRegistryDiscovery:
"""Discovery returns empty when plugin system disabled."""
ap = FakeApplication()
ap.plugin_connector.is_enable_plugin = False
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
runners = await registry.list_runners(TEST_CONTEXT, use_cache=False)
@@ -152,7 +152,7 @@ class TestRegistryDiscovery:
so subsequent list_runners(bound_plugins=None) should return all runners.
"""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
# First: get with bound_plugins filter (should not pollute cache)
descriptor = await registry.get(
@@ -189,7 +189,7 @@ class TestRegistryGet:
async def test_get_existing_runner(self):
"""Get existing runner by ID."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
descriptor = await registry.get(
TEST_CONTEXT,
@@ -205,7 +205,7 @@ class TestRegistryGet:
async def test_get_nonexistent_runner(self):
"""Get nonexistent runner raises RunnerNotFoundError."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
with pytest.raises(RunnerNotFoundError) as exc_info:
await registry.get(TEST_CONTEXT, 'plugin:notexist/unknown/default')
@@ -216,10 +216,10 @@ class TestRegistryGet:
async def test_get_refreshes_partial_startup_cache_on_miss(self):
"""A runner initialized after early discovery should become available."""
ap = FakeApplication()
ap.plugin_connector.list_agent_runners = AsyncMock(
side_effect=ap.plugin_connector.list_agent_runners,
ap.plugin_connector.list_runners = AsyncMock(
side_effect=ap.plugin_connector.list_runners,
)
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
await registry.list_runners(TEST_CONTEXT)
cache = registry._cache[('instance-test', 'workspace-test', 1)]
@@ -231,13 +231,13 @@ class TestRegistryGet:
)
assert descriptor.id == 'plugin:alice/my-agent/custom'
assert ap.plugin_connector.list_agent_runners.await_count == 2
assert ap.plugin_connector.list_runners.await_count == 2
@pytest.mark.asyncio
async def test_get_runner_with_bound_plugins_filter(self):
"""Get runner with bound plugins authorization."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
# Authorized - langbot plugin in bound list
descriptor = await registry.get(
@@ -263,7 +263,7 @@ class TestRegistryMetadataForPipeline:
async def test_get_metadata_options_and_stages(self):
"""Get metadata options and stages for pipeline UI."""
ap = FakeApplication()
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
options, stages = await registry.get_runner_metadata_for_pipeline(TEST_CONTEXT)
@@ -284,10 +284,10 @@ class TestRegistryMetadataForPipeline:
async def test_metadata_refreshes_partial_startup_cache(self):
"""Pipeline metadata should not preserve an early partial discovery."""
ap = FakeApplication()
ap.plugin_connector.list_agent_runners = AsyncMock(
side_effect=ap.plugin_connector.list_agent_runners,
ap.plugin_connector.list_runners = AsyncMock(
side_effect=ap.plugin_connector.list_runners,
)
registry = AgentRunnerRegistry(ap)
registry = RunnerRegistry(ap)
await registry.list_runners(TEST_CONTEXT)
cache = registry._cache[('instance-test', 'workspace-test', 1)]
@@ -299,7 +299,7 @@ class TestRegistryMetadataForPipeline:
'plugin:langbot-team/LocalAgent/default',
'plugin:alice/my-agent/custom',
}
assert ap.plugin_connector.list_agent_runners.await_count == 2
assert ap.plugin_connector.list_runners.await_count == 2
class TestDescriptorValidation:
@@ -307,7 +307,7 @@ class TestDescriptorValidation:
def test_validate_runner_descriptor(self):
"""Validate correctly built descriptor."""
descriptor = AgentRunnerDescriptor(
descriptor = RunnerDescriptor(
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -318,11 +318,11 @@ class TestDescriptorValidation:
assert descriptor.id == 'plugin:test/my-runner/default'
assert descriptor.get_plugin_id() == 'test/my-runner'
assert 'protocol_version' not in AgentRunnerDescriptor.model_fields
assert 'protocol_version' not in RunnerDescriptor.model_fields
def test_descriptor_capabilities(self):
"""Descriptor capability helper methods."""
descriptor = AgentRunnerDescriptor(
descriptor = RunnerDescriptor(
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -338,28 +338,30 @@ class TestDescriptorValidation:
@pytest.mark.asyncio
async def test_registry_separates_processor_kinds_with_same_plugin_component_name():
async def test_registry_filters_usages_without_splitting_component_identity():
ap = FakeApplication()
entries = []
for kind, prefix in [('AgentRunner', 'plugin'), ('EventProcessor', 'event_processor')]:
for name, usages in [('agent', ['agent']), ('events', ['event']), ('both', ['agent', 'event'])]:
entries.append(
{
'plugin_author': 'test',
'plugin_name': 'both',
'runner_name': 'default',
'plugin_name': 'runners',
'runner_name': name,
'manifest': {
'id': f'{prefix}:test/both/default',
'name': 'default',
'component_kind': kind,
'label': {'en_US': kind},
'id': f'plugin:test/runners/{name}',
'name': name,
'component_kind': 'Runner',
'usages': usages,
'label': {'en_US': name},
'supported_event_patterns': ['group.member_joined'],
},
}
)
ap.plugin_connector.list_agent_runners = AsyncMock(return_value=entries)
registry = AgentRunnerRegistry(ap)
ap.plugin_connector.list_runners = AsyncMock(return_value=entries)
registry = RunnerRegistry(ap)
agents = await registry.list_runners(TEST_CONTEXT)
processors = await registry.list_runners(TEST_CONTEXT, component_kind='EventProcessor')
assert [item.id for item in agents] == ['plugin:test/both/default']
assert [item.id for item in processors] == ['event_processor:test/both/default']
assert (await registry.get(TEST_CONTEXT, processors[0].id)).component_kind == 'EventProcessor'
processors = await registry.list_runners(TEST_CONTEXT, usage='event')
assert [item.runner_name for item in agents] == ['agent', 'both']
assert [item.runner_name for item in processors] == ['events', 'both']
assert agents[-1].id == processors[-1].id
assert (await registry.get(TEST_CONTEXT, processors[0].id)).usages == ['event']
+23 -14
View File
@@ -7,9 +7,9 @@ from uuid import uuid4
import pytest
from langbot_plugin.api.entities.builtin.platform import events, entities, message
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAPICapabilities
from langbot_plugin.api.proxies.agent_run import AgentRunAPIProxy
from langbot_plugin.api.proxies.agent_run.common import PermissionDeniedError
from langbot_plugin.api.entities.builtin.runner.context_access import ContextAPICapabilities
from langbot_plugin.api.proxies.runner import RunnerAPIProxy
from langbot_plugin.api.proxies.runner.common import PermissionDeniedError
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot.pkg.agent.runner.reply_stream import ReplyStreamRequest, ReplyStreamSession
@@ -50,7 +50,7 @@ def request(key, operation='update', text='hello'):
def proxy_for(session, *, allowed=True, advertised=True):
if not hasattr(AgentRunAPIProxy, 'reply_stream'):
if not hasattr(RunnerAPIProxy, 'reply_stream'):
pytest.skip('SDK does not provide the optional streaming reply API')
context = SimpleNamespace(
run_id='run-1',
@@ -73,7 +73,7 @@ def proxy_for(session, *, allowed=True, advertised=True):
}
transport = SimpleNamespace(call_action=AsyncMock(side_effect=action))
return AgentRunAPIProxy(context, transport), transport
return RunnerAPIProxy(context, transport), transport
@pytest.mark.parametrize(
@@ -211,11 +211,11 @@ async def test_streams_are_isolated_by_run_and_bounded():
async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_stream():
from unittest.mock import Mock
from langbot_plugin.api.definition.components.event_processor import EventProcessor
from langbot_plugin.api.definition.components.runner import Runner, RunnerContext
session, adapter, incoming = make_session()
api, _ = proxy_for(session)
processor = EventProcessor()
processor = Runner()
processor.get_run_api = Mock(return_value=api)
@processor.handler(events.MessageReceivedEvent)
@@ -224,14 +224,23 @@ async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_s
await stream.update('one')
await stream.update('one two')
context = SimpleNamespace(
run_id='run-1',
config={},
event=SimpleNamespace(
data=incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
),
context = RunnerContext.model_validate(
{
'run_id': 'run-1',
'trigger': {'type': incoming.type},
'event': {
'event_id': 'one',
'event_type': incoming.type,
'source': 'test',
'data': incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'}),
},
'input': {},
'delivery': {'surface': 'test'},
'resources': {},
'runtime': {},
}
)
results = [result async for result in processor.run(context)]
results = [result async for result in processor.invoke(context)]
assert [r.type for r in results] == ['tool.call.started', 'tool.call.completed', 'run.completed']
assert results[1].data['result']['text'] == 'one two'
assert adapter.reply_message_chunk.await_count == 3
@@ -6,9 +6,9 @@ from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot_plugin.api.entities.builtin.agent_runner import AgentInput, DeliveryContext
from langbot_plugin.api.entities.builtin.runner import AgentInput, DeliveryContext
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
@@ -37,8 +37,8 @@ def make_descriptor(
config_schema: list[dict] | None = None,
capabilities: dict | None = None,
permissions: dict | None = None,
) -> AgentRunnerDescriptor:
return AgentRunnerDescriptor(
) -> RunnerDescriptor:
return RunnerDescriptor(
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -1,4 +1,4 @@
"""Tests for generic AgentRunner resource-policy projection."""
"""Tests for generic Runner resource-policy projection."""
from types import SimpleNamespace
@@ -5,7 +5,7 @@ from __future__ import annotations
import pytest
from langbot.pkg.agent.runner.result_normalizer import AgentResultNormalizer
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.errors import RunnerExecutionError, RunnerProtocolError
from langbot_plugin.api.entities.builtin.provider import message as provider_message
@@ -36,7 +36,7 @@ class FakeApplication:
def make_descriptor():
"""Create a test descriptor."""
return AgentRunnerDescriptor(
return RunnerDescriptor(
id='plugin:langbot-team/LocalAgent/default',
source='plugin',
label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'},
@@ -1,4 +1,4 @@
"""Tests for AgentRunner run ledger pull API authorization."""
"""Tests for Runner run ledger pull API authorization."""
from __future__ import annotations
@@ -17,7 +17,7 @@ from langbot.pkg.agent.runner.session_registry import AgentRunSessionRegistry
from langbot.pkg.entity.persistence import agent_run as agent_run_model
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.api.entities.builtin.agent_runner.run_ledger import (
from langbot_plugin.api.entities.builtin.runner.run_ledger import (
AgentRun,
AgentRunEvent,
RunEventPage,
@@ -37,10 +37,10 @@ class FakeApplication:
self.logger = MagicMock()
self.persistence_mgr = MagicMock()
self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
self.agent_runner_registry = runner_registry
self.runner_registry = runner_registry
self.instance_config = SimpleNamespace(
data={
'agent_runner': {
'runner': {
'admin_plugins': admin_plugins or [],
}
}
+131 -93
View File
@@ -17,6 +17,7 @@ Authorization rules:
- enable_state must be True
- scope must be in state_scopes
"""
from __future__ import annotations
import pytest
@@ -34,11 +35,13 @@ from .conftest import bind_runtime_action_context, make_resources
class FakeConnection:
"""Fake connection for testing."""
pass
class FakeApplication:
"""Fake Application for testing."""
def __init__(self, db_engine=None):
self.logger = MagicMock()
self.logger.debug = MagicMock()
@@ -77,10 +80,10 @@ async def persistent_store(db_engine):
store = PersistentStateStore(db_engine)
# Create the table
from langbot.pkg.entity.persistence.agent_runner_state import AgentRunnerState
from langbot.pkg.entity.persistence.runner_state import RunnerState
async with db_engine.begin() as conn:
await conn.run_sync(AgentRunnerState.__table__.create, checkfirst=True)
await conn.run_sync(RunnerState.__table__.create, checkfirst=True)
yield store
reset_persistent_state_store()
@@ -124,11 +127,13 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Call with non-existent run_id
result = await state_get_handler({
'run_id': 'nonexistent_run',
'scope': 'conversation',
'key': 'test_key',
})
result = await state_get_handler(
{
'run_id': 'nonexistent_run',
'scope': 'conversation',
'key': 'test_key',
}
)
assert result.code != 0
assert 'not found' in result.message.lower()
@@ -164,11 +169,13 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Call without caller_plugin_identity
result = await state_get_handler({
'run_id': 'run_test_missing_identity',
'scope': 'conversation',
'key': 'test_key',
})
result = await state_get_handler(
{
'run_id': 'run_test_missing_identity',
'scope': 'conversation',
'key': 'test_key',
}
)
assert result.code == 0
assert result.data == {'value': None}
@@ -176,7 +183,9 @@ class TestStateAPIHandlerAuthorization:
await session_registry.unregister('run_test_missing_identity')
@pytest.mark.asyncio
async def test_state_get_caller_identity_mismatch_returns_error(self, session_registry, db_engine, persistent_store):
async def test_state_get_caller_identity_mismatch_returns_error(
self, session_registry, db_engine, persistent_store
):
"""STATE_GET: caller_plugin_identity mismatch returns error."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@@ -200,12 +209,14 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Call with wrong caller_plugin_identity
result = await state_get_handler({
'run_id': 'run_test_mismatch',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'other/plugin',
})
result = await state_get_handler(
{
'run_id': 'run_test_mismatch',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'other/plugin',
}
)
assert result.code != 0
assert 'does not match' in result.message.lower()
@@ -236,12 +247,14 @@ class TestStateAPIHandlerAuthorization:
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
result = await state_get_handler({
'run_id': 'run_test_disabled',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_test_disabled',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'disabled' in result.message.lower()
@@ -262,7 +275,10 @@ class TestStateAPIHandlerAuthorization:
resources=make_resources(),
available_apis={'state': True},
state_policy={'enable_state': True, 'state_scopes': ['conversation']},
state_context={'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'}, 'binding_identity': 'binding_1'},
state_context={
'scope_keys': {'conversation': 'conv_key', 'actor': 'actor_key'},
'binding_identity': 'binding_1',
},
)
async def fake_disconnect():
@@ -273,12 +289,14 @@ class TestStateAPIHandlerAuthorization:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Request 'actor' scope which is not in state_scopes
result = await state_get_handler({
'run_id': 'run_test_scope_disabled',
'scope': 'actor',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_test_scope_disabled',
'scope': 'actor',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not enabled' in result.message.lower() or 'scope' in result.message.lower()
@@ -309,12 +327,14 @@ class TestStateAPIHandlerAuthorization:
handler = RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
result = await state_get_handler({
'run_id': 'run_test_no_scope_key',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_test_no_scope_key',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'not available' in result.message.lower()
@@ -360,9 +380,11 @@ class TestStateAPIFullFlowWithRealDB:
session = await session_registry.get('run_full_flow')
assert session is not None
state_ctx = session['authorization']['state_context']
assert state_ctx is not None, f"state_context is None. Session keys: {list(session.keys())}"
assert 'scope_keys' in state_ctx, f"scope_keys not in state_context: {state_ctx}"
assert 'conversation' in state_ctx['scope_keys'], f"conversation not in scope_keys: {state_ctx['scope_keys']}"
assert state_ctx is not None, f'state_context is None. Session keys: {list(session.keys())}'
assert 'scope_keys' in state_ctx, f'scope_keys not in state_context: {state_ctx}'
assert 'conversation' in state_ctx['scope_keys'], (
f'conversation not in scope_keys: {state_ctx["scope_keys"]}'
)
# Get handlers (actions dict is keyed by action value string)
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
@@ -371,57 +393,67 @@ class TestStateAPIFullFlowWithRealDB:
state_delete_handler = handler.actions[PluginToRuntimeAction.STATE_DELETE.value]
# 1. STATE_SET
set_result = await state_set_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'value': {'data': 'test_value'},
'caller_plugin_identity': 'test/runner',
})
set_result = await state_set_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'value': {'data': 'test_value'},
'caller_plugin_identity': 'test/runner',
}
)
assert set_result.code == 0
assert set_result.data.get('success') is True
# 2. STATE_GET
get_result = await state_get_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
})
get_result = await state_get_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert get_result.code == 0
assert get_result.data.get('value') == {'data': 'test_value'}
# 3. STATE_LIST
list_result = await state_list_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'prefix': 'external.',
'caller_plugin_identity': 'test/runner',
})
list_result = await state_list_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'prefix': 'external.',
'caller_plugin_identity': 'test/runner',
}
)
assert list_result.code == 0
keys = list_result.data.get('keys', [])
assert 'external.test_key' in keys
# 4. STATE_DELETE
delete_result = await state_delete_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
})
delete_result = await state_delete_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert delete_result.code == 0
# 5. Verify deleted
get_after_delete = await state_get_handler({
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
})
get_after_delete = await state_get_handler(
{
'run_id': 'run_full_flow',
'scope': 'conversation',
'key': 'external.test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert get_after_delete.code == 0
assert get_after_delete.data.get('value') is None
@@ -433,7 +465,9 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
"""Tests verifying handlers read state_policy/state_context from authorization snapshot."""
@pytest.mark.asyncio
async def test_state_handler_reads_state_policy_from_authorization(self, session_registry, db_engine, persistent_store):
async def test_state_handler_reads_state_policy_from_authorization(
self, session_registry, db_engine, persistent_store
):
"""Handler reads state_policy from session['authorization'], not resources."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@@ -454,7 +488,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
session = await session_registry.get('run_policy_top_level')
assert session is not None
resources = session['authorization']['resources']
assert 'state_policy' not in resources, "resources should NOT contain state_policy"
assert 'state_policy' not in resources, 'resources should NOT contain state_policy'
async def fake_disconnect():
return True
@@ -464,12 +498,14 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
state_get_handler = handler.actions[PluginToRuntimeAction.STATE_GET.value]
# Should fail because enable_state=False in authorization.state_policy
result = await state_get_handler({
'run_id': 'run_policy_top_level',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
})
result = await state_get_handler(
{
'run_id': 'run_policy_top_level',
'scope': 'conversation',
'key': 'test_key',
'caller_plugin_identity': 'test/runner',
}
)
assert result.code != 0
assert 'disabled' in result.message.lower()
@@ -477,7 +513,9 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
await session_registry.unregister('run_policy_top_level')
@pytest.mark.asyncio
async def test_state_handler_reads_state_context_from_authorization(self, session_registry, db_engine, persistent_store):
async def test_state_handler_reads_state_context_from_authorization(
self, session_registry, db_engine, persistent_store
):
"""Handler reads state_context from session['authorization'], not resources."""
fake_app = FakeApplication(db_engine)
fake_app.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
@@ -498,7 +536,7 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
session = await session_registry.get('run_context_top_level')
assert session is not None
resources = session['authorization']['resources']
assert 'state_context' not in resources, "resources should NOT contain state_context"
assert 'state_context' not in resources, 'resources should NOT contain state_context'
async def fake_disconnect():
return True
@@ -508,13 +546,15 @@ class TestStateHandlerReadsFromAuthorizationSnapshot:
state_set_handler = handler.actions[PluginToRuntimeAction.STATE_SET.value]
# Should use scope_key from authorization.state_context.scope_keys.conversation
result = await state_set_handler({
'run_id': 'run_context_top_level',
'scope': 'conversation',
'key': 'test_key',
'value': 'test_value',
'caller_plugin_identity': 'test/runner',
})
result = await state_set_handler(
{
'run_id': 'run_context_top_level',
'scope': 'conversation',
'key': 'test_key',
'value': 'test_value',
'caller_plugin_identity': 'test/runner',
}
)
# Should succeed - scope_key was found in state_context
assert result.code == 0
@@ -546,10 +586,8 @@ class TestResourcesDoesNotContainStateMetadata:
# Verify resources is nested under authorization and is clean.
assert 'resources' not in session
session_resources = session['authorization']['resources']
assert 'state_policy' not in session_resources, \
"authorization['resources'] should NOT contain state_policy"
assert 'state_context' not in session_resources, \
"authorization['resources'] should NOT contain state_context"
assert 'state_policy' not in session_resources, "authorization['resources'] should NOT contain state_policy"
assert 'state_context' not in session_resources, "authorization['resources'] should NOT contain state_context"
assert 'state_policy' in session['authorization']
assert 'state_context' in session['authorization']
+12 -11
View File
@@ -1,4 +1,5 @@
"""Tests for persistent AgentRunner state store."""
"""Tests for persistent Runner state store."""
from __future__ import annotations
import asyncio
@@ -8,7 +9,7 @@ import tempfile
import pytest
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.host_models import BindingScope, StatePolicy
from langbot.pkg.agent.runner.persistent_state_store import PersistentStateStore
from langbot.pkg.agent.runner.state_scope import (
@@ -21,9 +22,9 @@ from langbot.pkg.agent.runner.state_scope import (
)
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRunnerDescriptor:
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> RunnerDescriptor:
"""Create a test descriptor."""
return AgentRunnerDescriptor(
return RunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -36,6 +37,7 @@ def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> AgentRu
class FakeActorContext:
"""Fake actor context for event testing."""
def __init__(self, actor_type: str = 'user', actor_id: str = 'user_123', actor_name: str = 'Test User'):
self.actor_type = actor_type
self.actor_id = actor_id
@@ -44,6 +46,7 @@ class FakeActorContext:
class FakeSubjectContext:
"""Fake subject context for event testing."""
def __init__(self, subject_type: str = 'message', subject_id: str = 'msg_001', data: dict | None = None):
self.subject_type = subject_type
self.subject_id = subject_id
@@ -52,6 +55,7 @@ class FakeSubjectContext:
class FakeEventEnvelope:
"""Fake event envelope for testing event-first state."""
def __init__(
self,
event_id: str = 'evt_001',
@@ -78,6 +82,7 @@ class FakeEventEnvelope:
class FakeBinding:
"""Fake binding for testing state."""
def __init__(
self,
binding_id: str = 'binding_001',
@@ -119,10 +124,7 @@ class TestStateScopeHelpers:
thread_id='thread_001',
)
keys = {
scope: build_state_scope_key(scope, event, binding, descriptor)
for scope in VALID_STATE_SCOPES
}
keys = {scope: build_state_scope_key(scope, event, binding, descriptor) for scope in VALID_STATE_SCOPES}
assert keys['conversation'].startswith('conversation:v2:')
assert keys['actor'].startswith('actor:v2:')
@@ -168,6 +170,7 @@ class TestPersistentStateStore:
engine = create_async_engine(f'sqlite+aiosqlite:///{db_path}', echo=False)
from langbot.pkg.entity.persistence.base import Base
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
@@ -366,9 +369,7 @@ class TestPersistentStateStore:
event = FakeEventEnvelope(conversation_id='conv_001')
binding = FakeBinding()
await persistent_store.apply_update_from_event(
event, binding, descriptor, 'conversation', 'key', 'value', None
)
await persistent_store.apply_update_from_event(event, binding, descriptor, 'conversation', 'key', 'value', None)
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
assert snapshot['conversation']['key'] == 'value'
@@ -95,7 +95,10 @@ def _make_app():
delete_pipeline=AsyncMock(),
_get_default_values_from_schema=Mock(return_value={}),
)
app.agent_runner_registry = None
app.runner_registry = SimpleNamespace(
get=AsyncMock(return_value=SimpleNamespace(usages=['agent'])),
list_runners=AsyncMock(return_value=[]),
)
app.tool_mgr = None
app.logger = Mock()
return app
@@ -178,7 +181,7 @@ class TestAgentServiceDebug:
}
observer = AsyncMock() if streaming else None
async def run_agent(event, binding, adapter_context):
async def run_runner(event, binding, adapter_context):
assert binding.delivery_policy.enable_streaming is streaming
await adapter_context['_result_observer']({**visible_event, 'private_context': 'must not leak'})
await adapter_context['_result_observer']({'type': 'state.updated', 'data': {'private': True}})
@@ -191,7 +194,7 @@ class TestAgentServiceDebug:
all_content=None,
)
app.agent_run_orchestrator = SimpleNamespace(run=Mock(side_effect=run_agent))
app.agent_run_orchestrator = SimpleNamespace(run=Mock(side_effect=run_runner))
service = AgentService(app)
service.get_agent = AsyncMock(
return_value={
@@ -462,13 +465,16 @@ class TestAgentServiceCreateUpdateDelete:
app = _make_app()
runner = SimpleNamespace(
id='plugin:langbot-team/LocalAgent/default',
usages=['agent'],
config_schema=[
{'name': 'model', 'default': 'gpt-4.1'},
{'name': 'temperature', 'default': 0.2},
{'name': 'no-default'},
],
)
app.agent_runner_registry = SimpleNamespace(list_runners=AsyncMock(return_value=[runner]))
app.runner_registry = SimpleNamespace(
list_runners=AsyncMock(return_value=[runner]), get=AsyncMock(return_value=runner)
)
app.pipeline_service._get_default_values_from_schema = Mock(
return_value={'model': 'gpt-4.1', 'temperature': 0.2}
)
@@ -800,13 +806,14 @@ async def test_event_processor_can_be_created_before_selecting_a_plugin():
async def test_event_processor_creation_uses_installed_component_scope():
app = _make_app()
ref = 'event_processor:test/welcome/default'
ref = 'plugin:test/welcome/default'
descriptor = SimpleNamespace(
component_kind='EventProcessor',
component_kind='Runner',
usages=['event'],
supported_event_patterns=['group.member_joined'],
config_schema=[{'name': 'greeting', 'required': True}],
)
app.agent_runner_registry = SimpleNamespace(get=AsyncMock(return_value=descriptor))
app.runner_registry = SimpleNamespace(get=AsyncMock(return_value=descriptor))
service = AgentService(app)
result = await service.create_agent(
WORKSPACE_UUID,
@@ -830,11 +837,12 @@ async def test_unconfigured_event_processor_can_select_a_plugin_after_creation()
row = _agent_row(config={})
row.kind = 'event_processor'
row.component_ref = None
ref = 'event_processor:test/welcome/default'
app.agent_runner_registry = SimpleNamespace(
ref = 'plugin:test/welcome/default'
app.runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
component_kind='EventProcessor',
component_kind='Runner',
usages=['event'],
supported_event_patterns=['group.member_joined'],
config_schema=[{'name': 'greeting', 'required': True}],
)
@@ -857,27 +865,26 @@ async def test_event_processor_rejects_invalid_component_and_missing_parameters(
app = _make_app()
service = AgentService(app)
with pytest.raises(ValueError, match='Select an installed'):
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c'})
app.agent_runner_registry = SimpleNamespace(
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'invalid:a/b/c'})
app.runner_registry = SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
component_kind='EventProcessor',
component_kind='Runner',
usages=['event'],
supported_event_patterns=['*'],
config_schema=[{'name': 'greeting', 'required': True}],
)
)
)
with pytest.raises(ValueError, match='Required processor parameter'):
await service.create_agent(
WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'event_processor:a/b/c'}
)
await service.create_agent(WORKSPACE_UUID, {'kind': 'event_processor', 'component_ref': 'plugin:a/b/c'})
async def test_unavailable_event_processor_can_still_be_renamed():
app = _make_app()
row = _agent_row(config={'runner': {'id': 'event_processor:a/b/c'}, 'runner_config': {'event_processor:a/b/c': {}}})
row = _agent_row(config={'runner': {'id': 'plugin:a/b/c'}, 'runner_config': {'plugin:a/b/c': {}}})
row.kind = 'event_processor'
row.component_ref = 'event_processor:a/b/c'
row.component_ref = 'plugin:a/b/c'
service = AgentService(app)
service._get_agent_row = AsyncMock(return_value=row)
await service.update_agent(WORKSPACE_UUID, row.uuid, {'name': 'Renamed'})
@@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.api.http.service.pipeline import PipelineService
@@ -25,7 +25,7 @@ class FakeRegistry:
def make_runner(runner_id: str, config_schema: list[dict]):
parts = runner_id.removeprefix('plugin:').split('/')
return AgentRunnerDescriptor(
return RunnerDescriptor(
id=runner_id,
source='plugin',
label={'en_US': runner_id},
@@ -51,7 +51,7 @@ async def test_default_pipeline_config_uses_first_installed_runner_schema():
)
ap = SimpleNamespace(
logger=FakeLogger(),
agent_runner_registry=FakeRegistry([custom_agent, local_agent]),
runner_registry=FakeRegistry([custom_agent, local_agent]),
)
config = await PipelineService(ap).get_default_pipeline_config('workspace-test')
@@ -68,7 +68,7 @@ async def test_default_pipeline_config_uses_first_installed_runner_schema():
async def test_default_pipeline_config_stays_neutral_without_installed_runners():
ap = SimpleNamespace(
logger=FakeLogger(),
agent_runner_registry=FakeRegistry([]),
runner_registry=FakeRegistry([]),
)
config = await PipelineService(ap).get_default_pipeline_config('workspace-test')
@@ -0,0 +1,42 @@
"""Preserve persisted invocation state while normalizing the Runner table name."""
import importlib
from unittest.mock import patch
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0024_unify_runner_state')
@pytest.mark.parametrize('current_table_exists', [False, True])
def test_upgrade_preserves_state_and_downgrade_restores_name(current_table_exists):
engine = sa.create_engine('sqlite://')
with engine.begin() as conn:
conn.execute(sa.text('CREATE TABLE agent_runner_state (id INTEGER PRIMARY KEY, value_json TEXT)'))
conn.execute(sa.text("INSERT INTO agent_runner_state VALUES (1, 'saved value')"))
if current_table_exists:
conn.execute(sa.text('CREATE TABLE runner_state (id INTEGER PRIMARY KEY, value_json TEXT)'))
with patch.object(migration, 'op', Operations(MigrationContext.configure(conn))):
migration.upgrade()
assert 'agent_runner_state' not in sa.inspect(conn).get_table_names()
assert conn.scalar(sa.text('SELECT value_json FROM runner_state')) == 'saved value'
migration.downgrade()
assert 'runner_state' not in sa.inspect(conn).get_table_names()
assert conn.scalar(sa.text('SELECT value_json FROM agent_runner_state')) == 'saved value'
engine.dispose()
def test_two_populated_tables_fail_without_discarding_either():
engine = sa.create_engine('sqlite://')
with engine.begin() as conn:
for name in ('agent_runner_state', 'runner_state'):
conn.execute(sa.text(f'CREATE TABLE {name} (id INTEGER PRIMARY KEY)'))
conn.execute(sa.text(f'INSERT INTO {name} VALUES (1)'))
with patch.object(migration, 'op', Operations(MigrationContext.configure(conn))):
with pytest.raises(RuntimeError, match='Both .* contain state'):
migration.upgrade()
assert set(sa.inspect(conn).get_table_names()) == {'agent_runner_state', 'runner_state'}
engine.dispose()
@@ -395,7 +395,7 @@ async def test_runtime_pipeline_revalidates_after_awaited_stage(
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
"""AgentRunner resource selection should override legacy extension prefs."""
"""Runner resource selection should override legacy extension prefs."""
pipelinemgr = get_pipelinemgr_module()
persistence_pipeline = get_persistence_pipeline_module()
+8 -8
View File
@@ -17,7 +17,7 @@ from importlib import import_module
from langbot_plugin.api.entities.builtin.provider import session as provider_session
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from tests.factories import (
FakeApp,
text_query,
@@ -30,8 +30,8 @@ from tests.factories import (
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
def attach_agent_runner_descriptor(app):
descriptor = AgentRunnerDescriptor(
def attach_runner_descriptor(app):
descriptor = RunnerDescriptor(
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -44,8 +44,8 @@ def attach_agent_runner_descriptor(app):
],
capabilities={'tool_calling': True, 'multimodal_input': True},
)
app.agent_runner_registry = Mock()
app.agent_runner_registry.get = AsyncMock(return_value=descriptor)
app.runner_registry = Mock()
app.runner_registry.get = AsyncMock(return_value=descriptor)
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(return_value=[])
@@ -334,7 +334,7 @@ class TestPreProcessorModelSelection:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_agent_runner_descriptor(app)
attach_runner_descriptor(app)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -393,7 +393,7 @@ class TestPreProcessorModelSelection:
raise ValueError(f'Model {uuid} not found')
app.model_mgr.get_model_by_uuid = AsyncMock(side_effect=mock_get_model)
attach_agent_runner_descriptor(app)
attach_runner_descriptor(app)
mock_event_ctx = Mock()
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
@@ -518,7 +518,7 @@ class TestPreProcessorToolSelection:
mock_model = Mock()
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
attach_agent_runner_descriptor(app)
attach_runner_descriptor(app)
app.tool_mgr.get_resolved_tool_catalog = AsyncMock(
return_value=[
{'name': 'exec', 'source': 'builtin'},
@@ -13,10 +13,10 @@ from langbot_plugin.api.entities.builtin.provider import session as provider_ses
RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
def _attach_agent_runner_descriptor(app):
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
def _attach_runner_descriptor(app):
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
descriptor = AgentRunnerDescriptor(
descriptor = RunnerDescriptor(
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -29,8 +29,8 @@ def _attach_agent_runner_descriptor(app):
],
capabilities={'tool_calling': True, 'multimodal_input': True},
)
app.agent_runner_registry = Mock()
app.agent_runner_registry.get = AsyncMock(return_value=descriptor)
app.runner_registry = Mock()
app.runner_registry.get = AsyncMock(return_value=descriptor)
def _pipeline_config(model_config):
@@ -72,7 +72,7 @@ async def test_preprocessor_keeps_image_placeholder_for_text_only_local_agent(mo
model.model_entity.abilities = []
mock_app.model_mgr.get_model_by_uuid = AsyncMock(return_value=model)
_attach_agent_runner_descriptor(mock_app)
_attach_runner_descriptor(mock_app)
mock_app.sess_mgr.get_session = AsyncMock(
return_value=provider_session.Session(
launcher_type=sample_query.launcher_type,
@@ -732,7 +732,7 @@ async def test_bound_event_processor_receives_one_complete_typed_event():
if False:
yield None
ref = 'event_processor:test/welcome/default'
ref = 'plugin:test/welcome/default'
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
agent_service=SimpleNamespace(
@@ -80,7 +80,7 @@ def configure_handler(connector, runtime_handler):
async def _collect_agent_results(connector, context):
return [
result
async for result in connector.run_agent(
async for result in connector.run_runner(
'qa',
'agent-runner',
'default',
@@ -98,7 +98,7 @@ class TestRunAgent:
class RuntimeHandler:
installation_scope = Mock(side_effect=lambda _binding: nullcontext())
async def run_agent(self, *_args):
async def run_runner(self, *_args):
yield {'type': 'run.completed'}
configure_handler(connector, RuntimeHandler())
@@ -356,7 +356,7 @@ async def test_marketplace_upgrade_reports_multistep_progress():
connector._persist_installation_package = AsyncMock(side_effect=persist)
connector.handler.apply_plugin_installation = AsyncMock(side_effect=apply)
connector._wait_for_installed_plugin_ready = AsyncMock(side_effect=wait_until_ready)
connector._refresh_agent_runner_registry = AsyncMock(side_effect=refresh_registry)
connector._refresh_runner_registry = AsyncMock(side_effect=refresh_registry)
await connector.upgrade_plugin('author', 'plugin', task_context=task_context)
@@ -816,7 +816,7 @@ class TestHandlerQueryLookup:
class TestAgentRunProxyActions:
"""Tests for AgentRunner proxy actions that need host Query semantics."""
"""Tests for Runner proxy actions that need host Query semantics."""
@pytest.fixture
def app(self):
@@ -1556,8 +1556,8 @@ class TestAgentRunProxyActions:
from langbot.pkg.agent.runner.session_registry import get_session_registry
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.toolmgr import ToolManager
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.runner.input import AgentInput
event = AgentEventEnvelope(
event_id='event-native-exec',
@@ -13,7 +13,7 @@ from langbot_plugin.api.entities.builtin.resource import tool as resource_tool
@pytest.mark.asyncio
async def test_fake_requester_counts_messages_and_tools(runtime_provider):
"""Fake requester should support token-free AgentRunner context budgeting."""
"""Fake requester should support token-free Runner context budgeting."""
runtime_model = requester.RuntimeLLMModel(
model_entity=persistence_model.LLMModel(
uuid='fake-count-model',
@@ -1,7 +1,7 @@
"""Unit tests for LiteLLMRequester message/tool conversion.
This includes provider_specific_fields round-trip coverage for GitHub issue
#1899 and token counting preflight behavior for AgentRunner context budgeting.
#1899 and token counting preflight behavior for Runner context budgeting.
"""
from types import SimpleNamespace
@@ -73,7 +73,9 @@ async def test_count_tokens_uses_litellm_counter_with_request_messages_and_tools
func=lambda **kwargs: None,
)
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.litellm.token_counter', return_value=42) as counter:
with patch(
'langbot.pkg.provider.modelmgr.requesters.litellmchat.litellm.token_counter', return_value=42
) as counter:
tokens = await req.count_tokens(
model=model,
messages=[
+4 -4
View File
@@ -16,7 +16,7 @@ from langbot_plugin.api.entities.builtin.provider.message import Message
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.api.http.context import ExecutionContext
@@ -79,7 +79,7 @@ def _make_app(*, skill_service) -> SimpleNamespace:
conversation = _make_conversation()
model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'}))
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
descriptor = AgentRunnerDescriptor(
descriptor = RunnerDescriptor(
id=_RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -105,7 +105,7 @@ def _make_app(*, skill_service) -> SimpleNamespace:
get_conversation=AsyncMock(return_value=conversation),
),
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
agent_runner_registry=SimpleNamespace(get=AsyncMock(return_value=descriptor)),
runner_registry=SimpleNamespace(get=AsyncMock(return_value=descriptor)),
tool_mgr=tool_mgr,
plugin_connector=SimpleNamespace(
emit_event=AsyncMock(
@@ -207,7 +207,7 @@ async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disable
@pytest.mark.asyncio
async def test_preproc_leaves_skill_prompt_projection_to_agent_runner_resources():
async def test_preproc_leaves_skill_prompt_projection_to_runner_resources():
preproc_module, entities_module = _import_preproc_modules()
app = _make_app(skill_service=SimpleNamespace())