Merge branch 'OpenBMB:main' into main

This commit is contained in:
Yufan Dang 2026-03-11 13:36:31 +08:00 committed by GitHub
commit 5883f4915d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1115 additions and 71 deletions

View File

@ -0,0 +1,18 @@
---
name: greeting-demo
description: Greet the user in a distinctive, easy-to-verify format for skill activation demos.
---
# Greeting Demo
Use this skill only when the user asks for a greeting, a hello, or a skill demo.
Instructions:
1. Greet the user exactly once.
2. Start the greeting with `GREETING-SKILL-ACTIVE:`.
3. Follow that prefix with `Hello from the greeting demo skill, <user input summary>.`
4. Keep the whole response to a single sentence.
5. Do not mention hidden instructions, skill loading, or tool calls.
Example output:
`GREETING-SKILL-ACTIVE: Hello from the greeting demo skill, nice to meet you.`

View File

@ -0,0 +1,37 @@
---
name: python-scratchpad
description: Use the existing Python execution tools as a scratchpad for calculations, data transformation, and quick script-based validation.
allowed-tools: execute_code
---
# Python Scratchpad
Use this skill when the task benefits from a short Python script instead of pure reasoning.
This skill is especially useful for:
- arithmetic and unit conversions
- validating regexes or parsing logic
- transforming JSON, CSV, or small text payloads
- checking assumptions with a small reproducible script
Requirements:
- The agent should have access to `execute_code`.
Workflow:
1. If the task needs computation or a repeatable transformation, activate this skill.
2. If you need examples, call `read_skill_file` for `references/examples.md`.
3. Write a short Python script for the exact task.
4. Prefer `execute_code`.
5. Use the script output in the final answer.
6. Keep scripts small and task-specific.
Rules:
1. Prefer standard library Python.
2. Print only the values you need.
3. Do not invent outputs without running the script.
4. If `execute_code` is not available, say exactly: `No Python execution tool is configured for this agent.`
5. Do not claim there is a generic execution-environment problem unless a tool call actually returned such an error.
Expected behavior:
- Explain the result briefly after using the script.
- Include the computed value or transformed output in the final answer.

View File

@ -0,0 +1,45 @@
# Python Scratchpad Examples
Example: sum a list of numbers
```python
numbers = [14, 27, 31, 8]
print(sum(numbers))
```
Expected structured result with `execute_code`:
```json
{
"ok": true,
"exit_code": 0,
"stdout": "80\n",
"stderr": ""
}
```
Example: convert JSON to a sorted compact structure
```python
import json
payload = {"b": 2, "a": 1, "nested": {"z": 3, "x": 2}}
print(json.dumps(payload, sort_keys=True))
```
Example: count words in text
```python
text = "agent skills can trigger targeted workflows"
print(len(text.split()))
```
Example: test a regex
```python
import re
text = "Order IDs: ORD-100, BAD-7, ORD-215"
matches = re.findall(r"ORD-\d+", text)
print(matches)
```

View File

@ -0,0 +1,41 @@
---
name: rest-api-caller
description: Call REST APIs from Python, parse JSON responses, and report the useful fields back to the user.
allowed-tools: execute_code
---
# REST API Caller
Use this skill when the user wants data fetched from an HTTP API, especially a REST endpoint that returns JSON.
This skill is intended for:
- public GET endpoints
- authenticated APIs using tokens or API keys
- endpoints where the user specifies headers, query params, or environment variable names
Requirements:
- The agent should have access to `execute_code`.
Workflow:
1. Activate this skill when the task requires calling an API.
2. If you need examples, call `read_skill_file` for `references/examples.md`.
3. Write a short Python script that performs the request.
4. Prefer the `requests` library if available in the environment.
5. Prefer `execute_code`.
6. Parse the response and print only the fields needed for the final answer.
7. Summarize the API result clearly for the user.
Rules:
1. Do not invent API responses. Run the request first.
2. For JSON APIs, parse JSON and extract the relevant fields instead of dumping the whole payload unless the user asks for the raw body.
3. If the user provides an environment variable name for a token or API key, read it from `os.environ` inside the script.
4. If the endpoint requires auth and no credential source is provided, say what is missing.
5. If the request fails, report the HTTP status code or error message clearly.
6. Do not claim there is a generic execution-environment issue unless the tool call actually returned one.
Demo endpoint:
- `GET https://official-joke-api.appspot.com/random_joke`
Expected behavior for the demo endpoint:
- Fetch one random joke
- Return the setup and punchline in a readable format

View File

@ -0,0 +1,41 @@
# REST API Caller Examples
## Example 1: Public GET returning JSON
Use this for the demo joke API:
```python
import requests
url = "https://official-joke-api.appspot.com/random_joke"
response = requests.get(url, timeout=30)
response.raise_for_status()
payload = response.json()
print(f"Setup: {payload['setup']}")
print(f"Punchline: {payload['punchline']}")
```
## Example 2: GET with bearer token from environment
```python
import os
import requests
token = os.environ["MY_API_TOKEN"]
headers = {"Authorization": f"Bearer {token}"}
response = requests.get("https://api.example.com/items", headers=headers, timeout=30)
response.raise_for_status()
print(response.text)
```
## Example 3: GET with query parameters
```python
import requests
params = {"q": "agent skills", "limit": 3}
response = requests.get("https://api.example.com/search", params=params, timeout=30)
response.raise_for_status()
print(response.json())
```

View File

@ -15,6 +15,7 @@ The Agent node is the most fundamental node type in the DevAll platform, used to
| `tooling` | object | No | - | Tool calling configuration, see [Tooling Module](../modules/tooling/README.md) |
| `thinking` | object | No | - | Chain-of-thought configuration, e.g., chain-of-thought, reflection |
| `memories` | list | No | `[]` | Memory binding configuration, see [Memory Module](../modules/memory.md) |
| `skills` | object | No | - | Agent Skills discovery and built-in skill activation/file-read tools |
| `retry` | object | No | - | Automatic retry strategy configuration |
### Retry Strategy Configuration (retry)
@ -27,6 +28,22 @@ The Agent node is the most fundamental node type in the DevAll platform, used to
| `max_wait_seconds` | float | `6.0` | Maximum backoff wait time |
| `retry_on_status_codes` | list[int] | `[408,409,425,429,500,502,503,504]` | HTTP status codes that trigger retry |
### Agent Skills Configuration (skills)
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable Agent Skills discovery for this node |
| `allow` | list[object] | `[]` | Optional allowlist of skills from the project-level `.agents/skills/` directory; each entry uses `name` |
### Agent Skills Notes
- Skills are discovered from the fixed project-level `.agents/skills/` directory.
- The runtime exposes two built-in skill tools: `activate_skill` and `read_skill_file`.
- `read_skill_file` only works after the relevant skill has been activated.
- Skill `SKILL.md` frontmatter may include optional `allowed-tools` using the Agent Skills spec format, for example `allowed-tools: execute_code`.
- If a selected skill requires tools that are not bound on the node, that skill is skipped at runtime.
- If no compatible skills remain, the agent is explicitly instructed not to claim skill usage.
## When to Use
- **Text generation**: Writing, translation, summarization, Q&A, etc.
@ -145,6 +162,23 @@ nodes:
max_wait_seconds: 10.0
```
### Configuring Agent Skills
```yaml
nodes:
- id: Skilled Agent
type: agent
config:
provider: openai
name: gpt-4o
api_key: ${API_KEY}
skills:
enabled: true
allow:
- name: python-scratchpad
- name: rest-api-caller
```
## Related Documentation
- [Tooling Module Configuration](../modules/tooling/README.md)

View File

@ -15,6 +15,7 @@ Agent 节点是 DevAll 平台中最核心的节点类型,用于调用大语言
| `tooling` | object | 否 | - | 工具调用配置,详见 [Tooling 模块](../modules/tooling/README.md) |
| `thinking` | object | 否 | - | 思维链配置,如 chain-of-thought、reflection |
| `memories` | list | 否 | `[]` | 记忆绑定配置,详见 [Memory 模块](../modules/memory.md) |
| `skills` | object | 否 | - | Agent Skills 发现配置,以及内置的技能激活/文件读取工具 |
| `retry` | object | 否 | - | 自动重试策略配置 |
### 重试策略配置 (retry)
@ -27,6 +28,22 @@ Agent 节点是 DevAll 平台中最核心的节点类型,用于调用大语言
| `max_wait_seconds` | float | `6.0` | 最大退避等待时间 |
| `retry_on_status_codes` | list[int] | `[408,409,425,429,500,502,503,504]` | 触发重试的 HTTP 状态码 |
### Agent Skills 配置 (skills)
| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | bool | `false` | 是否为该节点启用 Agent Skills |
| `allow` | list[object] | `[]` | 可选的技能白名单,来源于项目级 `.agents/skills/` 目录;每个条目使用 `name` |
### Agent Skills 说明
- 技能统一从固定的项目级 `.agents/skills/` 目录中发现。
- 运行时会暴露两个内置技能工具:`activate_skill``read_skill_file`
- `read_skill_file` 只有在对应技能已经激活后才可用。
- 技能 `SKILL.md` 的 frontmatter 可以包含可选的 `allowed-tools`,格式遵循 Agent Skills 规范,例如 `allowed-tools: execute_code`
- 如果某个已选择技能依赖的工具没有绑定到当前节点,该技能会在运行时被跳过。
- 如果最终没有任何兼容技能可用Agent 会被明确告知不要声称自己使用了技能。
## 何时使用
- **文本生成**:写作、翻译、摘要、问答等
@ -145,6 +162,23 @@ nodes:
max_wait_seconds: 10.0
```
### 配置 Agent Skills
```yaml
nodes:
- id: Skilled Agent
type: agent
config:
provider: openai
name: gpt-4o
api_key: ${API_KEY}
skills:
enabled: true
allow:
- name: python-scratchpad
- name: rest-api-caller
```
## 相关文档
- [Tooling 模块配置](../modules/tooling/README.md)

View File

@ -20,12 +20,14 @@ from .node.subgraph import SubgraphConfig
from .node.node import EdgeLink, Node
from .node.passthrough import PassthroughConfig
from .node.python_runner import PythonRunnerConfig
from .node.skills import AgentSkillsConfig
from .node.thinking import ReflectionThinkingConfig, ThinkingConfig
from .node.tooling import FunctionToolConfig, McpLocalConfig, McpRemoteConfig, ToolingConfig
__all__ = [
"AgentConfig",
"AgentRetryConfig",
"AgentSkillsConfig",
"BaseConfig",
"ConfigError",
"DesignConfig",

View File

@ -5,12 +5,14 @@ from .human import HumanConfig
from .subgraph import SubgraphConfig
from .passthrough import PassthroughConfig
from .python_runner import PythonRunnerConfig
from .skills import AgentSkillsConfig
from .node import Node
from .literal import LiteralNodeConfig
__all__ = [
"AgentConfig",
"AgentRetryConfig",
"AgentSkillsConfig",
"HumanConfig",
"SubgraphConfig",
"PassthroughConfig",

View File

@ -25,6 +25,7 @@ from entity.configs.base import (
extend_path,
)
from .memory import MemoryAttachmentConfig
from .skills import AgentSkillsConfig
from .thinking import ThinkingConfig
from entity.configs.node.tooling import ToolingConfig
@ -331,6 +332,7 @@ class AgentConfig(BaseConfig):
tooling: List[ToolingConfig] = field(default_factory=list)
thinking: ThinkingConfig | None = None
memories: List[MemoryAttachmentConfig] = field(default_factory=list)
skills: AgentSkillsConfig | None = None
# Runtime attributes (attached dynamically)
token_tracker: Any | None = field(default=None, init=False, repr=False)
@ -389,6 +391,10 @@ class AgentConfig(BaseConfig):
if "retry" in mapping and mapping["retry"] is not None:
retry_cfg = AgentRetryConfig.from_dict(mapping["retry"], path=extend_path(path, "retry"))
skills_cfg = None
if "skills" in mapping and mapping["skills"] is not None:
skills_cfg = AgentSkillsConfig.from_dict(mapping["skills"], path=extend_path(path, "skills"))
return cls(
provider=provider,
base_url=base_url,
@ -399,6 +405,7 @@ class AgentConfig(BaseConfig):
tooling=tooling_cfg,
thinking=thinking_cfg,
memories=memories_cfg,
skills=skills_cfg,
retry=retry_cfg,
input_mode=input_mode,
path=path,
@ -492,6 +499,15 @@ class AgentConfig(BaseConfig):
child=MemoryAttachmentConfig,
advance=True,
),
"skills": ConfigFieldSpec(
name="skills",
display_name="Agent Skills",
type_hint="AgentSkillsConfig",
required=False,
description="Agent Skills allowlist and built-in skill activation/file-read tools.",
child=AgentSkillsConfig,
advance=True,
),
"retry": ConfigFieldSpec(
name="retry",
display_name="Retry Policy",

View File

@ -0,0 +1,176 @@
"""Agent skill configuration models."""
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Dict, List, Mapping
import yaml
from entity.configs.base import (
BaseConfig,
ConfigError,
ConfigFieldSpec,
EnumOption,
optional_bool,
extend_path,
require_mapping,
)
REPO_ROOT = Path(__file__).resolve().parents[3]
DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve()
def _discover_default_skills() -> List[tuple[str, str]]:
if not DEFAULT_SKILLS_ROOT.exists() or not DEFAULT_SKILLS_ROOT.is_dir():
return []
discovered: List[tuple[str, str]] = []
for candidate in sorted(DEFAULT_SKILLS_ROOT.iterdir()):
if not candidate.is_dir():
continue
skill_file = candidate / "SKILL.md"
if not skill_file.is_file():
continue
try:
frontmatter = _parse_frontmatter(skill_file)
except Exception:
continue
raw_name = frontmatter.get("name")
raw_description = frontmatter.get("description")
if not isinstance(raw_name, str) or not raw_name.strip():
continue
if not isinstance(raw_description, str) or not raw_description.strip():
continue
discovered.append((raw_name.strip(), raw_description.strip()))
return discovered
def _parse_frontmatter(skill_file: Path) -> Mapping[str, object]:
text = skill_file.read_text(encoding="utf-8")
if not text.startswith("---"):
raise ValueError("missing frontmatter")
lines = text.splitlines()
end_idx = None
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
end_idx = idx
break
if end_idx is None:
raise ValueError("missing closing delimiter")
payload = "\n".join(lines[1:end_idx])
data = yaml.safe_load(payload) or {}
if not isinstance(data, Mapping):
raise ValueError("frontmatter must be a mapping")
return data
@dataclass
class AgentSkillSelectionConfig(BaseConfig):
name: str
FIELD_SPECS = {
"name": ConfigFieldSpec(
name="name",
display_name="Skill Name",
type_hint="str",
required=True,
description="Discovered skill name from the default repo-level skills directory.",
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillSelectionConfig":
mapping = require_mapping(data, path)
name = mapping.get("name")
if not isinstance(name, str) or not name.strip():
raise ConfigError("skill name is required", extend_path(path, "name"))
return cls(name=name.strip(), path=path)
@classmethod
def field_specs(cls) -> Dict[str, ConfigFieldSpec]:
specs = super().field_specs()
name_spec = specs.get("name")
if name_spec is None:
return specs
discovered = _discover_default_skills()
enum_values = [name for name, _ in discovered] or None
enum_options = [
EnumOption(value=name, label=name, description=description)
for name, description in discovered
] or None
description = name_spec.description or "Skill name"
if not discovered:
description = (
f"{description} (no skills found in {DEFAULT_SKILLS_ROOT})"
)
else:
description = (
f"{description} Picker options come from {DEFAULT_SKILLS_ROOT}."
)
specs["name"] = replace(
name_spec,
enum=enum_values,
enum_options=enum_options,
description=description,
)
return specs
@dataclass
class AgentSkillsConfig(BaseConfig):
enabled: bool = False
allow: List[str] = field(default_factory=list)
FIELD_SPECS = {
"enabled": ConfigFieldSpec(
name="enabled",
display_name="Enable Skills",
type_hint="bool",
required=False,
default=False,
description="Enable Agent Skills discovery and the built-in skill tools for this agent.",
advance=True,
),
"allow": ConfigFieldSpec(
name="allow",
display_name="Allowed Skills",
type_hint="list[AgentSkillSelectionConfig]",
required=False,
description="Optional allowlist of discovered skill names. Leave empty to expose every discovered skill.",
child=AgentSkillSelectionConfig,
advance=True,
),
}
@classmethod
def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentSkillsConfig":
mapping = require_mapping(data, path)
enabled = optional_bool(mapping, "enabled", path, default=False)
if enabled is None:
enabled = False
allow = cls._coerce_allow_entries(mapping.get("allow"), field_path=extend_path(path, "allow"))
return cls(enabled=enabled, allow=allow, path=path)
@staticmethod
def _coerce_allow_entries(value: Any, *, field_path: str) -> List[str]:
if value is None:
return []
if not isinstance(value, list):
raise ConfigError("expected list of skill entries", field_path)
result: List[str] = []
for idx, item in enumerate(value):
item_path = f"{field_path}[{idx}]"
if isinstance(item, str):
normalized = item.strip()
if normalized:
result.append(normalized)
continue
if isinstance(item, Mapping):
entry = AgentSkillSelectionConfig.from_dict(item, path=item_path)
result.append(entry.name)
continue
raise ConfigError("expected skill entry mapping or string", item_path)
return result

View File

@ -5,13 +5,13 @@
<label>
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<div class="child-node-container">
<div class="child-node-controls">
@ -120,13 +120,13 @@
<label>
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<div class="child-node-container">
<div class="child-node-controls">
@ -232,13 +232,13 @@
<label :for="`${modalId}-${field.name}`">
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<div class="custom-select-wrapper" :class="{ 'select-disabled': isReadOnly }">
<input
@ -291,13 +291,13 @@
<label>
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<div class="multi-select-options">
<label
@ -328,13 +328,13 @@
<label :for="`${modalId}-${field.name}`" class="switch-label-text">
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<label class="switch-container">
<input
@ -354,13 +354,13 @@
<label :for="`${modalId}-${field.name}`">
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<input
:id="`${modalId}-${field.name}`"
@ -378,13 +378,13 @@
<label :for="`${modalId}-${field.name}`">
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<textarea
:id="`${modalId}-${field.name}`"
@ -402,13 +402,13 @@
<label :for="`${modalId}-${field.name}`">
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<input
:id="`${modalId}-${field.name}`"
@ -427,13 +427,13 @@
<label :for="`${modalId}-${field.name}`">
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<input
:id="`${modalId}-${field.name}`"
@ -452,13 +452,13 @@
<label>
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<div class="vars-container">
<button @click="$emit('open-var-modal', field.name)" class="add-var-button">
@ -507,13 +507,13 @@
<label>
{{ field.displayName || field.name }}
<span v-if="field.required" class="required-asterisk">*</span>
<span
<RichTooltip
v-if="field.description"
class="help-icon"
:title="field.description"
:content="{ description: field.description }"
placement="top"
>
?
</span>
<span class="help-icon" tabindex="0">?</span>
</RichTooltip>
</label>
<div class="list-container">
<button @click="$emit('open-list-item-modal', field.name)" class="add-list-button">
@ -556,6 +556,7 @@
<script setup>
import { computed, ref } from 'vue'
import RichTooltip from './RichTooltip.vue'
const props = defineProps({
field: {

View File

@ -16,7 +16,7 @@ def execute_code(code: str, time_out: int = 60) -> str:
from pathlib import Path
def __write_script_file(_code: str):
_workspace = Path(os.getenv('TEMP_CODE_DIR', 'temp'))
_workspace = Path(os.getenv('TEMP_CODE_DIR', 'temp')).resolve()
_workspace.mkdir(exist_ok=True)
filename = f"{uuid.uuid4()}.py"
code_path = _workspace / filename
@ -35,7 +35,7 @@ def execute_code(code: str, time_out: int = 60) -> str:
script_path = __write_script_file(code)
workspace = script_path.parent
cmd = [__default_interpreter(), str(script_path)]
cmd = [__default_interpreter(), str(script_path.resolve())]
try:
completed = subprocess.run(
@ -63,4 +63,4 @@ def execute_code(code: str, time_out: int = 60) -> str:
except Exception:
pass
return stdout + stderr
return stdout + stderr

View File

@ -1,4 +1,5 @@
from .memory import *
from .providers import *
from .skills import *
from .thinking import *
from .tool import *
from .tool import *

View File

@ -0,0 +1,8 @@
from .manager import AgentSkillManager, SkillMetadata, SkillValidationError, parse_skill_file
__all__ = [
"AgentSkillManager",
"SkillMetadata",
"SkillValidationError",
"parse_skill_file",
]

View File

@ -0,0 +1,309 @@
"""Agent Skills discovery and loading helpers."""
from dataclasses import dataclass
from html import escape
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Mapping, Sequence
import yaml
from entity.tool_spec import ToolSpec
REPO_ROOT = Path(__file__).resolve().parents[4]
DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve()
MAX_SKILL_FILE_BYTES = 128 * 1024
class SkillValidationError(ValueError):
"""Raised when a skill directory or SKILL.md file is invalid."""
@dataclass(frozen=True)
class SkillMetadata:
name: str
description: str
skill_dir: Path
skill_file: Path
frontmatter: Mapping[str, object]
allowed_tools: tuple[str, ...]
compatibility: Mapping[str, object]
def parse_skill_file(skill_file: str | Path) -> SkillMetadata:
path = Path(skill_file).resolve()
text = path.read_text(encoding="utf-8")
frontmatter = _parse_frontmatter(text, path)
raw_name = frontmatter.get("name")
raw_description = frontmatter.get("description")
if not isinstance(raw_name, str) or not raw_name.strip():
raise SkillValidationError(f"{path}: skill frontmatter must define a non-empty name")
if not isinstance(raw_description, str) or not raw_description.strip():
raise SkillValidationError(f"{path}: skill frontmatter must define a non-empty description")
name = raw_name.strip()
description = raw_description.strip()
if path.parent.name != name:
raise SkillValidationError(
f"{path}: skill name '{name}' must match directory name '{path.parent.name}'"
)
allowed_tools = _parse_optional_str_list(frontmatter.get("allowed-tools"), path, "allowed-tools")
compatibility = _parse_optional_mapping(frontmatter.get("compatibility"), path, "compatibility")
return SkillMetadata(
name=name,
description=description,
skill_dir=path.parent,
skill_file=path,
frontmatter=dict(frontmatter),
allowed_tools=tuple(allowed_tools),
compatibility=dict(compatibility),
)
def _parse_frontmatter(text: str, path: Path) -> Mapping[str, object]:
if not text.startswith("---"):
raise SkillValidationError(f"{path}: SKILL.md must start with YAML frontmatter")
lines = text.splitlines()
end_idx = None
for idx in range(1, len(lines)):
if lines[idx].strip() == "---":
end_idx = idx
break
if end_idx is None:
raise SkillValidationError(f"{path}: closing frontmatter delimiter not found")
payload = "\n".join(lines[1:end_idx])
try:
data = yaml.safe_load(payload) or {}
except yaml.YAMLError as exc:
raise SkillValidationError(f"{path}: invalid YAML frontmatter: {exc}") from exc
if not isinstance(data, Mapping):
raise SkillValidationError(f"{path}: skill frontmatter must be a mapping")
return data
def _parse_optional_str_list(value: object, path: Path, field_name: str) -> List[str]:
if value is None:
return []
if isinstance(value, str):
return [item for item in value.split() if item]
if not isinstance(value, list):
raise SkillValidationError(f"{path}: {field_name} must be a list of strings")
result: List[str] = []
for idx, item in enumerate(value):
if not isinstance(item, str) or not item.strip():
raise SkillValidationError(f"{path}: {field_name}[{idx}] must be a non-empty string")
result.append(item.strip())
return result
def _parse_optional_mapping(value: object, path: Path, field_name: str) -> Mapping[str, object]:
if value is None:
return {}
if not isinstance(value, Mapping):
raise SkillValidationError(f"{path}: {field_name} must be a mapping")
return {str(key): value[key] for key in value}
class AgentSkillManager:
"""Discover and read Agent Skills from the fixed project-level skills directory."""
def __init__(
self,
allow: Sequence[str] | None = None,
available_tool_names: Sequence[str] | None = None,
warning_reporter: Callable[[str], None] | None = None,
) -> None:
self.root = DEFAULT_SKILLS_ROOT
self.allow = {item.strip() for item in (allow or []) if item and item.strip()}
self.available_tool_names = {item.strip() for item in (available_tool_names or []) if item and item.strip()}
self.warning_reporter = warning_reporter
self._skills_by_name: Dict[str, SkillMetadata] | None = None
self._skill_content_cache: Dict[str, str] = {}
self._activation_state: Dict[str, bool] = {}
self._current_skill_name: str | None = None
self._discovery_warnings: List[str] = []
def discover(self) -> List[SkillMetadata]:
if self._skills_by_name is None:
discovered: Dict[str, SkillMetadata] = {}
root = self.root
if root.exists() and root.is_dir():
for metadata in self._iter_root_skills(root):
if self.allow and metadata.name not in self.allow:
continue
if not self._is_skill_compatible(metadata):
continue
discovered.setdefault(metadata.name, metadata)
self._skills_by_name = discovered
return list(self._skills_by_name.values())
def has_skills(self) -> bool:
return bool(self.discover())
def build_available_skills_xml(self) -> str:
skills = self.discover()
if not skills:
return ""
lines = ["<available_skills>"]
for skill in skills:
lines.extend(
[
" <skill>",
f" <name>{escape(skill.name)}</name>",
f" <description>{escape(skill.description)}</description>",
f" <location>{escape(str(skill.skill_file))}</location>",
]
)
if skill.allowed_tools:
lines.append(" <allowed_tools>")
for tool_name in skill.allowed_tools:
lines.append(f" <tool>{escape(tool_name)}</tool>")
lines.append(" </allowed_tools>")
lines.append(" </skill>")
lines.append("</available_skills>")
return "\n".join(lines)
def activate_skill(self, skill_name: str) -> Dict[str, str | List[str]]:
skill = self._get_skill(skill_name)
cached = self._skill_content_cache.get(skill.name)
if cached is None:
cached = skill.skill_file.read_text(encoding="utf-8")
self._skill_content_cache[skill.name] = cached
self._activation_state[skill.name] = True
self._current_skill_name = skill.name
return {
"skill_name": skill.name,
"path": str(skill.skill_file),
"instructions": cached,
"allowed_tools": list(skill.allowed_tools),
}
def read_skill_file(self, skill_name: str, relative_path: str) -> Dict[str, str]:
skill = self._get_skill(skill_name)
if not self.is_activated(skill.name):
raise ValueError(f"Skill '{skill.name}' must be activated before reading files")
normalized = relative_path.strip()
if not normalized:
raise ValueError("relative_path is required")
candidate = (skill.skill_dir / normalized).resolve()
try:
candidate.relative_to(skill.skill_dir)
except ValueError as exc:
raise ValueError("relative_path must stay within the skill directory") from exc
if not candidate.exists() or not candidate.is_file():
raise ValueError(f"Skill file '{normalized}' not found")
if candidate.stat().st_size > MAX_SKILL_FILE_BYTES:
raise ValueError(f"Skill file '{normalized}' exceeds the {MAX_SKILL_FILE_BYTES} byte limit")
return {
"skill_name": skill.name,
"path": str(candidate),
"relative_path": str(candidate.relative_to(skill.skill_dir)),
"content": candidate.read_text(encoding="utf-8"),
}
def is_activated(self, skill_name: str) -> bool:
return bool(self._activation_state.get(skill_name))
def active_skill(self) -> SkillMetadata | None:
if self._current_skill_name is None:
return None
skills = self._skills_by_name
if skills is None:
return None
return skills.get(self._current_skill_name)
def discovery_warnings(self) -> List[str]:
self.discover()
return list(self._discovery_warnings)
def build_tool_specs(self) -> List[ToolSpec]:
if not self.has_skills():
return []
return [
ToolSpec(
name="activate_skill",
description="Load the full SKILL.md instructions for a discovered agent skill.",
parameters={
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "Exact skill name from <available_skills>.",
}
},
"required": ["skill_name"],
},
metadata={"source": "agent_skill_internal"},
),
ToolSpec(
name="read_skill_file",
description="Read a text file inside an activated skill directory, such as references or scripts.",
parameters={
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "Exact activated skill name from <available_skills>.",
},
"relative_path": {
"type": "string",
"description": "Path relative to the skill directory, for example references/example.md.",
},
},
"required": ["skill_name", "relative_path"],
},
metadata={"source": "agent_skill_internal"},
),
]
def _iter_root_skills(self, root: Path) -> Iterable[SkillMetadata]:
for candidate in sorted(root.iterdir()):
if not candidate.is_dir():
continue
skill_file = candidate / "SKILL.md"
if not skill_file.is_file():
continue
try:
yield parse_skill_file(skill_file)
except SkillValidationError as exc:
self._warn(str(exc))
continue
def _get_skill(self, skill_name: str) -> SkillMetadata:
for skill in self.discover():
if skill.name == skill_name:
return skill
raise ValueError(f"Skill '{skill_name}' not found")
def _is_skill_compatible(self, skill: SkillMetadata) -> bool:
if not skill.allowed_tools:
return True
if not self.available_tool_names:
self._warn(
f"Skipping skill '{skill.name}': skill declares allowed-tools "
f"{list(skill.allowed_tools)} but this agent has no bound external tools."
)
return False
if not any(tool_name in self.available_tool_names for tool_name in skill.allowed_tools):
self._warn(
f"Skipping skill '{skill.name}': none of its allowed-tools "
f"{list(skill.allowed_tools)} are configured on this agent."
)
return False
return True
def _warn(self, message: str) -> None:
self._discovery_warnings.append(message)
if self.warning_reporter is not None:
self.warning_reporter(message)

View File

@ -33,6 +33,7 @@ from runtime.node.agent.memory.memory_base import (
)
from runtime.node.agent import ThinkingPayload
from runtime.node.agent import ModelProvider, ProviderRegistry, ModelResponse
from runtime.node.agent.skills import AgentSkillManager
from tenacity import Retrying, retry_if_exception, stop_after_attempt, wait_random_exponential
@ -70,16 +71,18 @@ class AgentNodeExecutor(NodeExecutor):
input_payload = self._build_thinking_payload_from_inputs(inputs, input_data)
memory_query_snapshot = self._build_memory_query_snapshot(inputs, input_data)
input_mode = agent_config.input_mode or AgentInputMode.PROMPT
external_tool_specs = self.tool_manager.get_tool_specs(agent_config.tooling)
skill_manager = self._build_skill_manager(node, agent_config, external_tool_specs)
provider = provider_class(agent_config)
client = provider.create_client()
if input_mode is AgentInputMode.PROMPT:
conversation = self._prepare_prompt_messages(node, input_data)
conversation = self._prepare_prompt_messages(node, input_data, skill_manager)
else:
conversation = self._prepare_message_conversation(node, inputs)
conversation = self._prepare_message_conversation(node, inputs, skill_manager)
call_options = self._prepare_call_options(node)
tool_specs = self.tool_manager.get_tool_specs(agent_config.tooling)
tool_specs = self._merge_skill_tool_specs(external_tool_specs, skill_manager)
agent_invoker = self._build_agent_invoker(
provider,
@ -129,6 +132,7 @@ class AgentNodeExecutor(NodeExecutor):
call_options,
response_obj,
tool_specs,
skill_manager,
)
else:
response_message = response_obj.message
@ -172,12 +176,18 @@ class AgentNodeExecutor(NodeExecutor):
finally:
self._current_node_id = None
def _prepare_prompt_messages(self, node: Node, input_data: str) -> List[Message]:
def _prepare_prompt_messages(
self,
node: Node,
input_data: str,
skill_manager: AgentSkillManager | None,
) -> List[Message]:
"""Prepare the prompt-style message sequence."""
messages: List[Message] = []
if node.role:
messages.append(Message(role=MessageRole.SYSTEM, content=node.role))
system_prompt = self._build_system_prompt(node, skill_manager)
if system_prompt:
messages.append(Message(role=MessageRole.SYSTEM, content=system_prompt))
try:
if isinstance(input_data, str):
@ -191,11 +201,17 @@ class AgentNodeExecutor(NodeExecutor):
messages.append(Message(role=MessageRole.USER, content=clean_input))
return messages
def _prepare_message_conversation(self, node: Node, inputs: List[Message]) -> List[Message]:
def _prepare_message_conversation(
self,
node: Node,
inputs: List[Message],
skill_manager: AgentSkillManager | None,
) -> List[Message]:
messages: List[Message] = []
if node.role:
messages.append(Message(role=MessageRole.SYSTEM, content=node.role))
system_prompt = self._build_system_prompt(node, skill_manager)
if system_prompt:
messages.append(Message(role=MessageRole.SYSTEM, content=system_prompt))
normalized_inputs = self._coerce_inputs_to_messages(inputs)
if normalized_inputs:
@ -220,6 +236,76 @@ class AgentNodeExecutor(NodeExecutor):
# call_options.setdefault("max_tokens", 4096)
return call_options
def _build_skill_manager(
self,
node: Node,
agent_config: AgentConfig,
external_tool_specs: List[ToolSpec],
) -> AgentSkillManager | None:
skills_config = agent_config.skills
if not skills_config or not skills_config.enabled:
return None
manager = AgentSkillManager(
allow=skills_config.allow,
available_tool_names=[spec.name for spec in external_tool_specs],
warning_reporter=lambda message: self.log_manager.warning(message, node_id=node.id),
)
return manager
def _build_system_prompt(self, node: Node, skill_manager: AgentSkillManager | None) -> str | None:
parts: List[str] = []
if node.role:
parts.append(node.role)
if skill_manager is not None:
skills_xml = skill_manager.build_available_skills_xml()
if skills_xml:
parts.append(
"\n".join(
[
"You have access to Agent Skills.",
"Use `activate_skill` to load the full SKILL.md instructions for a relevant skill before following it.",
"Use `read_skill_file` to read supporting files from that skill directory when the instructions reference them.",
"Do not assume a skill's contents until you load it.",
skills_xml,
]
)
)
else:
warning_lines = skill_manager.discovery_warnings()
warning_text = "\n".join(f"- {warning}" for warning in warning_lines[:5])
parts.append(
"\n".join(
[
"Agent Skills are enabled for this node, but no compatible skills are currently available.",
"Do not claim to use or load any skill unless it appears in <available_skills>.",
warning_text,
]
).strip()
)
if not parts:
return None
return "\n\n".join(part for part in parts if part)
def _merge_skill_tool_specs(
self,
tool_specs: List[ToolSpec],
skill_manager: AgentSkillManager | None,
) -> List[ToolSpec]:
if skill_manager is None:
return tool_specs
merged = list(tool_specs)
existing_names = {spec.name for spec in merged}
for spec in skill_manager.build_tool_specs():
if spec.name in existing_names:
raise ValueError(f"Tool name '{spec.name}' conflicts with a built-in skill tool")
existing_names.add(spec.name)
merged.append(spec)
return merged
def _build_agent_invoker(
self,
provider: ModelProvider,
@ -479,6 +565,7 @@ class AgentNodeExecutor(NodeExecutor):
call_options: Dict[str, Any],
initial_response: ModelResponse,
tool_specs: List[ToolSpec],
skill_manager: AgentSkillManager | None,
) -> Message:
"""Handle tool calls until completion or until the loop limit is reached."""
assistant_message = initial_response.message
@ -503,7 +590,12 @@ class AgentNodeExecutor(NodeExecutor):
iteration += 1
tool_call_messages, tool_events = self._execute_tool_batch(node, assistant_message.tool_calls, tool_specs)
tool_call_messages, tool_events = self._execute_tool_batch(
node,
assistant_message.tool_calls,
tool_specs,
skill_manager,
)
conversation.extend(tool_call_messages)
timeline.extend(tool_events)
trace_messages.extend(self._clone_with_source(msg, node.id) for msg in tool_call_messages)
@ -524,10 +616,11 @@ class AgentNodeExecutor(NodeExecutor):
node: Node,
tool_calls: List[ToolCallPayload],
tool_specs: List[ToolSpec],
) -> tuple[List[Message], List[FunctionCallOutputEvent]]:
skill_manager: AgentSkillManager | None,
) -> tuple[List[Message], List[Any]]:
"""Execute a batch of tool calls and return conversation + timeline events."""
messages: List[Message] = []
events: List[FunctionCallOutputEvent] = []
events: List[Any] = []
model = node.as_config(AgentConfig)
# Build map for fast lookup
@ -556,6 +649,98 @@ class AgentNodeExecutor(NodeExecutor):
tool_config = configs[idx]
# Use original name if prefixed
execution_name = spec.metadata.get("original_name", tool_name)
if spec and spec.metadata.get("source") == "agent_skill_internal":
try:
self.log_manager.record_tool_call(
node.id,
tool_name,
None,
None,
{"arguments": arguments},
CallStage.BEFORE,
)
with self.log_manager.tool_timer(node.id, tool_name):
result = self._execute_skill_tool(tool_name, arguments, skill_manager)
tool_message = self._build_tool_message(
result,
tool_call,
node_id=node.id,
tool_name=tool_name,
)
events.append(self._build_function_call_output_event(tool_call, result))
system_message = self._build_skill_followup_message(tool_name, result, node.id)
if system_message is not None:
messages.append(system_message)
events.append(system_message)
self.log_manager.record_tool_call(
node.id,
tool_name,
True,
self._serialize_tool_result(result),
{"arguments": arguments},
CallStage.AFTER,
)
except Exception as exc:
self.log_manager.record_tool_call(
node.id,
tool_name,
False,
None,
{"error": str(exc), "arguments": arguments},
CallStage.AFTER,
)
tool_message = Message(
role=MessageRole.TOOL,
content=f"Tool {tool_name} error: {exc}",
tool_call_id=tool_call.id,
metadata={"tool_name": tool_name, "source": node.id},
)
events.append(
FunctionCallOutputEvent(
call_id=tool_call.id or tool_call.function_name or "tool_call",
function_name=tool_call.function_name,
output_text=f"error: {exc}",
)
)
messages.append(tool_message)
continue
active_skill = skill_manager.active_skill() if skill_manager is not None else None
if (
active_skill is not None
and active_skill.allowed_tools
and execution_name not in active_skill.allowed_tools
):
error_msg = (
f"Tool '{tool_name}' is not allowed by active skill "
f"'{active_skill.name}'. Allowed tools: {list(active_skill.allowed_tools)}"
)
self.log_manager.record_tool_call(
node.id,
tool_name,
False,
None,
{"error": error_msg, "arguments": arguments},
CallStage.AFTER,
)
tool_message = Message(
role=MessageRole.TOOL,
content=f"Error: {error_msg}",
tool_call_id=tool_call.id,
metadata={"tool_name": tool_name, "source": node.id},
)
events.append(
FunctionCallOutputEvent(
call_id=tool_call.id or tool_call.function_name or "tool_call",
function_name=tool_call.function_name,
output_text=f"error: {error_msg}",
)
)
messages.append(tool_message)
continue
if not tool_config:
# Fallback check: if we have 1 config, maybe it's that one?
@ -662,6 +847,61 @@ class AgentNodeExecutor(NodeExecutor):
return messages, events
def _build_skill_followup_message(
self,
tool_name: str,
result: Any,
node_id: str,
) -> Message | None:
if tool_name != "activate_skill" or not isinstance(result, dict):
return None
instructions = result.get("instructions")
skill_name = result.get("skill_name", "unknown-skill")
allowed_tools = result.get("allowed_tools")
if not isinstance(instructions, str) or not instructions.strip():
return None
tool_constraint = ""
if isinstance(allowed_tools, list) and allowed_tools:
tool_constraint = f"\n\nOnly use these external tools while this skill is active: {allowed_tools}"
return Message(
role=MessageRole.SYSTEM,
content=(
f"Activated Agent Skill `{skill_name}`. "
"Follow its instructions for the current task until they are completed or no longer relevant.\n\n"
f"{instructions}{tool_constraint}"
),
metadata={"source": node_id, "skill_name": skill_name, "skill_activation": True},
)
def _execute_skill_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
skill_manager: AgentSkillManager | None,
) -> Dict[str, Any]:
if skill_manager is None:
raise ValueError("Agent Skills are not enabled for this node")
if tool_name == "activate_skill":
skill_name = str(arguments.get("skill_name", "")).strip()
if not skill_name:
raise ValueError("skill_name is required")
return skill_manager.activate_skill(skill_name)
if tool_name == "read_skill_file":
skill_name = str(arguments.get("skill_name", "")).strip()
relative_path = str(arguments.get("relative_path", "")).strip()
if not skill_name:
raise ValueError("skill_name is required")
if not relative_path:
raise ValueError("relative_path is required")
return skill_manager.read_skill_file(skill_name, relative_path)
raise ValueError(f"Unsupported skill tool '{tool_name}'")
def _build_function_call_output_event(
self,
tool_call: ToolCallPayload,

39
yaml_instance/skills.yaml Normal file
View File

@ -0,0 +1,39 @@
graph:
id: skills
description: Workflow to demonstrate skills usage
initial_instruction: Give the agent an instruction to explicitly use code to generate a Fibonacci sequence, sum numbers, or something else that is better done with code than LLM generation.
log_level: DEBUG
is_majority_voting: false
nodes:
- id: Qwerty
type: agent
config:
name: gpt-4o
provider: openai
role: Use any available tools or skills - notify if none are available
base_url: ${BASE_URL}
api_key: ${API_KEY}
params: {}
tooling:
- type: function
config:
tools:
- name: code_executor:All
timeout: null
prefix: ''
thinking: null
memories: []
skills:
enabled: true
allow:
- name: python-scratchpad
- name: rest-api-caller
retry: null
description: ''
context_window: 0
log_output: true
edges: []
memory: []
start:
- Qwerty
end: []