mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(runner): align SDK pin and workspace-aware integration fixtures
This commit is contained in:
+1
-1
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user