feat(agent-runner): expose skill resources through host context

This commit is contained in:
huanghuoguoguo
2026-06-07 12:48:06 +08:00
parent a9a2c18719
commit fa7b1b53a6
20 changed files with 463 additions and 193 deletions

View File

@@ -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', [])},
}

View File

@@ -80,6 +80,7 @@ class TestContextValidation:
'models': [],
'tools': [],
'knowledge_bases': [],
'skills': [],
'files': [],
'storage': {'plugin_storage': True, 'workspace_storage': True},
'platform_capabilities': {},

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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'],

View File

@@ -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

View File

@@ -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