feat(agent): add filesystem-backed Agent Skills with activation and demo skills

This commit is contained in:
Petar Zivkovic 2026-03-02 18:08:16 +01:00
parent 6fe4fd1a0a
commit cefe90fd5e
18 changed files with 1110 additions and 16 deletions

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) | | `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 | | `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) | | `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` | object | No | - | Automatic retry strategy configuration |
### Retry Strategy Configuration (retry) ### 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 | | `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 | | `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 `skills/` directory; each entry uses `name` |
### Agent Skills Notes
- Skills are discovered from the fixed project-level `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: run_python_script 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 ## When to Use
- **Text generation**: Writing, translation, summarization, Q&A, etc. - **Text generation**: Writing, translation, summarization, Q&A, etc.
@ -145,6 +162,23 @@ nodes:
max_wait_seconds: 10.0 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 ## Related Documentation
- [Tooling Module Configuration](../modules/tooling/README.md) - [Tooling Module Configuration](../modules/tooling/README.md)

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1,54 @@
def run_python_script(script: str, timeout_seconds: int = 60) -> dict:
"""
Run a short Python script and return a structured result with stdout, stderr, and exit code.
This tool is intended for agent workflows that need a reliable Python scratchpad for
calculations, parsing, formatting, or quick validation.
"""
import os
import subprocess
import sys
import uuid
from pathlib import Path
workspace = Path(os.getenv("TEMP_CODE_DIR", "temp")).resolve()
workspace.mkdir(exist_ok=True)
script_path = workspace / f"{uuid.uuid4()}.py"
payload = script if script.endswith("\n") else script + "\n"
script_path.write_text(payload, encoding="utf-8")
try:
completed = subprocess.run(
[sys.executable or "python3", str(script_path.resolve())],
cwd=str(workspace),
capture_output=True,
text=True,
timeout=timeout_seconds,
check=False,
)
return {
"ok": completed.returncode == 0,
"exit_code": completed.returncode,
"stdout": completed.stdout,
"stderr": completed.stderr,
}
except subprocess.TimeoutExpired as exc:
return {
"ok": False,
"exit_code": None,
"stdout": exc.stdout or "",
"stderr": (exc.stderr or "") + f"\nError: Execution timed out after {timeout_seconds} seconds.",
}
except Exception as exc:
return {
"ok": False,
"exit_code": None,
"stdout": "",
"stderr": f"Execution error: {exc}",
}
finally:
try:
script_path.unlink(missing_ok=True)
except Exception:
pass

View File

@ -1,4 +1,5 @@
from .memory import * from .memory import *
from .providers import * from .providers import *
from .skills import *
from .thinking 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,305 @@
"""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 / "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._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
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:
for skill in self.discover():
if self.is_activated(skill.name):
return skill
return None
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 ThinkingPayload
from runtime.node.agent import ModelProvider, ProviderRegistry, ModelResponse 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 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) input_payload = self._build_thinking_payload_from_inputs(inputs, input_data)
memory_query_snapshot = self._build_memory_query_snapshot(inputs, input_data) memory_query_snapshot = self._build_memory_query_snapshot(inputs, input_data)
input_mode = agent_config.input_mode or AgentInputMode.PROMPT 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) provider = provider_class(agent_config)
client = provider.create_client() client = provider.create_client()
if input_mode is AgentInputMode.PROMPT: 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: else:
conversation = self._prepare_message_conversation(node, inputs) conversation = self._prepare_message_conversation(node, inputs, skill_manager)
call_options = self._prepare_call_options(node) 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( agent_invoker = self._build_agent_invoker(
provider, provider,
@ -129,6 +132,7 @@ class AgentNodeExecutor(NodeExecutor):
call_options, call_options,
response_obj, response_obj,
tool_specs, tool_specs,
skill_manager,
) )
else: else:
response_message = response_obj.message response_message = response_obj.message
@ -172,12 +176,18 @@ class AgentNodeExecutor(NodeExecutor):
finally: finally:
self._current_node_id = None 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.""" """Prepare the prompt-style message sequence."""
messages: List[Message] = [] messages: List[Message] = []
if node.role: system_prompt = self._build_system_prompt(node, skill_manager)
messages.append(Message(role=MessageRole.SYSTEM, content=node.role)) if system_prompt:
messages.append(Message(role=MessageRole.SYSTEM, content=system_prompt))
try: try:
if isinstance(input_data, str): if isinstance(input_data, str):
@ -191,11 +201,17 @@ class AgentNodeExecutor(NodeExecutor):
messages.append(Message(role=MessageRole.USER, content=clean_input)) messages.append(Message(role=MessageRole.USER, content=clean_input))
return messages 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] = [] messages: List[Message] = []
if node.role: system_prompt = self._build_system_prompt(node, skill_manager)
messages.append(Message(role=MessageRole.SYSTEM, content=node.role)) if system_prompt:
messages.append(Message(role=MessageRole.SYSTEM, content=system_prompt))
normalized_inputs = self._coerce_inputs_to_messages(inputs) normalized_inputs = self._coerce_inputs_to_messages(inputs)
if normalized_inputs: if normalized_inputs:
@ -220,6 +236,76 @@ class AgentNodeExecutor(NodeExecutor):
# call_options.setdefault("max_tokens", 4096) # call_options.setdefault("max_tokens", 4096)
return call_options 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( def _build_agent_invoker(
self, self,
provider: ModelProvider, provider: ModelProvider,
@ -479,6 +565,7 @@ class AgentNodeExecutor(NodeExecutor):
call_options: Dict[str, Any], call_options: Dict[str, Any],
initial_response: ModelResponse, initial_response: ModelResponse,
tool_specs: List[ToolSpec], tool_specs: List[ToolSpec],
skill_manager: AgentSkillManager | None,
) -> Message: ) -> Message:
"""Handle tool calls until completion or until the loop limit is reached.""" """Handle tool calls until completion or until the loop limit is reached."""
assistant_message = initial_response.message assistant_message = initial_response.message
@ -503,7 +590,12 @@ class AgentNodeExecutor(NodeExecutor):
iteration += 1 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) conversation.extend(tool_call_messages)
timeline.extend(tool_events) timeline.extend(tool_events)
trace_messages.extend(self._clone_with_source(msg, node.id) for msg in tool_call_messages) 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, node: Node,
tool_calls: List[ToolCallPayload], tool_calls: List[ToolCallPayload],
tool_specs: List[ToolSpec], 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.""" """Execute a batch of tool calls and return conversation + timeline events."""
messages: List[Message] = [] messages: List[Message] = []
events: List[FunctionCallOutputEvent] = [] events: List[Any] = []
model = node.as_config(AgentConfig) model = node.as_config(AgentConfig)
# Build map for fast lookup # Build map for fast lookup
@ -556,6 +649,98 @@ class AgentNodeExecutor(NodeExecutor):
tool_config = configs[idx] tool_config = configs[idx]
# Use original name if prefixed # Use original name if prefixed
execution_name = spec.metadata.get("original_name", tool_name) 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: if not tool_config:
# Fallback check: if we have 1 config, maybe it's that one? # Fallback check: if we have 1 config, maybe it's that one?
@ -662,6 +847,61 @@ class AgentNodeExecutor(NodeExecutor):
return messages, events 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( def _build_function_call_output_event(
self, self,
tool_call: ToolCallPayload, tool_call: ToolCallPayload,

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,38 @@
---
name: python-scratchpad
description: Use the existing Python execution tools as a scratchpad for calculations, data transformation, and quick script-based validation.
allowed-tools: run_python_script 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 `run_python_script` or `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 `run_python_script` with the script in its `script` argument.
5. If `run_python_script` is unavailable, call `execute_code` once with that script.
6. Use the script output in the final answer.
7. 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 neither `run_python_script` nor `execute_code` is 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 `run_python_script`:
```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: run_python_script 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 `run_python_script` or `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 `run_python_script`; if it is unavailable, fall back to `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())
```

39
yaml_instance/skills.yaml Normal file
View File

@ -0,0 +1,39 @@
graph:
id: skills
description: ''
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: python_execution: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: []
initial_instruction: ''
start:
- Qwerty
end: []