mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-17 01:46:07 +00:00
test(agent-runner): strengthen local agent e2e gate
This commit is contained in:
@@ -0,0 +1,885 @@
|
||||
"""E2E coverage for the official Local Agent runner with fake Host resources.
|
||||
|
||||
These tests start the real LangBot application and the real SDK Plugin Runtime,
|
||||
load the sibling ``langbot-local-agent`` plugin, and verify Local Agent paths
|
||||
that must cross Host run-scoped APIs without calling any external provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories
|
||||
from tests.e2e.utils.process_manager import find_project_root
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot/local-agent/default'
|
||||
FAKE_PROVIDER_UUID = 'e2e-fake-provider'
|
||||
FAKE_MODEL_UUID = 'e2e-fake-local-agent-model'
|
||||
LOCAL_AGENT_PLUGIN_DIRNAME = 'langbot__local-agent'
|
||||
E2E_TOOL_NAME = 'e2e_lookup'
|
||||
E2E_KB_UUID = 'e2e-kb-local-agent'
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Reserve a currently-free localhost TCP port for this E2E process."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(('127.0.0.1', 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def _local_agent_repo() -> Path:
|
||||
"""Return the sibling local-agent repository used by this workspace E2E."""
|
||||
project_root = find_project_root()
|
||||
return project_root.parent / 'langbot-local-agent'
|
||||
|
||||
|
||||
def _copy_local_agent_plugin(tmpdir: Path) -> None:
|
||||
"""Copy the sibling Local Agent plugin into the temporary LangBot data dir."""
|
||||
local_agent_src = _local_agent_repo()
|
||||
if not (local_agent_src / 'manifest.yaml').exists():
|
||||
pytest.skip(f'local-agent repository not found at {local_agent_src}')
|
||||
|
||||
plugin_dst = tmpdir / 'data' / 'plugins' / LOCAL_AGENT_PLUGIN_DIRNAME
|
||||
ignore = shutil.ignore_patterns(
|
||||
'.git',
|
||||
'.venv',
|
||||
'__pycache__',
|
||||
'.pytest_cache',
|
||||
'.ruff_cache',
|
||||
'build',
|
||||
'dist',
|
||||
)
|
||||
shutil.copytree(local_agent_src, plugin_dst, ignore=ignore)
|
||||
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
"""Flatten provider message content into text for assertions."""
|
||||
if content is None:
|
||||
return ''
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
text = item.get('text') if isinstance(item, dict) else getattr(item, 'text', None)
|
||||
if text:
|
||||
parts.append(str(text))
|
||||
return ''.join(parts)
|
||||
return str(content)
|
||||
|
||||
|
||||
def _message_text(message: Any) -> str:
|
||||
"""Flatten a provider message into text for assertions."""
|
||||
return _content_text(getattr(message, 'content', None))
|
||||
|
||||
|
||||
def _invoke_payload_texts(fake_requester: Any) -> list[list[str]]:
|
||||
"""Return model-facing text for every fake LLM invocation."""
|
||||
payloads: list[list[str]] = []
|
||||
for payload in fake_requester._invoke_payloads:
|
||||
payloads.append([_message_text(message) for message in payload['messages']])
|
||||
return payloads
|
||||
|
||||
|
||||
def _event(
|
||||
*,
|
||||
event_id: str,
|
||||
conversation_id: str,
|
||||
text: str,
|
||||
thread_id: str = 'e2e-local-agent-thread',
|
||||
):
|
||||
"""Build an AgentRunner 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
|
||||
|
||||
return AgentEventEnvelope(
|
||||
event_id=event_id,
|
||||
event_type='message.received',
|
||||
source='api',
|
||||
conversation_id=conversation_id,
|
||||
thread_id=thread_id,
|
||||
actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'),
|
||||
subject=SubjectContext(subject_type='chat', subject_id='chat-001'),
|
||||
input=AgentInput(text=text),
|
||||
delivery=DeliveryContext(surface='e2e', supports_streaming=False),
|
||||
)
|
||||
|
||||
|
||||
def _binding(
|
||||
*,
|
||||
binding_id: str = 'e2e-local-agent-binding',
|
||||
runner_config: dict[str, Any] | None = None,
|
||||
allowed_tool_names: list[str] | None = None,
|
||||
allowed_kb_uuids: list[str] | None = None,
|
||||
):
|
||||
"""Build a Local Agent binding with fake model access."""
|
||||
from langbot.pkg.agent.runner.host_models import (
|
||||
AgentBinding,
|
||||
BindingScope,
|
||||
DeliveryPolicy,
|
||||
ResourcePolicy,
|
||||
StatePolicy,
|
||||
)
|
||||
|
||||
config = {
|
||||
'model': {'primary': FAKE_MODEL_UUID, 'fallbacks': []},
|
||||
'timeout': 60,
|
||||
'prompt': [{'role': 'system', 'content': 'You are a concise test assistant.'}],
|
||||
'knowledge-bases': [],
|
||||
'context-window-tokens': 8192,
|
||||
'context-reserve-tokens': 1024,
|
||||
'context-keep-recent-tokens': 1000,
|
||||
'context-summary-tokens': 500,
|
||||
}
|
||||
if runner_config:
|
||||
config.update(runner_config)
|
||||
|
||||
return AgentBinding(
|
||||
binding_id=binding_id,
|
||||
scope=BindingScope(scope_type='global'),
|
||||
runner_id=LOCAL_AGENT_RUNNER_ID,
|
||||
runner_config=config,
|
||||
resource_policy=ResourcePolicy(
|
||||
allowed_model_uuids=[FAKE_MODEL_UUID],
|
||||
allowed_tool_names=allowed_tool_names,
|
||||
allowed_kb_uuids=allowed_kb_uuids,
|
||||
),
|
||||
state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']),
|
||||
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
|
||||
)
|
||||
|
||||
|
||||
class _FakeToolManager:
|
||||
"""Deterministic tool manager used behind the real Host CALL_TOOL action."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
async def get_tool_schema(self, tool_name: str):
|
||||
if tool_name != E2E_TOOL_NAME:
|
||||
return None, None
|
||||
return (
|
||||
'Lookup a deterministic E2E value.',
|
||||
{
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'query': {'type': 'string'},
|
||||
},
|
||||
'required': ['query'],
|
||||
},
|
||||
)
|
||||
|
||||
async def get_tool_detail(self, tool_name: str):
|
||||
description, parameters = await self.get_tool_schema(tool_name)
|
||||
if parameters is None:
|
||||
return None
|
||||
return {'name': tool_name, 'description': description, 'parameters': parameters}
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None):
|
||||
del query
|
||||
self.calls.append({'name': name, 'parameters': dict(parameters)})
|
||||
return {
|
||||
'value': f"tool-result:{parameters.get('query')}",
|
||||
'source': 'fake-tool-manager',
|
||||
}
|
||||
|
||||
|
||||
class _FakeKnowledgeBase:
|
||||
"""Minimal KB object used behind the real Host RETRIEVE_KNOWLEDGE action."""
|
||||
|
||||
def __init__(self):
|
||||
self.knowledge_base_entity = SimpleNamespace(kb_type='fake')
|
||||
self.retrieve_calls: list[dict[str, Any]] = []
|
||||
|
||||
def get_uuid(self) -> str:
|
||||
return E2E_KB_UUID
|
||||
|
||||
def get_name(self) -> str:
|
||||
return 'E2E Fake KB'
|
||||
|
||||
async def retrieve(self, query_text: str, settings: dict[str, Any]):
|
||||
self.retrieve_calls.append({'query_text': query_text, 'settings': settings})
|
||||
return [
|
||||
SimpleNamespace(
|
||||
content='RAG_SENTINEL Local Agent retrieved this deterministic chunk.',
|
||||
metadata={'source': 'fake-kb'},
|
||||
id='fake-kb-chunk-1',
|
||||
score=0.99,
|
||||
model_dump=lambda mode='json': {
|
||||
'content': 'RAG_SENTINEL Local Agent retrieved this deterministic chunk.',
|
||||
'metadata': {'source': 'fake-kb'},
|
||||
'id': 'fake-kb-chunk-1',
|
||||
'score': 0.99,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
class _FakeRagManager:
|
||||
"""Deterministic RAG manager used by resource builder and retrieval action."""
|
||||
|
||||
def __init__(self, kb: _FakeKnowledgeBase):
|
||||
self.kb = kb
|
||||
self.knowledge_bases = {E2E_KB_UUID: kb}
|
||||
|
||||
async def get_knowledge_base_by_uuid(self, kb_uuid: str):
|
||||
if kb_uuid == E2E_KB_UUID:
|
||||
return self.kb
|
||||
return None
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def local_agent_e2e_tmpdir():
|
||||
"""Create temporary directory for Local Agent E2E testing."""
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix='langbot_local_agent_e2e_'))
|
||||
yield tmpdir
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def local_agent_e2e_port() -> int:
|
||||
"""HTTP port for the real LangBot app used by this E2E."""
|
||||
return _free_port()
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def local_agent_runtime_ports() -> tuple[int, int]:
|
||||
"""Control/debug ports for the standalone plugin runtime."""
|
||||
control_port = _free_port()
|
||||
debug_port = _free_port()
|
||||
while debug_port == control_port:
|
||||
debug_port = _free_port()
|
||||
return control_port, debug_port
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def local_agent_e2e_config_path(local_agent_e2e_tmpdir, local_agent_e2e_port, local_agent_runtime_ports):
|
||||
"""Create a plugin-enabled config and install the Local Agent plugin fixture."""
|
||||
config_path = create_minimal_config(local_agent_e2e_tmpdir, port=local_agent_e2e_port)
|
||||
create_test_directories(local_agent_e2e_tmpdir)
|
||||
|
||||
import yaml
|
||||
|
||||
with open(config_path, encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f)
|
||||
runtime_control_port, _runtime_debug_port = local_agent_runtime_ports
|
||||
config['api']['global_api_key'] = 'e2e-local-agent-key'
|
||||
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
|
||||
config['box']['enabled'] = False
|
||||
config['system']['jwt']['secret'] = 'e2e-local-agent-secret-key'
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
yaml.safe_dump(config, f, default_flow_style=False)
|
||||
|
||||
_copy_local_agent_plugin(local_agent_e2e_tmpdir)
|
||||
return config_path
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_ports, local_agent_e2e_config_path):
|
||||
"""Start the real SDK plugin runtime over WebSocket."""
|
||||
del local_agent_e2e_config_path
|
||||
control_port, debug_port = local_agent_runtime_ports
|
||||
stdout_path = local_agent_e2e_tmpdir / 'plugin-runtime.stdout.log'
|
||||
stderr_path = local_agent_e2e_tmpdir / 'plugin-runtime.stderr.log'
|
||||
stdout_file = open(stdout_path, 'wb')
|
||||
stderr_file = open(stderr_path, 'wb')
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(find_project_root() / '.venv' / 'bin' / 'python'),
|
||||
'-m',
|
||||
'langbot_plugin.cli.__init__',
|
||||
'rt',
|
||||
'--ws-control-port',
|
||||
str(control_port),
|
||||
'--ws-debug-port',
|
||||
str(debug_port),
|
||||
],
|
||||
cwd=local_agent_e2e_tmpdir,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
start_new_session=True,
|
||||
)
|
||||
yield proc
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
stdout_file.close()
|
||||
stderr_file.close()
|
||||
|
||||
|
||||
def _inject_fake_llm_model(ap) -> Any:
|
||||
"""Register a runtime-only fake model that supports count_tokens/invoke."""
|
||||
from langbot.pkg.entity.persistence import model as persistence_model
|
||||
from langbot.pkg.provider.modelmgr import requester, token
|
||||
from tests.unit_tests.provider.conftest import FakeProviderAPIRequester
|
||||
|
||||
provider_entity = persistence_model.ModelProvider(
|
||||
uuid=FAKE_PROVIDER_UUID,
|
||||
name='E2E Fake Provider',
|
||||
requester='fake-requester',
|
||||
base_url='https://fake.invalid',
|
||||
api_keys=['fake-key'],
|
||||
)
|
||||
fake_requester = FakeProviderAPIRequester(ap, {'base_url': provider_entity.base_url})
|
||||
runtime_provider = requester.RuntimeProvider(
|
||||
provider_entity=provider_entity,
|
||||
token_mgr=token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys),
|
||||
requester=fake_requester,
|
||||
)
|
||||
runtime_model = requester.RuntimeLLMModel(
|
||||
model_entity=persistence_model.LLMModel(
|
||||
uuid=FAKE_MODEL_UUID,
|
||||
name=FAKE_MODEL_UUID,
|
||||
provider_uuid=provider_entity.uuid,
|
||||
abilities=['func_call'],
|
||||
context_length=8192,
|
||||
extra_args={},
|
||||
),
|
||||
provider=runtime_provider,
|
||||
)
|
||||
ap.model_mgr.provider_dict[provider_entity.uuid] = runtime_provider
|
||||
ap.model_mgr.llm_models.append(runtime_model)
|
||||
return fake_requester
|
||||
|
||||
|
||||
def _scripted_tool_call(
|
||||
tool_name: str = E2E_TOOL_NAME,
|
||||
*,
|
||||
call_id: str = 'call-e2e-lookup',
|
||||
query: str = 'alpha',
|
||||
):
|
||||
"""Build an assistant message requesting a deterministic tool call."""
|
||||
from langbot_plugin.api.entities.builtin.provider import message as provider_message
|
||||
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=call_id,
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=tool_name,
|
||||
arguments=json.dumps({'query': query}, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def _boot_local_agent_app(tmpdir: Path):
|
||||
"""Boot LangBot and wait until the Local Agent runner is discoverable."""
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
ap = await boot.make_app(asyncio.get_running_loop())
|
||||
for _ in range(60):
|
||||
handler = getattr(ap.plugin_connector, 'handler', None)
|
||||
if handler is not None:
|
||||
await handler.ping()
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
raise AssertionError(f'Plugin runtime did not connect; tmpdir={tmpdir}')
|
||||
|
||||
for _ in range(60):
|
||||
runners = await ap.agent_runner_registry.list_runners(use_cache=False)
|
||||
if any(runner.id == LOCAL_AGENT_RUNNER_ID for runner in runners):
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered')
|
||||
|
||||
return ap
|
||||
|
||||
|
||||
def _run_local_agent_probe(tmpdir: Path, probe):
|
||||
"""Run one Local Agent probe inside the temporary LangBot app."""
|
||||
from langbot.pkg.utils import platform as platform_utils
|
||||
|
||||
async def _run():
|
||||
previous_cwd = Path.cwd()
|
||||
previous_standalone_runtime = platform_utils.standalone_runtime
|
||||
os.chdir(tmpdir)
|
||||
platform_utils.standalone_runtime = True
|
||||
ap = None
|
||||
try:
|
||||
ap = await _boot_local_agent_app(tmpdir)
|
||||
return await probe(ap)
|
||||
finally:
|
||||
if ap is not None:
|
||||
ap.dispose()
|
||||
platform_utils.standalone_runtime = previous_standalone_runtime
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger(
|
||||
local_agent_e2e_tmpdir,
|
||||
local_agent_e2e_config_path,
|
||||
local_agent_runtime_process,
|
||||
):
|
||||
"""Local Agent should execute through Host APIs with a token-free fake provider."""
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
event = _event(
|
||||
event_id='e2e-local-agent-event-001',
|
||||
conversation_id='e2e-local-agent-conversation',
|
||||
text='Say pong through the fake provider.',
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, _binding())]
|
||||
return messages, list(fake_requester._count_tokens_payloads)
|
||||
|
||||
messages, token_payloads = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == 'assistant'
|
||||
assert _content_text(messages[0].content) == 'Fake LLM response'
|
||||
assert token_payloads
|
||||
flattened_token_payloads = [item for payload in token_payloads for item in payload]
|
||||
assert any(item.get('role') == 'system' for item in flattened_token_payloads)
|
||||
assert any('Say pong through the fake provider.' in item.get('content', '') for item in flattened_token_payloads)
|
||||
|
||||
db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db'
|
||||
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 = ?",
|
||||
('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 status_reason == 'stop'
|
||||
|
||||
event_rows = conn.execute(
|
||||
'SELECT sequence, type, data_json FROM agent_run_event WHERE run_id = ? ORDER BY sequence',
|
||||
(run_id,),
|
||||
).fetchall()
|
||||
event_types = [row[1] for row in event_rows]
|
||||
assert event_types == ['message.completed', 'run.completed']
|
||||
assert 'Fake LLM response' in event_rows[0][2]
|
||||
|
||||
transcript_rows = conn.execute(
|
||||
'SELECT role, content, run_id, runner_id FROM transcript WHERE conversation_id = ? ORDER BY seq',
|
||||
('e2e-local-agent-conversation',),
|
||||
).fetchall()
|
||||
assert [row[0] for row in transcript_rows] == ['user', 'assistant']
|
||||
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
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
|
||||
local_agent_e2e_tmpdir,
|
||||
local_agent_e2e_config_path,
|
||||
local_agent_runtime_process,
|
||||
):
|
||||
"""Local Agent should execute a model tool call through Host CALL_TOOL and finish."""
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester.queue_llm_responses(
|
||||
_scripted_tool_call(),
|
||||
'Tool loop final answer after tool-result:alpha',
|
||||
)
|
||||
tool_mgr = _FakeToolManager()
|
||||
ap.tool_mgr = tool_mgr
|
||||
|
||||
event = _event(
|
||||
event_id='e2e-local-agent-tool-event-001',
|
||||
conversation_id='e2e-local-agent-tool-conversation',
|
||||
text='Use the e2e lookup tool before answering.',
|
||||
)
|
||||
binding = _binding(
|
||||
binding_id='e2e-local-agent-tool-binding',
|
||||
allowed_tool_names=[E2E_TOOL_NAME],
|
||||
runner_config={
|
||||
'max-tool-iterations': 2,
|
||||
'tool-execution-mode': 'serial',
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(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)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert _content_text(messages[0].content) == 'Tool loop final answer after tool-result:alpha'
|
||||
assert tool_calls == [{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}]
|
||||
assert any('tool-result:alpha' in text for text in invoke_payload_texts[-1])
|
||||
|
||||
db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db'
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
run_row = conn.execute(
|
||||
"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
|
||||
run_id, status, status_reason = run_row
|
||||
assert status == 'completed'
|
||||
assert status_reason == 'stop'
|
||||
|
||||
event_rows = conn.execute(
|
||||
'SELECT type, data_json FROM agent_run_event WHERE run_id = ? ORDER BY sequence',
|
||||
(run_id,),
|
||||
).fetchall()
|
||||
event_types = [row[0] for row in event_rows]
|
||||
assert event_types == [
|
||||
'tool.call.started',
|
||||
'tool.call.completed',
|
||||
'message.completed',
|
||||
'run.completed',
|
||||
]
|
||||
assert E2E_TOOL_NAME in event_rows[0][1]
|
||||
assert 'tool-result:alpha' in event_rows[1][1]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action(
|
||||
local_agent_e2e_tmpdir,
|
||||
local_agent_e2e_config_path,
|
||||
local_agent_runtime_process,
|
||||
):
|
||||
"""Local Agent should retrieve configured KB context through Host RETRIEVE_KNOWLEDGE."""
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester.queue_llm_responses('RAG final answer with RAG_SENTINEL')
|
||||
fake_kb = _FakeKnowledgeBase()
|
||||
ap.rag_mgr = _FakeRagManager(fake_kb)
|
||||
|
||||
event = _event(
|
||||
event_id='e2e-local-agent-rag-event-001',
|
||||
conversation_id='e2e-local-agent-rag-conversation',
|
||||
text='Answer with the retrieved RAG sentinel.',
|
||||
)
|
||||
binding = _binding(
|
||||
binding_id='e2e-local-agent-rag-binding',
|
||||
allowed_kb_uuids=[E2E_KB_UUID],
|
||||
runner_config={
|
||||
'knowledge-bases': [E2E_KB_UUID],
|
||||
'retrieval-top-k': 1,
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(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)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert _content_text(messages[0].content) == 'RAG final answer with RAG_SENTINEL'
|
||||
assert retrieve_calls == [
|
||||
{
|
||||
'query_text': 'Answer with the retrieved RAG sentinel.',
|
||||
'settings': {'top_k': 1, 'filters': {}},
|
||||
}
|
||||
]
|
||||
assert any(
|
||||
'RAG_SENTINEL Local Agent retrieved this deterministic chunk.' in text
|
||||
for payload_texts in invoke_payload_texts
|
||||
for text in payload_texts
|
||||
)
|
||||
|
||||
db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db'
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
run_row = conn.execute(
|
||||
"SELECT run_id, status FROM agent_run WHERE event_id = ?",
|
||||
('e2e-local-agent-rag-event-001',),
|
||||
).fetchone()
|
||||
assert run_row is not None
|
||||
run_id, status = run_row
|
||||
assert status == 'completed'
|
||||
event_types = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
'SELECT type FROM agent_run_event WHERE run_id = ? ORDER BY sequence',
|
||||
(run_id,),
|
||||
)
|
||||
]
|
||||
assert event_types == ['message.completed', 'run.completed']
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_local_agent_runner_compacts_history_and_persists_checkpoint(
|
||||
local_agent_e2e_tmpdir,
|
||||
local_agent_e2e_config_path,
|
||||
local_agent_runtime_process,
|
||||
):
|
||||
"""Local Agent should compact old Host history and write conversation checkpoint state."""
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester.queue_llm_responses(
|
||||
'SUMMARY_SENTINEL compacted older history including HIST_SENTINEL',
|
||||
'Compaction final answer',
|
||||
)
|
||||
|
||||
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
|
||||
for index in range(12):
|
||||
await store.append_transcript(
|
||||
transcript_id=None,
|
||||
event_id=f'e2e-local-agent-history-{index}',
|
||||
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
|
||||
),
|
||||
thread_id='e2e-local-agent-thread',
|
||||
item_type='message',
|
||||
)
|
||||
|
||||
event = _event(
|
||||
event_id='e2e-local-agent-compaction-event-001',
|
||||
conversation_id='e2e-local-agent-compaction-conversation',
|
||||
text='Use compacted context and answer.',
|
||||
)
|
||||
binding = _binding(
|
||||
binding_id='e2e-local-agent-compaction-binding',
|
||||
runner_config={
|
||||
'context-window-tokens': 900,
|
||||
'context-reserve-tokens': 300,
|
||||
'context-keep-recent-tokens': 160,
|
||||
'context-summary-tokens': 240,
|
||||
'context-history-fetch-limit': 20,
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(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)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert _content_text(messages[0].content) == 'Compaction final answer'
|
||||
assert invoke_count == 2
|
||||
assert any('HIST_SENTINEL' in text for text in invoke_payload_texts[0])
|
||||
assert any('SUMMARY_SENTINEL compacted older history' in text for text in invoke_payload_texts[-1])
|
||||
|
||||
db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db'
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
run_row = conn.execute(
|
||||
"SELECT run_id, status FROM agent_run WHERE event_id = ?",
|
||||
('e2e-local-agent-compaction-event-001',),
|
||||
).fetchone()
|
||||
assert run_row is not None
|
||||
run_id, status = run_row
|
||||
assert status == 'completed'
|
||||
|
||||
event_types = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
'SELECT type FROM agent_run_event WHERE run_id = ? ORDER BY sequence',
|
||||
(run_id,),
|
||||
)
|
||||
]
|
||||
assert event_types == ['message.completed', 'run.completed']
|
||||
|
||||
state_row = conn.execute(
|
||||
"SELECT value_json FROM agent_runner_state WHERE state_key = 'runner.compaction.checkpoint'"
|
||||
).fetchone()
|
||||
assert state_row is not None
|
||||
checkpoint = json.loads(state_row[0])
|
||||
assert checkpoint['schema_version'] == 'langbot.local_agent.compaction_checkpoint.v1'
|
||||
assert 'SUMMARY_SENTINEL compacted older history' in checkpoint['summary']
|
||||
assert checkpoint['conversation_id'] == 'e2e-local-agent-compaction-conversation'
|
||||
assert checkpoint['covers_until']
|
||||
assert checkpoint['tokens_before'] > 600
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
|
||||
local_agent_e2e_tmpdir,
|
||||
local_agent_e2e_config_path,
|
||||
local_agent_runtime_process,
|
||||
):
|
||||
"""Local Agent should preserve RAG, compressed history, and multi-turn tool results together."""
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
|
||||
async def scripted_response(**kwargs):
|
||||
messages = kwargs['messages']
|
||||
text = '\n'.join(_message_text(message) for message in messages)
|
||||
if 'context summarization assistant' in text or '<conversation>' in text:
|
||||
return 'SUMMARY_COMBO compacted older history including HIST_COMBO_SENTINEL and RAG_TOOL_COMBO_GOAL'
|
||||
if 'tool-result:alpha' not in text:
|
||||
return _scripted_tool_call(call_id='call-combo-alpha', query='alpha')
|
||||
if 'tool-result:beta' not in text:
|
||||
return _scripted_tool_call(call_id='call-combo-beta', query='beta')
|
||||
assert 'RAG_SENTINEL Local Agent retrieved this deterministic chunk.' in text
|
||||
assert 'SUMMARY_COMBO compacted older history' in text
|
||||
assert 'tool-result:alpha' in text
|
||||
assert 'tool-result:beta' in text
|
||||
assert 'current combo request must survive' in text
|
||||
return 'COMBO_FINAL RAG_SENTINEL HIST_COMBO_SENTINEL tool-result:alpha tool-result:beta'
|
||||
|
||||
fake_requester.queue_llm_responses(*(scripted_response for _ in range(20)))
|
||||
tool_mgr = _FakeToolManager()
|
||||
fake_kb = _FakeKnowledgeBase()
|
||||
ap.tool_mgr = tool_mgr
|
||||
ap.rag_mgr = _FakeRagManager(fake_kb)
|
||||
|
||||
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
|
||||
for index in range(16):
|
||||
await store.append_transcript(
|
||||
transcript_id=None,
|
||||
event_id=f'e2e-local-agent-combo-history-{index}',
|
||||
conversation_id='e2e-local-agent-combo-conversation',
|
||||
role='user' if index % 2 == 0 else 'assistant',
|
||||
content=(
|
||||
f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL '
|
||||
'This old message intentionally creates pressure for combo compaction. ' * 8
|
||||
),
|
||||
thread_id='e2e-local-agent-thread',
|
||||
item_type='message',
|
||||
)
|
||||
|
||||
event = _event(
|
||||
event_id='e2e-local-agent-combo-event-001',
|
||||
conversation_id='e2e-local-agent-combo-conversation',
|
||||
text='current combo request must survive; use RAG and tools before answering.',
|
||||
)
|
||||
binding = _binding(
|
||||
binding_id='e2e-local-agent-combo-binding',
|
||||
allowed_tool_names=[E2E_TOOL_NAME],
|
||||
allowed_kb_uuids=[E2E_KB_UUID],
|
||||
runner_config={
|
||||
'knowledge-bases': [E2E_KB_UUID],
|
||||
'retrieval-top-k': 1,
|
||||
'max-tool-iterations': 4,
|
||||
'tool-execution-mode': 'serial',
|
||||
'context-window-tokens': 950,
|
||||
'context-reserve-tokens': 300,
|
||||
'context-keep-recent-tokens': 140,
|
||||
'context-summary-tokens': 260,
|
||||
'context-history-fetch-limit': 25,
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
||||
return (
|
||||
messages,
|
||||
tool_mgr.calls,
|
||||
fake_kb.retrieve_calls,
|
||||
_invoke_payload_texts(fake_requester),
|
||||
fake_requester._invoke_count,
|
||||
)
|
||||
|
||||
messages, tool_calls, retrieve_calls, invoke_payload_texts, invoke_count = _run_local_agent_probe(
|
||||
local_agent_e2e_tmpdir,
|
||||
_run_probe,
|
||||
)
|
||||
|
||||
assert len(messages) == 1
|
||||
assert _content_text(messages[0].content) == (
|
||||
'COMBO_FINAL RAG_SENTINEL HIST_COMBO_SENTINEL tool-result:alpha tool-result:beta'
|
||||
)
|
||||
assert tool_calls == [
|
||||
{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}},
|
||||
{'name': E2E_TOOL_NAME, 'parameters': {'query': 'beta'}},
|
||||
]
|
||||
assert retrieve_calls == [
|
||||
{
|
||||
'query_text': 'current combo request must survive; use RAG and tools before answering.',
|
||||
'settings': {'top_k': 1, 'filters': {}},
|
||||
}
|
||||
]
|
||||
assert invoke_count >= 4
|
||||
assert any(
|
||||
'RAG_SENTINEL Local Agent retrieved this deterministic chunk.' in text
|
||||
for payload_texts in invoke_payload_texts
|
||||
for text in payload_texts
|
||||
)
|
||||
assert any('SUMMARY_COMBO compacted older history' in text for text in invoke_payload_texts[-1])
|
||||
assert any('tool-result:alpha' in text for text in invoke_payload_texts[-1])
|
||||
assert any('tool-result:beta' in text for text in invoke_payload_texts[-1])
|
||||
assert any('current combo request must survive' in text for text in invoke_payload_texts[-1])
|
||||
|
||||
db_path = local_agent_e2e_tmpdir / 'data' / 'langbot.db'
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
run_row = conn.execute(
|
||||
"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
|
||||
run_id, status, status_reason = run_row
|
||||
assert status == 'completed'
|
||||
assert status_reason == 'stop'
|
||||
|
||||
event_rows = conn.execute(
|
||||
'SELECT type, data_json FROM agent_run_event WHERE run_id = ? ORDER BY sequence',
|
||||
(run_id,),
|
||||
).fetchall()
|
||||
event_types = [row[0] for row in event_rows]
|
||||
assert event_types == [
|
||||
'tool.call.started',
|
||||
'tool.call.completed',
|
||||
'tool.call.started',
|
||||
'tool.call.completed',
|
||||
'message.completed',
|
||||
'run.completed',
|
||||
]
|
||||
assert 'tool-result:alpha' in event_rows[1][1]
|
||||
assert 'tool-result:beta' in event_rows[3][1]
|
||||
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'"
|
||||
).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'
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert checkpoint is not None
|
||||
assert checkpoint['conversation_id'] == 'e2e-local-agent-combo-conversation'
|
||||
assert 'SUMMARY_COMBO compacted older history' in checkpoint['summary']
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user