mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 07:16:04 +00:00
feat(agent-runner): expose skill resources through host context
This commit is contained in:
@@ -122,11 +122,9 @@ class FakeApp:
|
||||
return cmd_mgr
|
||||
|
||||
def _create_mock_skill_mgr(self):
|
||||
"""Mock SkillManager that returns no skill index addition by default."""
|
||||
"""Mock SkillManager with no loaded skills by default."""
|
||||
skill_mgr = Mock()
|
||||
skill_mgr.skills = {}
|
||||
skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
||||
skill_mgr.get_skill_index = Mock(return_value=[])
|
||||
return skill_mgr
|
||||
|
||||
def _create_mock_pipeline_service(self):
|
||||
|
||||
@@ -8,6 +8,7 @@ def make_resources(
|
||||
models: list[dict] | None = None,
|
||||
tools: list[dict] | None = None,
|
||||
knowledge_bases: list[dict] | None = None,
|
||||
skills: list[dict] | None = None,
|
||||
storage: dict | None = None,
|
||||
files: list[dict] | None = None,
|
||||
) -> dict[str, typing.Any]:
|
||||
@@ -17,6 +18,7 @@ def make_resources(
|
||||
models: List of model dicts with 'model_id' key
|
||||
tools: List of tool dicts with 'tool_name' key
|
||||
knowledge_bases: List of KB dicts with 'kb_id' key
|
||||
skills: List of skill dicts with 'skill_name' key
|
||||
storage: Storage permissions dict
|
||||
files: List of file dicts with 'file_id' key
|
||||
|
||||
@@ -27,6 +29,7 @@ def make_resources(
|
||||
'models': models or [],
|
||||
'tools': tools or [],
|
||||
'knowledge_bases': knowledge_bases or [],
|
||||
'skills': skills or [],
|
||||
'files': files or [],
|
||||
'storage': storage or {'plugin_storage': False, 'workspace_storage': False},
|
||||
'platform_capabilities': {},
|
||||
@@ -71,6 +74,7 @@ def make_session(
|
||||
'model': {m.get('model_id') for m in res.get('models', [])},
|
||||
'tool': {t.get('tool_name') for t in res.get('tools', [])},
|
||||
'knowledge_base': {kb.get('kb_id') for kb in res.get('knowledge_bases', [])},
|
||||
'skill': {s.get('skill_name') for s in res.get('skills', [])},
|
||||
'file': {f.get('file_id') for f in res.get('files', [])},
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ class TestContextValidation:
|
||||
'models': [],
|
||||
'tools': [],
|
||||
'knowledge_bases': [],
|
||||
'skills': [],
|
||||
'files': [],
|
||||
'storage': {'plugin_storage': True, 'workspace_storage': True},
|
||||
'platform_capabilities': {},
|
||||
|
||||
@@ -124,6 +124,20 @@ class FakeApplication:
|
||||
self.rag_mgr = types.SimpleNamespace(
|
||||
get_knowledge_base_by_uuid=AsyncMock(return_value=FakeKnowledgeBase("kb_001"))
|
||||
)
|
||||
self.skill_mgr = types.SimpleNamespace(
|
||||
skills={
|
||||
"demo": {
|
||||
"name": "demo",
|
||||
"display_name": "Demo Skill",
|
||||
"description": "Helps with demo tasks.",
|
||||
},
|
||||
"hidden": {
|
||||
"name": "hidden",
|
||||
"display_name": "Hidden Skill",
|
||||
"description": "Not bound to this pipeline.",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FakeConversation:
|
||||
@@ -140,7 +154,12 @@ def make_descriptor() -> AgentRunnerDescriptor:
|
||||
plugin_name="local-agent",
|
||||
runner_name="default",
|
||||
protocol_version="1",
|
||||
capabilities={"streaming": True, "tool_calling": True, "knowledge_retrieval": True},
|
||||
capabilities={
|
||||
"streaming": True,
|
||||
"tool_calling": True,
|
||||
"knowledge_retrieval": True,
|
||||
"skill_authoring": True,
|
||||
},
|
||||
config_schema=[
|
||||
{"name": "model", "type": "model-fallback-selector"},
|
||||
{"name": "knowledge-bases", "type": "knowledge-base-multi-selector", "default": []},
|
||||
@@ -211,6 +230,7 @@ def make_query():
|
||||
variables={
|
||||
"_pipeline_bound_plugins": ["langbot/local-agent"],
|
||||
"_fallback_model_uuids": ["model_fallback"],
|
||||
"_pipeline_bound_skills": ["demo"],
|
||||
"public_param": "visible",
|
||||
},
|
||||
use_llm_model_uuid="model_primary",
|
||||
@@ -323,12 +343,20 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent
|
||||
assert {m["model_id"] for m in resources["models"]} == {"model_primary", "model_fallback"}
|
||||
assert resources["tools"][0]["tool_name"] == "langbot/test-tool/search"
|
||||
assert resources["knowledge_bases"][0]["kb_id"] == "kb_001"
|
||||
assert resources["skills"] == [
|
||||
{
|
||||
"skill_name": "demo",
|
||||
"display_name": "Demo Skill",
|
||||
"description": "Helps with demo tasks.",
|
||||
}
|
||||
]
|
||||
assert resources["storage"]["plugin_storage"] is True
|
||||
|
||||
session_during_run = plugin_connector.sessions_during_run[0]
|
||||
assert session_during_run is not None
|
||||
assert session_during_run["plugin_identity"] == "langbot/local-agent"
|
||||
assert session_during_run["authorization"]["authorized_ids"]["tool"] == {"langbot/test-tool/search"}
|
||||
assert session_during_run["authorization"]["authorized_ids"]["skill"] == {"demo"}
|
||||
assert await get_session_registry().get(context["run_id"]) is None
|
||||
|
||||
|
||||
@@ -766,6 +794,50 @@ class TestQueryEntryAdapterHostCapabilities:
|
||||
snapshot = await persistent_store.build_snapshot_from_event(event, binding, descriptor)
|
||||
assert snapshot["conversation"]["external.test_key"] == "test_value"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_from_query_restores_activated_skills_from_state(self, clean_agent_state):
|
||||
"""Persisted activated skill names are restored into the next Query run."""
|
||||
from langbot.pkg.agent.runner.persistent_state_store import get_persistent_state_store
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||
ACTIVATED_SKILLS_KEY,
|
||||
)
|
||||
|
||||
db_engine = clean_agent_state
|
||||
descriptor = make_descriptor()
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
"type": "message.completed",
|
||||
"data": {"message": {"role": "assistant", "content": "restored"}},
|
||||
}
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, db_engine)
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor))
|
||||
query = make_query()
|
||||
|
||||
persistent_store = get_persistent_state_store(db_engine)
|
||||
event = QueryEntryAdapter.query_to_event(query)
|
||||
agent_config = QueryEntryAdapter.config_to_agent_config(query, RUNNER_ID)
|
||||
binding = AgentBindingResolver().resolve_one(event, [agent_config])
|
||||
success, error = await persistent_store.apply_update_from_event(
|
||||
event,
|
||||
binding,
|
||||
descriptor,
|
||||
"conversation",
|
||||
ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||
["demo"],
|
||||
None,
|
||||
)
|
||||
assert success is True
|
||||
assert error is None
|
||||
|
||||
messages = [message async for message in orchestrator.run_from_query(query)]
|
||||
|
||||
assert len(messages) == 1
|
||||
assert query.variables[ACTIVATED_SKILLS_KEY]["demo"]["name"] == "demo"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_log_and_transcript_written(self, clean_agent_state):
|
||||
"""EventLog and Transcript are written via Pipeline path."""
|
||||
|
||||
@@ -307,6 +307,21 @@ class TestIsResourceAllowed:
|
||||
|
||||
assert registry.is_resource_allowed(session, 'knowledge_base', 'kb_001') is False
|
||||
|
||||
def test_skill_allowed(self):
|
||||
"""Skill in resources should be allowed."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
resources = make_resources(
|
||||
skills=[
|
||||
{'skill_name': 'demo', 'display_name': 'Demo'},
|
||||
{'skill_name': 'writer', 'display_name': 'Writer'},
|
||||
]
|
||||
)
|
||||
session = make_session(resources=resources)
|
||||
|
||||
assert registry.is_resource_allowed(session, 'skill', 'demo') is True
|
||||
assert registry.is_resource_allowed(session, 'skill', 'writer') is True
|
||||
assert registry.is_resource_allowed(session, 'skill', 'hidden') is False
|
||||
|
||||
def test_storage_plugin_allowed(self):
|
||||
"""Plugin storage permission should be checked."""
|
||||
registry = AgentRunSessionRegistry()
|
||||
|
||||
@@ -51,7 +51,6 @@ def make_host_model_runner_descriptor(
|
||||
tool_calling: bool = True,
|
||||
knowledge_retrieval: bool = True,
|
||||
skill_authoring: bool = False,
|
||||
skill_injection: bool = False,
|
||||
):
|
||||
from langbot.pkg.agent.runner.descriptor import AgentRunnerDescriptor
|
||||
|
||||
@@ -72,7 +71,6 @@ def make_host_model_runner_descriptor(
|
||||
'knowledge_retrieval': knowledge_retrieval,
|
||||
'multimodal_input': multimodal_input,
|
||||
'skill_authoring': skill_authoring,
|
||||
'skill_injection': skill_injection,
|
||||
},
|
||||
permissions={
|
||||
'models': ['list', 'invoke', 'stream'],
|
||||
|
||||
@@ -274,6 +274,130 @@ class TestSkillToolLoader:
|
||||
SimpleNamespace(variables={}),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_rejects_pipeline_hidden_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
SkillToolLoader,
|
||||
)
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
ACTIVATED_SKILLS_KEY,
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
)
|
||||
|
||||
demo = _make_skill_data(name='demo', package_root='/data/skills/demo', instructions='Demo instructions')
|
||||
hidden = _make_skill_data(
|
||||
name='hidden',
|
||||
package_root='/data/skills/hidden',
|
||||
instructions='Hidden instructions',
|
||||
)
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={'demo': demo, 'hidden': hidden},
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
|
||||
with pytest.raises(ValueError, match='Available skills: demo'):
|
||||
await loader.invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'hidden'}, query)
|
||||
|
||||
result = await loader.invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'demo'}, query)
|
||||
|
||||
assert result['activated'] is True
|
||||
assert result['skill_name'] == 'demo'
|
||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'demo'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_activate_persists_and_restores_for_next_query_exec(self, tmp_path):
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.agent.runner.persistent_state_store import (
|
||||
get_persistent_state_store,
|
||||
reset_persistent_state_store,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
ACTIVATE_SKILL_TOOL_NAME,
|
||||
SkillToolLoader,
|
||||
)
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
ACTIVATED_SKILL_NAMES_STATE_KEY,
|
||||
ACTIVATED_SKILLS_KEY,
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
restore_activated_skills_from_state,
|
||||
)
|
||||
|
||||
reset_persistent_state_store()
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "state.db"}')
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
try:
|
||||
skill = _make_skill_data(name='demo', package_root=str(tmp_path), instructions='Demo instructions')
|
||||
ap = _make_ap()
|
||||
ap.persistence_mgr.get_db_engine = Mock(return_value=engine)
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=str(tmp_path),
|
||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={'demo': skill},
|
||||
refresh_skill_from_disk=Mock(),
|
||||
)
|
||||
|
||||
scope_key = 'conversation:plugin:langbot/local-agent/default:binding_001:conv_001'
|
||||
query1 = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
object.__setattr__(
|
||||
query1,
|
||||
'_agent_run_session',
|
||||
{
|
||||
'runner_id': 'plugin:langbot/local-agent/default',
|
||||
'authorization': {
|
||||
'state_policy': {'enable_state': True, 'state_scopes': ['conversation']},
|
||||
'state_context': {
|
||||
'scope_keys': {'conversation': scope_key},
|
||||
'binding_identity': 'binding_001',
|
||||
'conversation_id': 'conv_001',
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await SkillToolLoader(ap).invoke_tool(ACTIVATE_SKILL_TOOL_NAME, {'skill_name': 'demo'}, query1)
|
||||
|
||||
store = get_persistent_state_store(engine)
|
||||
persisted_names = await store.state_get(scope_key, ACTIVATED_SKILL_NAMES_STATE_KEY)
|
||||
assert persisted_names == ['demo']
|
||||
|
||||
query2 = SimpleNamespace(query_id='q2', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
restored = restore_activated_skills_from_state(
|
||||
ap,
|
||||
query2,
|
||||
{'conversation': {ACTIVATED_SKILL_NAMES_STATE_KEY: persisted_names}},
|
||||
)
|
||||
|
||||
assert restored == ['demo']
|
||||
assert set(query2.variables[ACTIVATED_SKILLS_KEY]) == {'demo'}
|
||||
|
||||
result = await NativeToolLoader(ap).invoke_tool(
|
||||
'exec',
|
||||
{
|
||||
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||
'workdir': '/workspace/.skills/demo',
|
||||
},
|
||||
query2,
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
ap.box_service.execute_tool.assert_awaited_once()
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
||||
finally:
|
||||
reset_persistent_state_store()
|
||||
await engine.dispose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_skill_scans_directory_and_creates_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
@@ -461,6 +585,35 @@ class TestNativeToolLoaderSkillPaths:
|
||||
assert tool_parameters['workdir'] == '/workspace/.skills/demo'
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_requires_skill_activation_even_when_skill_visible(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
execute_tool=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
query = SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
|
||||
with pytest.raises(ValueError, match='must be activated before exec'):
|
||||
await loader.invoke_tool(
|
||||
'exec',
|
||||
{
|
||||
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||
'workdir': '/workspace',
|
||||
},
|
||||
query,
|
||||
)
|
||||
|
||||
ap.box_service.execute_tool.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_requires_skill_activation(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
|
||||
@@ -33,7 +33,6 @@ class _FakeRunnerDescriptor:
|
||||
'knowledge_retrieval': True,
|
||||
'multimodal_input': True,
|
||||
'skill_authoring': True,
|
||||
'skill_injection': True,
|
||||
}
|
||||
|
||||
def supports_tool_calling(self):
|
||||
@@ -122,7 +121,6 @@ def _make_app(*, skill_service) -> SimpleNamespace:
|
||||
),
|
||||
agent_runner_registry=SimpleNamespace(get=AsyncMock(return_value=_FakeRunnerDescriptor())),
|
||||
skill_mgr=SimpleNamespace(
|
||||
build_skill_aware_prompt_addition=Mock(return_value=''),
|
||||
skills={},
|
||||
),
|
||||
skill_service=skill_service,
|
||||
@@ -195,30 +193,24 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_injects_skill_index_into_system_prompt():
|
||||
"""The Tool Call activation pattern still needs the LLM to know which
|
||||
skills exist. PreProcessor must append the SkillManager's index
|
||||
addendum to the first system message."""
|
||||
async def test_preproc_records_all_visible_skills_without_prompt_injection():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
addendum = '\n\nAvailable Skills:\n- demo (demo): Demo skill.\n\nCall activate ...'
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value=addendum)
|
||||
|
||||
query = _make_query()
|
||||
result = await stage_process_capture(preproc_module, app, query)
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=None)
|
||||
app.pipeline_service.get_pipeline.assert_awaited_once_with('pipe-1')
|
||||
assert query.variables.get('_pipeline_bound_skills') is None
|
||||
head = query.prompt.messages[0]
|
||||
assert head.role == 'system'
|
||||
assert head.content.endswith(addendum)
|
||||
assert head.content == 'system prompt'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_respects_pipeline_bound_skills_subset():
|
||||
"""When ``enable_all_skills`` is false the bound list is passed through
|
||||
so the addendum only mentions skills allowed for this pipeline."""
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
@@ -230,31 +222,28 @@ async def test_preproc_respects_pipeline_bound_skills_subset():
|
||||
}
|
||||
}
|
||||
)
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
||||
|
||||
query = _make_query()
|
||||
result = await stage_process_capture(preproc_module, app, query)
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.skill_mgr.build_skill_aware_prompt_addition.assert_called_once_with(bound_skills=['only-this'])
|
||||
assert query.variables.get('_pipeline_bound_skills') == ['only-this']
|
||||
assert query.prompt.messages[0].content == 'system prompt'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_skips_injection_when_addendum_is_empty():
|
||||
"""No visible skills → system prompt is left untouched (no
|
||||
``Available Skills`` block appended)."""
|
||||
async def test_preproc_does_not_load_skill_preferences_without_skill_authoring_service():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
app.skill_mgr.build_skill_aware_prompt_addition = Mock(return_value='')
|
||||
app = _make_app(skill_service=None)
|
||||
|
||||
query = _make_query()
|
||||
result = await stage_process_capture(preproc_module, app, query)
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
if query.prompt and query.prompt.messages:
|
||||
assert 'Available Skills' not in (query.prompt.messages[0].content or '')
|
||||
app.pipeline_service.get_pipeline.assert_not_awaited()
|
||||
assert '_pipeline_bound_skills' not in query.variables
|
||||
assert query.prompt.messages[0].content == 'system prompt'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user