fix(runner): align SDK pin and workspace-aware integration fixtures

This commit is contained in:
dadachann
2026-09-11 04:16:55 +00:00
parent 24ddcfe13e
commit 6189b06dfc
4 changed files with 186 additions and 16 deletions
+1 -1
View File
@@ -232,4 +232,4 @@ line-ending = "auto"
[tool.uv.sources] [tool.uv.sources]
# Development contract: update to the matching SDK release before publishing. # Development contract: update to the matching SDK release before publishing.
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "eac2c60509534512f9a373cd0c801e75985e0612" } langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
+60 -13
View File
@@ -57,7 +57,7 @@ def _package_local_agent_plugin(tmpdir: Path) -> Path:
package_source = tmpdir / 'local-agent-package' package_source = tmpdir / 'local-agent-package'
ignore = shutil.ignore_patterns( ignore = shutil.ignore_patterns(
'.git', '.git',
'.venv', '.venv*',
'__pycache__', '__pycache__',
'.pytest_cache', '.pytest_cache',
'.ruff_cache', '.ruff_cache',
@@ -181,7 +181,20 @@ class _FakeToolManager:
def __init__(self): def __init__(self):
self.calls: list[dict[str, Any]] = [] self.calls: list[dict[str, Any]] = []
async def get_tool_schema(self, tool_name: str): async def get_resolved_tool_catalog(
self,
context,
bound_plugins=None,
bound_mcp_servers=None,
include_skill_authoring=True,
include_mcp_resource_tools=False,
):
assert context.workspace_uuid
return [{'name': E2E_TOOL_NAME, 'source': 'native', 'source_id': None}]
async def get_tool_schema(self, context, tool_name: str, source_ref=None):
assert context.workspace_uuid
assert source_ref == {'source': 'native', 'source_id': None}
if tool_name != E2E_TOOL_NAME: if tool_name != E2E_TOOL_NAME:
return None, None return None, None
return ( return (
@@ -195,14 +208,15 @@ class _FakeToolManager:
}, },
) )
async def get_tool_detail(self, tool_name: str): async def get_tool_detail(self, context, tool_name: str, source_ref=None):
description, parameters = await self.get_tool_schema(tool_name) description, parameters = await self.get_tool_schema(context, tool_name, source_ref=source_ref)
if parameters is None: if parameters is None:
return None return None
return {'name': tool_name, 'description': description, 'parameters': parameters} return {'name': tool_name, 'description': description, 'parameters': parameters}
async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None): async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None, source_ref=None):
del query assert query.workspace_uuid
assert source_ref == {'source': 'native', 'source_id': None}
self.calls.append({'name': name, 'parameters': dict(parameters)}) self.calls.append({'name': name, 'parameters': dict(parameters)})
return { return {
'value': f'tool-result:{parameters.get("query")}', 'value': f'tool-result:{parameters.get("query")}',
@@ -223,7 +237,8 @@ class _FakeKnowledgeBase:
def get_name(self) -> str: def get_name(self) -> str:
return 'E2E Fake KB' return 'E2E Fake KB'
async def retrieve(self, query_text: str, settings: dict[str, Any]): async def retrieve(self, context, query_text: str, settings: dict[str, Any]):
assert context.workspace_uuid
self.retrieve_calls.append({'query_text': query_text, 'settings': settings}) self.retrieve_calls.append({'query_text': query_text, 'settings': settings})
return [ return [
SimpleNamespace( SimpleNamespace(
@@ -248,7 +263,8 @@ class _FakeRagManager:
self.kb = kb self.kb = kb
self.knowledge_bases = {E2E_KB_UUID: kb} self.knowledge_bases = {E2E_KB_UUID: kb}
async def get_knowledge_base_by_uuid(self, kb_uuid: str): async def get_knowledge_base_by_uuid(self, context, kb_uuid: str):
assert context.workspace_uuid
if kb_uuid == E2E_KB_UUID: if kb_uuid == E2E_KB_UUID:
return self.kb return self.kb
return None return None
@@ -443,10 +459,27 @@ def _scripted_tool_call(
async def _boot_local_agent_app(tmpdir: Path): async def _boot_local_agent_app(tmpdir: Path):
"""Boot LangBot and wait until the Local Agent runner is discoverable.""" """Boot LangBot and wait until the Local Agent runner is discoverable."""
from langbot.pkg.core import boot from langbot.pkg.core import boot
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
ap = await boot.make_app(asyncio.get_running_loop()) ap = await boot.make_app(asyncio.get_running_loop())
run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app') run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app')
try:
await _wait_for_local_agent_runner(ap, tmpdir)
except BaseException:
# The caller has not received ap yet. Close it here so a boot failure
# cannot reconnect after the probe restores the global transport mode.
try:
await ap.shutdown()
finally:
run_task.cancel()
await asyncio.gather(run_task, return_exceptions=True)
raise
return ap, run_task
async def _wait_for_local_agent_runner(ap, tmpdir: Path):
"""Install and discover the runner on the application-owned connection."""
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
for _ in range(60): for _ in range(60):
handler = getattr(ap.plugin_connector, 'handler', None) handler = getattr(ap.plugin_connector, 'handler', None)
if handler is not None: if handler is not None:
@@ -478,8 +511,6 @@ async def _boot_local_agent_app(tmpdir: Path):
else: else:
raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation') raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation')
return ap, run_task
def _run_local_agent_probe(tmpdir: Path, probe): def _run_local_agent_probe(tmpdir: Path, probe):
"""Run one Local Agent probe inside the temporary LangBot app.""" """Run one Local Agent probe inside the temporary LangBot app."""
@@ -689,7 +720,13 @@ def test_local_runner_retrieves_authorized_rag_context_through_host_action(
assert retrieve_calls == [ assert retrieve_calls == [
{ {
'query_text': 'Answer with the retrieved RAG sentinel.', 'query_text': 'Answer with the retrieved RAG sentinel.',
'settings': {'top_k': 1, 'filters': {}}, 'settings': {
'top_k': 1,
'filters': {},
'session_name': 'person_e2e-local-agent-rag-conversation',
'bot_uuid': '',
'sender_id': 'user-001',
},
} }
] ]
assert any( assert any(
@@ -738,11 +775,13 @@ def test_local_runner_compacts_history_and_persists_checkpoint(
) )
store = TranscriptStore(ap.persistence_mgr.get_db_engine()) store = TranscriptStore(ap.persistence_mgr.get_db_engine())
execution_context = await ap.plugin_connector._current_execution_context()
for index in range(12): for index in range(12):
await store.append_transcript( await store.append_transcript(
transcript_id=None, transcript_id=None,
event_id=f'e2e-local-agent-history-{index}', event_id=f'e2e-local-agent-history-{index}',
conversation_id='e2e-local-agent-compaction-conversation', conversation_id='e2e-local-agent-compaction-conversation',
workspace_id=execution_context.workspace_uuid,
role='user' if index % 2 == 0 else 'assistant', role='user' if index % 2 == 0 else 'assistant',
content=( 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
@@ -847,11 +886,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
ap.rag_mgr = _FakeRagManager(fake_kb) ap.rag_mgr = _FakeRagManager(fake_kb)
store = TranscriptStore(ap.persistence_mgr.get_db_engine()) store = TranscriptStore(ap.persistence_mgr.get_db_engine())
execution_context = await ap.plugin_connector._current_execution_context()
for index in range(16): for index in range(16):
await store.append_transcript( await store.append_transcript(
transcript_id=None, transcript_id=None,
event_id=f'e2e-local-agent-combo-history-{index}', event_id=f'e2e-local-agent-combo-history-{index}',
conversation_id='e2e-local-agent-combo-conversation', conversation_id='e2e-local-agent-combo-conversation',
workspace_id=execution_context.workspace_uuid,
role='user' if index % 2 == 0 else 'assistant', role='user' if index % 2 == 0 else 'assistant',
content=( content=(
f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL ' f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL '
@@ -907,7 +948,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
assert retrieve_calls == [ assert retrieve_calls == [
{ {
'query_text': 'current combo request must survive; use RAG and tools before answering.', 'query_text': 'current combo request must survive; use RAG and tools before answering.',
'settings': {'top_k': 1, 'filters': {}}, 'settings': {
'top_k': 1,
'filters': {},
'session_name': 'person_e2e-local-agent-combo-conversation',
'bot_uuid': '',
'sender_id': 'user-001',
},
} }
] ]
assert invoke_count >= 4 assert invoke_count >= 4
@@ -0,0 +1,123 @@
"""Keep E2E fake resources compatible with the real Host contract."""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
from langbot.pkg.api.http.context import ExecutionContext
from tests.e2e.test_local_runner_fake_provider import (
E2E_KB_UUID,
E2E_TOOL_NAME,
LOCAL_RUNNER_ID,
_FakeKnowledgeBase,
_FakeRagManager,
_FakeToolManager,
_binding,
_event,
)
CONTEXT = ExecutionContext(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1)
SOURCE = {'source': 'native', 'source_id': None}
async def _resources(tool_mgr, rag_mgr, binding):
descriptor = RunnerDescriptor(
id=LOCAL_RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
capabilities={'tool_calling': True, 'knowledge_retrieval': True},
permissions={'tools': ['detail', 'call'], 'knowledge_bases': ['list', 'retrieve']},
)
app = SimpleNamespace(logger=Mock(), skill_mgr=None, tool_mgr=tool_mgr, rag_mgr=rag_mgr)
return await AgentResourceBuilder(app).build_resources_from_binding(
CONTEXT,
_event(event_id='evt', conversation_id='conv', text='test'),
binding,
descriptor,
)
@pytest.mark.asyncio
async def test_fake_tool_is_projected_with_frozen_source_and_executed():
manager = _FakeToolManager()
resources = await _resources(
manager, _FakeRagManager(_FakeKnowledgeBase()), _binding(allowed_tool_names=[E2E_TOOL_NAME])
)
assert len(resources['tools']) == 1
tool = resources['tools'][0]
assert tool['tool_name'] == E2E_TOOL_NAME
assert {key: tool[key] for key in SOURCE} == SOURCE
assert tool['parameters']['required'] == ['query']
detail = await manager.get_tool_detail(CONTEXT, E2E_TOOL_NAME, source_ref=SOURCE)
assert detail['parameters'] == tool['parameters']
result = await manager.execute_func_call(
name=E2E_TOOL_NAME,
parameters={'query': 'alpha'},
query=SimpleNamespace(workspace_uuid=CONTEXT.workspace_uuid),
source_ref=SOURCE,
)
assert result['value'] == 'tool-result:alpha'
assert manager.calls == [{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}]
@pytest.mark.asyncio
async def test_fake_kb_is_projected_and_retrieved_with_execution_context():
kb = _FakeKnowledgeBase()
manager = _FakeRagManager(kb)
resources = await _resources(_FakeToolManager(), manager, _binding(allowed_kb_uuids=[E2E_KB_UUID]))
assert [item['kb_id'] for item in resources['knowledge_bases']] == [E2E_KB_UUID]
resolved = await manager.get_knowledge_base_by_uuid(CONTEXT, E2E_KB_UUID)
entries = await resolved.retrieve(CONTEXT, 'test', settings={'top_k': 1, 'filters': {}})
assert 'RAG_SENTINEL' in entries[0].content
assert kb.retrieve_calls == [{'query_text': 'test', 'settings': {'top_k': 1, 'filters': {}}}]
def test_local_agent_package_excludes_alternate_virtualenvs(tmp_path, monkeypatch):
import zipfile
from tests.e2e import test_local_runner_fake_provider as fixtures
source = tmp_path / 'source'
source.mkdir()
(source / 'manifest.yaml').write_text('metadata: {author: langbot-team, name: LocalAgent}')
for name in ('.venv', '.venv311'):
(source / name).mkdir()
(source / name / 'not-plugin.py').write_text('pass')
monkeypatch.setattr(fixtures, '_local_agent_repo', lambda: source)
package = fixtures._package_local_agent_plugin(tmp_path / 'package')
with zipfile.ZipFile(package) as archive:
assert archive.namelist() == ['manifest.yaml']
@pytest.mark.asyncio
async def test_failed_local_agent_boot_shuts_down_before_restoring_transport(monkeypatch, tmp_path):
import asyncio
from unittest.mock import AsyncMock
from langbot.pkg.core import boot
from tests.e2e import test_local_runner_fake_provider as fixtures
async def running():
await asyncio.Event().wait()
app = SimpleNamespace(
run=running,
shutdown=AsyncMock(),
plugin_connector=SimpleNamespace(
handler=SimpleNamespace(ping=AsyncMock()), _current_execution_context=AsyncMock(return_value=CONTEXT)
),
runner_registry=SimpleNamespace(list_runners=AsyncMock(side_effect=ValueError('probe boot failure'))),
)
monkeypatch.setattr(boot, 'make_app', AsyncMock(return_value=app))
try:
with pytest.raises(ValueError, match='probe boot failure'):
await fixtures._boot_local_agent_app(tmp_path)
app.shutdown.assert_awaited_once()
assert not any(task.get_name() == 'local-agent-e2e-app' for task in asyncio.all_tasks())
finally:
tasks = [task for task in asyncio.all_tasks() if task.get_name() == 'local-agent-e2e-app']
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
Generated
+2 -2
View File
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612" }, { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2186,7 +2186,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.5" version = "0.5.5"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612#eac2c60509534512f9a373cd0c801e75985e0612" } source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=92a9e03fa9c791f4ed30cc3f5f0602c13b800d28#92a9e03fa9c791f4ed30cc3f5f0602c13b800d28" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },