mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
feat(skills): add Agent Skills management system (#1917)
* feat(skills): add Agent Skills management system
Implement comprehensive skills management feature inspired by agentskills spec:
Backend:
- Add Skill and SkillPipelineBinding database entities
- Add database migration (dbm018) for skills tables
- Implement SkillManager for skill loading, matching, and resolution
- Implement SkillService for CRUD operations
- Add skills API endpoints for skill and pipeline binding management
- Integrate skill index injection into pipeline preprocessor
- Add skill activation detection in LocalAgentRunner
Frontend:
- Add Skills page with listing, search, and type filter
- Add SkillDetailDialog for create/edit with preview
- Add SkillCard and SkillForm components
- Add skills API methods to BackendClient
- Add skills entry to sidebar navigation
- Add i18n translations (en-US, zh-Hans)
Features:
- Support skill and workflow types
- Sub-skill composition via {{INVOKE_SKILL: name}} syntax
- Progressive disclosure (index in prompt, full instructions on activation)
- Pipeline-specific skill bindings with priority
* fix: resolve cherry-pick conflicts for agentskills onto sandbox
- Remove non-existent external_kb service import
- Add skill_mgr mock to localagent sandbox_exec tests
- Keep database version at 24 (sandbox branch's latest)
* feat(skills): upgrade to package-backed skills with sandbox execution
Evolve the skills system from pure prompt-based to package-backed with
sandbox tool execution support:
- Add source_type/package_root/entry_file/skill_tools fields to Skill entity
- SkillManager loads SKILL.md from local package directories
- SkillToolLoader as 4th dispatch layer in ToolManager (query-scoped)
- LocalAgent injects skill tools into use_funcs on skill activation
- BoxService.execute_skill_tool() runs scripts in sandbox (ro mount, env params)
- Skill tool names auto-namespaced as skill__{skill}__{tool}
- API validation for package_root allowlist and entry path traversal
- Frontend source_type toggle, package_root input, skill_tools editor
- Migration renumbered to 025 with ALTER TABLE fallback for existing DBs
- Fix unclosed limitation section in i18n files
- Fix skills API methods misplaced outside BackendClient class
* fix: test info
* feat(skills): switch skills to package-backed storage and add import tooling
- skills 从 inline/package 双轨收敛成 package-first
- instructions 改为写入并读取 SKILL.md
- 新增本地目录扫描和 GitHub 安装 skill
- 前端把 skills 整合进 plugins 页,新增 SkillsComponent 和 GitHub 导入弹窗
- skill form 去掉 source_type / type 筛选,改成目录扫描驱动
- Box skill tool 挂载模式从 ro 改成 rw
- 测试和中英文文案同步更新
* feat: simplify langbot skill create and import
* refactor(skills): clean up legacy skill API and harden activation flow
* refactor(skills): remove skill dependency expansion and add skill_get
* fix: lint
* fix: delete
* fix(skills): align tool manager loader initialization
* refactor: remove sandbox execute skill
* fix(skills): hide activation markers and isolate skill activation flow
* refactor(skills): switch skill model to filesystem-backed packages
* refactor(skills): switch skill model to filesystem-backed packages
* refactor(skills): unify runtime skill access around filesystem paths
* refactor(skills): unify runtime skill access around filesystem paths
* feat(skills): align rw package design and fix skill activation, visibility, and lint issues
* refactor(skills): replace rich authoring API with import/reload flow and update
Box design doc
* feat(box): add sandbox_exec tool loop for local-agent calculations
* feat(box): add host workspace mounting and sandbox_exec guidance
* feat(box): add BoxProfile with resource limits and improved output truncation
- Implement head+tail output truncation (60/40 split) so LLM sees both
beginning and final results; add streaming byte-limited reads in backend
to prevent unbounded memory usage (_MAX_RAW_OUTPUT_BYTES = 1MB)
- Define BoxProfile model with locked fields and max_timeout_sec clamping
- Add four built-in profiles: default, offline_readonly, network_basic,
network_extended with differentiated resource and security constraints
- Add resource limit fields to BoxSpec (cpus, memory_mb, pids_limit,
read_only_rootfs) and pass corresponding container CLI flags
(--cpus, --memory, --pids-limit, --read-only, --tmpfs)
- Profile loaded from config (box.profile), applied in service layer
before BoxSpec validation; locked fields cannot be overridden by
tool-call parameters
* feat(box): add obs
* refactor(box): unify box service lifecycle and local runtime
management
* refactor(box): remove legacy in-process runtime code and clean up smells
After the architecture settled on always using an independent Box Runtime
service, several pieces of compatibility code and design shortcuts were
left behind. This commit cleans them up:
- Remove `LocalBoxRuntimeClient` and `create_box_runtime_client` from
production code (moved to test-only helper).
- Remove unused `_clip_bytes` method from backend.
- Remove `__langbot_session_placeholder__` hack by making `BoxSpec.cmd`
default to empty and validating non-empty only in `runtime.execute()`.
- Extract `get_box_config()` helper to eliminate 5× duplicated config
access boilerplate.
- Remove `session_id`/`host_path`/`host_path_mode` from the LLM-facing
tool schema to enforce request-scoped session isolation.
- Fix dual shutdown path: `NativeToolLoader.shutdown()` no longer calls
`box_service.shutdown()` (handled by `Application.dispose()`).
- Simplify `_assert_session_compatible` with a loop.
- Inline client creation in `BoxRuntimeConnector`.
- Remove redundant `BOX__RUNTIME_URL` env var from docker-compose
(auto-detected by code).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* feat(box/mcp): integrate MCP stdio with Box sandbox — auto-isolation, dep install, security
## Summary
When Podman/Docker is available, all stdio-mode MCP servers now automatically
run inside Box containers with dependency installation, path rewriting, and
lifecycle management. When no container runtime exists, LangBot starts normally
and stdio MCP falls back to host-direct execution.
## What changed
### MCP stdio → Box integration (mcp.py)
- Add `MCPServerBoxConfig` pydantic model for structured box configuration
with validation and defaults (network, host_path_mode, timeouts, resources)
- Auto-infer `host_path` from command/args with venv detection: recognizes
`.venv/bin/python` patterns and walks up to the project root
- Rewrite host paths to container `/workspace` paths transparently
- Replace venv python commands with container-native `python`
- Auto-detect `pyproject.toml`/`setup.py`/`requirements.txt` and run
`pip install` inside the container before starting the MCP server
- Copy project to `/tmp` before install to handle read-only mounts
- Add retry with exponential backoff (3 retries, 2s/4s/8s delays)
- Add Box managed process health monitoring (poll every 5s)
- Fix session leak: `_cleanup_box_stdio_session()` now runs in `finally`
block of `_lifecycle_loop`, covering all exit paths
- Fix retry logic: `_ready_event` is only set after all retries exhaust
or on success, not on first failure
- Enhance `get_runtime_info_dict()` with `box_session_id` and `box_enabled`
### Box security (security.py — new)
- `validate_sandbox_security()` blocks dangerous host paths:
`/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/boot`, `/run`,
docker.sock, podman socket
- Called at the start of `CLISandboxBackend.start_session()`
### Box models (models.py)
- Add `BoxHostMountMode.NONE` — skips volume mount entirely
- Adjust `validate_host_mount_consistency` to allow arbitrary workdir
when `host_path_mode=NONE`
### Box backend (backend.py)
- Add `validate_sandbox_security()` call in `start_session()`
- Add `langbot.box.config_hash` label on containers for drift detection
- Handle `BoxHostMountMode.NONE` — skip `-v` mount arg
- Add `cleanup_orphaned_containers()` to base class (no-op default) and
CLI implementation (single batched `rm -f` command)
### Box runtime (runtime.py)
- Call `cleanup_orphaned_containers()` during `initialize()` to remove
lingering containers from previous runs
### Box service (service.py)
- Graceful degradation: `initialize()` catches runtime errors and sets
`available=False` instead of crashing LangBot startup
- Add `available` property and guard on `execute_sandbox_tool()`
- Add `skip_host_mount_validation` parameter to `build_spec()` and
`create_session()` — MCP paths are admin-configured and trusted,
bypassing `allowed_host_mount_roots` restrictions meant for
LLM-generated sandbox_exec commands
### Default behavior
- stdio MCP servers automatically use Box when `box_service.available`
is True (Podman/Docker detected); no explicit `box` config needed
- When no container runtime exists, falls back to host-direct stdio
- MCP Box defaults: `network=on` (for pip install), `read_only_rootfs=false`
(for site-packages), `host_path_mode=ro`, `startup_timeout=120s`
### Tests
- `test_box_security.py`: blocked paths, safe paths, subpath rejection
- `test_mcp_box_integration.py`: config model, path rewriting, venv
unwrap, host_path inference, payload building, runtime info, box
availability check
- `test_box_service.py`: `BoxHostMountMode.NONE` validation tests
* feat(box/mcp): instance-based orphan cleanup, error classification, session API, and integration tests
## Changes
### Precise orphan container cleanup
- Runtime generates a unique instance_id on startup
- Every container gets a `langbot.box.instance_id` label
- `cleanup_orphaned_containers()` only removes containers from
previous instances, preserving containers owned by the current one
- Containers from older versions (no label) are also cleaned up
- `cleanup_orphaned_containers` added to `BaseSandboxBackend` as
a no-op default method, removing hasattr duck-typing
### Fine-grained MCP error classification
- New `MCPSessionErrorPhase` enum with 7 phases: session_create,
dep_install, process_start, relay_connect, mcp_init, runtime,
tool_call
- Each phase in `_init_box_stdio_server()` sets the error phase
before re-raising, enabling precise failure diagnosis
- `retry_count` tracked across retry attempts
- `get_runtime_info_dict()` exposes `error_phase` and `retry_count`
### GET /v1/sessions/{id} API
- `BoxRuntime.get_session()` returns session details including
managed process info when present
- `handle_get_session` HTTP handler + route in server.py
- `BoxRuntimeClient.get_session()` abstract method + remote impl
### stdio defaults to Box when runtime is available
- `_uses_box_stdio()` checks `box_service.available` instead of
requiring explicit `box` key in server_config
- `BoxService.initialize()` catches runtime errors gracefully,
sets `available=False` instead of crashing LangBot startup
- When no container runtime exists, stdio MCP falls back to
host-direct execution
### Code quality (from /simplify review)
- Extracted `_VENV_DIRS` / `_VENV_BIN_DIRS` module-level constants
- Removed dead `_box_network_mode()` method and unused `bc` variable
- Fixed broken import `from ....box.models` → `from ...box.models`
- Cached `_resolve_host_path()` result — computed once, passed through
- Config hash now includes `host_path` field
- Batched orphan cleanup into single `rm -f` command
### Session leak fix
- `_cleanup_box_stdio_session()` now runs in `_lifecycle_loop`'s
finally block, covering all exit paths (normal shutdown, error,
retry, final failure)
### Integration tests
- 6 end-to-end tests covering managed process lifecycle, WebSocket
stdio bidirectional IO, session cleanup verification, single
session query, process exit detection, and orphan cleanup safety
* refactor: use rpc
* fix: import
* refactor(box): clean up sandbox subsystem code quality and efficiency
- Fix O(n²) stderr trimming in runtime.py with running length tracker
- Remove dead code: RESERVED_CONTAINER_PATHS, _subprocess_wait_task,
unused config_hash computation, unused imports
- Deduplicate connection callback in BoxRuntimeConnector, parse URL once
- Use enum comparison instead of stringly-typed spec.network.value check
- Replace manual _result_to_dict/_session_to_dict with model_dump()
- Cache NativeToolLoader tool definition and sandbox system guidance
- Extract _is_path_under() helper to eliminate duplicated path checks
- Import SANDBOX_EXEC_TOOL_NAME from native.py instead of redefining
- Add JSON startswith guard in logging_utils to skip futile json.loads
- Fix ruff lint errors (F401 unused imports, F841 unused variables)
* fix: ruff
* refactor(sandbox): keep box logic out of pipeline and localagent
- Move sandbox system-prompt guidance from LocalAgentRunner into
BoxService.get_system_guidance() so all box domain knowledge stays
in the box module.
- Remove standalone logging_utils.py; merge format_result_log() into
MessageHandler base class alongside cut_str().
- Strip sandbox-specific JSON parsing from log formatting; tool
results now use generic truncation.
- Revert TYPE_CHECKING changes in stage.py and runner.py that were
unrelated to this feature.
- Skip two test files affected by a pre-existing circular import
(runner ↔ app) until the import cycle is resolved in a separate PR.
* refactor(box): move box runtime to langbot-plugin-sdk
Extract self-contained box runtime modules (actions, backend, client,
errors, models, runtime, security, server) to langbot-plugin-sdk and
update all imports to use `langbot_plugin.box.*`. Keep only service
and
connector in LangBot core as they depend on the Application context.
- Update docker-compose to use `langbot_plugin.box.server` entry
point
- Update pyproject.toml to use local SDK via `tool.uv.sources`
- Remove migrated source files and their unit/integration tests
- Update remaining test imports to match new module paths
* fix: ruff
* fix(box): tighten sandbox exposure and restore box integration coverage
* refactor(types): remove quoted annotations under postponed evaluation
* chore(sandbox): move MCP loader changes to follow-up branch
* refactor(plugins): simplify GitHub install flow to default master archive
* revert(api): restore plugin GitHub import flow in plugins controller
* Improve data-root handling and skill install previews
* Add managed skill authoring tools for local agents
* Refactor the skills UI around sidebar detail pages
* Document why managed skill authoring tools bypass box
* fix: lint
* feat(web): refactor plugin/skill install flows and fix skills page
- Fix sidebar skill icon
- Add skills route and error page component
- Refactor plugin GitHub install from dialog modal to inline card
- Add skill install dropdown menu (create/upload/github) in sidebar
- Wire sidebar → skills page communication via pendingSkillInstallAction context
- Add i18n keys for error page and skill install actions
* fix(web): persist sidebar collapsible section open state on navigation
Sections opened via sub-item navigation now retain their expanded state
when the user switches to a different section, instead of collapsing
because the isActive fallback becomes false.
---------
Co-authored-by: youhuanghe <1051233107@qq.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Junyan Qin <rockchinq@gmail.com>
This commit is contained in:
@@ -91,6 +91,101 @@ class RecordingStreamProvider:
|
||||
return _stream()
|
||||
|
||||
|
||||
class ActivationProvider:
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='[ACTIVATE_SKILL: demo]\nI will use the skill.',
|
||||
)
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='final answer after activation',
|
||||
)
|
||||
|
||||
|
||||
class FailingActivationProvider:
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='[ACTIVATE_SKILL: demo]\nI will use the skill.',
|
||||
)
|
||||
raise RuntimeError('activation failed')
|
||||
|
||||
|
||||
class ActivationStreamProvider:
|
||||
def __init__(self):
|
||||
self.stream_requests: list[dict] = []
|
||||
|
||||
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.stream_requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
if len(self.stream_requests) == 1:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='[ACTIVATE_SKILL: demo]\nI will use the skill.',
|
||||
is_final=True,
|
||||
)
|
||||
return
|
||||
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='final answer after activation',
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
def make_skill_manager():
|
||||
skill_data = {
|
||||
'uuid': 'skill-demo',
|
||||
'name': 'demo',
|
||||
'instructions': 'Do the demo task.',
|
||||
'type': 'skill',
|
||||
'package_root': '/tmp/demo-skill',
|
||||
'sandbox_timeout_sec': 120,
|
||||
'sandbox_network': False,
|
||||
}
|
||||
return SimpleNamespace(
|
||||
SKILL_ACTIVATION_MARKER='[ACTIVATE_SKILL:',
|
||||
detect_skill_activations=Mock(
|
||||
side_effect=lambda content: ['demo'] if '[ACTIVATE_SKILL: demo]' in (content or '') else []
|
||||
),
|
||||
build_activation_prompt_for_skills=Mock(return_value='skill prompt'),
|
||||
get_skill_by_name=Mock(side_effect=lambda name: skill_data if name == 'demo' else None),
|
||||
remove_activation_marker=Mock(side_effect=lambda content: (content or '').replace('[ACTIVATE_SKILL: demo]\n', '')),
|
||||
)
|
||||
|
||||
|
||||
def make_query() -> pipeline_query.Query:
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
@@ -168,6 +263,11 @@ async def test_localagent_uses_exec_for_exact_calculation():
|
||||
)
|
||||
),
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
@@ -222,6 +322,11 @@ async def test_localagent_streaming_tool_error_yields_message_chunks():
|
||||
box_service=SimpleNamespace(
|
||||
get_system_guidance=Mock(return_value='sandbox guidance'),
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
@@ -230,3 +335,110 @@ async def test_localagent_streaming_tool_error_yields_message_chunks():
|
||||
|
||||
assert all(isinstance(message, provider_message.MessageChunk) for message in results)
|
||||
assert any(message.role == 'tool' and message.content == 'err: boom' for message in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_hides_activation_marker_before_follow_up_request():
|
||||
provider = ActivationProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=make_skill_manager(),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
query.use_funcs = []
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert [(message.role, message.content) for message in results] == [
|
||||
('assistant', 'final answer after activation')
|
||||
]
|
||||
assert len(provider.requests) == 2
|
||||
assert provider.requests[1]['messages'][-2].content == 'I will use the skill.'
|
||||
assert '[ACTIVATE_SKILL:' not in provider.requests[1]['messages'][-2].content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_activation_failure_rolls_back_query_state_and_sanitizes_response():
|
||||
provider = FailingActivationProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=make_skill_manager(),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
query.use_funcs = []
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert [(message.role, message.content) for message in results] == [
|
||||
('assistant', 'I will use the skill.')
|
||||
]
|
||||
assert query.use_funcs == []
|
||||
assert query.variables == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_streaming_activation_does_not_leak_marker():
|
||||
provider = ActivationStreamProvider()
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(execute_func_call=AsyncMock()),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(get_system_guidance=Mock(return_value='sandbox guidance')),
|
||||
skill_mgr=make_skill_manager(),
|
||||
)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
query.adapter = adapter
|
||||
query.use_funcs = []
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
assert all(isinstance(message, provider_message.MessageChunk) for message in results)
|
||||
assert [message.content for message in results] == ['final answer after activation']
|
||||
|
||||
569
tests/unit_tests/provider/test_skill_tools.py
Normal file
569
tests/unit_tests/provider/test_skill_tools.py
Normal file
@@ -0,0 +1,569 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _make_ap(logger=None):
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = logger or Mock()
|
||||
ap.persistence_mgr = Mock()
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=Mock(all=Mock(return_value=[])))
|
||||
ap.persistence_mgr.serialize_model = Mock(side_effect=lambda cls, row: row)
|
||||
return ap
|
||||
|
||||
|
||||
def _make_skill_data(
|
||||
name='test-skill',
|
||||
instructions='Do something',
|
||||
package_root='',
|
||||
entry_file='SKILL.md',
|
||||
auto_activate=True,
|
||||
**kwargs,
|
||||
):
|
||||
return {
|
||||
'name': name,
|
||||
'display_name': kwargs.pop('display_name', name),
|
||||
'description': kwargs.pop('description', f'Description of {name}'),
|
||||
'instructions': instructions,
|
||||
'package_root': package_root,
|
||||
'entry_file': entry_file,
|
||||
'auto_activate': auto_activate,
|
||||
**kwargs,
|
||||
}
|
||||
|
||||
|
||||
class TestSkillManagerPackageLoading:
|
||||
def test_load_skill_file_success(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_md = os.path.join(tmpdir, 'SKILL.md')
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('---\ndescription: Test skill\n---\n\n# Test Skill\nDo things.')
|
||||
|
||||
skill_data = _make_skill_data(package_root=tmpdir)
|
||||
result = mgr._load_skill_file(skill_data)
|
||||
|
||||
assert result is True
|
||||
assert skill_data['instructions'] == '# Test Skill\nDo things.'
|
||||
assert skill_data['description'] == 'Test skill'
|
||||
|
||||
def test_refresh_skill_from_disk_updates_cached_dict_in_place(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_md = os.path.join(tmpdir, 'SKILL.md')
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('---\ndescription: First\n---\n\nOriginal instructions')
|
||||
|
||||
skill_data = _make_skill_data(name='test-skill', package_root=tmpdir)
|
||||
assert mgr._load_skill_file(skill_data) is True
|
||||
|
||||
mgr.skills['test-skill'] = skill_data
|
||||
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('---\ndescription: Second\n---\n\nUpdated instructions')
|
||||
|
||||
assert mgr.refresh_skill_from_disk('test-skill') is True
|
||||
assert mgr.skills['test-skill'] is skill_data
|
||||
assert skill_data['instructions'] == 'Updated instructions'
|
||||
assert skill_data['description'] == 'Second'
|
||||
|
||||
|
||||
class TestSkillManagerActivation:
|
||||
def test_detect_skill_activations_returns_unique_ordered_skills(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {
|
||||
'alpha': _make_skill_data(name='alpha'),
|
||||
'beta': _make_skill_data(name='beta'),
|
||||
}
|
||||
|
||||
response = (
|
||||
'[ACTIVATE_SKILL: alpha]\n'
|
||||
'[ACTIVATE_SKILL: beta]\n'
|
||||
'[ACTIVATE_SKILL: alpha]\n'
|
||||
'Let me handle this.'
|
||||
)
|
||||
|
||||
assert mgr.detect_skill_activations(response) == ['alpha', 'beta']
|
||||
assert mgr.detect_skill_activation(response) == 'alpha'
|
||||
|
||||
def test_build_activation_prompt_for_skills_includes_runtime_guidance(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {
|
||||
'primary': _make_skill_data(name='primary', instructions='Primary instructions'),
|
||||
'aux': _make_skill_data(name='aux', instructions='Aux instructions'),
|
||||
}
|
||||
|
||||
prompt = mgr.build_activation_prompt_for_skills(['primary', 'aux'])
|
||||
|
||||
assert 'Activated skills: primary, aux' in prompt
|
||||
assert 'role="primary"' in prompt
|
||||
assert 'role="auxiliary"' in prompt
|
||||
assert '/workspace/.skills/<skill-name>' in prompt
|
||||
|
||||
def test_remove_activation_marker_removes_multiple_markers(self):
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
|
||||
response = '[ACTIVATE_SKILL: alpha]\n[ACTIVATE_SKILL: beta]\nFinal answer'
|
||||
assert mgr.remove_activation_marker(response) == 'Final answer'
|
||||
|
||||
|
||||
class TestSkillActivationHelper:
|
||||
def test_prepare_skill_activation_registers_only_explicit_activated_skills(self):
|
||||
from langbot.pkg.skill.activation import prepare_skill_activation
|
||||
from langbot.pkg.provider.tools.loaders.skill import ACTIVATED_SKILLS_KEY
|
||||
from langbot.pkg.skill.manager import SkillManager
|
||||
|
||||
ap = _make_ap()
|
||||
mgr = SkillManager(ap)
|
||||
mgr.skills = {
|
||||
'primary': _make_skill_data(name='primary', instructions='Primary instructions'),
|
||||
'aux': _make_skill_data(name='aux', instructions='Aux instructions'),
|
||||
}
|
||||
ap.skill_mgr = mgr
|
||||
|
||||
query = SimpleNamespace(variables={}, use_funcs=[])
|
||||
activation = prepare_skill_activation(
|
||||
ap,
|
||||
query,
|
||||
'[ACTIVATE_SKILL: primary]\n[ACTIVATE_SKILL: aux]\nWorking on it.',
|
||||
)
|
||||
|
||||
assert activation is not None
|
||||
assert activation.activated_skill_names == ['primary', 'aux']
|
||||
assert activation.cleaned_content == 'Working on it.'
|
||||
assert set(query.variables[ACTIVATED_SKILLS_KEY].keys()) == {'primary', 'aux'}
|
||||
|
||||
|
||||
class TestSkillPathHelpers:
|
||||
def test_get_visible_skills_filters_by_bound_names(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import PIPELINE_BOUND_SKILLS_KEY, get_visible_skills
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(
|
||||
skills={
|
||||
'visible': _make_skill_data(name='visible'),
|
||||
'hidden': _make_skill_data(name='hidden'),
|
||||
}
|
||||
)
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['visible']})
|
||||
|
||||
result = get_visible_skills(ap, query)
|
||||
|
||||
assert list(result.keys()) == ['visible']
|
||||
|
||||
def test_resolve_virtual_skill_path_allows_visible_skill_reads(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import (
|
||||
PIPELINE_BOUND_SKILLS_KEY,
|
||||
resolve_virtual_skill_path,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
||||
query = SimpleNamespace(variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']})
|
||||
|
||||
skill, rewritten = resolve_virtual_skill_path(
|
||||
ap,
|
||||
query,
|
||||
'/workspace/.skills/demo/SKILL.md',
|
||||
include_visible=True,
|
||||
include_activated=False,
|
||||
)
|
||||
|
||||
assert skill['name'] == 'demo'
|
||||
assert rewritten == '/workspace/SKILL.md'
|
||||
|
||||
def test_build_skill_session_id_uses_name_based_identifier(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import build_skill_session_id
|
||||
|
||||
with_launcher = build_skill_session_id(
|
||||
{'name': 'writer'},
|
||||
SimpleNamespace(query_id=42, launcher_type='person', launcher_id='123'),
|
||||
)
|
||||
fallback = build_skill_session_id({'name': 'writer'}, SimpleNamespace(query_id=99))
|
||||
|
||||
assert with_launcher == 'skill-person_123-writer'
|
||||
assert fallback == 'skill-99-writer'
|
||||
|
||||
def test_should_prepare_skill_python_env_detects_manifests_and_venv(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import should_prepare_skill_python_env
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
assert should_prepare_skill_python_env(tmpdir) is False
|
||||
|
||||
with open(os.path.join(tmpdir, 'requirements.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('requests==2.32.0\n')
|
||||
assert should_prepare_skill_python_env(tmpdir) is True
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.makedirs(os.path.join(tmpdir, '.venv'))
|
||||
assert should_prepare_skill_python_env(tmpdir) is True
|
||||
|
||||
def test_wrap_skill_command_with_python_env_bootstraps_then_runs_command(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill import wrap_skill_command_with_python_env
|
||||
|
||||
command = wrap_skill_command_with_python_env('python scripts/run.py')
|
||||
|
||||
assert 'python -m venv "$_LB_VENV_DIR"' in command
|
||||
assert 'export VIRTUAL_ENV="$_LB_VENV_DIR"' in command
|
||||
assert command.rstrip().endswith('python scripts/run.py')
|
||||
|
||||
|
||||
class TestSkillAuthoringToolLoader:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_creates_managed_prompt_only_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
CREATE_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
create_skill=AsyncMock(return_value=_make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill')),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
CREATE_SKILL_TOOL_NAME,
|
||||
{
|
||||
'name': 'prompt-skill',
|
||||
'display_name': 'Prompt Skill',
|
||||
'description': 'Prompt only skill',
|
||||
'instructions': 'Follow these steps carefully.',
|
||||
'auto_activate': False,
|
||||
},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
ap.skill_service.create_skill.assert_awaited_once_with(
|
||||
{
|
||||
'name': 'prompt-skill',
|
||||
'display_name': 'Prompt Skill',
|
||||
'description': 'Prompt only skill',
|
||||
'instructions': 'Follow these steps carefully.',
|
||||
'auto_activate': False,
|
||||
}
|
||||
)
|
||||
assert result == {
|
||||
'created': True,
|
||||
'skill': _make_skill_data(name='prompt-skill', package_root='/data/skills/prompt-skill'),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_skills_returns_managed_skills(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
LIST_SKILLS_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
list_skills=AsyncMock(return_value=[_make_skill_data(name='alpha'), _make_skill_data(name='beta')]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(LIST_SKILLS_TOOL_NAME, {}, SimpleNamespace())
|
||||
|
||||
assert result == {
|
||||
'skills': [_make_skill_data(name='alpha'), _make_skill_data(name='beta')],
|
||||
'skill_names': ['alpha', 'beta'],
|
||||
'count': 2,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_skill_returns_one_managed_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
GET_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
get_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(GET_SKILL_TOOL_NAME, {'name': 'time-now'}, SimpleNamespace())
|
||||
|
||||
ap.skill_service.get_skill.assert_awaited_once_with('time-now')
|
||||
assert result == {
|
||||
'skill': _make_skill_data(name='time-now', package_root='/data/skills/time-now'),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_updates_managed_prompt_only_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
UPDATE_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
create_skill=AsyncMock(),
|
||||
update_skill=AsyncMock(return_value=_make_skill_data(name='time-now', package_root='/data/skills/time-now')),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
UPDATE_SKILL_TOOL_NAME,
|
||||
{
|
||||
'name': 'time-now',
|
||||
'description': 'Fixed to Beijing time',
|
||||
'instructions': 'Always use Asia/Shanghai and never offer other timezones.',
|
||||
'auto_activate': True,
|
||||
},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
ap.skill_service.update_skill.assert_awaited_once_with(
|
||||
'time-now',
|
||||
{
|
||||
'name': 'time-now',
|
||||
'description': 'Fixed to Beijing time',
|
||||
'instructions': 'Always use Asia/Shanghai and never offer other timezones.',
|
||||
'auto_activate': True,
|
||||
},
|
||||
)
|
||||
assert result == {
|
||||
'updated': True,
|
||||
'skill': _make_skill_data(name='time-now', package_root='/data/skills/time-now'),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_deletes_managed_skill(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
DELETE_SKILL_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
delete_skill=AsyncMock(return_value=True),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(DELETE_SKILL_TOOL_NAME, {'name': 'time-now'}, SimpleNamespace())
|
||||
|
||||
ap.skill_service.delete_skill.assert_awaited_once_with('time-now')
|
||||
assert result == {
|
||||
'deleted': True,
|
||||
'skill_name': 'time-now',
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_from_directory_uses_workspace_path_and_service_import(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace')
|
||||
ap.skill_service = SimpleNamespace(
|
||||
scan_directory=Mock(
|
||||
return_value={
|
||||
'name': 'cloned-skill',
|
||||
'display_name': 'Cloned Skill',
|
||||
'description': 'Imported from clone',
|
||||
'instructions': 'Do work',
|
||||
'auto_activate': True,
|
||||
}
|
||||
),
|
||||
create_skill=AsyncMock(return_value=_make_skill_data(name='cloned-skill', package_root='/repo/root')),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap.box_service.default_host_workspace = tmpdir
|
||||
repo_dir = os.path.join(tmpdir, 'repos', 'cloned-skill')
|
||||
os.makedirs(repo_dir)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
{'path': '/workspace/repos/cloned-skill'},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
ap.skill_service.scan_directory.assert_called_once_with(os.path.realpath(repo_dir))
|
||||
ap.skill_service.create_skill.assert_awaited_once_with(
|
||||
{
|
||||
'name': 'cloned-skill',
|
||||
'display_name': 'Cloned Skill',
|
||||
'description': 'Imported from clone',
|
||||
'instructions': 'Do work',
|
||||
'package_root': os.path.realpath(repo_dir),
|
||||
'auto_activate': True,
|
||||
}
|
||||
)
|
||||
assert result['imported'] is True
|
||||
assert result['source_path'] == '/workspace/repos/cloned-skill'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_import_skill_from_directory_rejects_workspace_escape(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(default_host_workspace='/tmp/langbot-workspace')
|
||||
ap.skill_service = SimpleNamespace(
|
||||
scan_directory=Mock(),
|
||||
create_skill=AsyncMock(),
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
with pytest.raises(ValueError, match='escapes the workspace boundary'):
|
||||
await loader.invoke_tool(
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
{'path': '/workspace/../../etc'},
|
||||
SimpleNamespace(),
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_skills_rescans_filesystem_and_returns_current_names(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import (
|
||||
RELOAD_SKILLS_TOOL_NAME,
|
||||
SkillAuthoringToolLoader,
|
||||
)
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_service = SimpleNamespace(
|
||||
reload_skills=AsyncMock(),
|
||||
list_skills=AsyncMock(return_value=[_make_skill_data(name='alpha'), _make_skill_data(name='beta')]),
|
||||
)
|
||||
|
||||
loader = SkillAuthoringToolLoader(ap)
|
||||
await loader.initialize()
|
||||
|
||||
result = await loader.invoke_tool(RELOAD_SKILLS_TOOL_NAME, {}, SimpleNamespace())
|
||||
|
||||
assert result == {
|
||||
'reloaded': True,
|
||||
'skill_names': ['alpha', 'beta'],
|
||||
'count': 2,
|
||||
}
|
||||
ap.skill_service.reload_skills.assert_awaited_once_with()
|
||||
|
||||
|
||||
class TestNativeToolLoaderSkillPaths:
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_visible_skill_file(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:
|
||||
skill_md = os.path.join(tmpdir, 'SKILL.md')
|
||||
with open(skill_md, 'w', encoding='utf-8') as f:
|
||||
f.write('demo instructions')
|
||||
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(available=True, default_host_workspace=tmpdir)
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo', package_root=tmpdir)})
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/.skills/demo/SKILL.md'},
|
||||
SimpleNamespace(query_id='q1', variables={PIPELINE_BOUND_SKILLS_KEY: ['demo']}),
|
||||
)
|
||||
|
||||
assert result == {'ok': True, 'content': 'demo instructions'}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_in_activated_skill_mount_rewrites_command_and_refreshes(self):
|
||||
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
|
||||
from langbot.pkg.provider.tools.loaders.skill import register_activated_skill
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
ap = _make_ap()
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_host_workspace=tmpdir,
|
||||
execute_spec_payload=AsyncMock(return_value={'ok': True}),
|
||||
)
|
||||
ap.skill_mgr = SimpleNamespace(refresh_skill_from_disk=Mock())
|
||||
loader = NativeToolLoader(ap)
|
||||
|
||||
query = SimpleNamespace(query_id='q1', launcher_type='person', launcher_id='123', variables={})
|
||||
register_activated_skill(query, _make_skill_data(name='demo', package_root=tmpdir))
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'exec',
|
||||
{
|
||||
'command': 'python /workspace/.skills/demo/scripts/run.py',
|
||||
'workdir': '/workspace/.skills/demo',
|
||||
},
|
||||
query,
|
||||
)
|
||||
|
||||
assert result == {'ok': True}
|
||||
spec_payload = ap.box_service.execute_spec_payload.await_args.args[0]
|
||||
assert spec_payload['cmd'] == 'python /workspace/scripts/run.py'
|
||||
assert spec_payload['workdir'] == '/workspace'
|
||||
assert spec_payload['host_path'] == tmpdir
|
||||
assert spec_payload['session_id'] == 'skill-person_123-demo'
|
||||
ap.skill_mgr.refresh_skill_from_disk.assert_called_once_with('demo')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_requires_skill_activation(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_host_workspace=tmpdir)
|
||||
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='Skill "demo" is not available at this path'):
|
||||
await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/.skills/demo/notes.txt', 'content': 'hi'},
|
||||
query,
|
||||
)
|
||||
@@ -42,9 +42,10 @@ def make_tool(name: str) -> resource_tool.LLMTool:
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_lists_native_tools_first():
|
||||
async def test_tool_manager_omits_skill_authoring_tools_by_default():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
@@ -53,11 +54,25 @@ async def test_tool_manager_lists_native_tools_first():
|
||||
assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_includes_skill_authoring_tools_when_requested():
|
||||
manager = ToolManager(SimpleNamespace())
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
||||
manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
tools = await manager.get_all_tools(include_skill_authoring=True)
|
||||
|
||||
assert [tool.name for tool in tools] == ['exec', 'reload_skills', 'plugin_tool', 'mcp_tool']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_manager_routes_native_tool_calls():
|
||||
app = SimpleNamespace()
|
||||
manager = ToolManager(app)
|
||||
manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'backend': 'fake'})
|
||||
manager.skill_authoring_tool_loader = StubLoader([make_tool('reload_skills')])
|
||||
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
|
||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
||||
|
||||
|
||||
23
tests/unit_tests/test_paths.py
Normal file
23
tests/unit_tests/test_paths.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.langbot.pkg.utils import paths
|
||||
|
||||
|
||||
def test_get_data_root_uses_source_root_in_repo_checkout():
|
||||
data_root = Path(paths.get_data_root())
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
assert data_root == repo_root / 'data'
|
||||
|
||||
|
||||
def test_get_data_path_joins_under_data_root():
|
||||
data_path = Path(paths.get_data_path('skills', 'demo-skill'))
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
assert data_path == repo_root / 'data' / 'skills' / 'demo-skill'
|
||||
|
||||
|
||||
def test_get_data_root_honors_env_override(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'custom-data'))
|
||||
|
||||
assert Path(paths.get_data_root()) == (tmp_path / 'custom-data').resolve()
|
||||
134
tests/unit_tests/test_preproc.py
Normal file
134
tests/unit_tests/test_preproc.py
Normal file
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.api.entities.builtin.pipeline.query import Query
|
||||
from langbot_plugin.api.entities.builtin.platform.entities import Friend
|
||||
from langbot_plugin.api.entities.builtin.platform.events import FriendMessage
|
||||
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
|
||||
from langbot_plugin.api.entities.builtin.provider.message import Message
|
||||
from langbot_plugin.api.entities.builtin.provider.prompt import Prompt
|
||||
from langbot_plugin.api.entities.builtin.provider.session import Conversation, LauncherTypes, Session
|
||||
|
||||
|
||||
def _make_query() -> Query:
|
||||
message_chain = MessageChain([Plain(text='create a skill')])
|
||||
return Query(
|
||||
query_id=1,
|
||||
launcher_type=LauncherTypes.PERSON,
|
||||
launcher_id='launcher-1',
|
||||
sender_id='sender-1',
|
||||
message_event=FriendMessage(
|
||||
message_chain=message_chain,
|
||||
time=0,
|
||||
sender=Friend(id='sender-1', nickname='Tester', remark='Tester'),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
bot_uuid='bot-1',
|
||||
pipeline_uuid='pipe-1',
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': 'model-1', 'fallbacks': []},
|
||||
'prompt': 'default',
|
||||
'knowledge-bases': [],
|
||||
},
|
||||
},
|
||||
'trigger': {'misc': {}},
|
||||
},
|
||||
variables={},
|
||||
)
|
||||
|
||||
|
||||
def _make_conversation() -> Conversation:
|
||||
return Conversation(
|
||||
prompt=Prompt(name='default', messages=[Message(role='system', content='system prompt')]),
|
||||
messages=[],
|
||||
pipeline_uuid='pipe-1',
|
||||
bot_uuid='bot-1',
|
||||
uuid='conv-1',
|
||||
)
|
||||
|
||||
|
||||
def _make_app(*, skill_service) -> SimpleNamespace:
|
||||
session = Session(launcher_type=LauncherTypes.PERSON, launcher_id='launcher-1', sender_id='sender-1')
|
||||
conversation = _make_conversation()
|
||||
model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'}))
|
||||
tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
|
||||
|
||||
return SimpleNamespace(
|
||||
sess_mgr=SimpleNamespace(
|
||||
get_session=AsyncMock(return_value=session),
|
||||
get_conversation=AsyncMock(return_value=conversation),
|
||||
),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=tool_mgr,
|
||||
plugin_connector=SimpleNamespace(
|
||||
emit_event=AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
event=SimpleNamespace(
|
||||
default_prompt=conversation.prompt.messages.copy(),
|
||||
prompt=conversation.messages.copy(),
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
pipeline_service=SimpleNamespace(
|
||||
get_pipeline=AsyncMock(return_value={'extensions_preferences': {'enable_all_skills': True}})
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
build_skill_aware_prompt_addition=Mock(return_value=''),
|
||||
skills={},
|
||||
),
|
||||
skill_service=skill_service,
|
||||
logger=Mock(),
|
||||
)
|
||||
|
||||
|
||||
def _import_preproc_modules():
|
||||
fake_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
fake_app_module.Application = object
|
||||
sys.modules['langbot.pkg.core.app'] = fake_app_module
|
||||
|
||||
for module_name in (
|
||||
'langbot.pkg.pipeline.preproc.preproc',
|
||||
'langbot.pkg.pipeline.stage',
|
||||
):
|
||||
sys.modules.pop(module_name, None)
|
||||
|
||||
preproc_module = importlib.import_module('langbot.pkg.pipeline.preproc.preproc')
|
||||
entities_module = importlib.import_module('langbot.pkg.pipeline.entities')
|
||||
return preproc_module, entities_module
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_enables_skill_authoring_tools_when_skill_service_available():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=SimpleNamespace())
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing():
|
||||
preproc_module, entities_module = _import_preproc_modules()
|
||||
|
||||
app = _make_app(skill_service=None)
|
||||
stage = preproc_module.PreProcessor(app)
|
||||
|
||||
result = await stage.process(_make_query(), 'PreProcessor')
|
||||
|
||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False)
|
||||
408
tests/unit_tests/test_skill_service.py
Normal file
408
tests/unit_tests/test_skill_service.py
Normal file
@@ -0,0 +1,408 @@
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.langbot.pkg.api.http.service.skill import SkillService
|
||||
|
||||
|
||||
def _create_skill_file(
|
||||
path,
|
||||
*,
|
||||
name: str = 'imported-skill',
|
||||
display_name: str = '',
|
||||
description: str = 'Imported from local directory',
|
||||
auto_activate: bool = True,
|
||||
body: str = 'Skill instructions',
|
||||
) -> None:
|
||||
frontmatter = ['name: ' + name, 'description: ' + description]
|
||||
if display_name:
|
||||
frontmatter.insert(1, 'display_name: ' + display_name)
|
||||
if not auto_activate:
|
||||
frontmatter.append('auto_activate: false')
|
||||
|
||||
path.write_text(
|
||||
'---\n' + '\n'.join(frontmatter) + f'\n---\n\n{body}\n',
|
||||
encoding='utf-8',
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skill_service():
|
||||
app = SimpleNamespace(
|
||||
skill_mgr=SimpleNamespace(
|
||||
refresh_skill_from_disk=lambda *_args, **_kwargs: True,
|
||||
reload_skills=AsyncMock(),
|
||||
)
|
||||
)
|
||||
return SkillService(app)
|
||||
|
||||
|
||||
def test_scan_directory_supports_nested_skill_within_two_levels(skill_service, tmp_path):
|
||||
nested_dir = tmp_path / 'downloaded' / 'self-improving-agent'
|
||||
nested_dir.mkdir(parents=True)
|
||||
_create_skill_file(nested_dir / 'SKILL.md')
|
||||
|
||||
result = skill_service.scan_directory(str(tmp_path))
|
||||
|
||||
assert result['package_root'] == str(nested_dir.resolve())
|
||||
assert result['entry_file'] == 'SKILL.md'
|
||||
assert result['name'] == 'imported-skill'
|
||||
assert result['instructions'] == 'Skill instructions'
|
||||
|
||||
|
||||
def test_scan_directory_rejects_ambiguous_nested_skill_directories(skill_service, tmp_path):
|
||||
first_dir = tmp_path / 'skills' / 'alpha'
|
||||
second_dir = tmp_path / 'skills' / 'beta'
|
||||
first_dir.mkdir(parents=True)
|
||||
second_dir.mkdir(parents=True)
|
||||
_create_skill_file(first_dir / 'SKILL.md', body='alpha instructions')
|
||||
_create_skill_file(second_dir / 'SKILL.md', body='beta instructions')
|
||||
|
||||
with pytest.raises(ValueError, match='Multiple skill directories found'):
|
||||
skill_service.scan_directory(str(tmp_path))
|
||||
|
||||
|
||||
def test_scan_directory_errors_when_skill_is_deeper_than_two_levels(skill_service, tmp_path):
|
||||
deep_dir = tmp_path / 'a' / 'b' / 'c'
|
||||
deep_dir.mkdir(parents=True)
|
||||
_create_skill_file(deep_dir / 'SKILL.md')
|
||||
|
||||
with pytest.raises(ValueError, match='max depth: 2'):
|
||||
skill_service.scan_directory(str(tmp_path))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_import_preserves_existing_skill_content_when_form_fields_blank(tmp_path, monkeypatch):
|
||||
source_dir = tmp_path / 'external-skills' / 'manual-skill'
|
||||
source_dir.mkdir(parents=True)
|
||||
_create_skill_file(
|
||||
source_dir / 'SKILL.md',
|
||||
display_name='Imported Skill',
|
||||
description='Imported description',
|
||||
auto_activate=False,
|
||||
body='Original instructions',
|
||||
)
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill_by_name = AsyncMock(return_value=None)
|
||||
managed_root = tmp_path / 'data' / 'skills' / 'imported-skill'
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'imported-skill',
|
||||
'package_root': str(managed_root.resolve()),
|
||||
'description': 'Imported description',
|
||||
'instructions': 'Original instructions',
|
||||
'auto_activate': False,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
await service.create_skill(
|
||||
{
|
||||
'name': 'imported-skill',
|
||||
'package_root': str(source_dir),
|
||||
'display_name': '',
|
||||
'description': '',
|
||||
'instructions': '',
|
||||
}
|
||||
)
|
||||
|
||||
content = (managed_root / 'SKILL.md').read_text(encoding='utf-8')
|
||||
assert 'display_name: Imported Skill' in content
|
||||
assert 'description: Imported description' in content
|
||||
assert 'auto_activate: false' in content
|
||||
assert content.endswith('Original instructions')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_skill_reuses_existing_managed_directory_without_copying(tmp_path, monkeypatch):
|
||||
managed_root = tmp_path / 'data' / 'skills' / 'demo-repo' / 'skills' / 'nested-skill'
|
||||
managed_root.mkdir(parents=True)
|
||||
_create_skill_file(
|
||||
managed_root / 'SKILL.md',
|
||||
name='nested-skill',
|
||||
display_name='Nested Skill',
|
||||
description='Already managed',
|
||||
body='Managed instructions',
|
||||
)
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill_by_name = AsyncMock(return_value=None)
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'nested-skill',
|
||||
'package_root': str(managed_root.resolve()),
|
||||
'description': 'Already managed',
|
||||
'instructions': 'Managed instructions',
|
||||
'auto_activate': True,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
await service.create_skill(
|
||||
{
|
||||
'name': 'nested-skill',
|
||||
'package_root': str(managed_root),
|
||||
'display_name': '',
|
||||
'description': '',
|
||||
'instructions': '',
|
||||
}
|
||||
)
|
||||
|
||||
copied_root = tmp_path / 'data' / 'skills' / 'nested-skill'
|
||||
assert not copied_root.exists()
|
||||
content = (managed_root / 'SKILL.md').read_text(encoding='utf-8')
|
||||
assert 'display_name: Nested Skill' in content
|
||||
assert content.endswith('Managed instructions')
|
||||
|
||||
|
||||
def _build_skill_archive() -> bytes:
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as archive:
|
||||
archive.writestr(
|
||||
'demo-repo-main/skills/nested-skill/SKILL.md',
|
||||
'---\n'
|
||||
'name: imported-skill\n'
|
||||
'description: Imported from GitHub archive\n'
|
||||
'---\n\n'
|
||||
'Skill instructions\n',
|
||||
)
|
||||
return stream.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_supports_nested_skill_archive(skill_service, tmp_path, monkeypatch):
|
||||
archive_bytes = _build_skill_archive()
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def get(self, url: str) -> _FakeResponse:
|
||||
return _FakeResponse(archive_bytes)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
monkeypatch.setattr('src.langbot.pkg.api.http.service.skill.httpx.AsyncClient', _FakeAsyncClient)
|
||||
skill_service.get_skill = AsyncMock(return_value=None)
|
||||
|
||||
result = await skill_service.install_from_github(
|
||||
{
|
||||
'asset_url': 'https://api.github.com/repos/example/demo-repo/zipball/main',
|
||||
'owner': 'example',
|
||||
'repo': 'demo-repo',
|
||||
'release_tag': 'main',
|
||||
}
|
||||
)
|
||||
|
||||
expected_root = tmp_path / 'data' / 'skills' / 'demo-repo-nested-skill-main'
|
||||
assert result[0]['package_root'] == str(expected_root.resolve())
|
||||
assert (expected_root / 'SKILL.md').read_text(encoding='utf-8').endswith('Skill instructions\n')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_rejects_asset_url_outside_requested_repo(skill_service, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
with pytest.raises(ValueError, match='owner/repo'):
|
||||
await skill_service.install_from_github(
|
||||
{
|
||||
'asset_url': 'https://api.github.com/repos/example/other-repo/zipball/main',
|
||||
'owner': 'example',
|
||||
'repo': 'demo-repo',
|
||||
'release_tag': 'main',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_from_github_rejects_zip_with_path_traversal(skill_service, tmp_path, monkeypatch):
|
||||
stream = io.BytesIO()
|
||||
with zipfile.ZipFile(stream, 'w') as archive:
|
||||
archive.writestr('../escape.txt', 'boom')
|
||||
archive_bytes = stream.getvalue()
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeAsyncClient:
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def get(self, url: str) -> _FakeResponse:
|
||||
return _FakeResponse(archive_bytes)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
monkeypatch.setattr('src.langbot.pkg.api.http.service.skill.httpx.AsyncClient', _FakeAsyncClient)
|
||||
|
||||
with pytest.raises(ValueError, match='unsafe path'):
|
||||
await skill_service.install_from_github(
|
||||
{
|
||||
'asset_url': 'https://api.github.com/repos/example/demo-repo/zipball/main',
|
||||
'owner': 'example',
|
||||
'repo': 'demo-repo',
|
||||
'release_tag': 'main',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_file_operations_stay_within_package_root(skill_service, tmp_path):
|
||||
skill_dir = tmp_path / 'mood-logger'
|
||||
skill_dir.mkdir()
|
||||
_create_skill_file(skill_dir / 'SKILL.md')
|
||||
(skill_dir / 'resources').mkdir()
|
||||
(skill_dir / 'resources' / 'keywords_zh.json').write_text('{"hello": 1}\n', encoding='utf-8')
|
||||
|
||||
skill_record = {
|
||||
'name': 'mood-logger',
|
||||
'package_root': str(skill_dir),
|
||||
'entry_file': 'SKILL.md',
|
||||
}
|
||||
skill_service.get_skill = AsyncMock(return_value=skill_record)
|
||||
|
||||
listed = await skill_service.list_skill_files('mood-logger', path='resources')
|
||||
assert listed['entries'] == [
|
||||
{
|
||||
'path': 'resources/keywords_zh.json',
|
||||
'name': 'keywords_zh.json',
|
||||
'is_dir': False,
|
||||
'size': os.path.getsize(skill_dir / 'resources' / 'keywords_zh.json'),
|
||||
}
|
||||
]
|
||||
|
||||
read_back = await skill_service.read_skill_file('mood-logger', 'resources/keywords_zh.json')
|
||||
assert read_back['content'] == '{"hello": 1}\n'
|
||||
|
||||
written = await skill_service.write_skill_file('mood-logger', 'resources/affinity.py', 'print("ok")\n')
|
||||
assert written['path'] == 'resources/affinity.py'
|
||||
assert (skill_dir / 'resources' / 'affinity.py').read_text(encoding='utf-8') == 'print("ok")\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_file_operations_reject_path_traversal(skill_service, tmp_path):
|
||||
skill_dir = tmp_path / 'mood-logger'
|
||||
skill_dir.mkdir()
|
||||
_create_skill_file(skill_dir / 'SKILL.md')
|
||||
|
||||
skill_service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'mood-logger',
|
||||
'package_root': str(skill_dir),
|
||||
'entry_file': 'SKILL.md',
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='path must stay within the skill package root'):
|
||||
await skill_service.read_skill_file('mood-logger', '../outside.txt')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_skill_rejects_package_root_change(tmp_path):
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
skill_root = tmp_path / 'data' / 'skills' / 'writer'
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'writer',
|
||||
'package_root': str(skill_root.resolve()),
|
||||
'display_name': 'Writer',
|
||||
'description': 'Writes things',
|
||||
'instructions': 'Do work',
|
||||
'auto_activate': True,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='Updating package_root is not supported'):
|
||||
await service.update_skill('writer', {'package_root': str(tmp_path / 'other-root')})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_removes_managed_skill_directory(tmp_path, monkeypatch):
|
||||
managed_root = tmp_path / 'data' / 'skills' / 'self-improving-agent'
|
||||
managed_root.mkdir(parents=True)
|
||||
_create_skill_file(managed_root / 'SKILL.md')
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'self-improving-agent',
|
||||
'package_root': str(managed_root.resolve()),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
result = await service.delete_skill('self-improving-agent')
|
||||
|
||||
assert result is True
|
||||
assert not managed_root.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_removes_managed_install_root_for_nested_package(tmp_path, monkeypatch):
|
||||
install_root = tmp_path / 'data' / 'skills' / 'demo-repo'
|
||||
package_root = install_root / 'skills' / 'nested-skill'
|
||||
package_root.mkdir(parents=True)
|
||||
_create_skill_file(package_root / 'SKILL.md')
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'nested-skill',
|
||||
'package_root': str(package_root.resolve()),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setenv('LANGBOT_DATA_ROOT', str(tmp_path / 'data'))
|
||||
|
||||
await service.delete_skill('nested-skill')
|
||||
|
||||
assert not install_root.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_skill_rejects_external_package_directory(tmp_path, monkeypatch):
|
||||
external_root = tmp_path / 'external-skills' / 'manual-skill'
|
||||
external_root.mkdir(parents=True)
|
||||
_create_skill_file(external_root / 'SKILL.md')
|
||||
|
||||
service = SkillService(SimpleNamespace(skill_mgr=SimpleNamespace(reload_skills=AsyncMock())))
|
||||
service.get_skill = AsyncMock(
|
||||
return_value={
|
||||
'name': 'manual-skill',
|
||||
'package_root': str(external_root.resolve()),
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match='Only managed skills under data/skills'):
|
||||
await service.delete_skill('manual-skill')
|
||||
Reference in New Issue
Block a user