mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 23:07:14 +00:00
feat(agent): add event-aware tool permissions
This commit is contained in:
@@ -10,6 +10,7 @@ import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import time
|
||||
@@ -25,7 +26,6 @@ pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default'
|
||||
QA_PLUGIN_DIRNAME = 'e2e__agent-runner-qa'
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
@@ -198,7 +198,13 @@ 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)
|
||||
|
||||
_write_qa_agent_runner_plugin(agent_runner_e2e_tmpdir / 'data' / 'plugins' / QA_PLUGIN_DIRNAME)
|
||||
plugin_source = agent_runner_e2e_tmpdir / 'agent-runner-qa-package'
|
||||
_write_qa_agent_runner_plugin(plugin_source)
|
||||
shutil.make_archive(
|
||||
str(agent_runner_e2e_tmpdir / 'agent-runner-qa'),
|
||||
'zip',
|
||||
root_dir=plugin_source,
|
||||
)
|
||||
return config_path
|
||||
|
||||
|
||||
@@ -212,7 +218,7 @@ def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_p
|
||||
stderr_file = open(stderr_path, 'wb')
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(find_project_root() / '.venv' / 'bin' / 'python'),
|
||||
sys.executable,
|
||||
'-m',
|
||||
'langbot_plugin.cli.__init__',
|
||||
'rt',
|
||||
@@ -278,132 +284,180 @@ def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process):
|
||||
|
||||
def _init_and_auth(client: httpx.Client) -> str:
|
||||
"""Initialize the test admin user and return a bearer token."""
|
||||
init_resp = client.post('/api/v1/user/init', json={'user': 'admin', 'password': 'admin'})
|
||||
credentials = {'user': 'admin@langbot.test', 'password': 'admin'}
|
||||
init_resp = client.post('/api/v1/user/init', json=credentials)
|
||||
assert init_resp.status_code == 200
|
||||
assert init_resp.json()['code'] in [0, 1]
|
||||
|
||||
auth_resp = client.post('/api/v1/user/auth', json={'user': 'admin', 'password': 'admin'})
|
||||
auth_resp = client.post('/api/v1/user/auth', json=credentials)
|
||||
assert auth_resp.status_code == 200
|
||||
payload = auth_resp.json()
|
||||
assert payload['code'] == 0
|
||||
return payload['data']['token']
|
||||
|
||||
|
||||
def test_plugin_runtime_discovers_agent_runner(agent_runner_client, agent_runner_langbot_process):
|
||||
"""Pipeline metadata should include the real runtime-discovered QA runner."""
|
||||
token = _init_and_auth(agent_runner_client)
|
||||
start = time.time()
|
||||
while time.time() - start < 60:
|
||||
response = agent_runner_client.get(
|
||||
def _install_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None:
|
||||
"""Install the QA Runner through the same asynchronous local-upload API as the UI."""
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
with package_path.open('rb') as package_file:
|
||||
response = client.post(
|
||||
'/api/v1/plugins/install/local',
|
||||
headers=headers,
|
||||
files={'file': ('agent-runner-qa.zip', package_file, 'application/zip')},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
payload = response.json()
|
||||
assert payload['code'] == 0, payload
|
||||
task_id = payload['data']['task_id']
|
||||
|
||||
deadline = time.time() + 90
|
||||
while time.time() < deadline:
|
||||
task_response = client.get(f'/api/v1/system/tasks/{task_id}', headers=headers)
|
||||
assert task_response.status_code == 200, task_response.text
|
||||
task_payload = task_response.json()
|
||||
assert task_payload['code'] == 0, task_payload
|
||||
task = task_payload['data']
|
||||
if task['runtime']['done']:
|
||||
assert task['runtime']['exception'] is None, task
|
||||
assert task['task_context']['metadata']['progress_percent'] == 100
|
||||
return
|
||||
time.sleep(1)
|
||||
raise AssertionError(f'Plugin installation task {task_id} did not complete')
|
||||
|
||||
|
||||
def _wait_for_qa_runner(client: httpx.Client, token: str, timeout: float = 60) -> set[str]:
|
||||
"""Return the latest Runner option set, waiting for the QA Runner when needed."""
|
||||
deadline = time.time() + timeout
|
||||
option_names: set[str] = set()
|
||||
while time.time() < deadline:
|
||||
response = client.get(
|
||||
'/api/v1/pipelines/_/metadata',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data['code'] == 0
|
||||
assert data['code'] == 0, data
|
||||
metadata_groups = data['data']['configs']
|
||||
ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai')
|
||||
|
||||
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
|
||||
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
|
||||
option_names = {option['name'] for option in runner_select['options']}
|
||||
if QA_RUNNER_ID in option_names:
|
||||
return
|
||||
time.sleep(2)
|
||||
break
|
||||
time.sleep(1)
|
||||
return option_names
|
||||
|
||||
assert QA_RUNNER_ID in option_names
|
||||
|
||||
def _ensure_qa_plugin(client: httpx.Client, token: str, package_path: Path) -> None:
|
||||
if QA_RUNNER_ID in _wait_for_qa_runner(client, token, timeout=2):
|
||||
return
|
||||
_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,
|
||||
):
|
||||
"""Pipeline metadata should include the real runtime-discovered QA runner."""
|
||||
token = _init_and_auth(agent_runner_client)
|
||||
_ensure_qa_plugin(
|
||||
agent_runner_client,
|
||||
token,
|
||||
agent_runner_e2e_tmpdir / 'agent-runner-qa.zip',
|
||||
)
|
||||
option_names = _wait_for_qa_runner(agent_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'
|
||||
)
|
||||
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'
|
||||
f'Runtime stdout (tail):\n{runtime_stdout[-20_000:]}\n'
|
||||
f'Runtime stderr (tail):\n{runtime_stderr[-20_000:]}'
|
||||
)
|
||||
|
||||
def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
agent_runner_e2e_config_path,
|
||||
agent_runner_client,
|
||||
agent_runner_langbot_process,
|
||||
agent_runner_e2e_tmpdir,
|
||||
agent_runner_runtime_process,
|
||||
):
|
||||
"""The Host orchestrator should run the pluginized runner and persist run side effects."""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from langbot.pkg.agent.runner.host_models import (
|
||||
AgentBinding,
|
||||
AgentEventEnvelope,
|
||||
BindingScope,
|
||||
DeliveryPolicy,
|
||||
StatePolicy,
|
||||
"""Create/configure/debug an Agent through HTTP and persist Runner side effects."""
|
||||
del agent_runner_langbot_process
|
||||
token = _init_and_auth(agent_runner_client)
|
||||
_ensure_qa_plugin(
|
||||
agent_runner_client,
|
||||
token,
|
||||
agent_runner_e2e_tmpdir / 'agent-runner-qa.zip',
|
||||
)
|
||||
from langbot.pkg.core import boot
|
||||
from langbot.pkg.utils import platform as platform_utils
|
||||
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
|
||||
headers = {'Authorization': f'Bearer {token}'}
|
||||
create_response = agent_runner_client.post(
|
||||
'/api/v1/agents',
|
||||
headers=headers,
|
||||
json={
|
||||
'kind': 'agent',
|
||||
'name': 'AgentRunner E2E Agent',
|
||||
'description': 'Exercises the installed QA Runner.',
|
||||
'emoji': 'QA',
|
||||
'supported_event_patterns': ['message.*'],
|
||||
'config': {
|
||||
'runner': {'id': QA_RUNNER_ID},
|
||||
'runner_config': {QA_RUNNER_ID: {}},
|
||||
'allowed_platform_tools': ['event_reply', 'platform_get_user_info'],
|
||||
},
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 200, create_response.text
|
||||
create_payload = create_response.json()
|
||||
assert create_payload['code'] == 0, create_payload
|
||||
agent_uuid = create_payload['data']['uuid']
|
||||
|
||||
async def _run_probe():
|
||||
previous_cwd = Path.cwd()
|
||||
previous_standalone_runtime = platform_utils.standalone_runtime
|
||||
os.chdir(agent_runner_e2e_tmpdir)
|
||||
platform_utils.standalone_runtime = True
|
||||
ap = None
|
||||
try:
|
||||
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('Plugin runtime did not connect')
|
||||
get_response = agent_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'] == [
|
||||
'event_reply',
|
||||
'platform_get_user_info',
|
||||
]
|
||||
|
||||
for _ in range(60):
|
||||
runners = await ap.agent_runner_registry.list_runners(use_cache=False)
|
||||
if any(runner.id == QA_RUNNER_ID for runner in runners):
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
raise AssertionError(f'{QA_RUNNER_ID} was not discovered')
|
||||
|
||||
event = AgentEventEnvelope(
|
||||
event_id='e2e-orchestrator-event-001',
|
||||
event_type='message.received',
|
||||
source='api',
|
||||
conversation_id='e2e-conversation',
|
||||
thread_id='e2e-thread',
|
||||
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='hello from orchestrator e2e'),
|
||||
delivery=DeliveryContext(surface='e2e'),
|
||||
)
|
||||
binding = AgentBinding(
|
||||
binding_id='e2e-binding',
|
||||
scope=BindingScope(scope_type='global'),
|
||||
runner_id=QA_RUNNER_ID,
|
||||
state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']),
|
||||
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
|
||||
)
|
||||
return [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
||||
finally:
|
||||
if ap is not None:
|
||||
ap.dispose()
|
||||
platform_utils.standalone_runtime = previous_standalone_runtime
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
messages = asyncio.run(_run_probe())
|
||||
|
||||
assert len(messages) == 1
|
||||
assert messages[0].role == 'assistant'
|
||||
assert messages[0].content == 'e2e echo: hello from orchestrator e2e'
|
||||
debug_response = agent_runner_client.post(
|
||||
f'/api/v1/agents/{agent_uuid}/debug',
|
||||
headers=headers,
|
||||
json={
|
||||
'event_type': 'message.received',
|
||||
'text': 'hello from orchestrator e2e',
|
||||
'conversation_id': 'e2e-conversation',
|
||||
},
|
||||
)
|
||||
assert debug_response.status_code == 200, debug_response.text
|
||||
debug_payload = debug_response.json()
|
||||
assert debug_payload['code'] == 0, debug_payload
|
||||
result = debug_payload['data']
|
||||
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'
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
try:
|
||||
run_row = conn.execute(
|
||||
"SELECT status, runner_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001'"
|
||||
'SELECT status, runner_id FROM agent_run WHERE event_id = ?',
|
||||
(result['event_id'],),
|
||||
).fetchone()
|
||||
assert run_row == ('completed', QA_RUNNER_ID)
|
||||
|
||||
event_types = {
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001')"
|
||||
'SELECT type FROM agent_run_event WHERE run_id = '
|
||||
'(SELECT run_id FROM agent_run WHERE event_id = ?)',
|
||||
(result['event_id'],),
|
||||
).fetchall()
|
||||
}
|
||||
assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types)
|
||||
@@ -415,59 +469,3 @@ def test_host_orchestrator_runs_agent_runner_and_records_ledger(
|
||||
assert '"count": 1' in state_row[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_pluginized_agent_runner_executes_through_runtime(agent_runner_client, agent_runner_langbot_process):
|
||||
"""The Host debug surface should invoke the QA runner through the real Plugin Runtime."""
|
||||
token = _init_and_auth(agent_runner_client)
|
||||
start = time.time()
|
||||
while time.time() - start < 60:
|
||||
metadata_response = agent_runner_client.get(
|
||||
'/api/v1/pipelines/_/metadata',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
)
|
||||
assert metadata_response.status_code == 200
|
||||
metadata = metadata_response.json()['data']['configs']
|
||||
ai_metadata = next(group for group in metadata if group.get('name') == 'ai')
|
||||
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
|
||||
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
|
||||
if QA_RUNNER_ID in {option['name'] for option in runner_select['options']}:
|
||||
break
|
||||
time.sleep(2)
|
||||
else:
|
||||
pytest.fail(f'{QA_RUNNER_ID} was not discovered before run_agent')
|
||||
|
||||
response = agent_runner_client.post(
|
||||
'/api/v1/system/debug/plugin/action',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
json={
|
||||
'action': 'run_agent',
|
||||
'timeout': 60,
|
||||
'data': {
|
||||
'plugin_author': 'e2e',
|
||||
'plugin_name': 'agent-runner-qa',
|
||||
'runner_name': 'default',
|
||||
'context': {
|
||||
'run_id': 'e2e-run-001',
|
||||
'trigger': {'type': 'message.received'},
|
||||
'event': {
|
||||
'event_id': 'e2e-event-001',
|
||||
'event_type': 'message.received',
|
||||
'source': 'api',
|
||||
},
|
||||
'input': {'text': 'hello from real e2e'},
|
||||
'delivery': {'surface': 'e2e'},
|
||||
'resources': {},
|
||||
'runtime': {},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload['code'] == 0
|
||||
result = payload['data']
|
||||
assert result['type'] == 'message.completed', result
|
||||
assert result['data']['message']['role'] == 'assistant'
|
||||
assert result['data']['message']['content'] == 'e2e echo: hello from real e2e'
|
||||
|
||||
@@ -14,6 +14,7 @@ import shutil
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -30,7 +31,6 @@ pytestmark = pytest.mark.e2e
|
||||
LOCAL_AGENT_RUNNER_ID = 'plugin:langbot-team/LocalAgent/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'
|
||||
|
||||
@@ -48,13 +48,13 @@ def _local_agent_repo() -> Path:
|
||||
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."""
|
||||
def _package_local_agent_plugin(tmpdir: Path) -> Path:
|
||||
"""Package the sibling Local Agent plugin for the real local-install flow."""
|
||||
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
|
||||
package_source = tmpdir / 'local-agent-package'
|
||||
ignore = shutil.ignore_patterns(
|
||||
'.git',
|
||||
'.venv',
|
||||
@@ -64,7 +64,16 @@ def _copy_local_agent_plugin(tmpdir: Path) -> None:
|
||||
'build',
|
||||
'dist',
|
||||
)
|
||||
shutil.copytree(local_agent_src, plugin_dst, ignore=ignore)
|
||||
shutil.copytree(local_agent_src, package_source, ignore=ignore)
|
||||
archive_path = Path(
|
||||
shutil.make_archive(
|
||||
str(tmpdir / 'langbot-local-agent'),
|
||||
'zip',
|
||||
root_dir=package_source,
|
||||
)
|
||||
)
|
||||
shutil.rmtree(package_source)
|
||||
return archive_path
|
||||
|
||||
|
||||
def _content_text(content: Any) -> str:
|
||||
@@ -289,7 +298,7 @@ def local_agent_e2e_config_path(local_agent_e2e_tmpdir, local_agent_e2e_port, lo
|
||||
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)
|
||||
_package_local_agent_plugin(local_agent_e2e_tmpdir)
|
||||
return config_path
|
||||
|
||||
|
||||
@@ -304,7 +313,7 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port
|
||||
stderr_file = open(stderr_path, 'wb')
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
str(find_project_root() / '.venv' / 'bin' / 'python'),
|
||||
sys.executable,
|
||||
'-m',
|
||||
'langbot_plugin.cli.__init__',
|
||||
'rt',
|
||||
@@ -329,14 +338,18 @@ def local_agent_runtime_process(local_agent_e2e_tmpdir, local_agent_runtime_port
|
||||
stderr_file.close()
|
||||
|
||||
|
||||
def _inject_fake_llm_model(ap) -> Any:
|
||||
async def _inject_fake_llm_model(ap) -> Any:
|
||||
"""Register a runtime-only fake model that supports count_tokens/invoke."""
|
||||
import sqlalchemy
|
||||
|
||||
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
|
||||
|
||||
execution_context = await ap.plugin_connector._current_execution_context()
|
||||
provider_entity = persistence_model.ModelProvider(
|
||||
uuid=FAKE_PROVIDER_UUID,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
name='E2E Fake Provider',
|
||||
requester='fake-requester',
|
||||
base_url='https://fake.invalid',
|
||||
@@ -344,26 +357,64 @@ def _inject_fake_llm_model(ap) -> Any:
|
||||
)
|
||||
fake_requester = FakeProviderAPIRequester(ap, {'base_url': provider_entity.base_url})
|
||||
runtime_provider = requester.RuntimeProvider(
|
||||
execution_context=execution_context,
|
||||
provider_entity=provider_entity,
|
||||
token_mgr=token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys),
|
||||
requester=fake_requester,
|
||||
)
|
||||
model_entity = persistence_model.LLMModel(
|
||||
uuid=FAKE_MODEL_UUID,
|
||||
workspace_uuid=execution_context.workspace_uuid,
|
||||
name=FAKE_MODEL_UUID,
|
||||
provider_uuid=provider_entity.uuid,
|
||||
abilities=['func_call'],
|
||||
context_length=8192,
|
||||
extra_args={},
|
||||
)
|
||||
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={},
|
||||
),
|
||||
model_entity=model_entity,
|
||||
execution_context=execution_context,
|
||||
provider=runtime_provider,
|
||||
)
|
||||
ap.model_mgr.provider_dict[provider_entity.uuid] = runtime_provider
|
||||
ap.model_mgr.llm_models.append(runtime_model)
|
||||
await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.ModelProvider).values(
|
||||
uuid=provider_entity.uuid,
|
||||
workspace_uuid=provider_entity.workspace_uuid,
|
||||
name=provider_entity.name,
|
||||
requester=provider_entity.requester,
|
||||
base_url=provider_entity.base_url,
|
||||
api_keys=provider_entity.api_keys,
|
||||
)
|
||||
)
|
||||
await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_model.LLMModel).values(
|
||||
uuid=model_entity.uuid,
|
||||
workspace_uuid=model_entity.workspace_uuid,
|
||||
name=model_entity.name,
|
||||
provider_uuid=model_entity.provider_uuid,
|
||||
abilities=model_entity.abilities,
|
||||
context_length=model_entity.context_length,
|
||||
extra_args=model_entity.extra_args,
|
||||
)
|
||||
)
|
||||
await ap.model_mgr.cache_provider(execution_context, runtime_provider)
|
||||
await ap.model_mgr.cache_llm_model(execution_context, runtime_model)
|
||||
return fake_requester
|
||||
|
||||
|
||||
async def _run_agent(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 [
|
||||
message
|
||||
async for message in ap.agent_run_orchestrator.run(
|
||||
event,
|
||||
binding,
|
||||
adapter_context={'_execution_context': execution_context},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _scripted_tool_call(
|
||||
tool_name: str = E2E_TOOL_NAME,
|
||||
*,
|
||||
@@ -392,8 +443,10 @@ def _scripted_tool_call(
|
||||
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
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
ap = await boot.make_app(asyncio.get_running_loop())
|
||||
run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app')
|
||||
for _ in range(60):
|
||||
handler = getattr(ap.plugin_connector, 'handler', None)
|
||||
if handler is not None:
|
||||
@@ -401,17 +454,31 @@ async def _boot_local_agent_app(tmpdir: Path):
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
raise AssertionError(f'Plugin runtime did not connect; tmpdir={tmpdir}')
|
||||
runtime_stdout = (tmpdir / 'plugin-runtime.stdout.log').read_text(encoding='utf-8', errors='replace')
|
||||
runtime_stderr = (tmpdir / 'plugin-runtime.stderr.log').read_text(encoding='utf-8', errors='replace')
|
||||
raise AssertionError(
|
||||
f'Plugin runtime did not connect; tmpdir={tmpdir}\n'
|
||||
f'Runtime stdout:\n{runtime_stdout[-20_000:]}\n'
|
||||
f'Runtime stderr:\n{runtime_stderr[-20_000:]}'
|
||||
)
|
||||
|
||||
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')
|
||||
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):
|
||||
await ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.LOCAL,
|
||||
{'plugin_file': (tmpdir / 'langbot-local-agent.zip').read_bytes()},
|
||||
)
|
||||
|
||||
return ap
|
||||
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):
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
raise AssertionError(f'{LOCAL_AGENT_RUNNER_ID} was not discovered after installation')
|
||||
|
||||
return ap, run_task
|
||||
|
||||
|
||||
def _run_local_agent_probe(tmpdir: Path, probe):
|
||||
@@ -424,12 +491,30 @@ def _run_local_agent_probe(tmpdir: Path, probe):
|
||||
os.chdir(tmpdir)
|
||||
platform_utils.standalone_runtime = True
|
||||
ap = None
|
||||
run_task = None
|
||||
try:
|
||||
ap = await _boot_local_agent_app(tmpdir)
|
||||
ap, run_task = await _boot_local_agent_app(tmpdir)
|
||||
return await probe(ap)
|
||||
finally:
|
||||
if ap is not None:
|
||||
ap.dispose()
|
||||
import sqlalchemy
|
||||
|
||||
from langbot.pkg.entity.persistence import model as persistence_model
|
||||
|
||||
await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.LLMModel).where(
|
||||
persistence_model.LLMModel.uuid == FAKE_MODEL_UUID
|
||||
)
|
||||
)
|
||||
await ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(persistence_model.ModelProvider).where(
|
||||
persistence_model.ModelProvider.uuid == FAKE_PROVIDER_UUID
|
||||
)
|
||||
)
|
||||
await ap.shutdown()
|
||||
if run_task is not None:
|
||||
run_task.cancel()
|
||||
await asyncio.gather(run_task, return_exceptions=True)
|
||||
platform_utils.standalone_runtime = previous_standalone_runtime
|
||||
os.chdir(previous_cwd)
|
||||
|
||||
@@ -445,13 +530,13 @@ def test_local_agent_runner_uses_host_fake_provider_and_persists_ledger(
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester = await _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())]
|
||||
messages = await _run_agent(ap, event, _binding())
|
||||
return messages, list(fake_requester._count_tokens_payloads)
|
||||
|
||||
messages, token_payloads = _run_local_agent_probe(local_agent_e2e_tmpdir, _run_probe)
|
||||
@@ -507,7 +592,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester = await _inject_fake_llm_model(ap)
|
||||
fake_requester.queue_llm_responses(
|
||||
_scripted_tool_call(),
|
||||
'Tool loop final answer after tool-result:alpha',
|
||||
@@ -528,7 +613,7 @@ def test_local_agent_runner_executes_authorized_tool_loop_through_host_action(
|
||||
'tool-execution-mode': 'serial',
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
||||
messages = await _run_agent(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)
|
||||
@@ -576,7 +661,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action
|
||||
del local_agent_e2e_config_path, local_agent_runtime_process
|
||||
|
||||
async def _run_probe(ap):
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester = await _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)
|
||||
@@ -594,7 +679,7 @@ def test_local_agent_runner_retrieves_authorized_rag_context_through_host_action
|
||||
'retrieval-top-k': 1,
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
||||
messages = await _run_agent(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)
|
||||
@@ -646,7 +731,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
|
||||
async def _run_probe(ap):
|
||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester = await _inject_fake_llm_model(ap)
|
||||
fake_requester.queue_llm_responses(
|
||||
'SUMMARY_SENTINEL compacted older history including HIST_SENTINEL',
|
||||
'Compaction final answer',
|
||||
@@ -682,7 +767,7 @@ def test_local_agent_runner_compacts_history_and_persists_checkpoint(
|
||||
'context-history-fetch-limit': 20,
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
||||
messages = await _run_agent(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)
|
||||
@@ -738,7 +823,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
|
||||
async def _run_probe(ap):
|
||||
from langbot.pkg.agent.runner.transcript_store import TranscriptStore
|
||||
|
||||
fake_requester = _inject_fake_llm_model(ap)
|
||||
fake_requester = await _inject_fake_llm_model(ap)
|
||||
|
||||
async def scripted_response(**kwargs):
|
||||
messages = kwargs['messages']
|
||||
@@ -798,7 +883,7 @@ def test_local_agent_runner_combines_rag_compaction_and_multi_turn_tool_loop(
|
||||
'context-history-fetch-limit': 25,
|
||||
},
|
||||
)
|
||||
messages = [message async for message in ap.agent_run_orchestrator.run(event, binding)]
|
||||
messages = await _run_agent(ap, event, binding)
|
||||
return (
|
||||
messages,
|
||||
tool_mgr.calls,
|
||||
|
||||
@@ -64,6 +64,11 @@ class LangBotProcess:
|
||||
env.pop(proxy_key, None)
|
||||
env['NO_PROXY'] = '127.0.0.1,localhost'
|
||||
env['no_proxy'] = '127.0.0.1,localhost'
|
||||
# The startup banner contains Unicode symbols. Force deterministic
|
||||
# UTF-8 subprocess streams so Windows locales such as GBK do not crash
|
||||
# before the application can bind its HTTP port.
|
||||
env['PYTHONUTF8'] = '1'
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
# Set API port via environment variable
|
||||
env['API__PORT'] = str(self.port)
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langbot_plugin.api.entities.builtin.agent_runner import (
|
||||
ActorContext,
|
||||
AgentInput,
|
||||
DeliveryContext,
|
||||
RawEventRef,
|
||||
SubjectContext,
|
||||
)
|
||||
from langbot_plugin.api.entities.builtin.platform import message as platform_message
|
||||
|
||||
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
|
||||
from langbot.pkg.agent.runner.platform_tools import (
|
||||
build_platform_tool_resources,
|
||||
execute_platform_tool,
|
||||
freeze_platform_context,
|
||||
resolve_agent_platform_tool_names,
|
||||
)
|
||||
|
||||
|
||||
def _event(event_type: str = 'friend.request_received') -> AgentEventEnvelope:
|
||||
return AgentEventEnvelope(
|
||||
event_id='event-1',
|
||||
event_type=event_type,
|
||||
source='platform',
|
||||
bot_id='bot-1',
|
||||
input=AgentInput(text='event'),
|
||||
actor=ActorContext(actor_type='user', actor_id='user-1'),
|
||||
subject=SubjectContext(subject_type='group', subject_id='group-1'),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={
|
||||
'target_type': 'group',
|
||||
'target_id': 'group-1',
|
||||
'group_id': 'group-1',
|
||||
'message_id': 'message-1',
|
||||
},
|
||||
platform_capabilities={
|
||||
'adapter': 'FakeAdapter',
|
||||
'supported_apis': [
|
||||
'send_message',
|
||||
'approve_friend_request',
|
||||
'get_group_info',
|
||||
],
|
||||
},
|
||||
),
|
||||
raw_ref=RawEventRef(ref_id='request-fallback'),
|
||||
data={'request_id': 'request-1'},
|
||||
)
|
||||
|
||||
|
||||
def test_platform_resources_intersect_selection_adapter_and_event() -> None:
|
||||
resources, capabilities = build_platform_tool_resources(
|
||||
_event(),
|
||||
[
|
||||
'event_reply',
|
||||
'event_respond_friend_request',
|
||||
'event_kick_member',
|
||||
'platform_get_group_info',
|
||||
'unknown_tool',
|
||||
],
|
||||
['detail', 'call'],
|
||||
)
|
||||
|
||||
assert {item['tool_name'] for item in resources} == {
|
||||
'event_reply',
|
||||
'event_respond_friend_request',
|
||||
'platform_get_group_info',
|
||||
}
|
||||
assert all(item['source'] == 'platform' for item in resources)
|
||||
assert capabilities['authorized_tools'] == [item['tool_name'] for item in resources]
|
||||
assert {item['reason'] for item in capabilities['unavailable_tools']} == {
|
||||
'adapter_api_unsupported',
|
||||
'unknown_tool',
|
||||
}
|
||||
|
||||
|
||||
def test_platform_resources_require_runner_call_permission() -> None:
|
||||
resources, capabilities = build_platform_tool_resources(
|
||||
_event(),
|
||||
['event_reply'],
|
||||
['detail'],
|
||||
)
|
||||
|
||||
assert resources == []
|
||||
assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}]
|
||||
|
||||
|
||||
def test_agent_platform_tools_are_resolved_for_the_current_event() -> None:
|
||||
selected = resolve_agent_platform_tool_names(
|
||||
{
|
||||
'allowed_platform_tools': ['platform_get_user_info', 'event_reply'],
|
||||
'event_tool_permissions': {
|
||||
'message.*': ['event_reply'],
|
||||
'group.member.joined': ['event_get_actor'],
|
||||
'group.*': ['event_get_group', 'unknown_tool'],
|
||||
},
|
||||
},
|
||||
'group.member.joined',
|
||||
)
|
||||
|
||||
assert selected == [
|
||||
'platform_get_user_info',
|
||||
'event_reply',
|
||||
'event_get_actor',
|
||||
'event_get_group',
|
||||
'event_get_group_member',
|
||||
]
|
||||
|
||||
|
||||
def test_agent_event_tools_are_automatic_without_permission_configuration() -> None:
|
||||
assert resolve_agent_platform_tool_names(
|
||||
{'allowed_platform_tools': ['event_reply', 'platform_get_user_info']},
|
||||
'friend.request_received',
|
||||
) == [
|
||||
'platform_get_user_info',
|
||||
'event_reply',
|
||||
'event_get_actor',
|
||||
'event_respond_friend_request',
|
||||
]
|
||||
|
||||
|
||||
def test_request_action_does_not_treat_host_event_ref_as_platform_request_id() -> None:
|
||||
event = _event()
|
||||
event.data = {}
|
||||
|
||||
resources, capabilities = build_platform_tool_resources(
|
||||
event,
|
||||
['event_respond_friend_request'],
|
||||
['detail', 'call'],
|
||||
)
|
||||
|
||||
assert resources == []
|
||||
assert capabilities['unavailable_tools'] == [
|
||||
{'name': 'event_respond_friend_request', 'reason': 'event_target_unavailable'}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_action_execution_uses_frozen_target_and_current_bot() -> None:
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['approve_friend_request'],
|
||||
approve_friend_request=AsyncMock(return_value=None),
|
||||
)
|
||||
platform_mgr = SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||
ap = SimpleNamespace(platform_mgr=platform_mgr)
|
||||
event = _event()
|
||||
session = {
|
||||
'authorization': {
|
||||
'bot_id': 'bot-1',
|
||||
'platform_context': freeze_platform_context(event),
|
||||
}
|
||||
}
|
||||
execution_context = object()
|
||||
|
||||
await execute_platform_tool(
|
||||
ap,
|
||||
execution_context,
|
||||
session,
|
||||
'event_respond_friend_request',
|
||||
{'approve': False, 'remark': 'not now'},
|
||||
)
|
||||
|
||||
platform_mgr.get_bot_by_uuid.assert_awaited_once_with(execution_context, 'bot-1')
|
||||
adapter.approve_friend_request.assert_awaited_once_with(
|
||||
request_id='request-1',
|
||||
approve=False,
|
||||
remark='not now',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_reply_builds_message_chain_for_the_frozen_target() -> None:
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['send_message'],
|
||||
send_message=AsyncMock(return_value=None),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||
)
|
||||
session = {
|
||||
'authorization': {
|
||||
'bot_id': 'bot-1',
|
||||
'platform_context': freeze_platform_context(_event()),
|
||||
}
|
||||
}
|
||||
|
||||
await execute_platform_tool(ap, object(), session, 'event_reply', {'text': 'hello'})
|
||||
|
||||
call = adapter.send_message.await_args
|
||||
assert call.kwargs['target_type'] == 'group'
|
||||
assert call.kwargs['target_id'] == 'group-1'
|
||||
assert isinstance(call.kwargs['message'], platform_message.MessageChain)
|
||||
assert call.kwargs['message'][0].text == 'hello'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_action_rejects_parameters_outside_the_declared_schema() -> None:
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['get_group_info'],
|
||||
get_group_info=AsyncMock(),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter)))
|
||||
)
|
||||
session = {'authorization': {'bot_id': 'bot-1', 'platform_context': {}}}
|
||||
|
||||
with pytest.raises(ValueError, match='Unexpected parameters'):
|
||||
await execute_platform_tool(
|
||||
ap,
|
||||
object(),
|
||||
session,
|
||||
'platform_get_group_info',
|
||||
{'group_id': 'group-1', 'raw_action': 'unsafe'},
|
||||
)
|
||||
|
||||
adapter.get_group_info.assert_not_awaited()
|
||||
@@ -6,12 +6,13 @@ 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.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
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
|
||||
from langbot.pkg.agent.runner.host_models import AgentBinding, BindingScope, ResourcePolicy
|
||||
from langbot.pkg.agent.runner.host_models import AgentBinding, AgentEventEnvelope, BindingScope, ResourcePolicy
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
|
||||
|
||||
@@ -201,6 +202,41 @@ async def test_build_models_from_config_without_manifest_acl(app):
|
||||
assert resources['models'] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_platform_tools_are_not_claimed_when_runner_disables_tool_calling(app):
|
||||
event = AgentEventEnvelope(
|
||||
event_id='event-platform-disabled',
|
||||
event_type='message.received',
|
||||
source='platform',
|
||||
bot_id='bot-1',
|
||||
input=AgentInput(text='hello'),
|
||||
delivery=DeliveryContext(
|
||||
surface='platform',
|
||||
reply_target={'target_type': 'person', 'target_id': 'user-1'},
|
||||
platform_capabilities={'supported_apis': ['send_message']},
|
||||
),
|
||||
)
|
||||
binding = AgentBinding(
|
||||
binding_id='binding-platform-disabled',
|
||||
scope=BindingScope(scope_type='global'),
|
||||
runner_id=RUNNER_ID,
|
||||
resource_policy=ResourcePolicy(allowed_platform_tool_names=['event_reply']),
|
||||
)
|
||||
|
||||
resources = await AgentResourceBuilder(app).build_resources_from_binding(
|
||||
execution_context=TEST_CONTEXT,
|
||||
event=event,
|
||||
binding=binding,
|
||||
descriptor=make_descriptor(capabilities={'tool_calling': False}),
|
||||
)
|
||||
|
||||
assert resources['tools'] == []
|
||||
assert resources['platform_capabilities']['authorized_tools'] == []
|
||||
assert resources['platform_capabilities']['unavailable_tools'] == [
|
||||
{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
|
||||
"""Config-selected model references are projected regardless of method granularity."""
|
||||
|
||||
@@ -53,10 +53,24 @@ def test_pipeline_projection_keeps_sources_only_for_authorized_tools():
|
||||
|
||||
|
||||
def test_independent_agent_projection_preserves_all_tools_intent():
|
||||
policy = ResourcePolicyProjector.from_runner_config({})
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{}, allowed_platform_tool_names=['event_reply', '', 'event_reply']
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is True
|
||||
assert policy.allowed_tool_names is None
|
||||
assert policy.allowed_platform_tool_names == ['event_reply']
|
||||
|
||||
|
||||
def test_runner_config_cannot_self_grant_platform_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{
|
||||
'platform-tools': ['platform_send_message'],
|
||||
'allowed_platform_tools': ['platform_delete_message'],
|
||||
}
|
||||
)
|
||||
|
||||
assert policy.allowed_platform_tool_names == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_value', [0, None, 'false', [], {}])
|
||||
@@ -78,6 +92,28 @@ def test_independent_agent_projection_preserves_selected_tools():
|
||||
assert policy.allowed_tool_names == ['exec']
|
||||
|
||||
|
||||
def test_agent_level_host_tool_policy_overrides_runner_tool_defaults():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': True, 'tools': ['runner-tool']},
|
||||
allowed_host_tool_names=['exec', 'mcp_tool', 'exec'],
|
||||
override_runner_tools=True,
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == ['exec', 'mcp_tool']
|
||||
|
||||
|
||||
def test_agent_level_empty_host_tool_policy_fails_closed():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': True},
|
||||
allowed_host_tool_names=[],
|
||||
override_runner_tools=True,
|
||||
)
|
||||
|
||||
assert policy.allow_all_tools is False
|
||||
assert policy.allowed_tool_names == []
|
||||
|
||||
|
||||
def test_filter_tools_supports_sdk_objects_and_dictionary_tools():
|
||||
policy = ResourcePolicyProjector.from_runner_config(
|
||||
{'enable-all-tools': False, 'tools': ['dict-tool', 'object-tool']},
|
||||
|
||||
@@ -46,7 +46,7 @@ def _agent_row(
|
||||
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
|
||||
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
|
||||
},
|
||||
supported_event_patterns=supported_event_patterns or ['*'],
|
||||
supported_event_patterns=(supported_event_patterns if supported_event_patterns is not None else ['*']),
|
||||
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
|
||||
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
|
||||
)
|
||||
@@ -96,6 +96,7 @@ def _make_app():
|
||||
_get_default_values_from_schema=Mock(return_value={}),
|
||||
)
|
||||
app.agent_runner_registry = None
|
||||
app.tool_mgr = None
|
||||
app.logger = Mock()
|
||||
return app
|
||||
|
||||
@@ -107,11 +108,32 @@ class TestAgentServiceMetadata:
|
||||
app.pipeline_service.get_pipeline_metadata = AsyncMock(
|
||||
return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}]
|
||||
)
|
||||
host_tools = [
|
||||
{
|
||||
'name': 'exec',
|
||||
'source': 'builtin',
|
||||
'source_name': 'LangBot',
|
||||
},
|
||||
{
|
||||
'name': 'weather',
|
||||
'source': 'mcp',
|
||||
'source_name': 'weather-server',
|
||||
},
|
||||
]
|
||||
app.tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=host_tools))
|
||||
|
||||
metadata = await AgentService(app).get_agent_metadata(WORKSPACE_UUID)
|
||||
app.pipeline_service.get_pipeline_metadata.assert_awaited_once_with(WORKSPACE_UUID)
|
||||
|
||||
assert metadata['runner_config'] == ai_metadata
|
||||
assert any(tool['name'] == 'event_reply' for tool in metadata['platform_tools'])
|
||||
assert all(tool['name'] != 'call_platform_api' for tool in metadata['platform_tools'])
|
||||
assert metadata['host_tools'] == host_tools
|
||||
app.tool_mgr.get_resolved_tool_catalog.assert_awaited_once_with(
|
||||
WORKSPACE_UUID,
|
||||
include_skill_authoring=True,
|
||||
include_mcp_resource_tools=True,
|
||||
)
|
||||
assert metadata['kinds'] == [
|
||||
{
|
||||
'name': AGENT_KIND_AGENT,
|
||||
@@ -129,6 +151,13 @@ class TestAgentServiceMetadata:
|
||||
class TestAgentServiceDebug:
|
||||
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self):
|
||||
app = _make_app()
|
||||
agent_config = _agent_row().config
|
||||
agent_config['allowed_platform_tools'] = ['platform_get_user_info']
|
||||
agent_config['event_tool_permissions'] = {
|
||||
'message.*': ['event_reply'],
|
||||
'group.member.joined': ['event_get_actor'],
|
||||
}
|
||||
agent_config['allowed_tools'] = ['exec', 'weather']
|
||||
|
||||
async def run_agent(event, binding, adapter_context):
|
||||
yield SimpleNamespace(
|
||||
@@ -144,7 +173,7 @@ class TestAgentServiceDebug:
|
||||
'uuid': 'agent-1',
|
||||
'kind': AGENT_KIND_AGENT,
|
||||
'supported_event_patterns': ['*'],
|
||||
'config': _agent_row().config,
|
||||
'config': agent_config,
|
||||
}
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
@@ -181,6 +210,15 @@ class TestAgentServiceDebug:
|
||||
assert event.data == {'member_id': 'user-1'}
|
||||
assert binding.agent_id == 'agent-1'
|
||||
assert binding.runner_id == 'plugin:test/runner/default'
|
||||
assert binding.resource_policy.allowed_platform_tool_names == [
|
||||
'platform_get_user_info',
|
||||
'event_reply',
|
||||
'event_get_actor',
|
||||
'event_get_group',
|
||||
'event_get_group_member',
|
||||
]
|
||||
assert binding.resource_policy.allow_all_tools is False
|
||||
assert binding.resource_policy.allowed_tool_names == ['exec', 'weather']
|
||||
assert (
|
||||
app.agent_run_orchestrator.run.call_args.kwargs['adapter_context']['_execution_context'].workspace_uuid
|
||||
== WORKSPACE_UUID
|
||||
@@ -435,6 +473,25 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert insert_values['component_ref'] is None
|
||||
|
||||
async def test_create_agent_preserves_explicit_empty_event_scope(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
|
||||
|
||||
await AgentService(app).create_agent(
|
||||
WORKSPACE_UUID,
|
||||
{
|
||||
'name': 'Dormant Agent',
|
||||
'supported_event_patterns': [],
|
||||
'config': {
|
||||
'runner': {'id': ''},
|
||||
'runner_config': {},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
|
||||
assert insert_values['supported_event_patterns'] == []
|
||||
|
||||
async def test_update_agent_rejects_malformed_4x_runner_config_before_write(self):
|
||||
app = _make_app()
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=_agent_row(agent_uuid='agent-1')))
|
||||
@@ -486,7 +543,7 @@ class TestAgentServiceCreateUpdateDelete:
|
||||
assert update_values == {
|
||||
'name': 'Updated Agent',
|
||||
'config': new_config,
|
||||
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
|
||||
'supported_event_patterns': [],
|
||||
'component_ref': 'plugin:test/new-runner/default',
|
||||
}
|
||||
|
||||
|
||||
@@ -340,6 +340,12 @@ class TestEBAEventBindings:
|
||||
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
|
||||
return bot
|
||||
|
||||
def test_empty_agent_event_scope_matches_nothing(self):
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
assert RuntimeBot._agent_supports_event_type([], 'message.received') is False
|
||||
assert RuntimeBot._agent_supports_event_type(None, 'message.received') is True
|
||||
|
||||
def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self):
|
||||
"""The selected binding is the first matching highest-priority binding."""
|
||||
bot = self._make_bot(
|
||||
@@ -408,6 +414,12 @@ class TestEBAEventBindings:
|
||||
'component_ref': 'plugin:test/fallback/default',
|
||||
'config': {
|
||||
'runner': {'id': 'plugin:test/runner/default'},
|
||||
'allowed_platform_tools': ['platform_get_user_info'],
|
||||
'event_tool_permissions': {
|
||||
'message.*': ['event_reply'],
|
||||
'platform.member.joined': ['event_get_actor'],
|
||||
},
|
||||
'allowed_tools': ['exec', 'mcp_tool'],
|
||||
'runner_config': {
|
||||
'plugin:test/runner/default': {
|
||||
'temperature': 0.2,
|
||||
@@ -428,8 +440,12 @@ class TestEBAEventBindings:
|
||||
assert binding.event_types == ['platform.member.joined']
|
||||
assert binding.runner_id == 'plugin:test/runner/default'
|
||||
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
|
||||
assert binding.resource_policy.allow_all_tools is True
|
||||
assert binding.resource_policy.allowed_tool_names is None
|
||||
assert binding.resource_policy.allow_all_tools is False
|
||||
assert binding.resource_policy.allowed_tool_names == ['exec', 'mcp_tool']
|
||||
assert binding.resource_policy.allowed_platform_tool_names == [
|
||||
'platform_get_user_info',
|
||||
'event_get_actor',
|
||||
]
|
||||
assert binding.delivery_policy.enable_streaming is False
|
||||
assert binding.delivery_policy.enable_reply is True
|
||||
assert binding.delivery_policy.enable_interactions is True
|
||||
|
||||
Reference in New Issue
Block a user