mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-11 16:26:02 +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:
@@ -7,6 +7,7 @@ import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
from .. import loader
|
||||
from . import skill as skill_loader
|
||||
|
||||
EXEC_TOOL_NAME = 'exec'
|
||||
READ_TOOL_NAME = 'read'
|
||||
@@ -43,44 +44,116 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
f'query_id={query.query_id} '
|
||||
f'parameters={json.dumps(self._summarize_parameters(parameters), ensure_ascii=False)}'
|
||||
)
|
||||
return await self.ap.box_service.execute_tool(parameters, query)
|
||||
elif name == READ_TOOL_NAME:
|
||||
return await self._invoke_exec(parameters, query)
|
||||
if name == READ_TOOL_NAME:
|
||||
return await self._invoke_read(parameters, query)
|
||||
elif name == WRITE_TOOL_NAME:
|
||||
if name == WRITE_TOOL_NAME:
|
||||
return await self._invoke_write(parameters, query)
|
||||
elif name == EDIT_TOOL_NAME:
|
||||
if name == EDIT_TOOL_NAME:
|
||||
return await self._invoke_edit(parameters, query)
|
||||
else:
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
pass
|
||||
|
||||
# ── File tool implementations ────────────────────────────────────
|
||||
async def _invoke_exec(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
command = str(parameters['command'])
|
||||
workdir = str(parameters.get('workdir', '/workspace') or '/workspace')
|
||||
|
||||
selected_skill, rewritten_workdir = skill_loader.resolve_virtual_skill_path(
|
||||
self.ap,
|
||||
query,
|
||||
workdir,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
referenced_skill_names = skill_loader.find_referenced_skill_names(command)
|
||||
|
||||
if selected_skill is None and referenced_skill_names:
|
||||
if len(referenced_skill_names) > 1:
|
||||
raise ValueError('exec can target at most one activated skill package per call.')
|
||||
selected_skill = skill_loader.get_activated_skill(query, referenced_skill_names[0])
|
||||
if selected_skill is None:
|
||||
raise ValueError(
|
||||
f'Skill "{referenced_skill_names[0]}" must be activated before exec can run in its package.'
|
||||
)
|
||||
rewritten_workdir = '/workspace'
|
||||
|
||||
if selected_skill is None:
|
||||
return await self.ap.box_service.execute_tool(parameters, query)
|
||||
|
||||
selected_skill_name = str(selected_skill.get('name', '') or '')
|
||||
if referenced_skill_names and any(name != selected_skill_name for name in referenced_skill_names):
|
||||
raise ValueError('exec can reference files from only one activated skill package per call.')
|
||||
|
||||
package_root = str(selected_skill.get('package_root', '') or '').strip()
|
||||
if not package_root:
|
||||
raise ValueError(f'Activated skill "{selected_skill_name}" has no package_root.')
|
||||
|
||||
rewritten_command = skill_loader.rewrite_command_for_skill_mount(command, selected_skill_name)
|
||||
if skill_loader.should_prepare_skill_python_env(package_root):
|
||||
rewritten_command = skill_loader.wrap_skill_command_with_python_env(rewritten_command)
|
||||
|
||||
spec_payload: dict = {
|
||||
'cmd': rewritten_command,
|
||||
'workdir': rewritten_workdir,
|
||||
'host_path': package_root,
|
||||
'host_path_mode': 'rw',
|
||||
'session_id': skill_loader.build_skill_session_id(selected_skill, query),
|
||||
}
|
||||
for key in ('timeout_sec', 'env'):
|
||||
if key in parameters:
|
||||
spec_payload[key] = parameters[key]
|
||||
|
||||
result = await self.ap.box_service.execute_spec_payload(spec_payload, query)
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
return result
|
||||
|
||||
def _resolve_host_path(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
sandbox_path: str,
|
||||
*,
|
||||
include_visible: bool,
|
||||
include_activated: bool,
|
||||
) -> tuple[str, dict | None]:
|
||||
selected_skill, rewritten_path = skill_loader.resolve_virtual_skill_path(
|
||||
self.ap,
|
||||
query,
|
||||
sandbox_path,
|
||||
include_visible=include_visible,
|
||||
include_activated=include_activated,
|
||||
)
|
||||
|
||||
def _resolve_host_path(self, sandbox_path: str) -> str:
|
||||
"""Map a sandbox /workspace path to the host filesystem path."""
|
||||
box_service = self.ap.box_service
|
||||
host_root = box_service.default_host_workspace
|
||||
if host_root is None:
|
||||
raise ValueError('No default host workspace configured for file operations.')
|
||||
host_root = (
|
||||
selected_skill.get('package_root') if selected_skill is not None else box_service.default_host_workspace
|
||||
)
|
||||
if not host_root:
|
||||
raise ValueError('No host workspace configured for file operations.')
|
||||
|
||||
mount_path = '/workspace'
|
||||
if not sandbox_path.startswith(mount_path):
|
||||
if not rewritten_path.startswith(mount_path):
|
||||
raise ValueError(f'Path must be under {mount_path}.')
|
||||
|
||||
relative = sandbox_path[len(mount_path):].lstrip('/')
|
||||
relative = rewritten_path[len(mount_path) :].lstrip('/')
|
||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
||||
host_root = os.path.realpath(host_root)
|
||||
|
||||
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||
raise ValueError('Path escapes the workspace boundary.')
|
||||
|
||||
return host_path
|
||||
return host_path, selected_skill
|
||||
|
||||
async def _invoke_read(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
path = parameters['path']
|
||||
self.ap.logger.info(f'read tool invoked: query_id={query.query_id} path={path}')
|
||||
host_path = self._resolve_host_path(path)
|
||||
host_path, _selected_skill = self._resolve_host_path(
|
||||
query,
|
||||
path,
|
||||
include_visible=True,
|
||||
include_activated=True,
|
||||
)
|
||||
if not os.path.exists(host_path):
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
if os.path.isdir(host_path):
|
||||
@@ -94,10 +167,16 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
path = parameters['path']
|
||||
content = parameters['content']
|
||||
self.ap.logger.info(f'write tool invoked: query_id={query.query_id} path={path} length={len(content)}')
|
||||
host_path = self._resolve_host_path(path)
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
query,
|
||||
path,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
os.makedirs(os.path.dirname(host_path), exist_ok=True)
|
||||
with open(host_path, 'w') as f:
|
||||
with open(host_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
async def _invoke_edit(self, parameters: dict, query: pipeline_query.Query) -> dict:
|
||||
@@ -108,10 +187,15 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
f'edit tool invoked: query_id={query.query_id} path={path} '
|
||||
f'old_len={len(old_string)} new_len={len(new_string)}'
|
||||
)
|
||||
host_path = self._resolve_host_path(path)
|
||||
host_path, selected_skill = self._resolve_host_path(
|
||||
query,
|
||||
path,
|
||||
include_visible=False,
|
||||
include_activated=True,
|
||||
)
|
||||
if not os.path.isfile(host_path):
|
||||
return {'ok': False, 'error': f'File not found: {path}'}
|
||||
with open(host_path, 'r', errors='replace') as f:
|
||||
with open(host_path, 'r', encoding='utf-8', errors='replace') as f:
|
||||
content = f.read()
|
||||
count = content.count(old_string)
|
||||
if count == 0:
|
||||
@@ -119,11 +203,22 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
if count > 1:
|
||||
return {'ok': False, 'error': f'old_string matches {count} locations; provide a more unique string.'}
|
||||
new_content = content.replace(old_string, new_string, 1)
|
||||
with open(host_path, 'w') as f:
|
||||
with open(host_path, 'w', encoding='utf-8') as f:
|
||||
f.write(new_content)
|
||||
self._refresh_skill_from_disk(selected_skill)
|
||||
return {'ok': True, 'path': path}
|
||||
|
||||
# ── Internals ────────────────────────────────────────────────────
|
||||
def _refresh_skill_from_disk(self, selected_skill: dict | None) -> None:
|
||||
if selected_skill is None:
|
||||
return
|
||||
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return
|
||||
|
||||
refresh_skill = getattr(skill_mgr, 'refresh_skill_from_disk', None)
|
||||
if callable(refresh_skill):
|
||||
refresh_skill(selected_skill.get('name', ''))
|
||||
|
||||
def _is_sandbox_available(self) -> bool:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
@@ -135,8 +230,10 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
human_desc='Execute a command in an isolated environment',
|
||||
description=(
|
||||
'Run shell commands in an isolated execution environment. '
|
||||
'Use this tool for bash commands, Python execution, and exact calculations '
|
||||
'over user-provided data.'
|
||||
'Use this tool for bash commands, Python execution, and exact calculations over '
|
||||
'user-provided data. Activated skill packages are addressable under '
|
||||
'/workspace/.skills/<skill-name>; when running inside one, set workdir to that path. '
|
||||
'To create a new skill package, prepare it under /workspace first, then use import_skill_from_directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
@@ -147,9 +244,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
},
|
||||
'workdir': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Working directory for the command. Defaults to /workspace.'
|
||||
),
|
||||
'description': 'Working directory for the command. Defaults to /workspace.',
|
||||
'default': '/workspace',
|
||||
},
|
||||
'timeout_sec': {
|
||||
@@ -179,7 +274,10 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return resource_tool.LLMTool(
|
||||
name=READ_TOOL_NAME,
|
||||
human_desc='Read a file from the workspace',
|
||||
description='Read the contents of a file at the given path under /workspace.',
|
||||
description=(
|
||||
'Read the contents of a file at the given path under /workspace. '
|
||||
'Visible skill packages can be inspected through /workspace/.skills/<skill-name>/... .'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
@@ -198,7 +296,11 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return resource_tool.LLMTool(
|
||||
name=WRITE_TOOL_NAME,
|
||||
human_desc='Write a file to the workspace',
|
||||
description='Create or overwrite a file at the given path under /workspace with the provided content.',
|
||||
description=(
|
||||
'Create or overwrite a file at the given path under /workspace with the provided content. '
|
||||
'Activated skill packages can be modified through /workspace/.skills/<skill-name>/... . '
|
||||
'For new skills, write files under /workspace and then call import_skill_from_directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
@@ -223,7 +325,9 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
human_desc='Edit a file in the workspace',
|
||||
description=(
|
||||
'Perform an exact string replacement in a file under /workspace. '
|
||||
'The old_string must appear exactly once in the file.'
|
||||
'The old_string must appear exactly once in the file. Activated skill packages '
|
||||
'can be edited through /workspace/.skills/<skill-name>/... . '
|
||||
'For new skills, edit files under /workspace and then call import_skill_from_directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
|
||||
285
src/langbot/pkg/provider/tools/loaders/skill.py
Normal file
285
src/langbot/pkg/provider/tools/loaders/skill.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import textwrap
|
||||
import typing
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from ....core import app
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
ACTIVATED_SKILLS_KEY = '_activated_skills'
|
||||
PIPELINE_BOUND_SKILLS_KEY = '_pipeline_bound_skills'
|
||||
SKILL_MOUNT_PREFIX = '/workspace/.skills'
|
||||
_SKILL_MOUNT_PATTERN = re.compile(r'/workspace/\.skills/([A-Za-z0-9_-]+)')
|
||||
_PYTHON_SKILL_MANIFESTS = (
|
||||
'requirements.txt',
|
||||
'pyproject.toml',
|
||||
'setup.py',
|
||||
'setup.cfg',
|
||||
)
|
||||
|
||||
|
||||
def _normalize_host_path(path: str | None) -> str:
|
||||
if path is None:
|
||||
return ''
|
||||
stripped = str(path).strip()
|
||||
if not stripped:
|
||||
return ''
|
||||
return os.path.realpath(os.path.abspath(stripped))
|
||||
|
||||
|
||||
def get_virtual_skill_mount_path(skill_name: str) -> str:
|
||||
return f'{SKILL_MOUNT_PREFIX}/{skill_name}'
|
||||
|
||||
|
||||
def get_bound_skill_names(query: pipeline_query.Query) -> list[str] | None:
|
||||
if query.variables is None:
|
||||
return None
|
||||
|
||||
bound_skills = query.variables.get(PIPELINE_BOUND_SKILLS_KEY)
|
||||
if bound_skills is None:
|
||||
return None
|
||||
if isinstance(bound_skills, list):
|
||||
return [str(item) for item in bound_skills]
|
||||
return None
|
||||
|
||||
|
||||
def get_visible_skills(ap: app.Application, query: pipeline_query.Query) -> dict[str, dict]:
|
||||
skill_mgr = getattr(ap, 'skill_mgr', None)
|
||||
if skill_mgr is None:
|
||||
return {}
|
||||
|
||||
visible_skills = getattr(skill_mgr, 'skills', {})
|
||||
bound_skills = get_bound_skill_names(query)
|
||||
if bound_skills is None:
|
||||
return visible_skills
|
||||
|
||||
return {skill_name: skill_data for skill_name, skill_data in visible_skills.items() if skill_name in bound_skills}
|
||||
|
||||
|
||||
def get_visible_skill(ap: app.Application, query: pipeline_query.Query, skill_name: str) -> dict | None:
|
||||
return get_visible_skills(ap, query).get(skill_name)
|
||||
|
||||
|
||||
def get_activated_skills(query: pipeline_query.Query) -> dict[str, dict]:
|
||||
if query.variables is None:
|
||||
return {}
|
||||
|
||||
activated = query.variables.get(ACTIVATED_SKILLS_KEY, {})
|
||||
if not isinstance(activated, dict):
|
||||
return {}
|
||||
return activated
|
||||
|
||||
|
||||
def get_activated_skill(query: pipeline_query.Query, skill_name: str) -> dict | None:
|
||||
return get_activated_skills(query).get(skill_name)
|
||||
|
||||
|
||||
def register_activated_skill(query: pipeline_query.Query, skill_data: dict) -> None:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
|
||||
activated = query.variables.setdefault(ACTIVATED_SKILLS_KEY, {})
|
||||
skill_name = str(skill_data.get('name', '') or '').strip()
|
||||
if skill_name and skill_name not in activated:
|
||||
activated[skill_name] = skill_data
|
||||
|
||||
|
||||
def parse_skill_mount_path(sandbox_path: str) -> tuple[str | None, str]:
|
||||
normalized_path = str(sandbox_path or '/workspace').strip() or '/workspace'
|
||||
if normalized_path == SKILL_MOUNT_PREFIX:
|
||||
raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/<skill-name>.')
|
||||
prefix = f'{SKILL_MOUNT_PREFIX}/'
|
||||
if not normalized_path.startswith(prefix):
|
||||
return None, normalized_path
|
||||
|
||||
remainder = normalized_path[len(prefix) :]
|
||||
skill_name, separator, tail = remainder.partition('/')
|
||||
if not skill_name:
|
||||
raise ValueError(f'Path must include a skill name under {SKILL_MOUNT_PREFIX}/<skill-name>.')
|
||||
|
||||
rewritten_path = '/workspace'
|
||||
if separator:
|
||||
rewritten_path = f'/workspace/{tail}'
|
||||
return skill_name, rewritten_path
|
||||
|
||||
|
||||
def resolve_virtual_skill_path(
|
||||
ap: app.Application,
|
||||
query: pipeline_query.Query,
|
||||
sandbox_path: str,
|
||||
*,
|
||||
include_visible: bool,
|
||||
include_activated: bool,
|
||||
) -> tuple[dict | None, str]:
|
||||
skill_name, rewritten_path = parse_skill_mount_path(sandbox_path)
|
||||
if skill_name is None:
|
||||
return None, rewritten_path
|
||||
|
||||
if include_activated:
|
||||
activated_skill = get_activated_skill(query, skill_name)
|
||||
if activated_skill is not None:
|
||||
return activated_skill, rewritten_path
|
||||
|
||||
if include_visible:
|
||||
visible_skill = get_visible_skill(ap, query, skill_name)
|
||||
if visible_skill is not None:
|
||||
return visible_skill, rewritten_path
|
||||
|
||||
activated_names = ', '.join(sorted(get_activated_skills(query).keys())) or 'none'
|
||||
visible_names = ', '.join(sorted(get_visible_skills(ap, query).keys())) or 'none'
|
||||
raise ValueError(
|
||||
f'Skill "{skill_name}" is not available at this path. '
|
||||
f'Activated skills: {activated_names}. Visible skills: {visible_names}.'
|
||||
)
|
||||
|
||||
|
||||
def find_referenced_skill_names(text: str) -> list[str]:
|
||||
if not text:
|
||||
return []
|
||||
|
||||
seen: list[str] = []
|
||||
for match in _SKILL_MOUNT_PATTERN.findall(text):
|
||||
if match not in seen:
|
||||
seen.append(match)
|
||||
return seen
|
||||
|
||||
|
||||
def rewrite_command_for_skill_mount(command: str, skill_name: str) -> str:
|
||||
virtual_root = get_virtual_skill_mount_path(skill_name)
|
||||
rewritten = command.replace(f'{virtual_root}/', '/workspace/')
|
||||
return rewritten.replace(virtual_root, '/workspace')
|
||||
|
||||
|
||||
def build_skill_session_id(skill_data: dict, query: pipeline_query.Query) -> str:
|
||||
skill_identifier = str(skill_data.get('name', 'unknown') or 'unknown')
|
||||
launcher_type = getattr(query, 'launcher_type', None)
|
||||
launcher_id = getattr(query, 'launcher_id', None)
|
||||
query_id = getattr(query, 'query_id', 'unknown')
|
||||
|
||||
if launcher_type is not None and launcher_id is not None:
|
||||
return f'skill-{launcher_type}_{launcher_id}-{skill_identifier}'
|
||||
return f'skill-{query_id}-{skill_identifier}'
|
||||
|
||||
|
||||
def should_prepare_skill_python_env(package_root: str | None) -> bool:
|
||||
normalized_root = _normalize_host_path(package_root)
|
||||
if not normalized_root:
|
||||
return False
|
||||
if os.path.isdir(os.path.join(normalized_root, '.venv')):
|
||||
return True
|
||||
return any(os.path.isfile(os.path.join(normalized_root, filename)) for filename in _PYTHON_SKILL_MANIFESTS)
|
||||
|
||||
|
||||
def wrap_skill_command_with_python_env(command: str) -> str:
|
||||
bootstrap = textwrap.dedent(
|
||||
"""
|
||||
set -e
|
||||
|
||||
_LB_VENV_DIR="/workspace/.venv"
|
||||
_LB_META_DIR="/workspace/.langbot"
|
||||
_LB_META_FILE="$_LB_META_DIR/python-env.json"
|
||||
_LB_LOCK_DIR="$_LB_META_DIR/python-env.lock"
|
||||
_LB_TMP_DIR="/workspace/.tmp"
|
||||
_LB_PIP_CACHE_DIR="/workspace/.cache/pip"
|
||||
|
||||
mkdir -p "$_LB_META_DIR" "$_LB_TMP_DIR" "$_LB_PIP_CACHE_DIR"
|
||||
export TMPDIR="$_LB_TMP_DIR"
|
||||
export TEMP="$_LB_TMP_DIR"
|
||||
export TMP="$_LB_TMP_DIR"
|
||||
export PIP_CACHE_DIR="$_LB_PIP_CACHE_DIR"
|
||||
|
||||
_lb_python_meta() {
|
||||
python - <<'PY'
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
root = "/workspace"
|
||||
digest = hashlib.sha256()
|
||||
manifest_files = []
|
||||
for rel in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg"):
|
||||
path = os.path.join(root, rel)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
manifest_files.append(rel)
|
||||
with open(path, "rb") as handle:
|
||||
digest.update(rel.encode("utf-8"))
|
||||
digest.update(b"\0")
|
||||
digest.update(handle.read())
|
||||
digest.update(b"\0")
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"python_executable": sys.executable,
|
||||
"python_version": list(sys.version_info[:3]),
|
||||
"manifest_files": manifest_files,
|
||||
"manifest_sha256": digest.hexdigest(),
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
PY
|
||||
}
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
_LB_LOCK_WAIT=0
|
||||
while ! mkdir "$_LB_LOCK_DIR" 2>/dev/null; do
|
||||
if [ "$_LB_LOCK_WAIT" -ge 120 ]; then
|
||||
echo "Timed out waiting for Python environment lock: $_LB_LOCK_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
_LB_LOCK_WAIT=$((_LB_LOCK_WAIT + 1))
|
||||
done
|
||||
|
||||
_lb_cleanup_lock() {
|
||||
rmdir "$_LB_LOCK_DIR" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap _lb_cleanup_lock EXIT INT TERM
|
||||
|
||||
_LB_CURRENT_META="$(_lb_python_meta)"
|
||||
_LB_NEEDS_BOOTSTRAP=0
|
||||
if [ ! -x "$_LB_VENV_DIR/bin/python" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ ! -f "$_LB_META_FILE" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
elif [ "$(cat "$_LB_META_FILE")" != "$_LB_CURRENT_META" ]; then
|
||||
_LB_NEEDS_BOOTSTRAP=1
|
||||
fi
|
||||
|
||||
if [ "$_LB_NEEDS_BOOTSTRAP" -eq 1 ]; then
|
||||
rm -rf "$_LB_VENV_DIR"
|
||||
python -m venv "$_LB_VENV_DIR"
|
||||
|
||||
if [ -f /workspace/requirements.txt ]; then
|
||||
"$_LB_VENV_DIR/bin/python" -m pip install -r /workspace/requirements.txt
|
||||
elif [ -f /workspace/pyproject.toml ] || [ -f /workspace/setup.py ] || [ -f /workspace/setup.cfg ]; then
|
||||
"$_LB_VENV_DIR/bin/python" -m pip install -e /workspace
|
||||
fi
|
||||
|
||||
printf '%s' "$_LB_CURRENT_META" > "$_LB_META_FILE"
|
||||
fi
|
||||
fi
|
||||
|
||||
export VIRTUAL_ENV="$_LB_VENV_DIR"
|
||||
export PATH="$_LB_VENV_DIR/bin:$PATH"
|
||||
"""
|
||||
).strip()
|
||||
|
||||
return f'{bootstrap}\n\n{command}'
|
||||
391
src/langbot/pkg/provider/tools/loaders/skill_authoring.py
Normal file
391
src/langbot/pkg/provider/tools/loaders/skill_authoring.py
Normal file
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import typing
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
|
||||
from .. import loader
|
||||
|
||||
# Skill authoring needs a managed abstraction above the generic box tools.
|
||||
# Pure prompt skills are just metadata plus SKILL.md instructions, so creating
|
||||
# or updating them should not require /workspace mounts, shell access, or box
|
||||
# to be enabled at all. These higher-level tools let local agents manage skills
|
||||
# directly through SkillService, while import_skill_from_directory remains the
|
||||
# path for file-based skills that actually need scripts or assets from box.
|
||||
|
||||
CREATE_SKILL_TOOL_NAME = 'create_skill'
|
||||
LIST_SKILLS_TOOL_NAME = 'list_skills'
|
||||
GET_SKILL_TOOL_NAME = 'get_skill'
|
||||
UPDATE_SKILL_TOOL_NAME = 'update_skill'
|
||||
DELETE_SKILL_TOOL_NAME = 'delete_skill'
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME = 'import_skill_from_directory'
|
||||
RELOAD_SKILLS_TOOL_NAME = 'reload_skills'
|
||||
|
||||
AUTHORING_TOOL_NAMES = {
|
||||
CREATE_SKILL_TOOL_NAME,
|
||||
LIST_SKILLS_TOOL_NAME,
|
||||
GET_SKILL_TOOL_NAME,
|
||||
UPDATE_SKILL_TOOL_NAME,
|
||||
DELETE_SKILL_TOOL_NAME,
|
||||
IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
RELOAD_SKILLS_TOOL_NAME,
|
||||
}
|
||||
|
||||
|
||||
class SkillAuthoringToolLoader(loader.ToolLoader):
|
||||
"""Minimal system actions for filesystem-backed skills."""
|
||||
|
||||
def __init__(self, ap):
|
||||
super().__init__(ap)
|
||||
self._tools: list[resource_tool.LLMTool] = []
|
||||
|
||||
async def initialize(self):
|
||||
self._tools = [
|
||||
self._build_create_skill_tool(),
|
||||
self._build_list_skills_tool(),
|
||||
self._build_get_skill_tool(),
|
||||
self._build_update_skill_tool(),
|
||||
self._build_delete_skill_tool(),
|
||||
self._build_import_skill_from_directory_tool(),
|
||||
self._build_reload_skills_tool(),
|
||||
]
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not self._has_authoring_services():
|
||||
return []
|
||||
return list(self._tools)
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return self._has_authoring_services() and name in AUTHORING_TOOL_NAMES
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||
if name == CREATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_create_skill(parameters)
|
||||
if name == LIST_SKILLS_TOOL_NAME:
|
||||
return await self._invoke_list_skills()
|
||||
if name == GET_SKILL_TOOL_NAME:
|
||||
return await self._invoke_get_skill(parameters)
|
||||
if name == UPDATE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_update_skill(parameters)
|
||||
if name == DELETE_SKILL_TOOL_NAME:
|
||||
return await self._invoke_delete_skill(parameters)
|
||||
if name == IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME:
|
||||
return await self._invoke_import_skill_from_directory(parameters)
|
||||
if name == RELOAD_SKILLS_TOOL_NAME:
|
||||
return await self._invoke_reload_skills()
|
||||
raise ValueError(f'Unknown skill authoring tool: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
pass
|
||||
|
||||
def _has_authoring_services(self) -> bool:
|
||||
return getattr(self.ap, 'skill_service', None) is not None
|
||||
|
||||
async def _invoke_reload_skills(self) -> typing.Any:
|
||||
await self.ap.skill_service.reload_skills()
|
||||
skills = await self.ap.skill_service.list_skills()
|
||||
return {
|
||||
'reloaded': True,
|
||||
'skill_names': [skill['name'] for skill in skills],
|
||||
'count': len(skills),
|
||||
}
|
||||
|
||||
async def _invoke_create_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
instructions = str(parameters.get('instructions', '') or '')
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
if not instructions.strip():
|
||||
raise ValueError('instructions is required')
|
||||
|
||||
created = await self.ap.skill_service.create_skill(
|
||||
{
|
||||
'name': name,
|
||||
'display_name': str(parameters.get('display_name', '') or '').strip(),
|
||||
'description': str(parameters.get('description', '') or '').strip(),
|
||||
'instructions': instructions,
|
||||
'auto_activate': parameters.get('auto_activate', True),
|
||||
}
|
||||
)
|
||||
return {
|
||||
'created': True,
|
||||
'skill': created,
|
||||
}
|
||||
|
||||
async def _invoke_list_skills(self) -> typing.Any:
|
||||
skills = await self.ap.skill_service.list_skills()
|
||||
return {
|
||||
'skills': skills,
|
||||
'skill_names': [skill['name'] for skill in skills],
|
||||
'count': len(skills),
|
||||
}
|
||||
|
||||
async def _invoke_get_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
|
||||
skill = await self.ap.skill_service.get_skill(name)
|
||||
if not skill:
|
||||
raise ValueError(f'Skill "{name}" not found')
|
||||
return {'skill': skill}
|
||||
|
||||
async def _invoke_update_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
|
||||
data = {'name': name}
|
||||
for field in ('display_name', 'description', 'instructions', 'auto_activate'):
|
||||
if field in parameters:
|
||||
data[field] = parameters[field]
|
||||
|
||||
updated = await self.ap.skill_service.update_skill(name, data)
|
||||
return {
|
||||
'updated': True,
|
||||
'skill': updated,
|
||||
}
|
||||
|
||||
async def _invoke_delete_skill(self, parameters: dict) -> typing.Any:
|
||||
name = str(parameters.get('name', '') or '').strip()
|
||||
if not name:
|
||||
raise ValueError('name is required')
|
||||
|
||||
await self.ap.skill_service.delete_skill(name)
|
||||
return {
|
||||
'deleted': True,
|
||||
'skill_name': name,
|
||||
}
|
||||
|
||||
async def _invoke_import_skill_from_directory(self, parameters: dict) -> typing.Any:
|
||||
sandbox_path = str(parameters.get('path', '') or '').strip()
|
||||
if not sandbox_path:
|
||||
raise ValueError('path is required')
|
||||
|
||||
host_path = self._resolve_workspace_directory(sandbox_path)
|
||||
scanned = self.ap.skill_service.scan_directory(host_path)
|
||||
created = await self.ap.skill_service.create_skill(
|
||||
{
|
||||
'name': str(parameters.get('name') or scanned['name']).strip(),
|
||||
'display_name': str(parameters.get('display_name') or scanned.get('display_name', '')).strip(),
|
||||
'description': str(parameters.get('description') or scanned.get('description', '')).strip(),
|
||||
'instructions': str(parameters.get('instructions') or scanned.get('instructions', '')),
|
||||
'package_root': host_path,
|
||||
'auto_activate': parameters.get('auto_activate', scanned.get('auto_activate', True)),
|
||||
}
|
||||
)
|
||||
return {
|
||||
'imported': True,
|
||||
'source_path': sandbox_path,
|
||||
'skill': created,
|
||||
}
|
||||
|
||||
def _resolve_workspace_directory(self, sandbox_path: str) -> str:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
workspace_root = getattr(box_service, 'default_host_workspace', None)
|
||||
if not workspace_root:
|
||||
raise ValueError('No default host workspace configured for importing skills')
|
||||
|
||||
normalized_path = str(sandbox_path).strip() or '/workspace'
|
||||
if not normalized_path.startswith('/workspace'):
|
||||
raise ValueError('path must be under /workspace')
|
||||
|
||||
relative = normalized_path[len('/workspace') :].lstrip('/')
|
||||
host_root = os.path.realpath(workspace_root)
|
||||
host_path = os.path.realpath(os.path.join(host_root, relative))
|
||||
if not (host_path == host_root or host_path.startswith(host_root + os.sep)):
|
||||
raise ValueError('path escapes the workspace boundary')
|
||||
if not os.path.isdir(host_path):
|
||||
raise ValueError(f'Directory does not exist: {sandbox_path}')
|
||||
return host_path
|
||||
|
||||
def _build_create_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=CREATE_SKILL_TOOL_NAME,
|
||||
human_desc='Create a managed skill',
|
||||
description=(
|
||||
'Create a new managed skill directly in the skills store without using /workspace. '
|
||||
'Use this for prompt-only skills or simple skills whose main content is the SKILL.md instructions. '
|
||||
'Pure prompt skills should not depend on box or a workspace directory just to be created or edited later.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Skill name. Use lowercase letters, numbers, hyphens, or underscores.',
|
||||
},
|
||||
'display_name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional human-friendly display name.',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'description': 'Optional concise description of what the skill does and when to use it.',
|
||||
},
|
||||
'instructions': {
|
||||
'type': 'string',
|
||||
'description': 'The SKILL.md body instructions for the new skill.',
|
||||
},
|
||||
'auto_activate': {
|
||||
'type': 'boolean',
|
||||
'description': 'Whether the skill should be considered for automatic activation. Defaults to true.',
|
||||
},
|
||||
},
|
||||
'required': ['name', 'instructions'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_list_skills_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=LIST_SKILLS_TOOL_NAME,
|
||||
human_desc='List managed skills',
|
||||
description='List all managed skills so you can inspect what already exists before creating, updating, or deleting one.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_get_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=GET_SKILL_TOOL_NAME,
|
||||
human_desc='Get a managed skill',
|
||||
description='Fetch one managed skill by name, including its current metadata and instructions, without relying on /workspace or skill activation.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Existing skill name to fetch.',
|
||||
},
|
||||
},
|
||||
'required': ['name'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_update_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=UPDATE_SKILL_TOOL_NAME,
|
||||
human_desc='Update a managed skill',
|
||||
description=(
|
||||
'Update an existing managed skill directly in the skills store without using /workspace. '
|
||||
'Use this for prompt-only skills or for metadata and instruction changes to an existing skill. '
|
||||
'Pure prompt skills should remain editable through managed skill tools instead of depending on box.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Existing skill name to update.',
|
||||
},
|
||||
'display_name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional new human-friendly display name.',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'description': 'Optional new concise description.',
|
||||
},
|
||||
'instructions': {
|
||||
'type': 'string',
|
||||
'description': 'Optional replacement SKILL.md body instructions.',
|
||||
},
|
||||
'auto_activate': {
|
||||
'type': 'boolean',
|
||||
'description': 'Optional new auto_activate value.',
|
||||
},
|
||||
},
|
||||
'required': ['name'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_delete_skill_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=DELETE_SKILL_TOOL_NAME,
|
||||
human_desc='Delete a managed skill',
|
||||
description='Delete an existing managed skill by name from the managed skills store.',
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Existing skill name to delete.',
|
||||
},
|
||||
},
|
||||
'required': ['name'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_import_skill_from_directory_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=IMPORT_SKILL_FROM_DIRECTORY_TOOL_NAME,
|
||||
human_desc='Import skill from workspace directory',
|
||||
description=(
|
||||
'Import a skill package from a directory under /workspace into the managed skills store. '
|
||||
'Use this after cloning or preparing a skill repository in the default workspace. '
|
||||
'This is for file-based skills that actually need scripts, assets, or extra files. '
|
||||
'Pure prompt skills should use create_skill or update_skill instead of depending on box. '
|
||||
'If the source directory is already under the managed skills root, it will be registered in place instead of copied again.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'path': {
|
||||
'type': 'string',
|
||||
'description': 'Directory path under /workspace that contains a skill package or a nested SKILL.md.',
|
||||
},
|
||||
'name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional skill name override. Defaults to the scanned skill name.',
|
||||
},
|
||||
'display_name': {
|
||||
'type': 'string',
|
||||
'description': 'Optional display name override.',
|
||||
},
|
||||
'description': {
|
||||
'type': 'string',
|
||||
'description': 'Optional description override.',
|
||||
},
|
||||
'instructions': {
|
||||
'type': 'string',
|
||||
'description': 'Optional instructions override.',
|
||||
},
|
||||
'auto_activate': {
|
||||
'type': 'boolean',
|
||||
'description': 'Optional auto_activate override.',
|
||||
},
|
||||
},
|
||||
'required': ['path'],
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
|
||||
def _build_reload_skills_tool(self) -> resource_tool.LLMTool:
|
||||
return resource_tool.LLMTool(
|
||||
name=RELOAD_SKILLS_TOOL_NAME,
|
||||
human_desc='Reload filesystem skills',
|
||||
description=(
|
||||
'Reload skills from the filesystem after using the standard exec/read/write/edit tools '
|
||||
'to create, rename, or modify skill packages under the managed skills directory.'
|
||||
),
|
||||
parameters={
|
||||
'type': 'object',
|
||||
'properties': {},
|
||||
'additionalProperties': False,
|
||||
},
|
||||
func=lambda parameters: parameters,
|
||||
)
|
||||
@@ -8,7 +8,12 @@ from langbot_plugin.api.entities.events import pipeline_query
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core import app
|
||||
from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, native as native_loader, plugin as plugin_loader
|
||||
from langbot.pkg.provider.tools.loaders import (
|
||||
mcp as mcp_loader,
|
||||
native as native_loader,
|
||||
plugin as plugin_loader,
|
||||
skill_authoring as skill_authoring_loader,
|
||||
)
|
||||
|
||||
|
||||
class ToolManager:
|
||||
@@ -19,6 +24,7 @@ class ToolManager:
|
||||
native_tool_loader: native_loader.NativeToolLoader
|
||||
plugin_tool_loader: plugin_loader.PluginToolLoader
|
||||
mcp_tool_loader: mcp_loader.MCPLoader
|
||||
skill_authoring_tool_loader: skill_authoring_loader.SkillAuthoringToolLoader
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
self.ap = ap
|
||||
@@ -26,7 +32,12 @@ class ToolManager:
|
||||
async def initialize(self):
|
||||
from langbot.pkg.utils import importutil
|
||||
from langbot.pkg.provider.tools import loaders
|
||||
from langbot.pkg.provider.tools.loaders import mcp as mcp_loader, native as native_loader, plugin as plugin_loader
|
||||
from langbot.pkg.provider.tools.loaders import (
|
||||
mcp as mcp_loader,
|
||||
native as native_loader,
|
||||
plugin as plugin_loader,
|
||||
skill_authoring as skill_authoring_loader,
|
||||
)
|
||||
|
||||
importutil.import_modules_in_pkg(loaders)
|
||||
|
||||
@@ -36,21 +47,26 @@ class ToolManager:
|
||||
await self.plugin_tool_loader.initialize()
|
||||
self.mcp_tool_loader = mcp_loader.MCPLoader(self.ap)
|
||||
await self.mcp_tool_loader.initialize()
|
||||
self.skill_authoring_tool_loader = skill_authoring_loader.SkillAuthoringToolLoader(self.ap)
|
||||
await self.skill_authoring_tool_loader.initialize()
|
||||
|
||||
async def get_all_tools(
|
||||
self, bound_plugins: list[str] | None = None, bound_mcp_servers: list[str] | None = None
|
||||
self,
|
||||
bound_plugins: list[str] | None = None,
|
||||
bound_mcp_servers: list[str] | None = None,
|
||||
include_skill_authoring: bool = False,
|
||||
) -> list[resource_tool.LLMTool]:
|
||||
"""获取所有函数"""
|
||||
all_functions: list[resource_tool.LLMTool] = []
|
||||
|
||||
all_functions.extend(await self.native_tool_loader.get_tools())
|
||||
if include_skill_authoring:
|
||||
all_functions.extend(await self.skill_authoring_tool_loader.get_tools())
|
||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
||||
|
||||
return all_functions
|
||||
|
||||
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
"""生成函数列表"""
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
@@ -67,28 +83,6 @@ class ToolManager:
|
||||
return tools
|
||||
|
||||
async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
"""为anthropic生成函数列表
|
||||
|
||||
e.g.
|
||||
|
||||
[
|
||||
{
|
||||
"name": "get_stock_price",
|
||||
"description": "Get the current stock price for a given ticker symbol.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": {
|
||||
"type": "string",
|
||||
"description": "The stock ticker symbol, e.g. AAPL for Apple Inc."
|
||||
}
|
||||
},
|
||||
"required": ["ticker"]
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
@@ -102,19 +96,18 @@ class ToolManager:
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
"""执行函数调用"""
|
||||
|
||||
if await self.native_tool_loader.has_tool(name):
|
||||
return await self.native_tool_loader.invoke_tool(name, parameters, query)
|
||||
elif await self.plugin_tool_loader.has_tool(name):
|
||||
if await self.plugin_tool_loader.has_tool(name):
|
||||
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
|
||||
elif await self.mcp_tool_loader.has_tool(name):
|
||||
if await self.mcp_tool_loader.has_tool(name):
|
||||
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
|
||||
else:
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
if await self.skill_authoring_tool_loader.has_tool(name):
|
||||
return await self.skill_authoring_tool_loader.invoke_tool(name, parameters, query)
|
||||
raise ValueError(f'未找到工具: {name}')
|
||||
|
||||
async def shutdown(self):
|
||||
"""关闭所有工具"""
|
||||
await self.native_tool_loader.shutdown()
|
||||
await self.plugin_tool_loader.shutdown()
|
||||
await self.mcp_tool_loader.shutdown()
|
||||
await self.skill_authoring_tool_loader.shutdown()
|
||||
|
||||
Reference in New Issue
Block a user