From cefe90fd5e3d4ae2cb6f5619991e2585687046a0 Mon Sep 17 00:00:00 2001 From: Petar Zivkovic Date: Mon, 2 Mar 2026 18:08:16 +0100 Subject: [PATCH 1/3] feat(agent): add filesystem-backed Agent Skills with activation and demo skills --- docs/user_guide/en/nodes/agent.md | 34 ++ docs/user_guide/zh/nodes/agent.md | 34 ++ entity/configs/__init__.py | 2 + entity/configs/node/__init__.py | 2 + entity/configs/node/agent.py | 16 + entity/configs/node/skills.py | 176 ++++++++++ functions/function_calling/code_executor.py | 6 +- .../function_calling/python_execution.py | 54 ++++ runtime/node/agent/__init__.py | 3 +- runtime/node/agent/skills/__init__.py | 8 + runtime/node/agent/skills/manager.py | 305 ++++++++++++++++++ runtime/node/executor/agent_executor.py | 264 ++++++++++++++- skills/greeting-demo/SKILL.md | 18 ++ skills/python-scratchpad/SKILL.md | 38 +++ .../python-scratchpad/references/examples.md | 45 +++ skills/rest-api-caller/SKILL.md | 41 +++ skills/rest-api-caller/references/examples.md | 41 +++ yaml_instance/skills.yaml | 39 +++ 18 files changed, 1110 insertions(+), 16 deletions(-) create mode 100644 entity/configs/node/skills.py create mode 100644 functions/function_calling/python_execution.py create mode 100644 runtime/node/agent/skills/__init__.py create mode 100644 runtime/node/agent/skills/manager.py create mode 100644 skills/greeting-demo/SKILL.md create mode 100644 skills/python-scratchpad/SKILL.md create mode 100644 skills/python-scratchpad/references/examples.md create mode 100644 skills/rest-api-caller/SKILL.md create mode 100644 skills/rest-api-caller/references/examples.md create mode 100644 yaml_instance/skills.yaml diff --git a/docs/user_guide/en/nodes/agent.md b/docs/user_guide/en/nodes/agent.md index c26e5becd..8333bcbb0 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 968672c3b..2d8d36d07 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 c6fc1c073..d0ef7044f 100755 --- a/entity/configs/__init__.py +++ b/entity/configs/__init__.py @@ -20,12 +20,14 @@ 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 208878149..b5678f556 100755 --- a/entity/configs/node/__init__.py +++ b/entity/configs/node/__init__.py @@ -5,12 +5,14 @@ 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 294eb6ac8..d0abd4f51 100755 --- a/entity/configs/node/agent.py +++ b/entity/configs/node/agent.py @@ -25,6 +25,7 @@ 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 @@ def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentConfig": 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 @@ def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentConfig": 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 @@ def from_dict(cls, data: Mapping[str, Any], *, path: str) -> "AgentConfig": 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 000000000..f27713a74 --- /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 a10b0460a..ac668f3db 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 __default_interpreter() -> 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 __default_interpreter() -> 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 000000000..fcb63465d --- /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 dbe56abdd..a0e4674b7 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 000000000..82ee011ab --- /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 000000000..0d20c4e81 --- /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 ed034a40a..4cb6e7251 100755 --- a/runtime/node/executor/agent_executor.py +++ b/runtime/node/executor/agent_executor.py @@ -33,6 +33,7 @@ ) 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 @@ def execute(self, node: Node, inputs: List[Message]) -> List[Message]: 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 @@ def execute(self, node: Node, inputs: List[Message]) -> List[Message]: call_options, response_obj, tool_specs, + skill_manager, ) else: response_message = response_obj.message @@ -172,12 +176,18 @@ def execute(self, node: Node, inputs: List[Message]) -> List[Message]: 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 @@ def _prepare_prompt_messages(self, node: Node, input_data: str) -> List[Message] 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 @@ def _prepare_call_options(self, node: Node) -> Dict[str, Any]: # 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 @@ def _handle_tool_calls( 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 @@ def _handle_tool_calls( 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 @@ def _execute_tool_batch( 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 @@ def _execute_tool_batch( 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 @@ def _execute_tool_batch( 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 000000000..16c98eabe --- /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 000000000..33c42d37d --- /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 000000000..4aecc9d56 --- /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 000000000..8ee132d7f --- /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 000000000..9f0bb7d4f --- /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 000000000..d47b2de35 --- /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: [] From 49b8285c73fe685dfce0272b9a02a52e9b39aee0 Mon Sep 17 00:00:00 2001 From: Petar Zivkovic Date: Thu, 5 Mar 2026 22:06:11 +0100 Subject: [PATCH 2/3] feat(agent): remove unneeded python_execution tool --- .../function_calling/python_execution.py | 54 ------------------- skills/python-scratchpad/SKILL.md | 11 ++-- .../python-scratchpad/references/examples.md | 2 +- yaml_instance/skills.yaml | 6 +-- 4 files changed, 9 insertions(+), 64 deletions(-) delete mode 100644 functions/function_calling/python_execution.py diff --git a/functions/function_calling/python_execution.py b/functions/function_calling/python_execution.py deleted file mode 100644 index fcb63465d..000000000 --- a/functions/function_calling/python_execution.py +++ /dev/null @@ -1,54 +0,0 @@ -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/skills/python-scratchpad/SKILL.md b/skills/python-scratchpad/SKILL.md index 33c42d37d..638a01223 100644 --- a/skills/python-scratchpad/SKILL.md +++ b/skills/python-scratchpad/SKILL.md @@ -1,7 +1,7 @@ --- 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 +allowed-tools: execute_code --- # Python Scratchpad @@ -15,22 +15,21 @@ This skill is especially useful for: - checking assumptions with a small reproducible script Requirements: -- The agent should have access to `run_python_script` or `execute_code`. +- 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 `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. +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 neither `run_python_script` nor `execute_code` is available, say exactly: `No Python execution tool is configured for this agent.` +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: diff --git a/skills/python-scratchpad/references/examples.md b/skills/python-scratchpad/references/examples.md index 4aecc9d56..611d315eb 100644 --- a/skills/python-scratchpad/references/examples.md +++ b/skills/python-scratchpad/references/examples.md @@ -7,7 +7,7 @@ numbers = [14, 27, 31, 8] print(sum(numbers)) ``` -Expected structured result with `run_python_script`: +Expected structured result with `execute_code`: ```json { diff --git a/yaml_instance/skills.yaml b/yaml_instance/skills.yaml index d47b2de35..23ab42872 100644 --- a/yaml_instance/skills.yaml +++ b/yaml_instance/skills.yaml @@ -1,6 +1,7 @@ graph: id: skills - description: '' + 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: @@ -17,7 +18,7 @@ graph: - type: function config: tools: - - name: python_execution:All + - name: code_executor:All timeout: null prefix: '' thinking: null @@ -33,7 +34,6 @@ graph: log_output: true edges: [] memory: [] - initial_instruction: '' start: - Qwerty end: [] From a6717bed9bf625f51e047f3f20dee7c9fa62e725 Mon Sep 17 00:00:00 2001 From: Petar Zivkovic Date: Tue, 10 Mar 2026 19:38:14 +0100 Subject: [PATCH 3/3] fix(skills): track current active skill explicitly and relocate to .agents/skills/ --- {skills => .agents/skills}/greeting-demo/SKILL.md | 0 .../skills}/python-scratchpad/SKILL.md | 2 +- .../python-scratchpad/references/examples.md | 0 .../skills}/rest-api-caller/SKILL.md | 6 +++--- .../skills}/rest-api-caller/references/examples.md | 0 docs/user_guide/en/nodes/agent.md | 6 +++--- docs/user_guide/zh/nodes/agent.md | 6 +++--- entity/configs/node/skills.py | 2 +- runtime/node/agent/skills/manager.py | 14 +++++++++----- 9 files changed, 20 insertions(+), 16 deletions(-) rename {skills => .agents/skills}/greeting-demo/SKILL.md (100%) rename {skills => .agents/skills}/python-scratchpad/SKILL.md (95%) rename {skills => .agents/skills}/python-scratchpad/references/examples.md (100%) rename {skills => .agents/skills}/rest-api-caller/SKILL.md (89%) rename {skills => .agents/skills}/rest-api-caller/references/examples.md (100%) diff --git a/skills/greeting-demo/SKILL.md b/.agents/skills/greeting-demo/SKILL.md similarity index 100% rename from skills/greeting-demo/SKILL.md rename to .agents/skills/greeting-demo/SKILL.md diff --git a/skills/python-scratchpad/SKILL.md b/.agents/skills/python-scratchpad/SKILL.md similarity index 95% rename from skills/python-scratchpad/SKILL.md rename to .agents/skills/python-scratchpad/SKILL.md index 638a01223..987a789aa 100644 --- a/skills/python-scratchpad/SKILL.md +++ b/.agents/skills/python-scratchpad/SKILL.md @@ -21,7 +21,7 @@ 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. +4. Prefer `execute_code`. 5. Use the script output in the final answer. 6. Keep scripts small and task-specific. diff --git a/skills/python-scratchpad/references/examples.md b/.agents/skills/python-scratchpad/references/examples.md similarity index 100% rename from skills/python-scratchpad/references/examples.md rename to .agents/skills/python-scratchpad/references/examples.md diff --git a/skills/rest-api-caller/SKILL.md b/.agents/skills/rest-api-caller/SKILL.md similarity index 89% rename from skills/rest-api-caller/SKILL.md rename to .agents/skills/rest-api-caller/SKILL.md index 8ee132d7f..074a17477 100644 --- a/skills/rest-api-caller/SKILL.md +++ b/.agents/skills/rest-api-caller/SKILL.md @@ -1,7 +1,7 @@ --- 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 +allowed-tools: execute_code --- # REST API Caller @@ -14,14 +14,14 @@ This skill is intended for: - 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`. +- 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 `run_python_script`; if it is unavailable, fall back to `execute_code`. +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. diff --git a/skills/rest-api-caller/references/examples.md b/.agents/skills/rest-api-caller/references/examples.md similarity index 100% rename from skills/rest-api-caller/references/examples.md rename to .agents/skills/rest-api-caller/references/examples.md diff --git a/docs/user_guide/en/nodes/agent.md b/docs/user_guide/en/nodes/agent.md index 8333bcbb0..37485b00c 100755 --- a/docs/user_guide/en/nodes/agent.md +++ b/docs/user_guide/en/nodes/agent.md @@ -33,14 +33,14 @@ The Agent node is the most fundamental node type in the DevAll platform, used to | 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` | +| `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 `skills/` directory. +- 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: run_python_script execute_code`. +- 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. diff --git a/docs/user_guide/zh/nodes/agent.md b/docs/user_guide/zh/nodes/agent.md index 2d8d36d07..555d56d4c 100755 --- a/docs/user_guide/zh/nodes/agent.md +++ b/docs/user_guide/zh/nodes/agent.md @@ -33,14 +33,14 @@ Agent 节点是 DevAll 平台中最核心的节点类型,用于调用大语言 | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `enabled` | bool | `false` | 是否为该节点启用 Agent Skills | -| `allow` | list[object] | `[]` | 可选的技能白名单,来源于项目级 `skills/` 目录;每个条目使用 `name` | +| `allow` | list[object] | `[]` | 可选的技能白名单,来源于项目级 `.agents/skills/` 目录;每个条目使用 `name` | ### Agent Skills 说明 -- 技能统一从固定的项目级 `skills/` 目录中发现。 +- 技能统一从固定的项目级 `.agents/skills/` 目录中发现。 - 运行时会暴露两个内置技能工具:`activate_skill` 和 `read_skill_file`。 - `read_skill_file` 只有在对应技能已经激活后才可用。 -- 技能 `SKILL.md` 的 frontmatter 可以包含可选的 `allowed-tools`,格式遵循 Agent Skills 规范,例如 `allowed-tools: run_python_script execute_code`。 +- 技能 `SKILL.md` 的 frontmatter 可以包含可选的 `allowed-tools`,格式遵循 Agent Skills 规范,例如 `allowed-tools: execute_code`。 - 如果某个已选择技能依赖的工具没有绑定到当前节点,该技能会在运行时被跳过。 - 如果最终没有任何兼容技能可用,Agent 会被明确告知不要声称自己使用了技能。 diff --git a/entity/configs/node/skills.py b/entity/configs/node/skills.py index f27713a74..3d51e5763 100644 --- a/entity/configs/node/skills.py +++ b/entity/configs/node/skills.py @@ -18,7 +18,7 @@ REPO_ROOT = Path(__file__).resolve().parents[3] -DEFAULT_SKILLS_ROOT = (REPO_ROOT / "skills").resolve() +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 [] diff --git a/runtime/node/agent/skills/manager.py b/runtime/node/agent/skills/manager.py index 0d20c4e81..ff4a21f91 100644 --- a/runtime/node/agent/skills/manager.py +++ b/runtime/node/agent/skills/manager.py @@ -11,7 +11,7 @@ REPO_ROOT = Path(__file__).resolve().parents[4] -DEFAULT_SKILLS_ROOT = (REPO_ROOT / "skills").resolve() +DEFAULT_SKILLS_ROOT = (REPO_ROOT / ".agents" / "skills").resolve() MAX_SKILL_FILE_BYTES = 128 * 1024 @@ -126,6 +126,7 @@ def __init__( 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]: @@ -176,6 +177,7 @@ def activate_skill(self, skill_name: str) -> Dict[str, str | List[str]]: 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), @@ -214,10 +216,12 @@ 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 + 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()