diff --git a/docs/user_guide/en/nodes/agent.md b/docs/user_guide/en/nodes/agent.md
index c26e5bec..8333bcbb 100755
--- a/docs/user_guide/en/nodes/agent.md
+++ b/docs/user_guide/en/nodes/agent.md
@@ -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 `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
- **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)
diff --git a/docs/user_guide/zh/nodes/agent.md b/docs/user_guide/zh/nodes/agent.md
index 968672c3..2d8d36d0 100755
--- a/docs/user_guide/zh/nodes/agent.md
+++ b/docs/user_guide/zh/nodes/agent.md
@@ -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] | `[]` | 可选的技能白名单,来源于项目级 `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
```
+### 配置 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)
diff --git a/entity/configs/__init__.py b/entity/configs/__init__.py
index c6fc1c07..d0ef7044 100755
--- a/entity/configs/__init__.py
+++ b/entity/configs/__init__.py
@@ -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",
diff --git a/entity/configs/node/__init__.py b/entity/configs/node/__init__.py
index 20887814..b5678f55 100755
--- a/entity/configs/node/__init__.py
+++ b/entity/configs/node/__init__.py
@@ -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",
diff --git a/entity/configs/node/agent.py b/entity/configs/node/agent.py
index 294eb6ac..d0abd4f5 100755
--- a/entity/configs/node/agent.py
+++ b/entity/configs/node/agent.py
@@ -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",
diff --git a/entity/configs/node/skills.py b/entity/configs/node/skills.py
new file mode 100644
index 00000000..f27713a7
--- /dev/null
+++ b/entity/configs/node/skills.py
@@ -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
diff --git a/functions/function_calling/code_executor.py b/functions/function_calling/code_executor.py
index a10b0460..ac668f3d 100755
--- a/functions/function_calling/code_executor.py
+++ b/functions/function_calling/code_executor.py
@@ -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
\ No newline at end of file
+ return stdout + stderr
diff --git a/functions/function_calling/python_execution.py b/functions/function_calling/python_execution.py
new file mode 100644
index 00000000..fcb63465
--- /dev/null
+++ b/functions/function_calling/python_execution.py
@@ -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
diff --git a/runtime/node/agent/__init__.py b/runtime/node/agent/__init__.py
index dbe56abd..a0e4674b 100755
--- a/runtime/node/agent/__init__.py
+++ b/runtime/node/agent/__init__.py
@@ -1,4 +1,5 @@
from .memory import *
from .providers import *
+from .skills import *
from .thinking import *
-from .tool import *
\ No newline at end of file
+from .tool import *
diff --git a/runtime/node/agent/skills/__init__.py b/runtime/node/agent/skills/__init__.py
new file mode 100644
index 00000000..82ee011a
--- /dev/null
+++ b/runtime/node/agent/skills/__init__.py
@@ -0,0 +1,8 @@
+from .manager import AgentSkillManager, SkillMetadata, SkillValidationError, parse_skill_file
+
+__all__ = [
+ "AgentSkillManager",
+ "SkillMetadata",
+ "SkillValidationError",
+ "parse_skill_file",
+]
diff --git a/runtime/node/agent/skills/manager.py b/runtime/node/agent/skills/manager.py
new file mode 100644
index 00000000..0d20c4e8
--- /dev/null
+++ b/runtime/node/agent/skills/manager.py
@@ -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 = [""]
+ for skill in skills:
+ lines.extend(
+ [
+ " ",
+ f" {escape(skill.name)}",
+ f" {escape(skill.description)}",
+ f" {escape(str(skill.skill_file))}",
+ ]
+ )
+ if skill.allowed_tools:
+ lines.append(" ")
+ for tool_name in skill.allowed_tools:
+ lines.append(f" {escape(tool_name)}")
+ lines.append(" ")
+ lines.append(" ")
+ lines.append("")
+ 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 .",
+ }
+ },
+ "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 .",
+ },
+ "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)
diff --git a/runtime/node/executor/agent_executor.py b/runtime/node/executor/agent_executor.py
index ed034a40..4cb6e725 100755
--- a/runtime/node/executor/agent_executor.py
+++ b/runtime/node/executor/agent_executor.py
@@ -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 .",
+ 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,
diff --git a/skills/greeting-demo/SKILL.md b/skills/greeting-demo/SKILL.md
new file mode 100644
index 00000000..16c98eab
--- /dev/null
+++ b/skills/greeting-demo/SKILL.md
@@ -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, .`
+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.`
diff --git a/skills/python-scratchpad/SKILL.md b/skills/python-scratchpad/SKILL.md
new file mode 100644
index 00000000..33c42d37
--- /dev/null
+++ b/skills/python-scratchpad/SKILL.md
@@ -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.
diff --git a/skills/python-scratchpad/references/examples.md b/skills/python-scratchpad/references/examples.md
new file mode 100644
index 00000000..4aecc9d5
--- /dev/null
+++ b/skills/python-scratchpad/references/examples.md
@@ -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)
+```
diff --git a/skills/rest-api-caller/SKILL.md b/skills/rest-api-caller/SKILL.md
new file mode 100644
index 00000000..8ee132d7
--- /dev/null
+++ b/skills/rest-api-caller/SKILL.md
@@ -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
diff --git a/skills/rest-api-caller/references/examples.md b/skills/rest-api-caller/references/examples.md
new file mode 100644
index 00000000..9f0bb7d4
--- /dev/null
+++ b/skills/rest-api-caller/references/examples.md
@@ -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())
+```
diff --git a/yaml_instance/skills.yaml b/yaml_instance/skills.yaml
new file mode 100644
index 00000000..d47b2de3
--- /dev/null
+++ b/yaml_instance/skills.yaml
@@ -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: []